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
指向時,都需要加鎖,因為shared_ptr
本身的讀寫時非線程安全的,而Envoy不需要加鎖。
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
對象