很久没写笔记了,感觉自己都躺平了,最近在实现一个模块,特性是读请求的占比会比写请求高很多,简而言之呢就是读多写少的场景。

解决方案其实很多,最简单的,直接加一把锁,但是无论是普通锁还是读写锁,万一有写请求都会对即将来到的读请求造成影响,假设是一个性能不敏感的模块,那么其实无所谓,这样实现起来最简单,而且最不容易出错,能交差就得了。

但是在性能敏感的场景,如何去处理这种问题呢。

之前做win32编程的时候,咱们知道往一个设备上下文绘图,假设不进行任何的处理,那么画图的性能是不高的,容易出现肉眼可见的刷新过程,而解决这个办法的问题,是创建一个内存中的设备上下文,将绘图操作都先在内存中完成,然后一次性的拷贝到实际的设备上下文,这样能大大提升绘图的性能和效果。

所以我们能不能参照这个原理,存放两份数据,写操作只在后台操作,从而实现类似于读写分离的效果,当没有正在进行中的读操作了,再将新数据换到前台呢(很像copy on write)。

其实开源代码也有相应的实现了,直接拿来用即可。但是用归用,不了解里面的原理还是没办法学到东西的,我们就简单的来记录一下brpc库中实现的该类型的数据结构是如何实现的。

首先,大致的把核心的操作流程写一下。

  • 对于读操作,直接通过原子变量读取当前活动的数据即可
  • 对于写操作,我们需要对写操作进行互斥,并且经过变更新旧数据和原子变量来发布数据使得最新的数据对于读操作可见

首先我们理一下数据结构,由于内部实现使用了thread local来使得每个线程都有独立的读锁,所以我们这里就简化为每个读线程都会有独立的读锁,重点是独立的,所以对于读线程来说它们并不会互斥,是一种并行的读状态,所以性能会很高。该读锁理论上只对写操作互斥,具体后面分析。

除了每个读操作持有的读锁之外,还有一个实际的内容存储的数据结构,该结构是用户指定的存储类型,类似于stl中的各种容器可以存储各种类型。该数据结构也很简单,假设模版的typenameT,则只要定义为: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 ()的集合,包括各种对象,函数等等。

写操作的第一步,就是上锁,该锁是所有写操作共享的锁,所有的写都会互斥强制的串行化。简单理解就是同一个时刻只能有一个写操作,考虑到该锁的使用场景,其实没啥问题。

加锁成功之后,就可以获得后台数据的索引了,然后直接修改后台数据即可,因为后台数据只有写线程访问,并且写互斥。
数据修改之后,我们就可以将前后台互换了,这个只是一个原子操作,但是需要注意内存序,不注意的话可能在某些架构情况下,别的线程(核心)看不见修改过后的值。经过此操作之后,除了正在读取的读线程读取到的是旧数据,其它都能访问到新的前台数据。

最后一步,就是将在发布之前读操作读取的内容也做修改。为了防止数据冲突,我们需要等待所有的读锁释放,最后再次更新即可。该操作是安全的,因为在读锁释放之后,新的读操作将会读取新的前台数据,而旧前台数据将会被安全的独享的被写操作修改。经过修改之后,前后台的数据就一致了。

共 0 条回复
暂时没有人回复哦,赶紧抢沙发
发表新回复

作者

sryan
today is a good day