46、原子操作与无锁编程:GCC原子内置函数,CAS操作,无锁队列
多线程编程里,锁是个好东西,但也是个麻烦精。
锁用多了,性能上不去。锁用少了,数据乱成一锅粥。我早年做网络协议栈的时候,就被锁坑过——一个高并发场景下,锁竞争导致CPU利用率上不去,吞吐量死活提不起来。后来我琢磨着,有些场景其实可以不用锁。
嗯,这就是我们今天要聊的:原子操作与无锁编程。
为什么需要原子操作?
先想一个问题:两个线程同时对一个全局变量做 count++,会发生什么?
你可能会说,不就是加1嘛,有什么大不了的。但实际在CPU层面,count++ 被拆成了三步:
- 从内存读 count 到寄存器
- 寄存器里加1
- 写回内存
如果两个线程同时执行这三步,就可能出现「丢失更新」——明明加了两次,结果只加了1。这就是典型的竞态条件。
原子操作:要么全部执行完,要么看起来没执行过。中间不会被其他线程打断。
我习惯把原子操作看作「硬件级别的锁」。它不需要操作系统调度,不需要上下文切换,所以比互斥锁轻量得多。
GCC 原子内置函数
GCC 提供了一套内置函数,直接映射到CPU的原子指令。说白了,就是让你在C语言里直接调用硬件级别的原子操作。
常用的几个:
| 函数 | 作用 |
|---|---|
__sync_fetch_and_add |
原子加,返回旧值 |
__sync_add_and_fetch |
原子加,返回新值 |
__sync_bool_compare_and_swap |
CAS操作,成功返回true |
__sync_val_compare_and_swap |
CAS操作,返回旧值 |
__sync_lock_test_and_set |
原子写,返回旧值 |
举个例子:
// 原子加1,返回旧值
int old = __sync_fetch_and_add(&counter, 1);
// 原子比较并交换
int expected = 10;
int newval = 20;
int ret = __sync_val_compare_and_swap(&ptr, expected, newval);
if (ret == expected) {
// 交换成功
}
我的经验:这些函数在x86上编译出来就是一条 lock 前缀指令,开销极小。但在ARM上要注意内存屏障问题,有些平台需要额外加 dmb 指令。
CAS 操作:无锁编程的基石
CAS 的全称是 Compare And Swap,比较并交换。它的逻辑很简单:
bool CAS(int* ptr, int expected, int newval) {
if (*ptr == expected) {
*ptr = newval;
return true;
}
return false;
}
但注意,这个操作必须是原子的。硬件保证「比较」和「交换」两步之间不会被中断。
CAS 为什么重要?因为它可以让你实现「乐观锁」的思想。
传统锁是悲观的:我觉得别人会改数据,所以我先锁住。CAS 是乐观的:我先试试改,如果别人没动过,我就成功;如果别人动了,我就重试。
我曾在项目中用 CAS 实现了一个无锁的引用计数器。多个线程同时增加引用,不需要加锁,性能提升非常明显。
注意:CAS 有个经典问题叫 ABA 问题。线程1读到值是A,准备改成C。但中间线程2把A改成B又改回A。线程1的CAS仍然成功,但它不知道数据其实被改过。解决方案是加版本号,或者用 __sync_val_compare_and_swap 配合双字CAS。
无锁队列:从理论到实践
无锁队列,说白了就是不用互斥锁的队列。它利用原子操作来保证并发安全。
最简单的无锁队列是「单生产者单消费者」模型。这种场景下,连CAS都不需要,只用原子读写就行。
但「多生产者多消费者」就复杂了。我当年实现过一个基于链表的无锁队列,核心逻辑就是CAS操作:
typedef struct node {
int data;
struct node* next;
} node_t;
typedef struct {
node_t* head;
node_t* tail;
} queue_t;
void enqueue(queue_t* q, node_t* n) {
n->next = NULL;
node_t* tail;
do {
tail = q->tail;
} while (!__sync_bool_compare_and_swap(&tail->next, NULL, n));
// 更新尾指针,这里可以优化
__sync_bool_compare_and_swap(&q->tail, tail, n);
}
这段代码有个问题:如果两个生产者同时入队,CAS 保证了只有一个能成功链接新节点。另一个会重试。这就是典型的「自旋」——忙等待。
核心思想:无锁编程不是真的没有锁,而是把锁的粒度降到了CPU指令级别。你不再用操作系统提供的锁,而是用硬件提供的原子指令。
无锁编程的代价
别以为无锁编程就是万能药。它有几个明显的坑:
- 调试困难:竞态条件很难复现,我曾在线上环境排查过一个无锁队列的内存泄漏,花了整整三天。
- ABA问题:前面说过了,需要额外处理。
- 内存回收:无锁数据结构里,你不知道一个节点什么时候可以安全释放。因为可能有其他线程正在读它。
- 性能不一定更好:高冲突场景下,CAS自旋的开销可能比互斥锁还大。
我的建议:先加锁,跑通再说。性能瓶颈真的出现在锁竞争上,再考虑无锁优化。不要一开始就上无锁,那是给自己找麻烦。
知识体系总览
下面这张图概括了本章的核心逻辑:
避坑指南
我曾经在一个嵌入式项目里,用无锁队列做消息传递。测试环境跑得好好的,一上生产就随机崩溃。查了三天,最后发现是内存屏障的问题——ARM处理器上,读操作和写操作的重排导致了一个线程读到脏数据。
所以,如果你在ARM、RISC-V这类弱一致性模型上做无锁编程,一定要加内存屏障。GCC提供了 __sync_synchronize 来做全屏障,但性能开销不小。更好的做法是用 __atomic_load_n 和 __atomic_store_n 配合 __ATOMIC_SEQ_CST 等内存序参数。
血的教训:无锁编程不是「不加锁」,而是「用硬件锁代替软件锁」。如果你对CPU的内存模型、缓存一致性协议(如MESI)不了解,建议先别碰无锁编程。先把基础打牢,再考虑优化。
好了,关于原子操作和无锁编程,今天就聊这么多。记住一句话:能用锁解决的问题,别用无锁。无锁是锦上添花,不是雪中送炭。