前言
RocksDB被广泛应用在各种高性能场景中,如何能让向上层提供的接口拥有更小的延迟,是RocksDB一直追求的目标之一。对于一个系统来说,暂时忽略长尾场景,将critical path的时延降低是获取整个系统低延迟的重要手段。
RocksDB使用Version系统用于维护当前DB的状态信息,SuperVersion作为Version系统的重要模块之一,需要被后台线程(compaction/flush)更新和前台用户线程读取,多线程访问形成了竞态条件,为了降低前台线程的访问延迟,RocksDB对SuperVersion模块使用TLS(Thread-Local-Storage)机制。
本文首先简要介绍TLS的两种实现方式,然后介绍SuperVersion是什么以及RocksDB对其的访问模式,接着会讲述RocksDB对SuperVersion设计的并发访问协议。因为OS和编译器提供的TLS实现不够灵活,本文还会讲述RocksDB如何封装自身的ThreadLocalPtr实现,最后我会给出一点自己关于RocksDB并发访问协议的想法和问题。
TLS
TLS全称为Thread-Local-Storage,也就是线程私有存储,这个概念广为人知。POSIX系统提供了TLS变量的创建接口,另外编译器也有TLS变量的实现机制,对于GCC编译器来说通过__thread关键词可以声明一个TLS变量。
下面是POSIX系统接口的TLS实现:
// 创建一个thread-local变量,并且可以选择注册一个析构函数。当线程退出时,如果
// key对应的value不为nullptr,则会将value作为地址传入析构函数。
int pthread_key_create(pthread_key_t *keyp, void (*destructor)(void *));
// 将当前线程的thread-local变量key的值设置为value
int pthread_setspecific(pthread_key_t key, const void *value);
// 获取thread-local变量key的值
void *pthread_getspecific(pthread_key_t key);
RocksDB ThreadLocalPtr实现
以上说的两种TLS使用起来有不便之处,比如如果需要多个TLS变量,那么就需要定义多个TLS全局变量,如果一些TLS变量是运行时才确定的,那单靠这两种机制是无法做到的,另外有一种场景是RocksDB所需要的:所有线程的TLS变量可以被一个线程清空或者重置,靠这两种机制也无法实现。RocksDB在这两种TLS实现机制上,提供了更灵活的TLS实现。ThreadLocalPtr类就是RocksDB提供的TLS实现接口,使用的方法很简单:
// 定义一个tls变量
ThreadLocalPtr tls;
// 写入数据
// thread1
tls.Reset(reinterpret_cast<int*>(1))
// thread2
tls.Reset(reinterpret_cast<int*>(2))
// 获取数据
// thread1
tls.Get() == reinterpret_cast<int*>(1)
// thread2
tls.Get() == reinterpret_cast<int*>(2)
// 将所有线程存储的tls变量置为nullptr
tls.Scrape(&ptrs, nullptr);
在RocksDB的codebase中,每次定义个ThreadLocalPtr对象,就可以将其看成定义了一个TLS变量,但事实上所有的ThreadLocalPtr对象都指向了唯一一个TLS变量:
class ThreadLocalPtr::StaticMeta {
...
static __thread ThreadData* tls_;
...
}
__thread ThreadData* ThreadLocalPtr::StaticMeta::tls_ = nullptr;
ThreadLocalPtr::StaticMeta是一个单例对象。该对象的成员tls_是一个TLS变量,指向ThreadData类型。所有定义的ThreadLocalPtr对象都挂载在了tls_变量上。那么如何做到这一点呢:
struct ThreadData {
explicit ThreadData(ThreadLocalPtr::StaticMeta* _inst)
: entries(),
next(nullptr),
prev(nullptr),
inst(_inst) {}
std::vector<Entry> entries;
ThreadData* next;
ThreadData* prev;
ThreadLocalPtr::StaticMeta* inst;
}
ThreadData对象用于一个元素类型为Entry的vector,每次定义的ThreadLocalPtr对象都有一个id,该id对应于entries的索引,所以当需要获取一个ThreadLocalPtr的数据时,只需要通过ThreadData::entries[id]获取。 整体来说,每个线程有一个线程局部变量ThreadData,里面包含了一组指针,用于指向不同ThreadLocalPtr对象指向的数据。另外,ThreadData之间相互通过指针串联起来,所有线程的TLS变量可以被一个线程清空或者重置,达到通知业务线程缓冲失效的效果。
---------------------------------------------------
| | instance 1 | instance 2 | instnace 3 |
---------------------------------------------------
| thread 1 | void* | void* | void* | <- ThreadData
---------------------------------------------------
| thread 2 | void* | void* | void* | <- ThreadData
---------------------------------------------------
| thread 3 | void* | void* | void* | <- ThreadData
SuperVersion
RocksDB使用Version系统来管理自身的状态信息。比如当前系统管理了哪些列族,这些列族的memtable,immutalbe,SST磁盘文件信息等等。SuperVersion是RocksDB Version系统的重要模块之一,它记录了一个cf当前的状态信息。用户的前台线程通过SuperVersion获取必要的信息(memtable/immutable/sst等)完成数据的检索和更新。随着用户增删改查的进行,RocksDB会在后台做compaction和flush操作,这些操作会更改SuperVersion的信息,使之从一个状态转换成另一个状态。我们把用户的读操作看做前台的reader,更新cf的SuperVersion操作看做是后台的writer,那么reader和writer之间形成了一个临界区域。下图简单描述了RocksDB的Version体系,以及SuperVersion在Version系统中所处的位置。
与SuperVersion有关的几个重要函数
InstallSuperVersion用于在改变cf的状态后(比如发生了flush或者compaciton)向cf注册一个新的SuperVersion对象,一般由compaction流程和flush流程调用。下面是一个SuperVersion被构建并且被Install的调用链路。
- BackgroundCallFlush
- BackgroundFlush。构建SuperVersionContext对象,并构建空的SuperVersion对象。
- FlushMemTablesToOutputFiles
- FlushMemTableToOutputFile
- InstallSuperVersionAndScheduleWork
- InstallSuperVersion
InstallSuperVersion除了更新全局变量记录的superversion外,还会调用ResetThreadLocalSuperVersions将所有线程TLS变量记录的缓冲标识过期。标识过期的策略很简单,只是将所有线程的TLS变量记录的sv,更改为SuperVersion::kSVObsolete。
void ColumnFamilyData::InstallSuperVersion(
SuperVersionContext* sv_context, InstrumentedMutex* db_mutex,
const MutableCFOptions& mutable_cf_options) {
GetReferencedSuperVersion用于获取当前cf的SuperVersion,其先从tls中获取缓冲的sv,如果获取到的sv已经过期了,退化成加锁获取。注意从tls中获取缓冲sv时,还要将tls置为kSVInUse,以便告知writer。
SuperVersion* ColumnFamilyData::GetReferencedSuperVersion(DBImpl* db)
GetReferencedSuperVersion在获取tls变量时候,将其置为了kSVInUse,ReturnThreadLocalSuperVersion将更新本线程缓冲的信息。
bool ColumnFamilyData::ReturnThreadLocalSuperVersion(SuperVersion* sv)
SuperVersion读写并发协议
上文说到RockDB的前台线程(用户读操作)与后台线程(compaction/flush)都需要访问cf的SuperVersion变量,并且后台线程更新它,前台线程读取它。我们的需求是希望前台线程因为访问SuperVersion变量造成的时延越低越好,另外并不要求对于SuperVersion的读写严格线性,也就是说对SuperVersion来说,读操作不必阻塞写操作,写操作也不必阻塞读操作。 对于这种场景最简单的方式就是持有一把SuperVersion的锁,每次访问都加锁,这种做法可以满足正确性,但是这种方法带来的锁开销太高,前台线程一定会因为上锁而造成时延增大。为了避免这个问题,RocksDB使用TLS变量来实现SuperVersion读写并发。其整体的做法是这样的,使用一个全局变量记录cf当前最新的SuperVersion变量,然后使用TLS变量为每个线程缓冲全局变量的副本,如果全局变量没有变化,那么每次reader线程直接读取本线程的TLS变量记录的SuperVersion即可。如果发生了状态变化,后台线程会更新全局变量,并将所有线程的TLS变量置为过期,reader再读取本地的TLS变量时,如果发现了过期会重新从全局变量读取最新的SuperVersion并将其更新到本线程的TLS。
下图是后台线程与SuperVersion交互的时序图:
reader与与SuperVersion交互的时序图:
reader持有的TLS变量过期重新加载全局数据的时序图:
reader在执行过程中TLS变量过期的时序图:
一些想法和问题
因为对SuperVersion的访问处于critical path上,所以RocksDB这实现这一块的代码时才如此大费周章。针对如何实现SuperVersion的并发协议,我个人一直在思考有没有更简单的方法。
想法1:直接用shared_ptr
因为并不要求读写严格线性,那么writer无脑更新全局的shared_ptr
想法2:shared_ptr+原子读写 如果writer和reader可以原子读写shared_ptr的内容,那么一切就会很好。但是目前C++似乎是难以支持这一特性的。
还有一些问题需要澄清:
- 操作系统是如何实现threadlocal功能的
- C++11提供的thread_local关键词是否和__thread关键词一样
问题
- 操作系统是如何实现threadlocal功能的
- 编译器也提供了__thread关键词用于声明一个threadlocal变量,那和操作系统的有什么关系
- C++11提供的thread_local关键词是否和__thread关键词一样,如果是的话,那么可以重构rocksdb的代码吗