概述 在開發過程中,我們經常會遇到併發問題,解決併發問題通常的方法是加鎖保護,比如常用的spinlock,mutex或者rwlock,當然也可以採用無鎖編程,對實現要求就比較高了。對於任何一個共用變數,只要有讀寫併發,就需要加鎖保護,而讀寫併發通常就會面臨一個基本問題,寫阻塞讀,或則寫優先順序比較低, ...
概述
在開發過程中,我們經常會遇到併發問題,解決併發問題通常的方法是加鎖保護,比如常用的spinlock,mutex或者rwlock,當然也可以採用無鎖編程,對實現要求就比較高了。對於任何一個共用變數,只要有讀寫併發,就需要加鎖保護,而讀寫併發通常就會面臨一個基本問題,寫阻塞讀,或則寫優先順序比較低,就會出現寫餓死的現象。這些加鎖的方法可以歸類為悲觀鎖方法,今天介紹一種樂觀鎖機制來控制併發,每個線程通過線程局部變數緩存共用變數的副本,讀不加鎖,讀的時候如果感知到共用變數發生變化,再利用共用變數的最新值填充本地緩存;對於寫操作,則需要加鎖,通知所有線程局部變數發生變化。所以,簡單來說,就是讀不加鎖,讀寫不衝突,只有寫寫衝突。這個實現邏輯來源於Rocksdb的線程局部緩存實現,下麵詳細介紹Rocksdb的線程局部緩存ThreadLocalPtr的原理。
線程局部存儲(TLS)
簡單介紹下線程局部變數,線程局部變數就是每個線程有自己獨立的副本,各個線程對其修改相互不影響,雖然變數名相同,但存儲空間並沒有關係。一般在linux 下,我們可以通過以下三個函數來實現線程局部存儲創建,存取功能。
int pthread_key_create(pthread_key_t *key, void (*destr_function) (void*)), int pthread_setspecific(pthread_key_t key, const void *pointer) , void * pthread_getspecific(pthread_key_t key)
ThreadLocalPtr類
有時候,我們並不想要各個線程獨立的變數,我們仍然需要一個全局變數,線程局部變數只是作為全局變數的緩存,用以緩解併發。在RocksDB中ThreadLocalPtr這個類就是來乾這個事情的。ThreadLocalPtr類包含三個內部類,ThreadLocalPtr::StaticMeta,ThreadLocalPtr::ThreadData和ThreadLocalPtr::Entry。其中StaticMeta是一個單例,管理所有的ThreadLocalPtr對象,我們可以簡單認為一個ThreadLocalPtr對象,就是一個線程局部存儲(ThreadLocalStorage)。但實際上,全局我們只定義了一個線程局部變數,從StaticMeta構造函數可見一斑。那麼全局需要多個線程局部緩存怎麼辦,實際上是在局部存儲空間做文章,線程局部變數實際存儲的是ThreadData對象的指針,而ThreadData裡面包含一個數組,每個ThreadLocalPtr對象有一個獨立的id,在其中占有一個獨立空間。獲取某個變數局部緩存時,傳入分配的id即可,每個Entry中ptr指針就是對應變數的指針。
ThreadLocalPtr::StaticMeta::StaticMeta() : next_instance_id_(0), head_(this) { if (pthread_key_create(&pthread_key_, &OnThreadExit) != 0) { abort(); } ...... } void* ThreadLocalPtr::StaticMeta::Get(uint32_t id) const { auto* tls = GetThreadLocal(); return tls->entries[id].ptr.load(std::memory_order_acquire); } struct Entry { Entry() : ptr(nullptr) {} Entry(const Entry& e) : ptr(e.ptr.load(std::memory_order_relaxed)) {} std::atomic<void*> ptr; };
整體結構如下:每個線程有一個線程局部變數ThreadData,裡面包含了一組ThreadLocalPtr的指針,對應的是多個變數,同時ThreadData之間相互通過指針串聯起來,這個非常重要,因為執行寫操作時,寫線程需要修改所有thread的局部緩存值來通知共用變數發生變化了。
--------------------------------------------------- | | instance 1 | instance 2 | instnace 3 | --------------------------------------------------- | thread 1 | void* | void* | void* | <- ThreadData --------------------------------------------------- | thread 2 | void* | void* | void* | <- ThreadData --------------------------------------------------- | thread 3 | void* | void* | void* | <- ThreadData struct ThreadData { explicit ThreadData(ThreadLocalPtr::StaticMeta* _inst) : entries(), inst(_inst) {} std::vector<Entry> entries; ThreadData* next; ThreadData* prev; ThreadLocalPtr::StaticMeta* inst; };
讀寫無併發衝突
現在說到最核心的問題,我們如何實現利用TLS來實現本地局部緩存,做到讀不上鎖,讀寫無併發衝突。讀、寫邏輯和併發控制主要通過ThreadLocalPtr中通過3個關鍵介面Swap,CompareAndSwap和Scrape實現。對於ThreadLocalPtr< Type* > 變數來說,在具體的線程局部存儲中,會保存3中不同類型的值:
1). 正常的Type* 類型指針;
2). 一個Type*類型的Dummy變數,記為InUse;
3). nullptr值,記為obsolote;
讀線程通過Swap介面來獲取變數內容,寫線程則通過Scrape介面,遍歷並重置所有ThreadData為(obsolote)nullptr,達到通知其他線程局部緩存失效的目的。下次讀線程再讀取時,發現獲取的指針為nullptr,就需要重新構造局部緩存。
//獲取某個id對應的局部緩存內容,每個ThreadLocalPtr對象有單獨一個id,通過單例StaticMeta對象管理。 void* ThreadLocalPtr::StaticMeta::Swap(uint32_t id, void* ptr) { //獲取本地局部緩存 auto* tls = GetThreadLocal(); return tls->entries[id].ptr.exchange(ptr, std::memory_order_acquire); } bool ThreadLocalPtr::StaticMeta::CompareAndSwap(uint32_t id, void* ptr, void*& expected) { //獲取本地局部緩存 auto* tls = GetThreadLocal(); return tls->entries[id].ptr.compare_exchange_strong( expected, ptr, std::memory_order_release, std::memory_order_relaxed); } //將所有管理的對象指針設置為nullptr,將過期的指針返回,供上層釋放, //下次進行從局部線程棧獲取時,發現內容為nullptr,則重新申請對象。 void ThreadLocalPtr::StaticMeta::Scrape(uint32_t id, std::vector<void*>* ptrs, void* const replacement) { MutexLock l(Mutex()); for (ThreadData* t = head_.next; t != &head_; t = t->next) { if (id < t->entries.size()) { void* ptr = t->entries[id].ptr.exchange(replacement, std::memory_order_acquire); if (ptr != nullptr) { //搜集各個線程緩存,進行解引用,必要時釋放記憶體 ptrs->push_back(ptr); } } } } //初始化,或者被替換為nullptr後,說明緩存對象已經過期,需要重新申請。 ThreadData* ThreadLocalPtr::StaticMeta::GetThreadLocal() { 申請線程局部的ThreadData對象,通過StaticMeta對象管理成一個雙向鏈表,每個instance對象管理一組線程局部對象。 if (UNLIKELY(tls_ == nullptr)) { auto* inst = Instance(); tls_ = new ThreadData(inst); { // Register it in the global chain, needs to be done before thread exit // handler registration MutexLock l(Mutex()); inst->AddThreadData(tls_); } return tls_; } }
讀操作包括兩部分,Get和Release,這裡面除了從TLS中獲取緩存,還涉及到一個釋放舊對象記憶體的問題。Get時,利用InUse對象替換TLS對象,Release時再將TLS對象替換回去,讀寫沒有併發的場景比較簡單,如下圖,其中TLS Object代表本地線程局部緩存,GlobalObject是全局共用變數,對所有線程可見。
下麵我們再看看讀寫有併發的場景,讀線程讀到TLS object後,寫線程修改了全局對象,並且遍歷對所有的TLS object進行修改,設置nullptr。在此之後,讀線程進行Release時,compareAndSwap失敗,感知到使用的object已經過期,執行解引用,必要時釋放記憶體。當下次再次Get object時,發現TLS object為nullptr,就會使用當前最新的object,併在使用完成後,Release階段將object填回到TLS。
應用場景
從前面的分析來看,TLS作為cache,仍然需要一個全局變數,全局變數保持最新值,而TLS則可能存在滯後,這就要求我們的使用場景不要求讀寫要實時嚴格一致,或者能容忍多版本。全局變數和局部緩存有交互,交互邏輯是,全局變數變化後,局部線程要能及時感知到,但不需要實時。允許讀寫併發,即允許讀的時候,使用舊值讀,待下次讀的時候,再獲取到新值。Rocksdb中的superversion管理則符合這種使用場景,swich/flush/compaction會產生新的superversion,讀寫數據時,則需要讀supversion。往往讀寫等前臺操作相對於switch/flush/compaction更頻繁,所以讀superversion比寫superversion比例更高,而且允許系統中同時存留多個superversion。
每個線程可以拿superversion進行讀寫,若此時併發有flush/compaction產生,會導致superversion發生變化,只要後續再次讀取superversion時,能獲取到最新即可。細節上來說,擴展到應用場景,一般在讀場景下,我們需要獲取snapshot,並藉助superversion信息來確認這次讀取要讀哪些物理介質(mem,imm,L0,L1...LN)。
1).獲取snapshot後,拿superversion之前,其它線程做了flush/compaction導致superversion變化
這種情況下,可以拿到最新的superversion。
2).獲取snapshot後,拿superversion之後,其它線程做了flush/compaction導致superversion變化
這種情況下,雖然superversion比較舊,但是依然包含了所有snapshot需要的數據。那麼為什麼需要及時獲取最新的superversion,這裡主要是為了回收廢棄的sst文件和memtable,提高記憶體和存儲空間利用率。
總結
RocksDB的線程局部緩存是一個很不錯的實現,用戶使用局部緩存可以大大降低讀寫併發衝突,尤其在讀遠大於寫的場景下,整個緩存維護代價也比較低,只有寫操作時才需要鎖保護。只要系統中允許共用變數的多版本存在,並且不要求實時保證一致,那麼線程局部緩存是提升併發性能的一個不錯的選擇。