RocksDB線程局部緩存

概述

      在開發過程當中,咱們常常會遇到併發問題,解決併發問題一般的方法是加鎖保護,好比經常使用的spinlock,mutex或者rwlock,固然也能夠採用無鎖編程,對實現要求就比較高了。對於任何一個共享變量,只要有讀寫併發,就須要加鎖保護,而讀寫併發一般就會面臨一個基本問題,寫阻塞讀,或則寫優先級比較低,就會出現寫餓死的現象。這些加鎖的方法能夠歸類爲悲觀鎖方法,今天介紹一種樂觀鎖機制來控制併發,每一個線程經過線程局部變量緩存共享變量的副本,讀不加鎖,讀的時候若是感知到共享變量發生變化,再利用共享變量的最新值填充本地緩存;對於寫操做,則須要加鎖,通知全部線程局部變量發生變化。因此,簡單來講,就是讀不加鎖,讀寫不衝突,只有寫寫衝突。這個實現邏輯來源於Rocksdb的線程局部緩存實現,下面詳細介紹Rocksdb的線程局部緩存ThreadLocalPtr的原理。linux

線程局部存儲(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;ui

讀線程經過Swap接口來獲取變量內容,寫線程則經過Scrape接口,遍歷並重置全部ThreadData爲(obsolote)nullptr,達到通知其餘線程局部緩存失效的目的。下次讀線程再讀取時,發現獲取的指針爲nullptr,就須要從新構造局部緩存。this

//獲取某個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是全局共享變量,對全部線程可見。atom

下面咱們再看看讀寫有併發的場景,讀線程讀到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的線程局部緩存是一個很不錯的實現,用戶使用局部緩存能夠大大下降讀寫併發衝突,尤爲在讀遠大於寫的場景下,整個緩存維護代價也比較低,只有寫操做時才須要鎖保護。只要系統中容許共享變量的多版本存在,而且不要求實時保證一致,那麼線程局部緩存是提高併發性能的一個不錯的選擇。

相關文章
相關標籤/搜索