RocksDB線程局部緩存


概述

      在開發過程中,我們經常會遇到並發問題,解決並發問題通常的方法是加鎖保護,比如常用的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的線程局部緩存是一個很不錯的實現,用戶使用局部緩存可以大大降低讀寫並發沖突,尤其在讀遠大於寫的場景下,整個緩存維護代價也比較低,只有寫操作時才需要鎖保護。只要系統中允許共享變量的多版本存在,並且不要求實時保證一致,那么線程局部緩存是提升並發性能的一個不錯的選擇。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM