简介
前文介绍了如何通过内存顺序实现内存模型,本文基于前文的基础,利用内存顺序和内存模型的知识,带着大家探索无锁并发的应用,主要是通过无锁队列的实现来让大家熟悉无锁并发的实现方式。
环形队列
我们要实现无锁并发,经常会用到一种结构无锁队列,而无锁队列和我们经常使用的队列颇有不同,它采用的是环状的队列结构,为什么成环呢?主要有两个好处,一个是成环的队列大小是固定的,另外一个我们通过移动头和尾就能实现数据的插入和取出。
我们看下图是一个环形队列的基本结构
图1表示队列为空的时候,头节点和尾节点交会在一起,指向同一个扇区。
图2表示当我们你插入一个数字1后,队列大小为1,此时tail指针移动到下一个扇区,head指向头部,1被存储在头部了。
图3表示当我们将数字1出队后,head指针向后移动一个扇区,此时head和tail指向同一个扇区,表示队列又为空了。那有人会问队列中数字1为什么不清空呢?其实不用清空,因为当我们插入新数据时就可以覆盖掉1这个无效的数据。
比如我们继续3图,连续插入几个数字,将队列填满。
图4说明的就是当我们连续插入了几个数字,插入数据9的时候将原来1的数据覆盖了,所以环形队列删除数据的时候我们不用让数据出队,只要移动head指针即可。
另外我们从图4也能看出,此时tail指向的位置正好是head的前一个位置,这种情况表示队列满了。
用锁实现环形队列
我们可以用锁实现上述环形队列,在push和pop时分别加锁,并通过head和tail计算队列是否为满或者空。
代码比较简单,可以看看下面的写法
1 |
|
测试也比较简单,我们写一个函数,初始化队列大小为5,测试队列push满的情况和pop直到为空的情况
1 | void TestCircularQue() { |
结果如下
1 | called push const T& version |
无锁队列
那如果我们用原子变量而不是用锁实现环形队列,那就是无锁并发的队列了。还记得我们之前提到的原子变量的读改写操作吗?
1 | bool std::atomic<T>::compare_exchange_weak(T &expected, T desired); |
compare_exchange_strong
会比较原子变量atomic<T>
的值和expected
的值是否相等,如果相等则执行交换操作,将atomic<T>
的值换为desired
并且返回true,否则将expected的值修改为bool变量的值,并且返回false.
其伪代码可以这么理解
1 | template <typename T> |
compare_exchange_weak
功能比compare_exchange_strong
弱一些,他不能保证atomic<T>
的值和expected
的值相等时也会做交换,很可能原子变量和预期值相等也会返回false,所以使用要多次循环使用。
我们们定义一个类CircularQueSeq, 其内容和之前我们定义的类CircularQueLk差不多,只不过将类的成员变量mutex换成atomic类型的原子变量, 我们可以利用自旋锁的思路将锁替换为原子变量循环检测的方式,进而达到锁住互斥逻辑的效果。
大家可以先看一下全部的代码感受一下
1 | template<typename T, size_t Cap> |
我们可以看到emplace
函数以及pop
函数等将锁替换为原子变量。采用do while
的方式就是因为compare_exchange_strong
比较原子变量和use_expected
的值不同的时候会使use_expected
改变,所以我们需要在再次循环之前重置use_expected
和use_desired
的值。
我们可以写一个函数在单线程情况下下测试一下
1 | void TestCircularQueSeq() |
运行结果
1 | called push const T& version |
多线程情况下也能保证安全是因为原子变量循环检测保证有且只有一个线程修改成功。读取也是这样。
单一原子变量的弊端
我们考虑上述单一原子变量的弊端
多个线程push和pop操作耦合读太高,同一时刻仅有一个线程pop或者push,而且互斥逻辑的精度不够。影响效率。
我们需要考虑将pop和push操作解耦,我们采用的是环形队列,将tail
和head
作为原子变量可以实现精细控制。
比如我们做push操作的时候,一个线程更新万tail
标签和数据后,其他线程就可以pop或者push了,精细控制的好处就是效率提升。
我们定义一个新的类CircularQueLight,类的基本数据结构和CircularQueSeq差不多。
1 | template<typename T, size_t Cap> |
我们将_head
和_tail
替换为原子变量。
接下来我们考虑pop
逻辑
1 | bool pop(T& val) { |
在pop逻辑里我们在1处load获取头部head的值,在2处采用了复制的方式将头部元素取出赋值给val,而不是通过std::move
,因为多个线程同时pop
最后只有一个线程成功执行3处代码退出,而失败的则需要继续循环,从更新后的head处pop
元素。所以不能用std::move
,否则会破坏原有的队列数据。
接下来我们来做push的函数逻辑
1 | bool push(T& val) |
push函数的逻辑乍一看和pop一样,但是我们会发现多线程push的情况存在线程安全问题。
比如我们线程1 push(1)
而线程2 push(2)
. 很有可能的顺序是
1.1 -> 1.2 -> 2.1 -> 2.2 -> 1.3
这样我们看到的效果就是_data[t]
被存储为2了,而实际情况应该是被存储为1,因为线程1的原子变量生效,而线程2的原子变量不满足需继续循环。所以_data[t]
必须修改为1.
那我们改进一下push
的函数
1 | bool push(T& val) |
我们将2处的代码移动到循环之外,这样能保证多个线程push,仅有一个线程生效时,他写入的数据一定是本线程要写入到tail的数据,而此时tail被缓存在t里,那是一个线程本地变量,所以在这种情况下我们能确定即使多个线程运行到2处,他们的t值也是不同的,并不会产生线程安全问题。
毕竟多个线程push数据时对资源的竞争仅限tail。
但是这种push操作仍然会有安全问题
我们思考这种情况
此时head和tail都指向1这个位置,当我们执行push(9)
时,按照我们的逻辑会先执行3再执行2.
也就是会先将tail移动,然后更新1的值为9.
那如果我们更新了tail之后,还没来的及更新1为9,那么此时如果有其他的线程读取head的值,会读取到1,而不是9.
从多线程安全角度来讲这是不安全的,我们理想的情况是一个线程写完数据后另一个线程读取的就是之前写入的最新值。
为了解决这个问题,我们可以增加另一个原子变量_tail_update
来标记尾部数据是否修改完成,如果尾部数据没有修改完成,此时其他线程pop时获取的数据就是不安全的,所以pop要返回false。
先实现push版本
1 | bool push(const T& val) |
再实现pop版本
1 | bool pop(T& val) { |
pop版本也是,先判断队列是否为空,再判断h是否和_tail_update的值相等,如果相等说明有写数据的没更新完,直接返回false或者循环等待也行,为了方便我们直接返回false即可。
因为我们知道原子操作默认采用的是memory_order_seq_cst
内存顺序,性能上不是最优的,我们可以用acquire
和release
的内存顺序实现同步的效果。
优化性能
我们用acquire和release模型优化上述代码,实现同步。
最简单的方式就是将load的地方变为memory_order_relaxed
,compare_exchange_strong的地方变为memory_order_release
我们先看pop操作
1 | bool pop(T& val) { |
1 处为memory_order_relaxed
是因为即使多个线程pop,每个线程获取的head可能不及时,这个没关系,因为我们有4处的while来重试。
2 compare_exchange_strong操作,在期望的条件匹配时采用memory_order_release
, 期望的条件不匹配时memory_order_relaxed
可以提升效率,毕竟还是要重试的。
我们再看push 操作
1 | bool push(const T& val) |
两个线程协同工作,一个线程先push,另一个线程后pop,那么对于tail部分和_tail_update,我们要保证push的结果_data[t] = val;
先于pop的结果val = _data[h];
所以push线程中对于_tail_update的compare_exchange_strong操作采用memory_order_release
方式。
pop线程对于_tail_update的load操作采用memory_order_acquire
。
如果一个线程先pop,另一个线程先push,那么对于head部分,我们要保证pop的结果val = _data[h];
先于pop的结果_data[t] = val;
。
思考
优势
无锁高并发. 虽然存在循环重试, 但是这只会在相同操作并发的时候出现. push 不会因为与 pop 并发而重试, 反之亦然.
缺陷
这样队列只应该存储标量, 如果存储类对象时,多个push线程只有一个线程push成功,而拷贝复制的开销很大,其他线程会循环重试,每次重试都会有开销。
视频链接
https://space.bilibili.com/271469206/channel/collectiondetail?sid=1623290
源码链接
https://gitee.com/secondtonone1/boostasio-learn/tree/master/concurrent/day12-circularque