C++多线程 原子操作与无锁编程

原子操作

我们写的代码最终都会被翻译为CPU指令,一条最简单加减法语句都有可能会被翻译成几条指令执行;为了避免语句在CPU这一层级上的指令交叉带来的行为不可知,在多线程程序设计时我们必须通过一些方式来进行规范;这里面最常见的做法就是引入互斥锁,其大概的模型就是篮球模式:几个人一起抢球,谁抢到了谁玩,玩完了再把球丢出来重新抢;但互斥锁是操作系统这一层级的,最终映射到CPU上也是一堆指令,是指令就必然会带来额外的开销;

既然CPU指令是多线程不可再分的最小单元,那我们如果有办法将代码语句和指令对应起来,不就不需要引入互斥锁从而提高性能了吗? 而这个对应关系就是所谓的原子操作;在C++11的atomic中有两种做法:

  • 模拟, 比如说对于一个atomic类型,我们可以给他附带一个mutex,操作时lock/unlock一下,这种在多线程下进行访问,必然会导致线程阻塞;
  • 有相应的CPU层级的对应,这就是一个标准的lock-free类型;

可以通过is_lock_free函数,判断一个atomic是否是lock-free类型

自旋锁

使用原子操作模拟互斥锁的行为就是自旋锁,互斥锁状态是由操作系统控制的,自旋锁的状态是程序员自己控制的;要搞清楚自旋锁我们首先要搞清楚自旋锁模型,常用的自旋锁模型有:

  1. TAS, Test-and-set,有且只有atomic_flag类型与之对应
  2. CAS, Compare-and-swap,对应atomic的compare_exchange_strong 和 compare_exchange_weak,这两个版本的区别是:Weak版本如果数据符合条件被修改,其也可能返回false,就好像不符合修改状态一致;而Strong版本不会有这个问题,但在某些平台上Strong版本比Weak版本慢 [注:在x86平台我没发现他们之间有任何性能差距];绝大多数情况下,我们应该优先选择使用Strong版本;

我针对这两种模型分别实现了两个版本的自旋锁,最终代码可以在性能测试章节中找到,这里我们要注意以下问题:

LOCK时自旋锁是自己轮询状态,如果不引入中断机制,会有大量计算资源浪费到轮询本身上;常用的做法是使用yield切换到其他线程执行,或直接使用sleep暂停当前线程.

无锁编程

如果看了CAS实现的自旋锁代码会发现其有些别扭:每次都需要去重置exp的状态为false;CAS虽然也能实现自旋锁,但通常被我们用来进行无锁编程; 什么是无锁编程呢,让我们以一个例子开始:

template<typename _Ty>
struct LockFreeStackT
{
  struct Node
  {
    _Ty val;
    Node* next;
  };
  LockFreeStackT() : head_(nullptr) {}
  void push(const _Ty& val)
  {
    Node* node = new Node{ val, head_.load() };
    while (!head_.compare_exchange_strong(node->next, node)) {
    }
  }
  void pop()
  {
    Node* node = head_.load();
    while (node && !head_.compare_exchange_strong(node, node->next)) {
    }
    if (node) delete node;
  }
  std::atomic<Node*> head_;
};

整个逻辑很简单,pop只是push的逆过程,这里我们仅仅只分析一下push:每次push时去获得栈顶元素,生成一个指向栈顶的新元素,然后使用CAS操作去更新栈顶,这里可能有两种情况:

  1. 如果新元素的next和栈顶一样,证明在你之前没人操作它,使用新元素替换栈顶退出即可;
  2. 如果不一样,证明在你之前已经有人操作它,栈顶已发生改变,该函数会自动更新新元素的next值为改变后的栈顶;然后继续循环检测直到状态1成立退出;

不难看出,其实所谓无锁编程只是将多条指令合并成了一条指令形成一个逻辑完备的最小单元,通过兼容CPU指令执行逻辑形成的一种多线程编程模型;结束了吗,再等等,使用上面的代码,有很大的几率出delete不存在的内存,或内存被多次delete的错误,让我们进入下一节.

ABA问题

维基百科: ABA problem,如果有两个线程[1&2]操作上面的堆栈,初始状态有2个元素: top->A->B,线程1执行pop操作,在CAS前进行线程切换:

注意pop函数中的head_.compare_exchange_strong(node,node->next)语句,如果对C/C++不够熟悉,很容易发生误解;我们不考虑函数包装等复杂情况,只考虑最简单的情况下在调用CAS原子操作前至少还有参数压栈操作,也就是说node->next不是调用时确定的,而是在参数压栈时确定的;前面说的CAS操作前进行线程切换,切换时{node,node->next}对应的是{A, B}.

