19、指针与多线程:线程安全与指针、共享内存的指针、volatile关键字与指针
多线程编程,说白了就是让程序同时干好几件事。但指针这东西,一旦和多线程搅在一起,就容易出大乱子。我早年做嵌入式通信设备时,就吃过这个亏——两个线程同时操作一个全局指针,结果程序跑着跑着就崩了,查了三天才找到原因。
今天咱们就把这块硬骨头啃下来。我会从三个角度切入:线程安全与指针、共享内存的指针、以及volatile关键字。嗯,这三个点其实是互相关联的,咱们一个一个说。
19.1 线程安全与指针:别让指针变成“野指针”
先问个问题:两个线程同时读写同一个指针变量,会发生什么?
答案是:未定义行为。你想想看,线程A刚把指针指向一块新分配的内存,线程B却还在用旧地址读写数据。轻则数据错乱,重则段错误,直接崩掉。
核心原则:多个线程共享的指针,必须用互斥锁保护。没有例外。
我在项目中遇到过这样一个场景:一个全局的环形缓冲区,用指针管理读写位置。两个线程——一个生产者、一个消费者——同时操作这个指针。一开始没加锁,结果生产者的写入位置被消费者读走了,数据全乱套了。
正确的做法是这样的:
// 线程不安全的写法
int *shared_ptr = NULL;
void thread1() {
shared_ptr = malloc(100);
// 写数据...
}
void thread2() {
if (shared_ptr != NULL) {
// 读数据... 但此时shared_ptr可能已被thread1修改
}
}
// 线程安全的写法
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int *shared_ptr = NULL;
void thread1() {
pthread_mutex_lock(&lock);
shared_ptr = malloc(100);
// 写数据...
pthread_mutex_unlock(&lock);
}
void thread2() {
pthread_mutex_lock(&lock);
if (shared_ptr != NULL) {
// 读数据...
}
pthread_mutex_unlock(&lock);
}
注意,锁的粒度要把握好。锁得太细,性能差;锁得太粗,并发度低。我个人习惯是:对指针的读写操作,整个临界区尽量短。比如上面这个例子,只把指针赋值和判空的部分锁住,具体的数据读写可以放到锁外面。
小技巧:如果只是读指针指向的数据,可以用读写锁(pthread_rwlock_t)。多个读线程可以同时访问,写线程独占。这在读多写少的场景下性能提升很明显。
19.2 共享内存的指针:跨进程的“雷区”
共享内存,说白了就是让两个进程看到同一块物理内存。但指针这东西,在共享内存里用起来特别坑——因为每个进程的虚拟地址空间是独立的。
举个例子:进程A在共享内存里放了一个指针,指向共享内存里的另一块数据。进程B拿到这个指针后,直接解引用——崩了。为什么?因为进程A的虚拟地址,在进程B的地址空间里可能映射到了完全不同的地方。
我曾经在一个多进程的监控系统里踩过这个坑。两个进程通过共享内存交换数据,其中一个进程在共享内存里存了一个链表,链表节点里全是绝对地址指针。结果另一个进程一读,全崩了。
解决方案其实不复杂:共享内存里不要存绝对地址指针,改用偏移量。
// 错误的做法:存绝对地址
typedef struct {
int data;
void *next; // 绝对地址,跨进程无效
} Node;
// 正确的做法:存偏移量
typedef struct {
int data;
int next_offset; // 相对于共享内存基地址的偏移量
} Node;
// 使用方式
void *shm_base = attach_shared_memory(); // 获取共享内存基地址
Node *node = (Node *)((char *)shm_base + offset);
Node *next_node = (Node *)((char *)shm_base + node->next_offset);
警告:共享内存里的指针,一定要用偏移量。绝对地址指针在跨进程场景下就是定时炸弹。另外,共享内存的同步机制(信号量、互斥锁)也要放在共享内存里,否则其他进程拿不到。
还有一点要注意:共享内存的指针类型。我建议统一用 char * 或 void * 作为基地址,然后通过偏移量访问具体结构体。这样代码可读性好,也容易维护。
19.3 volatile关键字与指针:别让编译器“优化”掉你的逻辑
volatile这个关键字,很多人觉得它神秘。其实说白了,它就是告诉编译器:这个变量的值可能会被意想不到地改变,你别瞎优化。
什么时候需要volatile?三个典型场景:
- 硬件寄存器映射(内存映射I/O)
- 多线程共享变量(没有锁保护的情况下)
- 信号处理函数中修改的全局变量
我举个例子。你写了一个循环,等待某个硬件状态寄存器变成1:
// 没有volatile
int *status_reg = (int *)0x40001000;
while (*status_reg == 0) {
// 等待硬件置位
}
// 加了volatile
volatile int *status_reg = (volatile int *)0x40001000;
while (*status_reg == 0) {
// 等待硬件置位
}
没有volatile时,编译器可能会把 *status_reg 的值优化到寄存器里,然后循环就变成了死循环——因为寄存器里的值永远不会变。加了volatile后,编译器每次循环都会从内存地址重新读取,这才正确。
多线程场景下也一样。如果一个全局变量被多个线程共享,且没有用锁保护,那这个变量应该声明为volatile。不过要注意:volatile不能替代锁。它只保证读取的“可见性”,不保证操作的“原子性”。
重要区分:volatile ≠ 原子操作。volatile只防止编译器优化,不保证CPU级别的原子性。比如 volatile int count++ 在多核CPU上仍然可能出问题,因为自增操作不是原子的。
我个人习惯是:多线程共享的指针变量,如果不用锁保护,一定要加volatile。比如:
volatile int *g_flag = NULL;
// 线程1
void thread1() {
g_flag = some_pointer; // 写操作,volatile保证其他线程能看到
}
// 线程2
void thread2() {
while (g_flag == NULL) {
// 忙等待,volatile保证每次重新读取
}
// 此时g_flag非空,可以安全使用
}
但说实话,这种忙等待的方式效率很低。我建议还是用锁+条件变量,既安全又高效。
19.4 知识体系总览
下面这张图,把本章的核心逻辑串起来了。你看一眼就能明白:指针在多线程环境下的三个关键问题,以及它们之间的关联。
19.5 避坑指南
最后,我把自己这些年踩过的坑总结一下,你遇到了可以直接对照:
- 我曾经在共享内存里直接存了链表指针,结果跨进程访问时程序崩溃。后来全部改成偏移量,问题解决。
- 我曾经写了一个多线程程序,全局指针没加volatile,结果Release模式下行为异常,Debug模式正常。查了两天才发现是编译器优化搞的鬼。
- 我曾经以为volatile能保证原子性,结果在多核CPU上跑出了脏数据。后来老老实实加了原子操作或互斥锁。
终极建议:多线程环境下操作指针,优先用锁保护。volatile只是辅助手段,不是万能药。共享内存里绝对不要用绝对地址指针。记住这三条,能避开90%的坑。
嗯,指针与多线程这块,说难也难,说简单也简单。核心就是:想清楚谁在什么时候读写什么数据,然后加合适的保护。你多写几个项目,自然就形成肌肉记忆了。
公众号:蓝海资料掘金营,微信deep3321