資安

Envoy源碼分析之ThreadLocal機制

ThreadLocal機制

​ Envoy中的ThreadLocal機制其實就是我們經常說的線程本地存儲簡稱TLS(Thread Local Storage),顧名思義通過TLS定義的變量會在每一個線程專有的存儲區域存儲一份,訪問TLS的時候,其實訪問的是當前線程佔有存儲區域中的副本,因此可以使得線程可以無鎖的併發訪問同一個變量。Linux上一般有三種方式來定義一個TLS變量。

  • gcc對C語言的擴展__thread
  • pthread庫提供的pthread_key_create
  • C++11的std::thread_local關鍵字

​ Envoy的ThreadLocal機制就是在C++11的std::thread_local基礎上進行了封裝用於實現線程間的數據共享。Envoy因其配置的動態生效而出名,而配置動態生效的基石就是ThreadLocal機制,通過ThreadLocal機制將配置可以無鎖的在多個線程之間共享,當配置發生變更的時候,通過主線程將更新後的配置Post到各個線程中,交由各個線程來更新自己的ThreadLocal

ThreadLocalObject

​ Envoy要求所有的ThreadLocal數據對象都要繼承ThreadLocalObject,比如下面這個ThreadLocal對象。

struct ThreadLocalCachedDate : public ThreadLocal::ThreadLocalObject {
   ThreadLocalCachedDate(const std::string& date_string) : 
   date_string_(date_string) {}
  const std::string date_string_;
};

​ 但實際上ThreadLocalObject只是一個空的接口類,所以並非我們繼承了ThreadLocalObject就是一個TLS了。繼承ThreadLocalObject目的是為了可以統一對所有要進行TLS的對象進行管理。

class ThreadLocalObject {
public:
  virtual ~ThreadLocalObject() = default;
};
using ThreadLocalObjectSharedPtr = std::shared_ptr<ThreadLocalObject>;

​ Envoy中需要TLS的數據有很多,最重要的當屬配置,隨著配置的增多,這類數據所佔據的內存也會變得很大,如果每一種配置都聲明為TLS會導致不少內存浪費。為此Envoy通過ThreadLocalData將所有要進行TLS的對象都管理起來,然後將ThreadLocalData本身設置為TLS,通過TLS中保存的指針來訪問對應的數據。這樣就可以避免直接在TLS中保存數據而帶來內存上的浪費,只需要保存指向數據的指針即可,相關代碼如下。

struct ThreadLocalData {
  // 指向當前線程的Dispatcher對象 
  Event::Dispatcher* dispatcher_{};
  // 保存了所有要TLS的數據對象的智能指針,通過智能指針來訪問真正的數據對象
  std::vector<ThreadLocalObjectSharedPtr> data_;
};

4-2.jpg

 如上圖所示,每一個TLS通過指針指向實際的對象,每一個數據對象只在內存中保存一份,避免內存上的浪費,但是這樣帶來問題就是如何做到線程安全的訪問數據對象呢?  當我們要訪問數據對象的時候,如果此時正在對數據對象進行更新,這個時候就會存在一個線程安全的問題了。Envoy巧妙的通過在數據對象更新的時候,先構造出一個新的數據對象,然後將TLS中的數據對象指針指向新的數據對象來實現線程安全的訪問。本質上和COW(copy-on-write)很類似,但是存在兩點區別。
  • COW中是先拷貝原來的對象,然後更改對象,而Envoy在這裡是重新構建一個新的數據對象
  • COW中無論是讀還是寫,在更改shared_ptr指向時,都需要加鎖,因為shared_ptr本身的讀寫時非線程安全的,而Envoy不需要加鎖。

​ Envoy中指向數據對象的shared_ptr並非只有一個,而是每一個線程都有一個shared_ptr指向數據對象,更改shared_ptr指向新的數據對象時通過post一個任務到對應線程中,然後在同一個線程使shared_ptr指向新的數據對象,因此並沒有多線程操作shared_ptr,所以沒有線程安全問題,自然也不用加鎖,這是Envoy實現比較巧妙的地方。

4-3.jpg

