資安

Envoy源碼分析之Dispatcher機制

Dispatcher機制

​ Envoy和Nginx一樣都是基於事件驅動的架構,這種架構的核心就是事件循環(EventLoop)。業界目前典型的幾種事件循環實現主要有Libevent、Libev、Libuv、Boost.Asio等,也可以完全基於Linux系統調用epoll來實現。Envoy選擇在Libevent的基礎上進行了封裝,實現了自己的事件循環機制,在Envoy中被稱為Dispatcher,一個Dispatcher對象就是一個事件分發器,就如同它的名字一樣。Dispatcher是Envoy的核心,可以說Envoy中絕大部分的能力都是構建在Dispatcher的基礎上。所以理解Dispatcher機制是掌握Envoy的一個很重要的前提。

​ 在Envoy中Dispatcher不僅僅提供了網絡事件分發、定時器、信號處理等基本的事件循環能力,還在事件循環的基礎上實現任務執行隊列、DeferredDelet等,這兩個功能為Envoy中很多組件提供了必不可少的基礎能力。比如藉助DeferredDelet實現了安全的對象析構,通過任務執行隊列實現Thread Local機制等等。

Libevent事件封裝

​ Envoy在Libevent的基礎上進行了封裝最為重要的一個原因就是因為Libevent本身是C開發的,很多Libevent暴露出來的結構需要自己來管理內存的分配和釋放,這對於現代化的C++來說顯然是無法接受的,因此Envoy藉助了C++的RAII機制將這些結構封裝起來,自動管理內存資源的釋放。接下來我們看下Envoy是如何進行封裝的。

template <class T, void (*deleter)(T*)>
class CSmartPtr : public std::unique_ptr<T, void (*)(T*)> {
public:
  CSmartPtr() : std::unique_ptr<T, void (*)(T*)>(nullptr, deleter) {}
  CSmartPtr(T* object) : std::unique_ptr<T, void (*)(T*)>(object, deleter) {}
};

​ Envoy通過繼承unique_ptr自定義了一個CSmartPtr,通過繼承擁有了unqiue_ptr自動管理內存釋放的能力,離開作用域後自動釋放內存。藉助CSmartPtr,Envoy將Libevent中的event_base包裝成BasePtr,將evconnlistener包裝成ListenerPtr。其中event_base就是事件循環,一個event_base就是一個事件循環,可以擁有多個事件循環,Envoy內部就是每一個worker線程都會有一個事件循環,也就是最常見的one loop per thread模型。

using BasePtr = CSmartPtr<event_base, event_base_free>;
using ListenerPtr = CSmartPtr<evconnlistener, evconnlistener_free>;

​ 在Libevent中無論是定時器到期、收到信號、還是文件可讀寫等都是事件,統一使用event類型來表示,Envoy中則將event作為ImplBase的成員,然後讓所有的事件類型的對象都繼承ImplBase,從而實現了事件的抽象。同時也藉助了RAII機制自動實現了事件資源的釋放。

class ImplBase {
protected:
  ~ImplBase();
    
  event raw_event_;
};

ImplBase::~ImplBase() {
  // Derived classes are assumed to have already assigned the raw event in the constructor.
  event_del(&raw_event_);
}

​ 通過繼承ImplBase基類可以擁有event事件成員,但是每一種事件表現出的具體行為是不一樣的,比如說信號事件,需要有信號註冊的能力,定時器事件則需要可以開啟或者關閉定時的能力,文件事件則需要能夠開啟某些事件狀態的監聽。為此Envoy為每一種事件類型都抽象了對應的接口,例如文件事件接口。

class FileEvent {
public:
  virtual ~FileEvent() = default;
  // 激活指定事件,會自動觸發對應事件的callback
  virtual void activate(uint32_t events) PURE;
  // 開啟指定事件狀態的監聽
  virtual void setEnabled(uint32_t events) PURE;
};

​ 有了事件基類和對應的接口類後,讓我們來看下Envoy如何來實現一個文件事件對象。

// 通過繼承ImplBase擁有了event成員
class FileEventImpl : public FileEvent, ImplBase {
public:
  FileEventImpl(DispatcherImpl& dispatcher, int fd, FileReadyCb cb, 
                FileTriggerType trigger,
                uint32_t events);

