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_;
};

如上圖所示,每一個TLS通過指針指向實際的對象,每一個資料對象隻在記憶體中儲存一份,避免記憶體上的浪費,但是這樣帶來問題就是如何做到線程安全的通路資料對象呢? 當我們要通路資料對象的時候,如果此時正在對資料對象進行更新,這個時候就會存在一個線程安全的問題了。Envoy巧妙的通過在資料對象更新的時候,先構造出一個新的資料對象,然後将TLS中的資料對象指針指向新的資料對象來實作線程安全的通路。本質上和COW(copy-on-write)很類似,但是存在兩點差別。
- COW中是先拷貝原來的對象,然後更改對象,而Envoy在這裡是重新建構一個新的資料對象
- COW中無論是讀還是寫,在更改
指向時,都需要加鎖,因為shared_ptr
本身的讀寫時非線程安全的,而Envoy不需要加鎖。shared_ptr
Envoy中指向資料對象的
shared_ptr
并非隻有一個,而是每一個線程都有一個
shared_ptr
指向資料對象,更改
shared_ptr
指向新的資料對象時通過post一個任務到對應線程中,然後在同一個線程使
shared_ptr
指向新的資料對象,是以并沒有多線程操作
shared_ptr
,是以沒有線程安全問題,自然也不用加鎖,這是Envoy實作比較巧妙的地方。
如上圖所示,T1時刻,Thread1通過TLS對象通路
ThreadLocalObjectOld
,在T2時刻在main線程發現配置發生了變化,重新構造了一個新的
ThreadlocalObjectNew
對象,然後通過Thread1的
Dispatcher
對象post了一個任務到Thread1線程,到了T3時刻這個任務開始執行,将對應的指針指向了
ThreadLocalObjectNew
,最後在T4時刻再次通路配置的時候,就已經通路的是最新的配置了。到此為止就完成了一次配置更新,而且整個過程是線程安全的。
ThreadLocal
終于到了分析真正的ThreadLocal對象的時候,它的功能其實很簡單,大部分的能力都是依賴
Dispatcher
、還有上文中提到的
SlotImpl
、
ThreadLocalData
等,
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
對象