​ 如上圖所示,T1時刻,Thread1通過TLS對象訪問ThreadLocalObjectOld,在T2時刻在main線程發現配置發生了變化,重新構造了一個新的ThreadlocalObjectNew對象,然後通過Thread1的Dispatcher對象post了一個任務到Thread1線程,到了T3時刻這個任務開始執行,將對應的指針指向了 ThreadLocalObjectNew,最後在T4時刻再次訪問配置的時候,就已經訪問的是最新的配置了。到此為止就完成了一次配置更新,而且整個過程是線程安全的。

ThreadLocal

​ 終於到了分析真正的ThreadLocal對象的時候,它的功能其實很簡單,大部分的能力都是依賴Dispatcher、還有上文中提到的SlotImplThreadLocalData等,Instance是它的接口類,它繼承了SlotAllocator接口,也包含了上文中分析的allocateSlot方法。

class Instance : public SlotAllocator {
public:
  // 每啟動一個worker線程就需要通過這個方法進行註冊
  virtual void registerThread(Event::Dispatcher& dispatcher, bool main_thread) PURE;
  // 主線程在退出的時候調用,用於標記shutdown狀態
  virtual void shutdownGlobalThreading() PURE;
  // 每一個worker線程需要調用這個方法來釋放自己的TLS
  virtual void shutdownThread() PURE;
  virtual Event::Dispatcher& dispatcher() PURE;
};

​ 對應的實現是InstanceImpl對象,在Instance 的基礎上又擴展了一些post任務到所有線程的一些方法。