  // Event::FileEvent
  // 實現了文件事件的接口,通過這個接口可以實現文件事件的監聽
  void activate(uint32_t events) override;
  void setEnabled(uint32_t events) override;

private:
  // 初始化事件對象
  void assignEvents(uint32_t events, event_base* base);
    
  // 事件觸發時執行的callback
  FileReadyCb cb_;
  // 文件fd
  int fd_;
  // 事件觸發的類型,邊緣觸發,還是水平觸發
  FileTriggerType trigger_;
};

FileEventImpl::FileEventImpl(DispatcherImpl& dispatcher, int fd, FileReadyCb cb,
                             FileTriggerType trigger, uint32_t events)
    : cb_(cb), fd_(fd), trigger_(trigger) {
#ifdef WIN32
  RELEASE_ASSERT(trigger_ == FileTriggerType::Level,
                 "libevent does not support edge triggers on Windows");
#endif
  // dispatcher.base()返回的就是上文中說到的BasePtr,事件循環對象
  // 通過assignEvents初始化事件對象,設置好要監聽的事件狀態,以及事件回調callback等
  // 內部調用的就是Libevent的event_assign方法。
  assignEvents(events, &dispatcher.base());
  // 將事件對象註冊到事件循環中,內部調用的就是
  event_add(&raw_event_, nullptr);
}

​ 到此為止事件對象的封裝就分析完了,接下來看下核心的Dispatcher對象,它提供了幾個核心的方法來創建上文中分析的幾個事件對象。

  class DispatcherImpl {
   public:
     ....
     FileEventPtr createFileEvent(int fd, FileReadyCb cb, FileTriggerType trigger,
                                  uint32_t events) override;
     TimerPtr createTimer(TimerCb cb) override;
     SignalEventPtr listenForSignal(int signal_num, SignalCb cb) override;
     ....
  }

​ 這就是Dispatcher對象的幾個核心方法,在這幾個方法的基礎上又擴展了createServerConnectioncreateClientConnection等方法用於創建服務端和客戶端連接對象,這兩個方法內部最終都調用了createFileEvent方法,將socket文件的事件註冊到了事件循環中。到此為止關於Dispatcher事件相關的幾個方法都分析完了,但是Dispatcher對象遠遠還不止這些,比如說本文尚未提到的Scheduler,目前這個部分還尚未完成,這一塊是對事件循環的抽象,目前是為了讓事件循環組件可替換,目前只有LibeventScheduler一個實現。

任務執行隊列

 在上文中曾提到過Envoy在事件循環的基礎上實現了兩個比較重要的基礎功能,其中一個就是任務執行隊列了。可以隨時通過`post`方法提交多個函數對象,然後交由`Dispatcher`來執行。所有的函數對象執行都是順序的。是在`Dispatcher`所在的線程中執行。整個post方法的代碼非常短。
// 所有的要執行的函數對象原型都一樣,都是void()
void DispatcherImpl::post(std::function<void()> callback) {
  bool do_post;
  {
    // 因為post方法可以跨線程執行,因此這裡需要加鎖來保證線程安全
    // 可以看出post方法本質上是將函數對象放到隊列中,實際上並未執行
    Thread::LockGuard lock(post_lock_);
    do_post = post_callbacks_.empty();
    post_callbacks_.push_back(callback);
  }

  if (do_post) {
    post_timer_->enableTimer(std::chrono::milliseconds(0));
  }
}

post方法將傳遞進來的callback所代表的任務,添加到post_callbacks_所代表的類型為vector<callback>的成員變量中。如果post_callbacks_為空的話,說明背後的處理線程是處於非活動狀態,這時通過post_timer_設置一個超時時間時間為0的方式來喚醒它。post_timer_在構造的時候就已經設置好對應的callbackrunPostCallbacks,對應代碼如下:

DispatcherImpl::DispatcherImpl(TimeSystem& time_system,
                               Buffer::WatermarkFactoryPtr&& factory)
    : ......
      post_timer_(createTimer([this]() -> void { runPostCallbacks(); })),
      current_to_delete_(&to_delete_1_) {
  RELEASE_ASSERT(Libevent::Global::initialized(), "");
}

runPostCallbacks是一個while循環,每次都從post_callbacks_中取出一個callback所代表的任務去運行,直到post_callbacks_為空。每次運行runPostCallbacks都會確保所有的任務都執行完。顯然,在runPostCallbacks被線程執行的期間如果post進來了新的任務,那麼新任務直接追加到post_callbacks_尾部即可,而無需做喚醒線程這一動作。

