指针与多线程:线程间共享内存的指针问题

多线程编程,说白了就是让程序同时干几件事。但一旦涉及指针共享,事情就变得微妙了。我早年做嵌入式音视频处理时,就踩过这个坑——两个线程同时操作一个环形缓冲区指针,结果数据错乱得一塌糊涂。今天咱们就把这块硬骨头啃下来。

线程间共享指针的风险

多个线程共享同一块内存,本质上是共享同一个指针。但问题在于:指针的读写不是原子的

你想想看,一个 32 位系统里,指针本身是 4 个字节。如果线程 A 正在修改指针的值,线程 B 同时去读,可能读到一半新值、一半旧值——这叫“撕裂读”。我在项目中遇到过,一个全局指针指向动态分配的缓冲区,两个线程都在判断它是否为 NULL。结果一个线程刚释放完内存置 NULL,另一个线程读到的却是中间状态,直接崩溃。

⚠️ 核心风险点:
  • 数据竞争(Data Race):多个线程同时读写同一指针指向的内存,未加同步
  • 指针撕裂:指针本身不是原子类型,读写可能被中断
  • 悬空指针:一个线程释放内存,另一个线程还在使用
  • ABA 问题:指针值被改回原值,但指向的内容已变

原子操作:让指针读写变安全

C11 标准引入了 stdatomic.h,专门解决这类问题。原子操作保证:一个操作要么完全执行,要么完全不执行。没有中间状态。

我个人习惯,只要是多线程共享的指针,一律用 atomic_loadatomic_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。性能差一点,但安全第一。

实践中的常见模式

多线程指针共享,我总结了几种安全模式:

  1. 发布-订阅模式:一个线程写指针,多个线程只读。用 release 写,acquire 读。
  2. 双缓冲交换:两个缓冲区指针,通过原子交换来切换。适合生产者-消费者场景。
  3. 引用计数 + 原子指针:用原子操作管理引用计数,指针本身也用原子类型。我做过一个音视频解码器就是这么干的。
// 双缓冲交换示例
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 知识体系图

指针与多线程 共享内存风险 数据竞争 指针撕裂 悬空指针 原子操作 atomic_load/store atomic_exchange atomic_compare_exchange 内存屏障 acquire/release seq_cst relaxed 实践模式:发布-订阅 | 双缓冲 | 引用计数 核心原则:原子性 + 内存序 = 安全共享
📌 总结一下:

多线程指针共享,记住三件事:第一,指针本身要用原子类型;第二,选对内存序,别图快用 relaxed;第三,释放内存前确保没有其他线程还在用。做到这三点,大部分问题都能避免。

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