class InstanceImpl : public Instance {
 public:
    ....
 private:
  // post任務到所有註冊的線程中
  void runOnAllThreads(Event::PostCb cb);
  // post任務到所有註冊的線程中,完成後通過main_callback進行通知
  void runOnAllThreads(Event::PostCb cb, Event::PostCb main_callback);
  // 初始化TLS指向對應的數據對象指針
  static void setThreadLocal(uint32_t index, ThreadLocalObjectSharedPtr object);
  .....
  // 保存所有註冊的線程
  std::list<std::reference_wrapper<Event::Dispatcher>> registered_threads_;

​ 因為所有的線程都會註冊都InstanceImpl中,所以只需要遍歷所有的線程所對應的Dispatcher 對象,調用其post方法將任務投遞到對應線程即可,但是如何做到等所有任務執行完成後進行通知呢 ?

void InstanceImpl::runOnAllThreads(Event::PostCb cb, 
                                   Event::PostCb all_threads_complete_cb) {
  ASSERT(std::this_thread::get_id() == main_thread_id_);
  ASSERT(!shutdown_);
  // 首先在主線程執行任務
  cb();
  // 利用了shared_ptr自定義析構函數,在析構的時候向主線程post一個完成的通知任務
  // 這個機制和Bookkeeper的實現機制是一樣的。
  std::shared_ptr<Event::PostCb> cb_guard(new Event::PostCb(cb),
                   [this, all_threads_complete_cb](Event::PostCb* cb) {
                    main_thread_dispatcher_->post(all_threads_complete_cb);
                      delete cb; });

  for (Event::Dispatcher& dispatcher : registered_threads_) {
    dispatcher.post([cb_guard]() -> void { (*cb_guard)(); });
  }
}

​ 通過上面的代碼可以看到,這裡仍然利用到了shared_ptr的引用計數機制來實現的。每一個post到其他線程的任務都會導致cb_guard引用計數加1,post任務執行完成後cb_guard引用計數減1,等全部任務完成後,cb_guard 的引用計數就變成0了,這個時候就會執行自定義的刪除器,在刪除器中就會post一個任務到主線程中,從而實現了任務執行完成的通知回調機制。

​ 接下來我們來分析下shutdownGlobalThreading,這個函數是用於設置flag來表示正在關閉TLS,必須由主線程在其它worker線程退出之前來調用,調用完成後每一個worker線程還需要調用對應TLS的shutdownThread來清理TLS中的對象,到此為止才完成了全部的TLS清理工作。

void InstanceImpl::shutdownGlobalThreading() {
  ASSERT(std::this_thread::get_id() == main_thread_id_);
  ASSERT(!shutdown_);
  shutdown_ = true;
}

上面的代碼是shutdownGlobalThreading的實現,可以看到僅僅是設置了一個shutdown_的標誌。

​ 最後來分析一下shutdownThread,每一個work線程在退出事都需要調用這個函數,這個函數會將存儲的所有線程存儲的對象進行清除。每一個worker線程都持有InstanceImpl實例的引用,在析構的時候會調用shutdownThread來釋放自己線程的TLS內容,這個函數的實現如下:

void InstanceImpl::shutdownThread() {
  ASSERT(shutdown_);
  for (auto it = thread_local_data_.data_.rbegin(); 
       it != thread_local_data_.data_.rend(); ++it) {
    it->reset();
  }
  thread_local_data_.data_.clear();
}

​ 比較奇怪的點在於這裡是逆序遍歷所有的ThreadLocalObject對象來進行reset的,這是因為一些"持久"(活的比較長)的對象如ClusterManagerImpl很早就會創建ThreadLocalObject對象,但是直到shutdown的時候也不析構,而在此基礎上依賴ClusterManagerImpl的對象的如GrpcClientImpl等,則是後創建ThreadLocalObject對象,如果ClusterManagerImpl創建的ThreadLocalObject對象先析構,而GrpcClientImpl相關的ThreadLocalObject對象依賴了ClusterManagerImpl相關的TLS內容,那麼後析構就會導致未定義的問題。為此這裡選擇逆序來進行reset,先從一個高層的對象開始,最後才開始對一些基礎的對象所關聯的ThreadLocalObject進行reset。例如下面這個例子:

struct ThreadLocalPool : public ThreadLocal::ThreadLocalObject {
    .....
  InstanceImpl& parent_;
  Event::Dispatcher& dispatcher_;
  Upstream::ThreadLocalCluster* cluster_;
    .....
};

redis_proxy中定義了一個ThreadLocalPool,這個ThreadLocalPool又依賴較為基礎的ThreadLocalCluster(是ThreadLocalClusterManagerImpl的數據成員,也就是ClusterManagerImpl所對應的ThreadLocalObject對象),如果shutdownThread按照順序的方式析構的話,那麼ThreadLocalPool中使用的ThreadLocalCluster會先被析構,然後才是ThreadLocalPool的析構,而ThreadLocalPool析構的時候又會使用到ThreadLocalCluster,但是ThreadLocalCluster已經析構了,這個時候就會出現野指針的問題了。

ThreadLocalPool::ThreadLocalPool(InstanceImpl& parent, 
                                 Event::Dispatcher& dispatcher, const 
                                 std::string& cluster_name)
    : parent_(parent), dispatcher_(dispatcher), 
    cluster_(parent_.cm_.get(cluster_name)) {
  .....
  local_host_set_member_update_cb_handle_ = 
  cluster_->prioritySet().addMemberUpdateCb(
      [this](uint32_t, const std::vector<Upstream::HostSharedPtr>&,
             const std::vector<Upstream::HostSharedPtr>& hosts_removed) -> void {
        onHostsRemoved(hosts_removed);
      });
}

ThreadLocalPool::~ThreadLocalPool() {
  // local_host_set_member_update_cb_handle_是ThreadLocalCluster的一部分
  // ThreadLocalCluster析構會導致local_host_set_member_update_cb_handle_變成野指針
  local_host_set_member_update_cb_handle_->remove();
  while (!client_map_.empty()) {
    client_map_.begin()->second->redis_client_->close();
  }
}

​ 到此為止關於Envoy中的TLS實現就全部分析完畢了。

小結

​ 通過本節的分析相信我們應該足以駕馭Envoy中的ThreadLocal,從其設計可以看出它的一些其巧妙之處,比如抽象出一個Slot和對應的線程存儲進行了關聯,Slot可以任意傳遞,因為不包含實際的數據,拷貝的開銷很低,只包含了一個索引值,具體關聯的線程存儲數據是不知道的,避免直接暴露給用戶背後的數據。而InstanceImpl對象則管理著所有Slot的分配和移除以及整個ThreadLocal對象的shutdown。還有引入的Bookkeeper機制也甚是巧妙,和Envoy源碼分析之Dispatcher機制一文中的DeferredDeletable機制有著異曲同工之妙,通過這個機制可以做到安全的析構SlotImpl對象

Leave a Reply

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