然后线程2执行pop操作,将A,B都删掉,然后创建了一个新元素push进去,因为操作系统的内存分配机制会重复使用之前释放的内存,恰好push进去的内存地址和A一样,我们记为A’,这时候切换到线程1,CAS操作检查到A没变化成功将B设为栈顶,但B是一个已经被释放的内存块…

解决ABA问题的办法无非就是通过打标签标识A和A’为不同的指针,这下总结束了吧,事实上还没有,再次进入下一节.

内存回收

还是先看上面的代码,在Pop时先获得了头指针Node* node = head_.load();,如果这时候发生线程切换并且这个节点被另一个线程删除,当线程切回时候node无效造成node->next访问异常…,对于这个问题,现在流行的处理方式主要有:

  1. Lock-Free Reference Counting: 引用计数方式,严格的说应该是全局计数方式;每次POP时首先增加计数,然后处理完后做检测,计数如果大于1证明其他线程也在操作,把节点存起来;只要检测到计数等于1,证明目前只有自己操作,可以删除当前节点和之前缓存的节点,删除之前节点时必须进行二次判断来解决交换后,其他线程Acquire的问题;具体可以参见Paper: Lock-Free Reference Counting, Efficient and Reliable Lock-Free Memory Reclamation Based on Reference Counting
  2. Hazard pointer: 使用POP的线程,都有一个自己可以写的thread_local变量,每次得到的旧指针都存入其中,当交换完成后将该变量置为0;对于其他线程来说每次删除前对整个Hazard pointer表遍历一遍,如果发现其他线程也在访问,就把要删除的节点放入将删列表中,否则可以直接删除;定时对将删列表中的节点按照之前的规则和整个Hazard pointer表做比较,只要Hazard pointer表不持有这个节点就可删除;可参考维基百科: Hazard pointer
  3. Epoch Based Reclamation
  4. Quiescent State Based Reclamation

内存模型

C++11原子操作的很多函数都有个std::memory_order参数,这个参数就是这里所说的内存模型,其并不是类似POD的内存布局,而是一种数据同步模型,准确说法应该是储存一致性模型,其作用是对同一时间的读写操作进行排序;C++11中一个定义了6种类型,我们可以将其分为4类,下面我从我的角度以普通程序员能理解的语言描述一下,具体的可以参见 C++11 Memory Order:

  1. memory_order_relaxed: 很多文档都说这种模型是完全乱序的,但我理解同一线程内,基本上应该还是按照代码顺序执行的;
  2. memory_order_release & memory_order_acquire: 两个线程A&B,A线程Release后,B线程Acquire能保证一定读到的是最新被修改过的值;这种模型更强大的地方在于它能保证发生在A-Release前的所有写操作,在B-Acquire后都能读到最新值;
  3. memory_order_release & memory_order_consume: 上一个模型的同步是针对所有对象的,这种模型只针对依赖于该操作涉及的对象:比如这个操作发生在变量a上,而s = a + b; 那s依赖于a,但b不依赖于a; 当然这里也有循环依赖的问题,例如:t = s + 1,因为s依赖于a,那t其实也是依赖于a的;
  4. memory_order_seq_cst: 顺序一致性模型,这是C++11原子操作的默认模型;大概行为为对每一个变量都进行2中所说的Release-Acquire操作,当然这也是一个最慢的同步模型;

说到内存模型,就不得不提一下经常被大家误用的 volatile 关键字,这个关键字仅仅保证:数据只在内存中读写,直接操作它既不能保证操作是atomic的,也不能保证Memory Order;其实在我理解中,这个应该是嵌入式,内核或驱动程序员专用关键字:),当然如果在竞争不敏感的环境中用来做flag用一下也没太大问题.

最后要说一下x86体系中Release-Acquire是自动获取的,最终形成一个memory_order_seq_cst模型;因此绝大多数情况下memory_order_relaxed其实并没有什么用.

结语

无锁编程真的很难,如果要完全写对那就成了变态难,出错了平时常见的调试手段根本没用,几乎全靠脑补;并且这种轮询方式相对于锁的中断挂起方式来讲,只有在超高并发的前提下才能达到一个理想的效果,低并发下空载会对系统资源造成极大的浪费,因此原则上我不推崇这个玩意;从测试结果来看,所有平台上自旋锁性能都非常接近无锁实现,并且其使用方式和互斥锁几乎没差别,因此在没啃透之前,使用锁的方式才是明智的选择.

LinuxC/C++服务器开发/架构师教程:
https://ke.qq.com/course/417774?flowToken=1031343

LinuxC/C++服务器开发/架构师面试题、学习资料、教学视频和学习路线图,免费分享有需要的可以自行添加学习交流群973961276 获取

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值