12 无锁的四种设计原则
12 无锁的四种设计原则
前面我们实现过了无锁队列和无锁栈,其实可以发现它们不管是优化还是实现上,好像是比较相似的,这也引出了本节要介绍的主题 —— 无锁并发的四种设计原则。
内存序
俗话说: 过早的优化是万恶之源,在前面我们提到了 std::memory_order_seq_cst
会让所有线程都看到一个公认的全局总排序,因此它的性能其实是不太好的,但这个不太好也只是局限于与其他几种内存序相比,它其实还是比有锁结构更快的,但它可以让我们放心的开发,而不用担心可见性导致的问题,从这一点看,这个内存序是更利好开发的。
因此在实际设计中,更建议的是 先不要考虑内存序的优化,先全部使用默认的内存序,当我们所有操作都正常工作后,再选择性优化内存序。
原因在于我们通常只有先完全了解代码全貌,认清哪些代码操作核心数据结构,才可以确定放宽哪些操作的内存次序约束,否则,事情就会很棘手。
即便代码在测试过程中正常工作,也无法保证在生产环境下代码依然如此,所以,仅仅测试代码的运行并不足够,除非我们能够采用测试工具(假如真的存在),系统化地核查线程访问内存次序的全部可能的组合,验证它们是否与指定的内存次序约束保持一致,否则默认内存序会是更好的选择。
内存管控
无锁设计中如何安全回收内存也是一大难题,从设计上来说,我们会有两个选择:
预先分配内存,随后程序退出后再集中回收
动态分配内存,并动态回收
这两种方案各有优劣,对于前者,也是我们这两个结构的选择,它的好处显而易见: 回收安全,可以确保所有内存被回收,使用上效率也很高,毕竟无需频繁操作堆内存,而是预先在堆区分配固定大小。
但这也是它的不足之处: 固定大小导致它无法扩容,导致它容易出现两个极端,太够用或不够用,前者会导致一部分资源浪费,后者我们做了优化,不需要考虑,除此以外,固定内存的使用极易出现 ABA 问题,需要引入其他方案解决。
因此,如果采用这种方案,就需要在 容量和 ABA 问题 设计上下功夫。
而对于第二种选择,相比于第一种选择,它的好处在于可以按需分配内存,不会造成资源浪费,但是相对应的,它的不足之处在于每次都得分配内存,性能不好,且需要考虑内存回收: 只要仍有线程在操作,就不能回收此资源,但是长期以来会导致大量资源被分配但是没有被回收,针对这种情况,比较主流的三种方案:
- 暂缓全部删除对象的动作,等到没有线程访问数据结构的时候,才删除待销毁的对象;
- 采用风险指针,以辨识特定对象是否正在被某线程访问;
- 就对象进行引用计数,只要外部环境仍正在指涉目标对象,它就不会被删除;
这三种方案都是为了标记有多少线程正在访问资源,从而确定是否回收。
如果选择这种方案,就需要在 内存安全回收 上下功夫。
到这里来看,前者除了可能的内存浪费,其余貌似全面碾压后者,因此个人 更建议使用内存池等预先分配内存的方案。
ABA问题
ABA问题是无锁编程中的一个经典问题,在 CAS 操作中经常遇到,它描述的是这样一种情况:
- 线程1读取了某个内存位置的值A
- 线程1被抢占,线程2开始执行
- 线程2将该内存位置的值从A改为B,然后又改回A
- 线程1恢复执行,使用CAS操作检查该内存位置的值仍然是A,于是认为值没有被修改过
- 线程1继续执行,但实际上该内存位置的值已经被其他线程修改过了
ABA问题的本质在于:仅仅通过比较值是否相等,无法判断该值是否被其他线程修改过。即使值在表面上没有变化(从A变为A),但背后可能经历了复杂的状态变迁。
看过上一节的内容,你就会知道,ABA 问题是很严重的,比如无锁栈成的环,再比如,假设我们的值是个指针呢,那我指针地址没变,内容变了是不是照样看不出来,因此 千万不要觉得 ABA 问题不是问题。
解决 ABA 问题的方案也很多,我们说只通过值比较不行,那就多加一个变量,比如 时间戳或次数戳 一类的,此时比较时需要比较这两个参数,如果两个操作之间被修改了,那时间戳是不是就变了,CAS 操作就会失败,这样就解决了 ABA 问题,当然也可以参考我们设计无锁队列时引入的序列号机制。
总结下来就是: 采用递增原子或者 Tagged ptr 操作解决 ABA。
CPU 空转
在高并发情况下,多个线程对同一个原子变量进行 CAS,一定会有大量操作无效,导致 CPU 空转,白白消耗性能,这种情况下我们可以引入一些退避策略,如指数退避,让失败的线程歇一会,从而减少 CPU 空转。
到此为止,cpp并发编程的术就已经介绍完了,从基本的线程发起到实现亿级吞吐结构,内容还是非常多的,剩下的就是靠实战积累的经验了,希望都能有所收获。