第13章 原子操作与内存模型

多线程编程里,最让人头疼的是什么?我猜很多人会说是「同步」。锁用多了性能下降,用少了数据乱掉。其实,有些场景根本不需要锁——原子操作就是那把钥匙。

这一章,我们来聊聊C11标准引入的原子操作。说白了,就是让某些操作在硬件层面保证「不可分割」。你想想看,如果一条指令就能完成读写,那还要锁干嘛?

13.1 原子操作的概念

什么是原子操作?我习惯这么理解:一个操作要么全部执行完,要么完全不执行。中间不会插入其他线程的操作。

举个简单的例子:

// 非原子操作
int counter = 0;
// 线程A: counter++
// 线程B: counter++

这段代码,两个线程同时执行counter++,结果可能是1,也可能是2。为什么?因为counter++在底层其实是三步:读、改、写。线程A读到0,还没来得及写回去,线程B也读到0,两个都写回1——数据就丢了。

原子操作的核心价值:在硬件层面保证「读-改-写」三步一气呵成,中间不会被任何其他线程打断。

我在项目中遇到过这样一个坑:一个统计系统,用全局计数器记录请求量。上线后发现数字总是偏小,排查了半天才发现是并发写入冲突。换成原子操作后,问题立刻解决。

13.2 C11原子类型(stdatomic.h)

C11标准在里定义了一套原子类型。我刚开始用的时候觉得挺别扭,但用顺手了就会发现——真香。

13.2.1 基本原子类型

原子类型 对应普通类型 说明
atomic_int int 32位有符号整数
atomic_uint unsigned int 32位无符号整数
atomic_long long 平台相关长度
atomic_llong long long 64位整数
atomic_bool _Bool 布尔类型
atomic_flag 无锁布尔标志

13.2.2 基本操作函数

#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>

atomic_int counter = ATOMIC_VAR_INIT(0);

int worker(void* arg) {
    for (int i = 0; i < 100000; i++) {
        atomic_fetch_add(&counter, 1);
    }
    return 0;
}

int main() {
    thrd_t t1, t2;
    thrd_create(&t1, worker, NULL);
    thrd_create(&t2, worker, NULL);
    thrd_join(t1, NULL);
    thrd_join(t2, NULL);
    printf("最终结果: %d\n", atomic_load(&counter));
    return 0;
}

这段代码,两个线程各加10万次,结果一定是20万。换成普通int试试?我保证你每次跑的结果都不一样。

小技巧:atomic_fetch_add返回的是修改前的值,不是修改后的。如果你需要修改后的值,可以用atomic_add_fetch。

13.3 内存顺序(memory_order)

嗯,这里要重点说一下。内存顺序是原子操作里最容易让人懵圈的部分。我刚开始学的时候,看到memory_order_relaxed、memory_order_acquire这些名字,头都大了。

其实没那么复杂。说白了,内存顺序就是告诉编译器和CPU:这个原子操作周围的读写操作,能不能重排序?

13.3.1 六种内存顺序

内存顺序 含义 性能
memory_order_relaxed 只保证原子性,不保证顺序 最快
memory_order_consume 数据依赖顺序(很少用) 较快
memory_order_acquire 之后的读写不能重排到之前 中等
memory_order_release 之前的读写不能重排到之后 中等
memory_order_acq_rel acquire + release 较慢
memory_order_seq_cst 全局顺序一致 最慢

13.3.2 实际应用场景

我个人习惯:能用默认就用默认。默认是memory_order_seq_cst,虽然慢一点,但最安全。等性能瓶颈出现了,再考虑优化。

不过,有些场景确实需要手动指定。比如:

// 生产者-消费者模式
atomic_int data_ready = 0;
int shared_data = 0;

// 生产者线程
void producer() {
    shared_data = 42;  // 写数据
    atomic_store_explicit(&data_ready, 1, memory_order_release);
}

// 消费者线程
void consumer() {
    while (atomic_load_explicit(&data_ready, memory_order_acquire) == 0) {
        // 等待
    }
    printf("数据: %d\n", shared_data);  // 一定能读到42
}

这里用release-acquire配对,保证了shared_data的写入一定在data_ready置1之前完成。消费者读到data_ready为1时,shared_data一定已经是42了。

我曾经踩过的坑:用memory_order_relaxed做同步标志。结果消费者读到标志位变了,但数据还没写完。排查了整整两天才发现是内存顺序的问题。从那以后,我只要涉及同步,一律用acquire-release配对。

13.4 无锁编程入门

无锁编程,听起来很高大上对吧?其实核心思想很简单:用原子操作代替锁。但要注意,无锁不等于没有等待——它只是避免了线程被操作系统挂起。

13.4.1 无锁栈的实现

#include <stdatomic.h>
#include <stdlib.h>

typedef struct Node {
    int value;
    struct Node* next;
} Node;

typedef struct {
    atomic_intptr_t head;  // 用intptr_t存指针
} LockFreeStack;

void push(LockFreeStack* stack, int value) {
    Node* new_node = malloc(sizeof(Node));
    new_node->value = value;
    
    Node* old_head;
    do {
        old_head = (Node*)atomic_load(&stack->head);
        new_node->next = old_head;
    } while (!atomic_compare_exchange_weak(
        &stack->head, 
        (intptr_t*)&old_head, 
        (intptr_t)new_node
    ));
}

int pop(LockFreeStack* stack, int* result) {
    Node* old_head;
    do {
        old_head = (Node*)atomic_load(&stack->head);
        if (old_head == NULL) return 0;  // 空栈
    } while (!atomic_compare_exchange_weak(
        &stack->head,
        (intptr_t*)&old_head,
        (intptr_t)old_head->next
    ));
    *result = old_head->value;
    free(old_head);
    return 1;
}

这段代码的核心是CAS操作——Compare And Swap。它做的事情是:如果当前值等于期望值,就更新为新值,否则什么都不做。整个操作是原子的。

13.4.2 无锁编程的注意事项

  • ABA问题:指针被重用导致CAS误判。我遇到过,解决方案是用双指针或标记位。
  • 内存回收:无锁结构的内存释放很棘手。一个线程正在读的节点,另一个线程不能直接free掉。
  • 性能不一定更好:高竞争下,CAS循环可能比互斥锁还慢。

我的建议:无锁编程是屠龙刀,不是新手村武器。如果你刚开始学多线程,先用好互斥锁和条件变量。等真正遇到性能瓶颈了,再考虑无锁方案。

13.5 本章知识体系

下面这张图,是我梳理的原子操作与内存模型的知识脉络:

原子操作与内存模型知识体系 原子操作 原子类型 atomic_int atomic_flag atomic_bool atomic_long 内存顺序 relaxed acquire release acq_rel seq_cst(默认) 无锁编程 CAS操作 无锁栈 ABA问题 内存回收 核心原则 原子性保证操作不可分割 内存顺序控制可见性与重排序

这张图把本章的核心内容串起来了。原子类型是基础,内存顺序是灵魂,无锁编程是进阶应用。三者缺一不可。

13.6 避坑指南

最后,分享几个我踩过的坑:

  • 不要对非原子变量做原子操作——编译器不会报错,但结果不可预测。
  • atomic_flag是唯一保证无锁的类型——其他原子类型在某些平台上可能用锁实现。
  • CAS循环要加退避策略——高竞争下,空转会浪费CPU。我一般加个pause指令或者yield。
  • 测试要跑在多核机器上——单核下原子操作的问题很难复现。

一句话总结:原子操作是轻量级的同步工具,适合简单计数和标志位。复杂的数据结构,还是老老实实用锁吧。


公众号:蓝海资料掘金营,微信deep3321