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_storeatomic_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 知识体系总览

下面这张图总结了指针与多线程的核心关系:

指针与多线程 线程安全问题 数据竞争 读写不同步 野指针风险 互斥锁保护 pthread_mutex_lock 保护指针+数据 粒度控制 原子操作 atomic_store/load 只保护指针本身 高性能无锁 线程局部存储 _Thread_local 每个线程独立副本

从图中可以看出,指针与多线程的交互有四个核心方向:线程安全问题、互斥锁保护、原子操作、线程局部存储。每个方向解决不同的问题场景。

17.6 总结

指针在多线程环境下的使用,核心就一句话:共享指针要保护,私有指针用TLS

  • 共享数据:用互斥锁保护指针及其指向的数据
  • 指针本身:可以用原子操作,但要注意不保护数据
  • 线程私有:用线程局部存储,天然安全

嗯,这些经验都是我在实际项目中踩过坑才总结出来的。你写代码的时候,多想想「这个指针会被几个线程访问?」「访问时有没有中间状态?」——想清楚了,线程安全问题就解决了一大半。


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