无锁编程实践:RocksDB ThreadLocalPtr剖析

2021.08.10

前言

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系统中所处的位置。

1710bab2f318fa026ef156575524f111.png

与SuperVersion有关的几个重要函数

InstallSuperVersion用于在改变cf的状态后(比如发生了flush或者compaciton)向cf注册一个新的SuperVersion对象,一般由compaction流程和flush流程调用。下面是一个SuperVersion被构建并且被Install的调用链路。

  1. BackgroundCallFlush
  2. BackgroundFlush。构建SuperVersionContext对象,并构建空的SuperVersion对象。
  3. FlushMemTablesToOutputFiles
  4. FlushMemTableToOutputFile
  5. InstallSuperVersionAndScheduleWork
  6. 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交互的时序图:

8b03f6409d2c8f7f6add34d32a0c5936.png

reader与与SuperVersion交互的时序图:

3c55d94e849658581fff2c39bdd72d62.png

reader持有的TLS变量过期重新加载全局数据的时序图:

e0314224daa3645e1f2e1e56aed33fbd.png

reader在执行过程中TLS变量过期的时序图:

1e09f86a148e4c832349aab4a1e9bf64.png

一些想法和问题

因为对SuperVersion的访问处于critical path上,所以RocksDB这实现这一块的代码时才如此大费周章。针对如何实现SuperVersion的并发协议,我个人一直在思考有没有更简单的方法。

想法1:直接用shared_ptr 因为并不要求读写严格线性,那么writer无脑更新全局的shared_ptr就好,其他线程可以自由获取全局的shared_ptr,但是writer更新全局的shared_ptr的这个操作并不是原子的,这回造成问题。

想法2:shared_ptr+原子读写 如果writer和reader可以原子读写shared_ptr的内容,那么一切就会很好。但是目前C++似乎是难以支持这一特性的。

还有一些问题需要澄清:

  1. 操作系统是如何实现threadlocal功能的
  2. C++11提供的thread_local关键词是否和__thread关键词一样

问题

  1. 操作系统是如何实现threadlocal功能的
  2. 编译器也提供了__thread关键词用于声明一个threadlocal变量,那和操作系统的有什么关系
  3. C++11提供的thread_local关键词是否和__thread关键词一样,如果是的话,那么可以重构rocksdb的代码吗