void DispatcherImpl::runPostCallbacks() {
  while (true) {
    std::function<void()> callback;
    {
      Thread::LockGuard lock(post_lock_);
      if (post_callbacks_.empty()) {
        return;
      }
      callback = post_callbacks_.front();
      post_callbacks_.pop_front();
    }
    callback();
  }
}

​ 到此為止Envoy中的任務執行隊列就分析完了,可以看出這個部分的代碼實現還是很簡單的,也很容易驗證其正確性,在Envoy的代碼中被廣泛使用。這個能力和Boost::asio中的post task是類似的。

DeferredDeletable

​ 本小節是Dispatcher中最重要的一個部分DeferredDeletable,又被稱為延遲析構,目的是用於安全的進行對象析構。C++語言本身會存在對象析構了,但還有引用它的指針存在,這個時候通過這個指針訪問這個對象就會導致未定義行為了。因此寫C++的同學就需要特別注意一個對象的生命週期問題,要保證引用一個對象的時候,對象還沒有被析構。在C++中有不少方案可以來解決這個問題,典型的像使用shared_ptr的方式。而本文的要分析的DeferredDeletable則是使用另外一種方式來解決對象安全析構問題,這個方案的並不是一個通用的方案,僅能解決部分場景下的對象安全析構問題,但是對於Envoy使用到的場景已經足夠了,接下來我們將分析它是如何做到對象安全析構的。

DeferredDeletable本身是一個空接口,所有要進行延遲析構的對象都要繼承自這個空接口。在Envoy的代碼中像下面這樣繼承自DeferredDeletable的類隨處可見。

class DeferredDeletable {
public:
  virtual ~DeferredDeletable() {}
};

class Connection : public Event::DeferredDeletable { .... }

/**
 * An instance of a generic connection pool.
 */
class Instance : public Event::DeferredDeletable { ..... }

/**
 * Implementation of AsyncRequest. This implementation is capable of 
 * sending HTTP requests to a ConnectionPool asynchronously.
 */
class AsyncStreamImpl : public Event::DeferredDeletable{....}

​ 這些繼承DeferredDeletable接口的類都有一個特點,這些類基本上都是一些具有短暫生命週期的對象,比如連接對象、請求對象等。這也正是上文中提到的延遲析構並非是是一個通用方案,只是針對Envoy中的一些特定場景。DeferredDeletableDispatcher是密切相關,是基於Dispatcher來完成的。Dispatcher對象有一個vector保存了所有要延遲析構的對象。

class DispatcherImpl : public Dispatcher {
  ......
 private:
  ........
  std::vector<DeferredDeletablePtr> to_delete_1_;
  std::vector<DeferredDeletablePtr> to_delete_2_;
  std::vector<DeferredDeletablePtr>* current_to_delete_;
 }

to_delete_1_to_delete_2_就是用來存放所有的要延遲析構的對象,這裡使用兩個vector存放,為什麼要這樣做呢?或許可能有人會想這是因為要保證線程安全,不能往一個正在析構的列表中添加對象。其實並非如此,多線程操作一個隊列本就是非線程安全的,所以這裡使用兩個隊列的目的並非是為了線程安全的。帶著這個疑問繼續往下分析,current_to_delete_始終指向當前正要析構的對象列表,每次執行完析構後就交替指向另外一個對象列表,來回交替。

void DispatcherImpl::clearDeferredDeleteList() {
  ASSERT(isThreadSafe());
  std::vector<DeferredDeletablePtr>* to_delete = current_to_delete_;
  size_t num_to_delete = to_delete->size();
  // 如果正在刪除或者沒有對象可刪除就返回
  if (deferred_deleting_ || !num_to_delete) {
    return;
  }
  // 正式開始刪除對象
  ENVOY_LOG(trace, "clearing deferred deletion list (size={})", num_to_delete);
  // current_to_delete_指向另外一個沒有進行刪除的隊列
  if (current_to_delete_ == &to_delete_1_) {
    current_to_delete_ = &to_delete_2_;
  } else {
    current_to_delete_ = &to_delete_1_;
  }
  // 設置正在刪除的標誌
  deferred_deleting_ = true;
  // 開始進行對象析構
  for (size_t i = 0; i < num_to_delete; i++) {
    (*to_delete)[i].reset();
  }
    
  to_delete->clear();
  // 結束
  deferred_deleting_ = false;
}

