C++中的RingBuffer,双Buffer,RCU

RingBuffer

ringbuffer适用于单生产者,单消费者的场景,虽然是两个线程,但是却不用加锁,可以用数组或者链表实现,以下介绍一种典型的数组实现方法,注意整个Buffer的大小是预先要定好的。

如图,N = 5,但是要区分满和空,所以实际存储的大小要比N小1,也就是只能存储4个元素。write和read是两个指针,也可以看成是序号,都是指向下一个要写或者要读的位置!注意这一点很关键,是下一次要读/写的位置!

write和read实际中只会++,但是数组的序号只能[0, 4]范围,所以寻址时:read % N , write % N。

判断Buffer是否是空: if (write - read) % N == 0, 判断Buffer是否是满: if (write - read + 1) % N == 0

注意:这里非常巧妙的用少一个元素的状态判断是否满,如果不这样,也就是如果认为Buffer可以写入N个元素,则当read == write时,无法判断当前是空还是满,必须要借助各种其他辅助变量判断,较为复杂。

写入一个元素: if(Buffer满)return; buffer[write % N] = val; write++;这个顺序至关重要,先赋值,再增加index

读取一个元素: if (Buffer空)return; val = buff[read % N]; read++; 先取,再增加index

注意:这里写入和读取可以一次不止一个元素,如果判断buffer没有满,则写入从write到read-1都是安全的,因为即使此时有人要去read,那也是read完之后,再增加read index, 而且read最多也只到write-1,所以从write到read-1都是安全的,如果buffer不是空,则读取从read 到write-1都是安全的

用write和read两个序号保证线程安全,所以这两个序号得是原子的std::atomic。

所以这种RingBuffer,用简单的两个原子变量作为“屏障”,保证了单读单写下的线程安全,可谓精妙至极。

双Buffer

RingBuffer适合于单生产者单消费者,那如果是多读一写,能不能也做成无锁呢?可以!用双Buffer。

双buffer也能实现无锁,关键是借助于shared_ptr的use_count(),shared_ptr的use_count的增加减少和获取,都是原子的,所以,虽然对shared_ptr做swap, reset等操作不是线程安全的,但是count却是线程安全的。

考虑用两个buffer,一个buffer供多线程读,另一个buffer是给写用的,写完之后,交换两个buffer的index,下次再来读的任务时,就去读新的buffer了。

关键就在于,如图3,写更新buffer时,如何知道该buffer还有没有人在读,因为有可能有的读任务比较慢,下次开始读的时候,还在操作老buffer。不解决这个问题,这个方案就没有任何意义了

所以这种双buffer的实现,一定是借助于share_ptr实现,并且为了更小的交换开销,用index作为标志,所以定义一个两个元素的数组,数组中是两个buffer的shared_ptr:

std::shared_ptr<T> arr[2];

有一个index表示读buffer的index, 默认设为0:std::atomic_int readIndex{0}。

注意,这里一定要是原子的,每次的写bufferindex是 1 - readIndex, 而交换的过程为:readIndex = 1 -readIndex,这样可以最大限度的减少开销。

读的代码如下:

{

auto sp = arr[readIndex];

read from sp...; //从sp读取数据并处理

}

写的代码如下:

{

int writeIndex = 1 - readIndex;

// 自旋锁,判断写buffer能否被写入(有没有其他读线程占用)

while(arr[writeIndex].use_count() != 1)

Sleep(1);

write to arr[writeIndex]... // 写入写buffer

readIndex = 1 -readIndex; // 交换两个buffer

Sleep(1); // 高能,后面介绍

}

重点看一下写的代码,首先通过自旋判断写buffer能否被写入,这一步相当于也是"锁"了,只不过这种比mutex直接加锁效率高。

最后有个Sleep(1),这个是什么作用呢?

原来,因为shared_ptr的赋值并不是原子的,也就是读代码的第一行: auto sp = arr[readIndex];,这一行代码先赋值裸指针,再增加arr的引用计数,那么有极低的概率写线程while(arr[writeIndex].use_count() != 1)可能拿到的是1, 就往下走了,紧接着该内存use_count才被读线程增加1,出现读写冲突。

Sleep(1)的作用就是,如果两次写入无间隔,那么会有概率出现这种情况,sleep(1)后,这种情况出现的可能性为0了,因为这个赋值不操作外设,也没有锁,1ms内绝对可以执行完。

通过测试,如果用普通的lock_guard<std::mutex> 需要耗时13s, 如果用std::shard_mutex读写锁,耗时2.7s,如果用这种无锁双buffer,耗时1.7s,是多读一写场景下的最佳方案。

RCU

Read-Copy Update是指读取的时候不加锁,可以多读,写的时候先拷贝一个副本,改好之后,再更新过去,个人感觉如果是多读一写,还是双buffer效率更高。

不过RCU提供了一种编程思路,比如有一个公共的STL: std::vector<T> vec;

这个vec本身数据量不大,拷贝的开销小,但是有多个线程读取或者写入,一种用读写锁的写法是:

void read(){

std::shared_lock;//加读锁

// 读vec;

}

void write(){

std::unique_lock;//加写锁

// 写vec;

}

还有一种就是借鉴RCU的思路,只对set和get加锁:

std::vector<T> Get(){

std::shared_lock;//加读锁

return vec;

}

void Set(const std::vector<T>& newval){

std::shared_lock;//加写锁

Vec = newval;

}

然后用的时候:void read(){

auto v = Get();

// 读v

}

void write(){

auto v = Get();

// 写v

Set(v); // 写完v后,再更新回去

}

这样多了几次拷贝,但是减少了锁的作用域,而且这样做的前提是,拷贝的开销要远远小于操作的开销,如果拷贝的成本本身很大,那这样做也没有意义了。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值