指针与多线程:线程间共享内存的指针问题
多线程编程,说白了就是让程序同时干几件事。但一旦涉及指针共享,事情就变得微妙了。我早年做嵌入式音视频处理时,就踩过这个坑——两个线程同时操作一个环形缓冲区指针,结果数据错乱得一塌糊涂。今天咱们就把这块硬骨头啃下来。
线程间共享指针的风险
多个线程共享同一块内存,本质上是共享同一个指针。但问题在于:指针的读写不是原子的。
你想想看,一个 32 位系统里,指针本身是 4 个字节。如果线程 A 正在修改指针的值,线程 B 同时去读,可能读到一半新值、一半旧值——这叫“撕裂读”。我在项目中遇到过,一个全局指针指向动态分配的缓冲区,两个线程都在判断它是否为 NULL。结果一个线程刚释放完内存置 NULL,另一个线程读到的却是中间状态,直接崩溃。
- 数据竞争(Data Race):多个线程同时读写同一指针指向的内存,未加同步
- 指针撕裂:指针本身不是原子类型,读写可能被中断
- 悬空指针:一个线程释放内存,另一个线程还在使用
- ABA 问题:指针值被改回原值,但指向的内容已变
原子操作:让指针读写变安全
C11 标准引入了 stdatomic.h,专门解决这类问题。原子操作保证:一个操作要么完全执行,要么完全不执行。没有中间状态。
我个人习惯,只要是多线程共享的指针,一律用 atomic_load 和 atomic_store 来读写。别嫌麻烦,这能省掉 90% 的诡异 bug。
#include <stdatomic.h>
#include <stdio.h>
#include <pthread.h>
atomic_intptr_t shared_ptr; // 原子指针
void* writer_thread(void* arg) {
int* data = malloc(sizeof(int));
*data = 42;
atomic_store(&shared_ptr, (intptr_t)data);
return NULL;
}
void* reader_thread(void* arg) {
intptr_t val = atomic_load(&shared_ptr);
if (val != 0) {
int* data = (int*)val;
printf("读到值: %d\n", *data);
}
return NULL;
}
这里要注意:atomic_intptr_t 保证指针本身的读写是原子的。但它不保护指针指向的内容。如果你要同时保护数据和指针,还得加锁或用更高级的机制。
在 ARM Cortex-M 系列 MCU 上,原子操作通常由硬件指令(如 LDREX/STREX)实现。但有些低端芯片不支持,这时原子操作会退化为锁。写代码前最好查一下目标平台的实现方式。
内存屏障:看不见的秩序守护者
原子操作解决了“读写完整性”,但还有一个更隐蔽的问题:内存序(Memory Order)。
现代 CPU 和编译器会为了性能乱序执行指令。比如你写了:
data = 100;
flag = 1; // 假设 flag 是原子变量
实际执行时,flag = 1 可能先于 data = 100 被其他线程看到。这就是内存重排。
内存屏障(Memory Barrier)就是用来阻止这种重排的。C11 的原子操作提供了几种内存序模型:
| 内存序 | 含义 | 性能开销 |
|---|---|---|
memory_order_relaxed |
只保证原子性,不保证顺序 | 最低 |
memory_order_acquire |
之后的读写不能重排到该操作之前 | 中等 |
memory_order_release |
之前的读写不能重排到该操作之后 | 中等 |
memory_order_acq_rel |
同时具备 acquire 和 release | 较高 |
memory_order_seq_cst |
全局顺序一致性(默认) | 最高 |
我曾经在调试一个多核 DSP 上的通信协议时,发现明明用了原子变量,数据还是对不上。查了三天,最后发现是 memory_order_relaxed 导致的——两个核看到 flag 的顺序不一样。换成 acquire-release 对,问题立刻解决。
我曾经在项目中用 memory_order_relaxed 实现一个简单的计数器,想着反正只是累加,顺序无所谓。结果另一个线程读到的计数值比实际小——因为写操作被重排到了后面。从那以后,我给自己定了个规矩:除非你完全清楚内存序的语义,否则就用默认的 seq_cst。性能差一点,但安全第一。
实践中的常见模式
多线程指针共享,我总结了几种安全模式:
- 发布-订阅模式:一个线程写指针,多个线程只读。用
release写,acquire读。 - 双缓冲交换:两个缓冲区指针,通过原子交换来切换。适合生产者-消费者场景。
- 引用计数 + 原子指针:用原子操作管理引用计数,指针本身也用原子类型。我做过一个音视频解码器就是这么干的。
// 双缓冲交换示例
atomic_intptr_t active_buf;
atomic_intptr_t pending_buf;
void producer() {
void* new_buf = allocate_buffer();
fill_data(new_buf);
// 原子交换:把新缓冲区设为活跃
void* old = atomic_exchange(&active_buf, new_buf);
// 旧缓冲区可以回收或重用
}
void consumer() {
void* buf = atomic_load(&active_buf);
if (buf) {
process_data(buf);
}
}
嗯,这里要注意:atomic_exchange 本身是原子的,但交换后旧指针的处理需要额外同步。我一般配合一个 atomic_flag 来做双重检查。
SVG 知识体系图
多线程指针共享,记住三件事:第一,指针本身要用原子类型;第二,选对内存序,别图快用 relaxed;第三,释放内存前确保没有其他线程还在用。做到这三点,大部分问题都能避免。