​ 上面的代碼中我們可以看到在執行對象析構的時候先使用to_delete來指向當前正要析構的對象列表,然後將current_to_delete_指向另外一個列表,這裡為什麼要設置deferred_deleting_標誌呢? 這是因為clearDeferredDeleteList可能會被調用多次,如果已經有對象正在析構,那麼就不能再進行析構操作了,因此這裡通過deferred_deleting_標誌來保證同一時刻只能有一個對象析構的任務在執行。

假設沒有deferred_deleting_標誌,如果此時正在執行to_delete_1_隊列的對象析構,在析構的過程中調用了clearDeferredDeleteList,那麼這個時候會對to_delete_2_隊列開始析構,並且將current_to_delete_指向to_delete_1_,後續的待析構對象就都會添加到to_delete_1_隊列中,這可能會導致對to_delete_1_析構的任務執行較長時間。影響其它關鍵任務的執行。

​ 接下來我們來看下如何將對象添加到待析構的列表中。

void DispatcherImpl::deferredDelete(DeferredDeletablePtr&& to_delete) {
  ASSERT(isThreadSafe());
  current_to_delete_->emplace_back(std::move(to_delete));
  ENVOY_LOG(trace, "item added to deferred deletion list (size={})", current_to_delete_->size());
  if (1 == current_to_delete_->size()) {
    deferred_delete_timer_->enableTimer(std::chrono::milliseconds(0));
  }
}

deferredDeleteclearDeferredDeleteList這兩個方法都調用了 ASSERT(isThreadSafe());目的是斷言調用這兩個方法是在Dispatcher所在線程執行的,是單線程運行。可以保證線程安全。 既然如此我們便可以安全的往待析構的對象列表中追加對象了,這也驗證了兩個隊列的設計並非是為了線程安全。那為何還要搞出to_delete_1_to_delete_2_兩個列表呢? 完全可以通過一個列表來實現,通過while循環不斷的進行對象析構,直到列表為空。在處理的過程中還可以往列表中追加對象。

while(!current_to_delete_.empty()) {
    auto obj = current_to_delete_.pop_back();
    //  進行業務邏輯的處理
}

​ 從功能正確性的角度來看,這裡使用兩個列表,還是一個列表都可以正確實現,在上文中分析的任務執行隊列其實就是使用一個列表來完成的。但是Envoy在這裡選擇了兩個隊列的方式,這是因為相比於任務執行隊列來說延遲析構的重要性更低一些,大量對象的析構如果保存在一個隊列中循環的進行析構勢必會影響其他關鍵任務的執行,所以這裡拆分成兩個隊列,多個任務交替的執行,避免被一個大的耗時任務長期佔用,導致其他關鍵任務無法及時執行。

如果用一個隊列做對象析構,在對象的析構函數中可能還會再次調用deferredDelete將新的對象追加到待析構的列表中,所以可能會導致隊列中的任務不斷增加,造成整個對象析構耗時較長。

​ 繼續看deferredDelete的代碼我們會發現另外一個問題,為何要在當前待析構對象的列表大小等於1的時候喚起定時器任務呢?

if (1 == current_to_delete_->size()) {
  // deferred_delete_timer_(createTimerInternal([this]() -> void {               
  // clearDeferredDeleteList(); })),
  // deferred_delete_timer_定時器對應的任務就是clearDeferredDeleteList
  deferred_delete_timer_->enableTimer(std::chrono::milliseconds(0));
}

​ 假設我們每次添加對象到當前列表中都進行喚醒,那麼帶來的問題就是clearDeferredDeleteList的任務會有多個,但是實際上只有兩個隊列,只需要有兩個clearDeferredDeleteList任務就可以將兩個隊列中的對象都析構掉,那麼剩下的任務將不會進行任何實際的工作。很顯然這樣會帶來CPU上的浪費,因此我們應該儘可能的少喚醒,保證任何時候最多隻有兩個任務。因此我們只要能保證在每次隊列為空的時候喚醒一次即可,因為喚醒的這次任務會負責將這個隊列變為空,到時候在此喚醒一個任務即可。這也就是為什麼這裡通過判斷當前待析構對象的列表大小等於1的原因了。

​ 到此為止deferredDelete的實現原理就基本分析完了,可以看出它的實現和任務隊列的實現很類似,只不過一個是循環執行callback所代表的任務,另一個是讓對象進行析構。最後讓我們通過下圖來看下整個deferredDelete的流程。

