无锁并发编程实战:不加锁解决多线程安全问题的核心思路
很多多线程开发的新手,遇到共享资源的竞争问题时,第一反应就是直接给临界区加互斥锁,用锁来保证线程安全。但在高并发场景下,大量线程同时竞争同一把锁,会频繁触发线程阻塞和CPU上下文切换,带来极其高昂的性能开销,甚至直接把整个多线程程序的性能拖垮。无锁并发编程就是为了解决这个痛点诞生的技术体系,它完全不使用传统互斥锁,通过原子操作、CPU内存屏障等底层机制,实现非阻塞的并发控制,彻底避免线程阻塞的性能损耗,把高并发场景下的程序性能提升数倍。但无锁编程绝对不是简单替换掉锁这么简单,它有一整套严谨的设计思路和工程实践规范,理解这些核心逻辑,你才能写出稳定可用的无锁并发代码。
一、无锁编程的核心底层基石:原子操作与CAS机制
无锁编程能够成立的最核心前提,就是现代CPU硬件原生提供的原子操作能力。传统的普通运算指令,比如对一个普通变量执行自增操作,会被CPU拆分成读取、修改、写入多个独立的步骤,多线程并发执行的时候,不同线程的指令执行序列互相交错,就会出现数据竞争,最终得到完全错误的运算结果。而原子操作是CPU硬件直接提供的特殊指令,整个操作的执行过程是完全不可分割的,从CPU总线层面保证,整个操作执行的过程里,绝对不会被其他线程打断,多线程并发执行原子操作的时候,硬件层面会自动保证操作的串行化,天然就不会出现数据竞争的问题。
所有现代CPU都原生支持的CAS比较并交换指令,是绝大多数无锁数据结构的核心实现基础。CAS指令的逻辑非常简单:它会先检查当前变量的实际值是不是和预期的旧值完全相等,如果相等,就把变量更新为新的指定值,整个操作一步完成,返回操作是否成功的结果;如果不相等,就说明这个变量已经被其他线程提前修改过了,直接返回操作失败,不会做任何额外修改。
基于CAS的乐观重试机制,是无锁编程最经典的实现思路:线程想要修改共享变量的时候,先把变量的当前值读取出来作为预期旧值,基于这个旧值计算出新的目标值,然后调用CAS指令尝试原子更新变量。如果CAS返回成功,就说明这次更新操作顺利完成,没有其他线程和自己竞争;如果CAS返回失败,就说明在读取旧值和执行CAS的间隙,有其他线程已经抢先修改了这个变量,当前线程不需要阻塞等待,直接重新读取变量的最新值,重新计算新值,再次发起CAS尝试,直到操作成功为止。整个过程里完全不需要任何互斥锁,所有线程都在不断尝试,不会有任何线程被操作系统挂起阻塞,自然也就没有了锁带来的上下文切换开销。
二、从根源消除竞争:无锁编程的顶层设计思路
很多人一提到无锁编程,第一反应就是用CAS去替换所有锁,把原来加锁的临界区全部改成CAS重试,这是完全错误的思路。真正优秀的无锁设计,根本不会让大量线程去竞争同一个共享变量,而是从顶层架构层面,尽可能从根源上消除多线程之间的共享竞争,这才是性能最高、实现难度最低的无锁方案。
最常用的思路就是数据分片,也叫锁分离思想。把原本单个全局共享的资源,拆分成多个独立的分片,不同的线程只会访问不同的分片,同一个分片上几乎不会出现多线程竞争。比如你要实现一个全局的计数器,多线程并发累加的时候,如果所有人都竞争同一个全局计数器变量,哪怕用CAS实现,大量线程的CAS重试也会带来很大的CPU开销。你完全可以给每一个线程分配一个独立的本地计数器分片,线程只操作自己专属的分片变量,完全不会和其他线程产生任何竞争,当需要读取全局总和的时候,再把所有线程的本地分片数值累加起来得到结果。整个过程里没有任何竞争,不需要任何CAS重试,性能比全局CAS计数器高一个数量级,实现难度也几乎为零。
另一个非常经典的思路是线程局部存储,直接把共享变量变成每个线程完全独立的私有变量。如果一个共享资源的更新操作,不需要其他线程实时感知,你完全可以把这个变量放到线程局部存储区域里,每个线程都拥有这个变量的独立副本,线程之间完全没有任何共享,自然也就不存在任何数据竞争的问题,连原子操作都不需要使用,性能和单线程代码完全一样。很多高并发的日志系统、统计系统,大量使用线程局部存储的思路,彻底消除多线程竞争,实现极高的吞吐量。
还有一种思路就是批量合并提交,每个线程先把自己的修改操作暂存在本地的私有缓冲区里,等本地缓冲区攒够一批操作之后,再一次性用原子操作批量提交到全局共享数据结构里,大幅减少多线程之间的竞争次数。比如无锁队列的实现里,每个工作线程先把多个要入队的元素暂存在本地数组里,等数组满了之后,一次性把整个数组批量追加到全局队列的尾部,而不是每一个元素都单独执行一次CAS入队操作,全局竞争的次数直接降低几十上百倍,性能得到质的提升。
三、经典无锁数据结构的工程实现要点
当你确实需要一个多线程可以并发读写的共享数据结构,没办法通过分片、线程局部存储的思路完全消除竞争的时候,工业界已经有很多经过多年验证的经典无锁数据结构实现,其中最常用的就是无锁队列和无锁栈。
无锁队列最经典的Michael-Scott实现,通过CAS原子操作更新队列的头尾指针,多个线程可以并发执行入队和出队操作,完全不需要加锁,所有线程都可以无阻塞地完成操作。它的核心逻辑就是入队操作的时候,通过CAS原子更新队列的尾指针,把新节点追加到队列尾部;出队操作的时候,通过CAS原子更新队列的头指针,把旧的头节点从队列里摘出来。整个过程里没有任何锁,线程操作失败就立刻重试,不会阻塞等待。无锁队列非常适合高并发场景下的生产者消费者模型,多个生产者线程并发往队列里写入任务,多个消费者线程并发从队列里取出任务处理,整体吞吐量比加锁的阻塞队列高很多倍。
但是无锁编程里有一个非常经典的ABA问题必须解决:当一个线程读取共享变量的值是A,然后在执行CAS之前,其他线程把变量的值从A改成了B,之后又改回了A,这个时候原来的线程执行CAS的时候,会误以为变量从来没有被修改过,直接执行更新操作,最终会导致整个无锁数据结构的内存布局被破坏,出现难以排查的内存错误。解决ABA问题最常用的方案就是给每一个共享指针额外附加一个外部版本号,每次修改指针的时候,同时自动递增版本号,哪怕指针的地址值改回了原来的数值,版本号也不会回到原来的数值,CAS操作的时候同时对比指针地址和版本号,就可以彻底避免ABA问题的干扰。
很多新手实现无锁数据结构的时候,很容易忽略内存回收的问题。在加锁的场景里,你从链表里摘下一个节点之后,可以直接安全释放节点的内存,因为你持有锁,没有其他线程会再访问这个节点。但是在无锁场景里,当你把一个节点从链表上摘下来之后,很可能还有其他正在执行遍历操作的线程,依然持有这个节点的指针,正在访问节点里的内容。这个时候如果你直接释放节点的内存,其他线程后续访问的时候就会触发野指针访问,直接导致程序崩溃。工业界常用的解决思路是 hazard pointer 风险指针机制,线程在访问一个共享节点之前,先把这个节点的地址注册到全局的风险指针数组里,标记这个节点当前正在被访问,等到节点要被回收的时候,检查所有线程的风险指针,如果没有任何线程正在访问这个节点,才能安全释放它的内存,从根源上避免野指针访问的问题。
四、无锁编程的适用边界与避坑指南
无锁编程虽然性能极高,但是它绝对不是银弹,有非常明确的适用边界。无锁编程的实现难度和调试难度远高于加锁方案,哪怕一个非常小的逻辑疏漏,都可能导致极其难以复现的内存错误,这类错误在测试环境里几乎不会出现,只有在线上高并发场景下才会随机触发,排查成本极高。
对于绝大多数普通业务场景,并发竞争的烈度并不高,传统互斥锁的性能完全可以满足需求,完全没有必要强行追求无锁方案。只有在高并发、极高吞吐量的底层基础组件场景里,比如网络框架的任务队列、高性能日志系统、高频全局计数器这类核心路径组件,锁的开销已经成为明显的性能瓶颈的时候,才有必要投入成本去实现无锁方案。
同时你要合理利用现代C++标准库提供的原子操作封装,不要自己直接手写CAS汇编指令,标准库的std::atomic模板已经对不同平台的原子操作做了完美的封装,你只需要指定内存序参数,就可以精确控制CPU的内存重排序行为,写出跨平台兼容的无锁代码。
无锁并发是底层C++开发里的高阶技能,它不是简单的替换锁,而是从架构设计到底层硬件细节的全链路优化,掌握这套技术,你才能写出极致高性能的高并发底层组件。





