25、指针与多线程:线程安全与指针、共享内存的指针操作、原子操作与指针
多线程编程,说白了就是让程序同时干几件事。但指针这东西,一旦进了多线程的池子,就容易出乱子。我早年做嵌入式音视频处理时,就吃过共享指针的亏——两个线程同时写一个指针指向的缓冲区,结果画面直接花屏。嗯,从那以后,我对多线程下的指针操作就格外小心。
25.1 线程安全的核心问题:数据竞争
先问个问题:两个线程同时读一个指针指向的数据,有问题吗?
读没问题。但只要有任何一个线程在写,就出事了。这叫数据竞争(data race)。C语言标准里明确说了,数据竞争是未定义行为。你想想看,编译器可能优化掉你的代码,硬件也可能乱序执行,结果就是程序跑着跑着就崩了。
核心原则:多个线程共享同一个指针指向的内存时,必须保证所有写操作是互斥的,或者使用原子操作。
我在项目中遇到过一种典型场景:一个全局指针指向动态分配的缓冲区,线程A负责填充数据,线程B负责读取处理。如果不用同步机制,线程B可能读到一半的数据——也就是所谓的“脏读”。
25.2 互斥锁保护指针操作
最直接的办法,就是用互斥锁(mutex)把指针的读写包起来。我个人习惯的做法是:把“指针本身”和“指针指向的数据”一起保护。
// 共享数据
typedef struct {
int *buffer;
size_t size;
pthread_mutex_t lock;
} SharedBuffer;
// 写线程
void writer_thread(SharedBuffer *sb) {
pthread_mutex_lock(&sb->lock);
// 安全地操作指针和它指向的数据
for (size_t i = 0; i < sb->size; i++) {
sb->buffer[i] = rand();
}
pthread_mutex_unlock(&sb->lock);
}
// 读线程
void reader_thread(SharedBuffer *sb) {
pthread_mutex_lock(&sb->lock);
// 安全地读取
int sum = 0;
for (size_t i = 0; i < sb->size; i++) {
sum += sb->buffer[i];
}
pthread_mutex_unlock(&sb->lock);
}
小技巧:锁的粒度要适中。锁太大,性能差;锁太小,容易漏掉保护。我一般遵循“操作指针的整个临界区只用一个锁”的原则。
25.3 共享内存中的指针操作
多进程共享内存时,指针问题更棘手。因为不同进程的地址空间是独立的,一个进程里的指针值,在另一个进程里可能指向完全无效的地址。
举个例子:进程A在共享内存里放了一个结构体,里面有个指针指向堆里的数据。进程B拿到这个指针后直接解引用——必崩无疑。
正确的做法是:共享内存里只放偏移量,不放绝对地址。
// 共享内存中的结构体
typedef struct {
int data_offset; // 相对于共享内存基址的偏移
int data_size;
} SharedNode;
// 进程A:写入
void *shm_base = mmap(...); // 映射共享内存
SharedNode *node = (SharedNode *)shm_base;
node->data_offset = sizeof(SharedNode); // 数据紧跟在结构体后面
node->data_size = 100;
int *data_ptr = (int *)((char *)shm_base + node->data_offset);
// 填充数据...
// 进程B:读取
void *shm_base = mmap(...); // 映射同一块共享内存
SharedNode *node = (SharedNode *)shm_base;
int *data_ptr = (int *)((char *)shm_base + node->data_offset);
// 现在可以安全地读取数据了
警告:绝对不要在共享内存里存储指针值!用偏移量代替。我曾经见过一个项目,因为共享内存里存了指针,换了个机器就跑不起来了——因为ASLR(地址空间布局随机化)导致地址不同。
25.4 原子操作与指针
有些场景下,用锁太重了。比如只是更新一个指针的值,或者做一次指针的比较交换。这时候可以用C11标准引入的原子操作。
原子操作,说白了就是硬件级别保证的“不可分割”的操作。你想想看,一个指针的赋值,在底层可能是多条指令。原子操作保证这些指令要么全部执行完,要么一条都不执行。
#include <stdatomic.h>
// 原子指针
atomic_int *atomic_ptr;
// 原子加载
int value = atomic_load(&atomic_ptr);
// 原子存储
atomic_store(&atomic_ptr, new_value);
// 比较并交换(CAS)
int expected = old_value;
if (atomic_compare_exchange_strong(&atomic_ptr, &expected, new_value)) {
// 成功将atomic_ptr从old_value更新为new_value
}
我在项目中用过原子指针来实现无锁队列。一个生产者线程往队列里放数据,一个消费者线程取数据。用原子操作更新头尾指针,避免了锁的开销。
注意:原子操作只保证指针本身的读写是原子的,不保证指针指向的数据的读写是原子的。如果你需要同时保护指针和数据,还是得上锁。
25.5 内存序与指针可见性
原子操作还有一个关键概念:内存序(memory order)。它控制着不同线程看到的内存操作的顺序。
| 内存序 | 说明 | 使用场景 |
|---|---|---|
| memory_order_relaxed | 只保证原子性,不保证顺序 | 计数器,不依赖其他变量 |
| memory_order_acquire | 保证后续读操作不会重排到前面 | 读取指针,确保看到之前写入的数据 |
| memory_order_release | 保证前面的写操作不会重排到后面 | 写入指针,确保之前的数据对其他线程可见 |
| memory_order_seq_cst | 最强的顺序保证 | 默认值,简单但性能稍差 |
// 典型的生产者-消费者模式
atomic_int *shared_ptr = NULL;
// 生产者线程
void producer() {
int *data = malloc(sizeof(int));
*data = 42;
// 先写数据,再发布指针
atomic_store_explicit(&shared_ptr, data, memory_order_release);
}
// 消费者线程
void consumer() {
int *ptr = NULL;
while ((ptr = atomic_load_explicit(&shared_ptr, memory_order_acquire)) == NULL) {
// 等待生产者发布
}
// 此时可以安全地读取*ptr,因为acquire保证了看到release之前的所有写操作
printf("%d\n", *ptr);
}
我的建议:如果你不确定用哪个内存序,就用默认的memory_order_seq_cst。虽然性能不是最优,但至少不会出错。等性能瓶颈出现时,再优化内存序。
25.6 知识体系总览
下面这张图总结了指针与多线程的核心知识点和它们之间的关系:
25.7 避坑指南
最后,分享几个我踩过的坑:
- 不要假设指针赋值是原子的。在32位系统上,64位指针的赋值就不是原子的。用atomic_store吧。
- 释放指针时要小心。一个线程在free一个指针,另一个线程还在读它——这是典型的use-after-free。我建议用引用计数或者RCU(读-拷贝-更新)机制。
- 注意编译器优化。volatile关键字不能保证原子性,也不能保证内存序。要用原子操作或者锁。
- 共享内存里的指针一定要用偏移量。我曾经把一个链表放在共享内存里,结果不同进程的地址空间不同,指针全废了。后来改成偏移量才解决问题。
血的教训:多线程调试比单线程难十倍。有时候bug不是每次都能复现。我的经验是:写代码时就考虑好线程安全,别指望事后调试能找出所有问题。
嗯,指针与多线程这块,说白了就是“共享数据要同步,指针操作要小心”。掌握了互斥锁、原子操作和内存序这三个工具,大部分场景都能应付。但记住,没有银弹——无锁编程虽然性能好,但实现复杂,容易出错。我个人建议:能用锁解决的问题,先用锁解决。等性能瓶颈出现时,再考虑优化。