在开发过程当中,咱们常常会遇到并发问题,解决并发问题一般的方法是加锁保护,好比经常使用的spinlock,mutex或者rwlock,固然也能够采用无锁编程,对实现要求就比较高了。对于任何一个共享变量,只要有读写并发,就须要加锁保护,而读写并发一般就会面临一个基本问题,写阻塞读,或则写优先级比较低,就会出现写饿死的现象。这些加锁的方法能够归类为悲观锁方法,今天介绍一种乐观锁机制来控制并发,每一个线程经过线程局部变量缓存共享变量的副本,读不加锁,读的时候若是感知到共享变量发生变化,再利用共享变量的最新值填充本地缓存;对于写操做,则须要加锁,通知全部线程局部变量发生变化。因此,简单来讲,就是读不加锁,读写不冲突,只有写写冲突。这个实现逻辑来源于Rocksdb的线程局部缓存实现,下面详细介绍Rocksdb的线程局部缓存ThreadLocalPtr的原理。linux
简单介绍下线程局部变量,线程局部变量就是每一个线程有本身独立的副本,各个线程对其修改相互不影响,虽然变量名相同,但存储空间并无关系。通常在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)
有时候,咱们并不想要各个线程独立的变量,咱们仍然须要一个全局变量,线程局部变量只是做为全局变量的缓存,用以缓解并发。在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的线程局部缓存是一个很不错的实现,用户使用局部缓存能够大大下降读写并发冲突,尤为在读远大于写的场景下,整个缓存维护代价也比较低,只有写操做时才须要锁保护。只要系统中容许共享变量的多版本存在,而且不要求实时保证一致,那么线程局部缓存是提高并发性能的一个不错的选择。