46、原子操作与无锁编程:GCC原子内置函数,CAS操作,无锁队列

多线程编程里,锁是个好东西,但也是个麻烦精。

锁用多了,性能上不去。锁用少了,数据乱成一锅粥。我早年做网络协议栈的时候,就被锁坑过——一个高并发场景下,锁竞争导致CPU利用率上不去,吞吐量死活提不起来。后来我琢磨着,有些场景其实可以不用锁。

嗯,这就是我们今天要聊的:原子操作与无锁编程。

为什么需要原子操作?

先想一个问题:两个线程同时对一个全局变量做 count++,会发生什么?

你可能会说,不就是加1嘛,有什么大不了的。但实际在CPU层面,count++ 被拆成了三步:

  1. 从内存读 count 到寄存器
  2. 寄存器里加1
  3. 写回内存

如果两个线程同时执行这三步,就可能出现「丢失更新」——明明加了两次,结果只加了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自旋的开销可能比互斥锁还大。

我的建议:先加锁,跑通再说。性能瓶颈真的出现在锁竞争上,再考虑无锁优化。不要一开始就上无锁,那是给自己找麻烦。

知识体系总览

下面这张图概括了本章的核心逻辑:

原子操作与无锁编程知识体系 原子操作 GCC原子内置函数 fetch_and_add add_and_fetch lock_test_and_set CAS操作 比较并交换 乐观锁 ABA问题 无锁队列 单生产者单消费者 多生产者多消费者 自旋重试 核心原则 先加锁跑通 → 确认瓶颈 → 再考虑无锁优化

避坑指南

我曾经在一个嵌入式项目里,用无锁队列做消息传递。测试环境跑得好好的,一上生产就随机崩溃。查了三天,最后发现是内存屏障的问题——ARM处理器上,读操作和写操作的重排导致了一个线程读到脏数据。

所以,如果你在ARM、RISC-V这类弱一致性模型上做无锁编程,一定要加内存屏障。GCC提供了 __sync_synchronize 来做全屏障,但性能开销不小。更好的做法是用 __atomic_load_n__atomic_store_n 配合 __ATOMIC_SEQ_CST 等内存序参数。

血的教训:无锁编程不是「不加锁」,而是「用硬件锁代替软件锁」。如果你对CPU的内存模型、缓存一致性协议(如MESI)不了解,建议先别碰无锁编程。先把基础打牢,再考虑优化。

好了,关于原子操作和无锁编程,今天就聊这么多。记住一句话:能用锁解决的问题,别用无锁。无锁是锦上添花,不是雪中送炭。


专注资料整理