17、指针与多线程:线程安全与指针、互斥锁保护指针数据、原子操作与指针、线程局部存储与指针
多线程编程,说白了就是让程序同时干好几件事。但指针这东西,一旦进了多线程的战场,麻烦就来了。
我记得刚入行那会儿,写过一个多线程日志系统。两个线程同时往一个全局缓冲区里写数据,结果日志内容互相穿插,读出来全是乱码。查了两天才发现——指针指向的共享数据没加保护。
嗯,今天我们就来聊聊,指针在多线程环境里该怎么用,才不会翻车。
17.1 线程安全与指针:问题到底出在哪?
先看一个最简单的例子。两个线程同时修改一个全局指针指向的数据:
// 全局共享数据
int* shared_data = NULL;
// 线程1
void* thread1(void* arg) {
shared_data = (int*)malloc(sizeof(int));
*shared_data = 42;
return NULL;
}
// 线程2
void* thread2(void* arg) {
if (shared_data != NULL) {
printf("%d\n", *shared_data);
}
return NULL;
}
这段代码有什么问题?
线程1刚执行完 shared_data = (int*)malloc(...),还没来得及赋值42,线程2就开始读 *shared_data 了。读到的值是什么?不确定。可能是随机数,也可能是野指针访问导致崩溃。
核心问题:指针的读写操作不是原子的。一个线程写指针,另一个线程读指针,中间可能插入任何操作。这就是典型的「数据竞争」。
我在项目中遇到过类似的情况。一个嵌入式设备上的网络协议栈,多个任务共享一个socket指针。不加锁的情况下,一个任务关闭socket,另一个任务还在往里面写数据。结果?设备直接死机了。
17.2 互斥锁保护指针数据
解决多线程访问共享指针最直接的办法——加锁。
互斥锁(mutex)保证同一时间只有一个线程能访问被保护的资源。说白了,就是给共享数据加个门,谁进去谁锁门,出来再开门。
#include <pthread.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int* shared_data = NULL;
void* thread1(void* arg) {
pthread_mutex_lock(&lock);
shared_data = (int*)malloc(sizeof(int));
*shared_data = 42;
pthread_mutex_unlock(&lock);
return NULL;
}
void* thread2(void* arg) {
pthread_mutex_lock(&lock);
if (shared_data != NULL) {
printf("%d\n", *shared_data);
}
pthread_mutex_unlock(&lock);
return NULL;
}
这样写就安全了。线程1在锁内完成指针赋值和数据写入,线程2在锁内读取,不会出现中间状态。
我的习惯:锁的粒度要适中。锁的范围太大,性能下降;锁的范围太小,可能漏掉保护。我个人习惯把「指针赋值 + 数据初始化」放在同一个锁内完成,这样读线程拿到的指针一定是完整的。
但加锁也有代价。锁竞争会导致线程阻塞,高并发场景下性能会打折扣。你想想看,如果100个线程都在抢同一把锁,大部分时间都花在等锁上了。
17.3 原子操作与指针
有些场景下,我们不需要锁那么重的机制。比如只是简单地修改指针的值,不涉及指针指向的数据。这时候可以用原子操作。
原子操作,说白了就是「不可分割的操作」。要么全部执行完,要么没执行,中间不会被其他线程打断。
C11标准提供了 stdatomic.h,专门处理原子变量:
#include <stdatomic.h>
atomic_int* atomic_ptr = NULL;
void* thread1(void* arg) {
atomic_int* new_ptr = (atomic_int*)malloc(sizeof(atomic_int));
*new_ptr = 100;
atomic_store(&atomic_ptr, new_ptr);
return NULL;
}
void* thread2(void* arg) {
atomic_int* ptr = atomic_load(&atomic_ptr);
if (ptr != NULL) {
printf("%d\n", *ptr);
}
return NULL;
}
atomic_store 和 atomic_load 保证指针的读写是原子的。一个线程写指针时,另一个线程读到的要么是旧值,要么是新值,不会读到中间状态。
注意:原子操作只保护指针本身,不保护指针指向的数据。如果多个线程同时修改指针指向的内容,还是需要加锁。
我曾经在一个高性能消息队列里用过原子指针。生产者线程通过原子操作更新队列头指针,消费者线程原子读取。避免了锁竞争,吞吐量提升了3倍多。
但原子操作也不是万能的。复杂的数据结构(比如链表、树)用原子操作很难保证一致性。这时候还是老老实实加锁吧。
17.4 线程局部存储与指针
还有一种情况:每个线程都需要自己的指针副本,互不干扰。这时候可以用线程局部存储(Thread-Local Storage, TLS)。
说白了,就是给每个线程分配一个独立的变量副本。线程A修改自己的副本,不影响线程B的副本。
C11 用 _Thread_local 关键字声明:
#include <stdio.h>
#include <pthread.h>
_Thread_local int* tls_ptr = NULL;
void* thread_func(void* arg) {
// 每个线程都有自己的 tls_ptr
tls_ptr = (int*)malloc(sizeof(int));
*tls_ptr = (int)(long)arg;
printf("Thread %ld: %d\n", (long)arg, *tls_ptr);
free(tls_ptr);
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, thread_func, (void*)1);
pthread_create(&t2, NULL, thread_func, (void*)2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
每个线程的 tls_ptr 都是独立的。线程1修改它,线程2完全看不到。这就避免了锁竞争。
避坑指南:我曾经在项目里用TLS存储了一个全局错误码指针。每个线程遇到错误时,把错误信息写入自己的TLS缓冲区。主线程再统一收集。这样既避免了锁,又保证了线程安全。
但TLS也有局限。它只适合「每个线程独立使用」的场景。如果多个线程需要共享数据,TLS就不适用了。
17.5 知识体系总览
下面这张图总结了指针与多线程的核心关系:
从图中可以看出,指针与多线程的交互有四个核心方向:线程安全问题、互斥锁保护、原子操作、线程局部存储。每个方向解决不同的问题场景。
17.6 总结
指针在多线程环境下的使用,核心就一句话:共享指针要保护,私有指针用TLS。
- 共享数据:用互斥锁保护指针及其指向的数据
- 指针本身:可以用原子操作,但要注意不保护数据
- 线程私有:用线程局部存储,天然安全
嗯,这些经验都是我在实际项目中踩过坑才总结出来的。你写代码的时候,多想想「这个指针会被几个线程访问?」「访问时有没有中间状态?」——想清楚了,线程安全问题就解决了一大半。