很久没写笔记了,感觉自己都躺平了,最近在实现一个模块,特性是读请求的占比会比写请求高很多,简而言之呢就是读多写少的场景。
解决方案其实很多,最简单的,直接加一把锁,但是无论是普通锁还是读写锁,万一有写请求都会对即将来到的读请求造成影响,假设是一个性能不敏感的模块,那么其实无所谓,这样实现起来最简单,而且最不容易出错,能交差就得了。
但是在性能敏感的场景,如何去处理这种问题呢。
之前做win32
编程的时候,咱们知道往一个设备上下文绘图,假设不进行任何的处理,那么画图的性能是不高的,容易出现肉眼可见的刷新过程,而解决这个办法的问题,是创建一个内存中的设备上下文,将绘图操作都先在内存中完成,然后一次性的拷贝到实际的设备上下文,这样能大大提升绘图的性能和效果。
所以我们能不能参照这个原理,存放两份数据,写操作只在后台操作,从而实现类似于读写分离的效果,当没有正在进行中的读操作了,再将新数据换到前台呢(很像copy on write)。
其实开源代码也有相应的实现了,直接拿来用即可。但是用归用,不了解里面的原理还是没办法学到东西的,我们就简单的来记录一下brpc
库中实现的该类型的数据结构是如何实现的。
首先,大致的把核心的操作流程写一下。
首先我们理一下数据结构,由于内部实现使用了thread local
来使得每个线程都有独立的读锁,所以我们这里就简化为每个读线程都会有独立的读锁,重点是独立的,所以对于读线程来说它们并不会互斥,是一种并行的读状态,所以性能会很高。该读锁理论上只对写操作互斥,具体后面分析。
除了每个读操作持有的读锁之外,还有一个实际的内容存储的数据结构,该结构是用户指定的存储类型,类似于stl
中的各种容器可以存储各种类型。该数据结构也很简单,假设模版的typename
为T
,则只要定义为:T _data[2]
即可。数量为2表示一个前台一个后台。
为了辨别当前活动的前台,还会有一个原子变量_index
,它的值表明了当前读线程应该读取哪一份数据,也就是当前哪一份数据是属于前台数据,另一份就属于后台数据了。
首先我们来看读操作的实现:
template <typename T, typename TLS>
int DoublyBufferedData<T, TLS>::Read(
typename DoublyBufferedData<T, TLS>::ScopedPtr* ptr) {
if (BAIDU_UNLIKELY(!_created_key)) {
return -1;
}
Wrapper* w = static_cast<Wrapper*>(pthread_getspecific(_wrapper_key));
if (BAIDU_LIKELY(w != NULL)) {
w->BeginRead();
ptr->_data = UnsafeRead();
ptr->_w = w;
return 0;
}
w = AddWrapper();
if (BAIDU_LIKELY(w != NULL)) {
const int rc = pthread_setspecific(_wrapper_key, w);
if (rc == 0) {
w->BeginRead();
ptr->_data = UnsafeRead();
ptr->_w = w;
return 0;
}
}
return -1;
}
其实是一个很清晰的实现,但是这里设计到了另一个数据结构ScopePtr
,其实这个很简单,只要看作是一个类似于LockGuard
即可,负责在析构到时候释放读锁。这个其实是一个很重要的设计,我们可以理解为读操作会将数据存放于该数据结构内返回用户,而当该结构被销毁的时候,写操作才会进行,它保证了读操作读取到的数据结构不会被写操作改写而造成竞态。
在读操作里,首先会获取当前线程的读锁,假设存在,则直接加锁,并读取当前前台的数据后返回;不存在其实也一样,只是多了一个创建读锁的过程。所以要注意的是,在读取操作中,假设外部持有ScopePtr
,则读锁不会释放,它保证了前台数据的一致性。至于何时释放,则由用户自己控制,但是也需要注意,在释放之后不能再引用返回的数据了,不然会有问题。
相对而言,写操作会复杂一点,我们直接贴实现:
template <typename T, typename TLS>
template <typename Fn>
size_t DoublyBufferedData<T, TLS>::Modify(Fn& fn) {
// _modify_mutex sequences modifications. Using a separate mutex rather
// than _wrappers_mutex is to avoid blocking threads calling
// AddWrapper() or RemoveWrapper() too long. Most of the time, modifications
// are done by one thread, contention should be negligible.
BAIDU_SCOPED_LOCK(_modify_mutex);
int bg_index = !_index.load(butil::memory_order_relaxed);
// background instance is not accessed by other threads, being safe to
// modify.
const size_t ret = fn(_data[bg_index]);
if (!ret) {
return 0;
}
// Publish, flip background and foreground.
// The release fence matches with the acquire fence in UnsafeRead() to
// make readers which just begin to read the new foreground instance see
// all changes made in fn.
_index.store(bg_index, butil::memory_order_release);
bg_index = !bg_index;
// Wait until all threads finishes current reading. When they begin next
// read, they should see updated _index.
{
BAIDU_SCOPED_LOCK(_wrappers_mutex);
for (size_t i = 0; i < _wrappers.size(); ++i) {
_wrappers[i]->WaitReadDone();
}
}
const size_t ret2 = fn(_data[bg_index]);
CHECK_EQ(ret2, ret) << "index=" << _index.load(butil::memory_order_relaxed);
return ret2;
}
参数的Fn
就不解释了,可以理解为一个lambda
表达式或者各种可以有operator ()
的集合,包括各种对象,函数等等。
写操作的第一步,就是上锁,该锁是所有写操作共享的锁,所有的写都会互斥强制的串行化。简单理解就是同一个时刻只能有一个写操作,考虑到该锁的使用场景,其实没啥问题。
加锁成功之后,就可以获得后台数据的索引了,然后直接修改后台数据即可,因为后台数据只有写线程访问,并且写互斥。
数据修改之后,我们就可以将前后台互换了,这个只是一个原子操作,但是需要注意内存序,不注意的话可能在某些架构情况下,别的线程(核心)看不见修改过后的值。经过此操作之后,除了正在读取的读线程读取到的是旧数据,其它都能访问到新的前台数据。
最后一步,就是将在发布之前读操作读取的内容也做修改。为了防止数据冲突,我们需要等待所有的读锁释放,最后再次更新即可。该操作是安全的,因为在读锁释放之后,新的读操作将会读取新的前台数据,而旧前台数据将会被安全的独享的被写操作修改。经过修改之后,前后台的数据就一致了。