8. 线程间通信:全局变量通信、线程局部存储(__thread)、线程安全与可重入函数

线程间通信,说白了就是多个线程怎么“说话”。

我刚开始写多线程程序时,觉得这事儿特简单——共享一块内存不就行了?后来被各种诡异bug折磨得够呛,才明白这里面的水有多深。

今天咱们就聊聊三种核心手段:全局变量通信线程局部存储,以及绕不开的线程安全与可重入问题。

8.1 全局变量通信——最直接,也最危险

多个线程共享进程的地址空间。所以,一个全局变量,所有线程都能读写。这听起来很方便,对吧?

我在项目中遇到过这样一个场景:一个计数器,多个线程都要往上加1。代码写出来大概是这样:

#include <stdio.h>
#include <pthread.h>

int counter = 0;  // 全局变量,所有线程共享

void* worker(void* arg) {
    for (int i = 0; i < 100000; i++) {
        counter++;  // 这里有问题!
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, worker, NULL);
    pthread_create(&t2, NULL, worker, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %d\n", counter);
    return 0;
}

你猜结果是多少?理论上应该是200000。但实际跑起来,每次结果都不一样,而且大概率小于200000。

为什么会这样?因为 counter++ 不是原子操作。它至少拆成三步:读、加、写。两个线程可能同时读到同一个值,各自加1再写回去——结果只加了1次。

⚠️ 避坑指南:我曾经在一个生产环境的监控模块里犯过这个错。计数器不准,导致告警阈值一直触发不了。排查了两天才发现是并发累加的问题。记住:没有保护的全局变量,在多线程里就是一颗定时炸弹

解决办法?加锁、用原子操作,或者用下面要讲的线程局部存储。

8.2 线程局部存储(__thread)——每个线程自己的“小本本”

有时候,我们并不想共享数据。每个线程需要自己的一份独立副本。比如每个线程的日志缓冲区、错误码、或者临时状态。

C语言里,__thread 关键字就是干这个的。它是GCC和Clang都支持的扩展,C11标准里也有 _Thread_local

#include <stdio.h>
#include <pthread.h>

__thread int tls_var = 0;  // 每个线程独立一份

void* worker(void* arg) {
    tls_var = (int)(long)arg;  // 每个线程只改自己的副本
    printf("Thread %ld: tls_var = %d\n", (long)arg, tls_var);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, worker, (void*)1);
    pthread_create(&t2, NULL, worker, (void*)2);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    // 主线程的 tls_var 仍然是 0
    printf("Main thread: tls_var = %d\n", tls_var);
    return 0;
}

你看,每个线程修改自己的 tls_var,互不干扰。主线程的 tls_var 还是0。

💡 个人习惯:我一般用 __thread 来存每个线程的“上下文”信息,比如线程ID、当前处理的任务ID。这样既避免了全局锁的开销,又让代码逻辑更清晰。

不过要注意:__thread 只能修饰POD类型(基本类型、指针、结构体等),不能修饰复杂对象(比如C++的类,除非它没有构造函数/析构函数)。

8.3 线程安全与可重入函数——别让函数“打架”

这两个概念经常被混用,但其实是两码事。

  • 线程安全:函数在多个线程同时调用时,能正确工作。通常靠加锁、原子操作、或者避免共享数据来实现。
  • 可重入:函数在执行过程中被中断(比如信号处理函数),然后再次被调用,仍然能正确工作。可重入函数不能使用静态或全局数据,也不能调用不可重入的函数。

说白了:线程安全解决的是“多线程并发”的问题;可重入解决的是“单线程内被中断后重入”的问题。

我举个例子你就明白了:

#include <stdio.h>
#include <pthread.h>

// 线程安全,但不可重入
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static int shared = 0;

int safe_increment() {
    pthread_mutex_lock(&lock);
    shared++;
    int ret = shared;
    pthread_mutex_unlock(&lock);
    return ret;
}

// 可重入版本:不依赖任何共享数据
int reentrant_increment(int* counter) {
    (*counter)++;
    return *counter;
}

safe_increment 是线程安全的——多个线程同时调用没问题。但如果它在执行过程中被信号处理函数打断,而信号处理函数也调用了 safe_increment,那就死锁了(因为锁已经被自己持有了)。所以它不可重入。

reentrant_increment 只操作传入的指针,不碰任何全局或静态数据。它既是线程安全的,也是可重入的。

🔑 核心原则
- 可重入函数一定是线程安全的(反过来不一定)。
- 写可重入函数时,别用全局/静态变量,别调用不可重入的函数(比如 mallocprintf)。
- 写线程安全函数时,要么加锁,要么用线程局部存储,要么保证操作是原子的。

8.4 知识体系图

下面这张图帮你理清本章的核心逻辑:

线程间通信核心知识体系 全局变量通信 线程局部存储 (__thread) 线程安全与可重入 需要同步保护 原子操作 / 互斥锁 每个线程独立副本 无需加锁,性能好 线程安全 ≠ 可重入 可重入函数更严格 选择建议 需要共享数据 → 全局变量 + 同步保护 需要独立数据 → 线程局部存储 函数设计 → 优先考虑可重入

8.5 实战建议

嗯,这里我要多说几句。很多初学者一上来就用全局变量加锁,觉得这样最“通用”。但实际项目中,我建议你按这个顺序来思考:

  1. 能不能用线程局部存储? 如果每个线程只需要自己的数据副本,那就用 __thread。零锁、零等待、零bug。
  2. 能不能用原子操作? 如果只是简单的加减、标志位切换,用 __sync_fetch_and_add 或 C11 的 atomic_* 系列。比锁快一个数量级。
  3. 实在不行,才上锁。 锁会带来死锁、性能下降、优先级反转等问题。用锁的时候,记得锁的粒度要小,持有时间要短。
💡 一个小技巧:我写多线程代码时,习惯先把函数写成可重入的——所有数据都通过参数传入,不碰全局。这样天然就是线程安全的。如果后续需要共享状态,再小心翼翼地加锁。这个习惯帮我避免了很多线上事故。

最后,记住一句话:共享数据是万恶之源。能不用就不用,能少用就少用。如果非用不可,那就用最安全的方式去用。


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