第26章:指针与多线程:线程安全与指针、原子操作与指针、线程间共享数据的指针管理
多线程编程,说白了就是让程序同时干好几件事。但指针这东西,一旦进了多线程的池子,就容易出乱子。我早年做嵌入式音视频解码器时,就吃过共享指针的亏——两个线程同时写一个缓冲区,结果画面花得一塌糊涂。从那以后,我对指针的线程安全问题就格外敏感。
26.1 线程安全与指针:共享不是免费的
先问个问题:两个线程同时读一个指针指向的数据,安全吗?
答案是:读没问题,写就有问题了。
举个例子:
// 全局指针
int* shared_ptr = NULL;
// 线程1:分配内存并写入
void thread1() {
shared_ptr = (int*)malloc(sizeof(int) * 100);
for (int i = 0; i < 100; i++) {
shared_ptr[i] = i;
}
}
// 线程2:读取数据
void thread2() {
if (shared_ptr != NULL) {
for (int i = 0; i < 100; i++) {
printf("%d ", shared_ptr[i]);
}
}
}
这段代码看着简单,但跑起来可能崩溃。为什么?
因为线程1还没写完,线程2就开始读了。你想想看,线程1刚写到第50个元素,线程2就打印了——打印出来的数据一半是新的,一半是旧的,甚至可能是垃圾值。
核心原则:多个线程同时访问同一个指针指向的内存时,只要有任何一个线程在写,就必须加锁或使用原子操作。
我在项目中遇到过更隐蔽的问题:两个线程都在写,但写的是不同的内存区域。你以为安全了?不一定。如果它们共享同一个指针变量,指针本身也可能被破坏。比如线程A把指针指向区域1,线程B把指针指向区域2,结果指针可能变成个乱七八糟的地址。
26.2 原子操作与指针:轻量级的保护
锁是好东西,但有时候太重了。比如你只是想安全地更新一个指针,用互斥锁有点杀鸡用牛刀的感觉。这时候,原子操作就派上用场了。
C11标准引入了
#include <stdatomic.h>
// 原子指针
atomic_int* atomic_ptr = NULL;
atomic_int* old_ptr = NULL;
// 线程1:原子地交换指针
void thread1() {
atomic_int* new_ptr = (atomic_int*)malloc(sizeof(atomic_int) * 100);
// 原子交换:把atomic_ptr换成new_ptr,同时拿到旧的指针
old_ptr = atomic_exchange(&atomic_ptr, new_ptr);
}
// 线程2:安全地读取指针
void thread2() {
atomic_int* local_ptr = atomic_load(&atomic_ptr);
if (local_ptr != NULL) {
// 此时local_ptr指向的内存是稳定的
printf("%d\n", atomic_load(&local_ptr[0]));
}
}
注意看,atomic_exchange和atomic_load保证了指针的读写是原子的。也就是说,线程1在交换指针时,线程2要么看到旧指针,要么看到新指针,绝不会看到中间状态。
我的习惯:在嵌入式系统中,如果只是更新指针指向,我优先用原子操作而不是锁。因为原子操作不会引起上下文切换,实时性更好。但要注意,原子操作只保护指针本身,不保护指针指向的数据。
26.3 线程间共享数据的指针管理:避坑指南
共享数据的指针管理,说白了就是回答三个问题:
1. 谁分配内存?
2. 谁释放内存?
3. 什么时候能安全地访问?
我曾经在一个项目中犯过这样的错误:线程A分配了一块内存,把指针传给线程B,然后线程A自己就把内存释放了。线程B拿着野指针继续写,结果程序直接段错误。嗯,从那以后我定了个规矩:谁分配,谁释放;或者明确约定所有权转移。
来看一个典型的安全模式:
#include <pthread.h>
#include <stdatomic.h>
typedef struct {
int* data;
size_t size;
atomic_int ref_count; // 引用计数
} SharedBuffer;
// 创建共享缓冲区
SharedBuffer* create_buffer(size_t size) {
SharedBuffer* buf = (SharedBuffer*)malloc(sizeof(SharedBuffer));
buf->data = (int*)malloc(sizeof(int) * size);
buf->size = size;
atomic_init(&buf->ref_count, 1); // 初始引用计数为1
return buf;
}
// 增加引用
void retain_buffer(SharedBuffer* buf) {
atomic_fetch_add(&buf->ref_count, 1);
}
// 减少引用,当引用计数为0时释放
void release_buffer(SharedBuffer* buf) {
if (atomic_fetch_sub(&buf->ref_count, 1) == 1) {
// 最后一个引用被释放,安全地回收内存
free(buf->data);
free(buf);
}
}
// 线程函数:使用缓冲区
void* worker_thread(void* arg) {
SharedBuffer* buf = (SharedBuffer*)arg;
retain_buffer(buf); // 增加引用,确保使用期间不会被释放
for (size_t i = 0; i < buf->size; i++) {
buf->data[i] = (int)i * 2;
}
release_buffer(buf); // 使用完毕,减少引用
return NULL;
}
这个模式叫「引用计数」,说白了就是给共享数据加了个计数器。每个线程在使用前增加计数,使用后减少计数。当计数归零时,最后一个线程负责释放内存。这样就不会出现「一个线程在用,另一个线程已经释放了」的情况。
注意:引用计数本身必须是原子的。如果不用原子操作,两个线程同时增减计数,可能造成计数错误,导致内存泄漏或提前释放。我见过有人用普通int做引用计数,结果线上崩溃——教训深刻。
26.4 知识体系总览
下面这张图总结了本章的核心逻辑:
26.5 实战建议
说了这么多,总结几条我个人的经验:
- 能用原子操作就别用锁。原子操作在嵌入式系统里开销小,实时性好。但记住,它只保护指针本身。
- 引用计数是个好东西。我做过一个音频处理系统,多个线程共享同一个音频缓冲区,用引用计数管理生命周期,从来没出过内存问题。
- 尽量避免全局指针。全局指针在多线程环境里就是个定时炸弹。我建议把指针封装在结构体里,配合明确的接口函数来操作。
- 测试要覆盖并发场景。单线程跑得再好,多线程一上可能就崩。我习惯用压力测试工具反复跑,模拟高并发访问。
一句话总结:多线程下的指针管理,核心就两件事——保护指针本身的原子性,保护指针指向数据的一致性。做好这两点,你的程序就能在多核世界里稳稳地跑。
公众号:蓝海资料掘金营,微信deep3321