指针与多线程:共享内存中的指针操作与原子操作

多线程编程,说白了就是让程序同时干好几件事。但事情一多,就容易打架。尤其是当多个线程共享同一块内存,还都拿着指针往里写数据时——嗯,那场面,我见过太多次了。

今天我们就聊聊,在多线程环境下,指针操作有哪些坑,以及怎么用原子操作来填坑。

共享内存中的指针:一把双刃剑

多线程共享内存,最直接的方式就是让多个线程访问同一个指针指向的数据。听起来简单,对吧?但问题在于,CPU 的指令执行顺序和内存访问顺序,并不总是你想象的那样。

举个例子:

// 全局共享数据
int* shared_ptr = NULL;
int data = 0;

// 线程1:初始化
void thread1() {
    data = 42;
    shared_ptr = &data;  // 先写数据,再赋值指针
}

// 线程2:使用
void thread2() {
    if (shared_ptr != NULL) {
        int value = *shared_ptr;  // 读指针指向的数据
        printf("%d\n", value);
    }
}

这段代码看起来没问题?大错特错。我在项目中遇到过类似的情况,调试了整整两天才发现是 CPU 指令重排导致的。线程1 中,shared_ptr = &data 这条指令可能先于 data = 42 执行。结果线程2 看到指针非空,但读到的数据还是垃圾值。

⚠️ 注意: 编译器优化和 CPU 乱序执行,会打乱你的代码顺序。不要相信代码的书写顺序就是执行顺序。

原子操作:给指针加把锁

解决这个问题,最直接的办法就是用原子操作。C11 标准提供了 <stdatomic.h> 头文件,里面定义了原子类型和操作函数。

我个人习惯用 atomic_storeatomic_load 来读写共享指针:

#include <stdatomic.h>
#include <stdbool.h>

// 原子指针
atomic_intptr_t shared_ptr = ATOMIC_VAR_INIT(0);
int data = 0;

void thread1() {
    data = 42;
    atomic_store(&shared_ptr, (intptr_t)&data);
}

void thread2() {
    intptr_t ptr_val = atomic_load(&shared_ptr);
    if (ptr_val != 0) {
        int* ptr = (int*)ptr_val;
        int value = *ptr;
        printf("%d\n", value);
    }
}

你看,用了原子操作后,atomic_store 保证了写入的完整性,atomic_load 保证了读取的完整性。而且,原子操作自带内存屏障,能防止指令重排。

💡 小技巧: 如果你用的是 C11 之前的编译器,可以用 GCC 的 __sync 系列内置函数,或者用 POSIX 的 pthread_spinlock 自旋锁来模拟原子操作。

内存序:控制可见性的关键

原子操作还有一个重要参数——内存序(memory order)。它决定了原子操作对其他线程的可见性。

常用的内存序有四种:

内存序 含义 性能
memory_order_relaxed 只保证原子性,不保证顺序 最快
memory_order_acquire 之后的读操作不会被重排到前面 中等
memory_order_release 之前的写操作不会被重排到后面 中等
memory_order_seq_cst 全局顺序一致性,最严格 最慢

我曾经在调试一个高性能网络框架时,为了追求极致性能,用了 memory_order_relaxed。结果呢?数据同步时好时坏,完全不可预测。后来老老实实换成 memory_order_acquire/release 配对使用,问题才解决。

你想想看,如果只是计数器累加,用 relaxed 没问题。但如果是用指针传递数据,就必须用 acquire/release 来保证数据可见性。

// 正确的做法:acquire/release 配对
atomic_intptr_t ptr = ATOMIC_VAR_INIT(0);
int data = 0;

// 写线程
void writer() {
    data = 100;
    atomic_store_explicit(&ptr, (intptr_t)&data, memory_order_release);
}

// 读线程
void reader() {
    intptr_t val = atomic_load_explicit(&ptr, memory_order_acquire);
    if (val) {
        int* p = (int*)val;
        printf("%d\n", *p);  // 保证读到 100
    }
}
🔑 核心原则: 写线程用 release,读线程用 acquire。这样就能保证写线程在 release 之前的所有写入,对读线程在 acquire 之后的所有读取都是可见的。

指针的原子操作:不只是读写

除了基本的读写,原子操作还支持比较交换(CAS)、自增自减等。这些操作在实现无锁数据结构时特别有用。

比如,实现一个无锁的栈:

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

atomic_uintptr_t head = ATOMIC_VAR_INIT(0);

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

这里用了 atomic_compare_exchange_weak,它会在循环中不断尝试,直到成功把新节点插入到栈顶。这种操作在单线程里很简单,但在多线程里,没有原子操作根本没法做。

⚠️ 注意: CAS 操作有 weak 和 strong 两种版本。weak 版本在某些平台上可能假失败(spurious failure),但性能更好。我建议在循环中使用 weak 版本,在非循环场景用 strong 版本。

避坑指南:我踩过的那些坑

讲几个我亲身经历过的教训:

  • 不要用 volatile 代替原子操作。volatile 只告诉编译器不要优化,但无法保证 CPU 级别的原子性和内存序。我曾经以为 volatile 就够了,结果线上服务偶尔崩溃,查了三天才发现是这个问题。
  • 指针本身也要原子化。如果多个线程同时修改同一个指针变量,必须用原子操作。否则会出现部分写入(tearing),导致指针指向一个非法地址。
  • 注意指针的大小。在 32 位系统上,指针是 4 字节;在 64 位系统上,指针是 8 字节。原子操作能保证对齐的指针读写是原子的,但不对齐的指针不行。所以,确保指针变量是自然对齐的

知识体系总览

下面这张图,是我梳理的指针与多线程的核心知识结构:

指针与多线程:知识体系 共享内存中的指针 • 多线程访问同一指针 • 指令重排问题 • 数据竞争风险 • 可见性问题 原子操作 • atomic_store/load • CAS 比较交换 • 原子自增自减 • 内存屏障 内存序控制 • relaxed(最宽松) • acquire/release • seq_cst(最严格) • 配对使用原则 实践要点 1. 共享指针必须用原子操作,不要用 volatile 替代 2. 写线程用 release,读线程用 acquire,配对使用 3. 确保指针变量自然对齐,避免部分写入问题 图:指针与多线程核心知识结构

说实话,多线程编程最难的不是语法,而是思维方式的转变。你不能再假设代码是按顺序执行的,也不能假设一个线程的修改能立刻被另一个线程看到。原子操作和内存序,就是帮你建立这种「多线程思维」的工具。

我个人建议,刚开始接触多线程指针操作时,先用最严格的 memory_order_seq_cst,等程序跑稳定了,再根据性能需求逐步放宽内存序。别一上来就追求极致性能,先把正确性保住再说。

嗯,今天就聊到这里。指针与多线程这个话题,其实还有很多可以深入的地方,比如无锁数据结构、RCU(读-拷贝-更新)等。但掌握了今天讲的原子操作和内存序,你已经能应对大部分实际场景了。


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