4-1.png

  • 對象要被析構了,開始調用deferredDelete將對象添加到to_delete_1隊列中,然後喚醒clearDeferredDeleteList任務。
  • clearDeferredDeleteList任務開始執行,current_to_delete指向to_delete_2隊列
  • 對象在析構的過程中又通過deferredDelete添加了新的對象到to_delete_2隊列中,這個隊列初始是空的,因此再次喚醒一個clearDeferredDeleteList任務。
  • to_delete_1隊列繼續進行對象的析構,在析構期間有大量對象被添加到to_delete_2隊列中,但是沒有喚醒clearDeferredDeleteList任務。
  • to_delete_1對象析構完畢
  • 再次執行clearDeferredDeleteListto_delete_2中對象進行析構。
  • 如此反覆便可以高效的在兩個隊列之間來回切換進行對象的析構。

​ 雖然分析完了整個deferredDelete的過程,但是我們還沒有回答本節一開始提到的如何安全的進行對象析構的問題。讓我們先來看一下deferredDelete的應用場景,看看“為何要進行延遲析構?” 以及deferredDelete是如何解決對象安全析構的問題。在Envoy的源代碼中經常會看到像下面這樣的代碼片段。

ConnectionImpl::ConnectionImpl(Event::Dispatcher& dispatcher, 
                               ConnectionSocketPtr&& socket,
                               TransportSocketPtr&& transport_socket,
                               bool connected) {
......
  }
  // 傳遞裸指針到回調中
  file_event_ = dispatcher_.createFileEvent(
        // 這裡將this裸指針傳遞給了內部的callback
      // callback內部通過this指針訪問onFileEvent方法,如何保證
      // callback執行的時候,this指針是有效的呢?
      fd(), [this](uint32_t events) -> void { onFileEvent(events); }, 
      Event::FileTriggerType::Edge,
      Event::FileReadyType::Read | Event::FileReadyType::Write);
    ......
}

​ 傳遞給Dispatchercallback都是通過裸指針的方式進行回調,如果進行回調的時候對象已經析構了,就會出現野指針的問題,我相信學過C++的同學都會看出這個問題,除非能在邏輯上保證Dispatcher的生命週期比所有對象都短,這樣就能保證在回調的時候對象肯定不會析構,但是這不可能成立的,因為DispatcherEventLoop的核心。一個線程運行一個EventLoop直到線程結束,Dispatcher對象才會析構,這意味著Dispatcher對象的生命週期是最長的。所以從邏輯上沒辦法保證進行回調的時候對象沒有析構。可能有人會有疑問,對象在析構的時候把註冊的事件(file_event_)取消不就可以避免野指針的問題嗎? 那如果事件已經觸發了,callback正在等待運行? 又或者callback運行了一半呢?前者libevent是可以保證的,在調用event_del刪除事件的時候可以把處於等待運行的事件callback取消掉,但是後者就無能為力了,這個時候如果對象析構了,那行為就是未定義了。沿著這個思路想一想,是不是隻要保證對象析構的時候沒有callback正在運行就可以解決問題了呢?是的,只要保證所有在執行中的callback執行完了,再做對象析構就可以了。可以利用Dispatcher是順序執行所有callback的特點,向Dispatcher中插入一個任務就是用來對象析構的,那麼當這個任務執行的時候是可以保證沒有其他任何callback在運行。通過這個方法就完美解決了這裡遇到的野指針問題了。或許有人又會想,這裡是不是可以用shared_ptrshared_from_this來解這個呢? 是的,這是解決多線程環境下對象析構的祕密武器,通過延長對象的生命週期,把對象的生命週期延長到和callback一樣,等callback執行完再進行析構,同樣可以達到效果,但是這帶來了兩個問題,第一就是對象生命週期被無限拉長,雖然延遲析構也拉長了生命週期,但是時間是可預期的,一旦EventLoop執行了clearDeferredDeleteList任務就會立刻被回收,而通過shared_ptr的方式其生命週期取決於callback何時運行,而callback何時運行這個是沒辦法保證的,比如一個等待socket的可讀事件進行回調,如果對端一直不發送數據,那麼callback就一直不會被運行,對象就一直無法被析構,長時間累積會導致內存使用率上漲。第二就是在使用方式上侵入性較強,需要強制使用shared_ptr的方式創建對象。

Leave a Reply

Your email address will not be published. Required fields are marked *