指针与多线程:共享内存中的指针操作与原子操作
多线程编程,说白了就是让程序同时干好几件事。但事情一多,就容易打架。尤其是当多个线程共享同一块内存,还都拿着指针往里写数据时——嗯,那场面,我见过太多次了。
今天我们就聊聊,在多线程环境下,指针操作有哪些坑,以及怎么用原子操作来填坑。
共享内存中的指针:一把双刃剑
多线程共享内存,最直接的方式就是让多个线程访问同一个指针指向的数据。听起来简单,对吧?但问题在于,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 看到指针非空,但读到的数据还是垃圾值。
原子操作:给指针加把锁
解决这个问题,最直接的办法就是用原子操作。C11 标准提供了 <stdatomic.h> 头文件,里面定义了原子类型和操作函数。
我个人习惯用 atomic_store 和 atomic_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 保证了读取的完整性。而且,原子操作自带内存屏障,能防止指令重排。
内存序:控制可见性的关键
原子操作还有一个重要参数——内存序(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
}
}
指针的原子操作:不只是读写
除了基本的读写,原子操作还支持比较交换(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,它会在循环中不断尝试,直到成功把新节点插入到栈顶。这种操作在单线程里很简单,但在多线程里,没有原子操作根本没法做。
避坑指南:我踩过的那些坑
讲几个我亲身经历过的教训:
- 不要用 volatile 代替原子操作。volatile 只告诉编译器不要优化,但无法保证 CPU 级别的原子性和内存序。我曾经以为 volatile 就够了,结果线上服务偶尔崩溃,查了三天才发现是这个问题。
- 指针本身也要原子化。如果多个线程同时修改同一个指针变量,必须用原子操作。否则会出现部分写入(tearing),导致指针指向一个非法地址。
- 注意指针的大小。在 32 位系统上,指针是 4 字节;在 64 位系统上,指针是 8 字节。原子操作能保证对齐的指针读写是原子的,但不对齐的指针不行。所以,确保指针变量是自然对齐的。
知识体系总览
下面这张图,是我梳理的指针与多线程的核心知识结构:
说实话,多线程编程最难的不是语法,而是思维方式的转变。你不能再假设代码是按顺序执行的,也不能假设一个线程的修改能立刻被另一个线程看到。原子操作和内存序,就是帮你建立这种「多线程思维」的工具。
我个人建议,刚开始接触多线程指针操作时,先用最严格的 memory_order_seq_cst,等程序跑稳定了,再根据性能需求逐步放宽内存序。别一上来就追求极致性能,先把正确性保住再说。
嗯,今天就聊到这里。指针与多线程这个话题,其实还有很多可以深入的地方,比如无锁数据结构、RCU(读-拷贝-更新)等。但掌握了今天讲的原子操作和内存序,你已经能应对大部分实际场景了。