在服务端开发中,我们经常会碰到需要热加载的情况,需要在不影响持续请求的情况下更新数据,双buffer是很常见的一种手段,具体概念这里不过多展开,相关资料很多,在brpc里,有一个叫doublybuffereddata(以下简称dbd)的双buffer结构,利用thread local data来减少竞争,读和读之间没有竞争,只有写会和读发生竞争,brpc中的所有load balancer都使用了这个数据结构,适合读多写少的场景,官方文档描述的整体过程大概如下:
- 数据分前台和后台。
- 读拿到自己所在线程的thread-local锁,执行查询逻辑后释放锁。
- 同时只有一个写:修改后台数据,切换前后台,挨个获得所有thread-local锁并立刻释放,结束后再改一遍新后台(老前台)。
成员变量注释都已经说得比较明白了,双buffer结构少不了的肯定是前台后台两份数据和指向前台的index,_wrappers则是包含了所有的thread local数据,包括读锁和用户tls。
2.1 构造函数
构造函数主要是初始化互斥锁和tls key,并且由于数组并不会默认初始化元素,因此_data需要手动初始化,确保初始值符合预期。
2.2 析构函数
析构函数则是销毁相关互斥锁和删除tls key,并逐个删除各个线程创建的tls数据。
3.1 tls锁及用户数据wrapper
首先我们来看doublybuffereddatawrapperbase,这是个wrapper的基类,有两个模板参数,tls是用户自定义thread local数据的类型,下面那个是偏特化,供不需要自定义tls的时候匹配。
wrapper继承自doublybuffereddatawrapperbase,成员变量有两个,所属的doublybuffereddata指针_control和一个互斥锁_mutex。这个互斥锁在读的时候以及更新数据的时候都要用到。beginread()和endread()都是供读线程使用,beginread()在读数据之前被调用,endread()在scopedptr的析构函数里被调用,waitreaddone()则是在修改的时候被修改线程调用,这几个函数都是对锁的操作。
3.1 读辅助类scopedptr
scopedptr可以理解为一个带锁的指针,通过read函数读取数据后会把当前前台数据的指针和当前线程的wrapper指针保存到scopedptr里,可以通过它来获取数据和用户侧tls变量,并且通过wrapper进行同步,一旦使用scopedptr发起了读,只要scopedptr还存活,当前wrapper就是上锁的状态,因为解锁在析构函数里,这可以确保读取过程当前在读取的数据不被修改。
3.3 修改所用的functor
functor,也叫仿函数,是c 里常用的一种结构,简单来说可以理解为定义了()运算符的类,它除了有普通类的性质还可以像函数一样被调用。相比较于独立的函数,它最大的优势之一在于可以使用类变量来保存一些额外的信息,也就是带状态,所以也叫闭包。
closure1和closure2就是一个简单的调用封装,分别对应一个参数和两个参数。
withfg0~withfg3则是用于需要前台数据的修改,可以看到除了bg参数还有一个const t&类型的参数,也就是_data中另一个元素,这几个functor使用于参考前台数据修改后台数据,
4.1 直接读取函数
一个简单的根据当前index返回数据指针的读函数,用了acquire memory order和修改函数对index的修改的release order 配对。
4.2 wrapper增删函数
如果当前线程没有调用过read,需要添加wrapper,创建wrapper对象并将指针添加到wrapper列表。
removewrapper则是将wrapper从全局wrapper列表里移除,wrapper析构的时候会调用。
5.1 读函数
dbd的读基于scopedptr,读的时候首先去尝试获取tls的wrapper,如果没获取到,则需要新建并加入全局wrapper列表,获取到wrapper后,执行beginread加tls读锁,随后进行读取,read只会加锁不会释放锁,scopedptr销毁的时候才会释放。
5.2 写函数
dbd供外部调用的写函数分为两种,一种是直接修改,另一种是依赖前台数据(当前生效数据)的修改,各有3个,分别对应不带参数、带一个参数、带两个参数的,也所以dbd结构最多只支持两个参数的修改函数。
核心的修改逻辑在size_t doublybuffereddata
首先是获得修改锁,然后调用fn修改后台数据,如果修改失败就直接返回了。修改完之后反转index,这里用的是release内存序,可以保证读线程一旦读到了新的index,数据的修改也一定可见,在这个操作之后,所有的read获得的就是修改后的新前台数据了,然后依次调用所有wrapper的waitreaddone(),其实就是挨个每获取完一个锁就释放,确保在index反转之前的所有读取都已经结束,这个循环过后当前的后台数据就没有线程在用了,可以安全修改,再次调用fn。
一个参数和两个参数的重载均是通过定义functor来实现,从而屏蔽掉参数个数的diff,实际均是由第一个modify函数处理。
modifywithforeground系列函数和modify系列功能上的区别在于额外给用户修改函数传了一个当前的前台数据,供需要依赖当前前台数据的修改操作使用。
1.修改没有使用std::function之类的新特性应该是考虑到兼容性。
2.如果先执行读后执行修改要小心死锁,因为scopedptr在读之后只要还在生命周期锁就不会释放,此时进行修改就会死锁。
3.tls锁存在的主要意义是前后台数据切换后能比较迅速地安全修改后台数据(没人在继续读已经切到后台的数据),另外一种常见方案是没有锁,sleep一段时间再修改,确保新后台不再被检索线程访问。
4.比较适合读取速度快的场景,如果读取时间很长修改很容易被阻塞。