一、线程安全与可重入函数

各位同学,今天我们来聊聊多线程编程里一个绕不开的话题——线程安全。说实话,我刚开始接触多线程时,觉得这东西不就是加个锁嘛,有什么难的?直到有一次线上服务莫名其妙地崩溃,排查了整整两天,最后发现是一个看似人畜无害的全局变量在作祟。嗯,从那以后,我再也不敢小看线程安全了。

1.1 线程安全的定义

什么叫线程安全?说白了,就是一段代码在多个线程同时执行时,不会出现数据混乱、结果错误的情况。你想想看,如果两个线程同时往一个银行账户里存钱,结果账目对不上,那还得了?

线程安全的定义:一个函数或数据结构,在多个线程并发调用时,能够正确地处理共享数据,不会产生竞态条件(Race Condition),也不会导致数据不一致或程序崩溃。

我个人习惯把线程安全分为几个等级:

  • 完全线程安全:所有接口都能被多线程安全调用
  • 有条件线程安全:需要调用者遵守某些约定(比如先加锁再调用)
  • 线程不安全:绝对不能在多线程环境下使用

我在项目中遇到过最典型的例子就是 strtok() 函数。这个函数内部维护了一个静态指针,用来记录当前解析位置。两个线程同时调用它,结果可想而知——数据全乱套了。

1.2 可重入与不可重入函数

这里有个容易混淆的概念:可重入函数。很多人以为线程安全就等于可重入,其实不是这么回事。

可重入函数是指:函数在执行过程中被中断(比如信号处理函数),然后再次被调用,两次调用不会互相影响。它比线程安全的要求更严格——可重入函数不能使用任何静态或全局数据,也不能调用不可重入的函数。

特性 可重入函数 线程安全函数
使用全局/静态变量 不允许 允许(但需加锁)
修改自身代码 不允许 不允许
调用不可重入函数 不允许 允许(但需加锁)
信号安全 不一定

举个例子,rand() 函数内部维护了一个全局的种子状态,所以它是不可重入的。但如果你给每个线程分配独立的种子,那就可以做到线程安全。C标准库提供了 rand_r() 这个可重入版本,它把状态保存在调用者提供的参数中。

避坑指南:我曾经在信号处理函数里调用了 printf(),结果程序莫名其妙地死锁了。后来才意识到,printf() 内部用了锁,而信号处理函数可能中断了正在持有锁的代码——这就是典型的不可重入问题。

1.3 线程安全的实现策略

实现线程安全,说白了就是管好共享数据。我总结了几个常用策略:

1.3.1 互斥锁(Mutex)

最基础的手段。访问共享数据前加锁,访问完后解锁。但要注意死锁问题——我曾经在代码里嵌套加锁,结果两个线程互相等对方释放锁,程序直接卡死。

// 线程安全的计数器
static int counter = 0;
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void safe_increment() {
    pthread_mutex_lock(&mutex);
    counter++;  // 临界区
    pthread_mutex_unlock(&mutex);
}

1.3.2 读写锁(RWLock)

读多写少的场景,用读写锁效率更高。多个线程可以同时读,但写的时候必须独占。

1.3.3 无锁编程

用原子操作(如 __sync_fetch_and_add)实现线程安全,避免锁的开销。但说实话,无锁编程非常考验功底,我建议新手先别碰。

1.3.4 线程本地存储(TLS)

每个线程拥有自己的数据副本,从根本上避免共享。比如 errno 在 Linux 中就是线程本地的,所以每个线程的错误码互不干扰。

注意:不要以为用了 TLS 就万事大吉。如果线程 A 把指针传给了线程 B,而指针指向的是 TLS 数据,那照样会有问题。

1.4 C标准库中的线程安全问题

C标准库在设计之初并没有考虑多线程,所以很多函数都是线程不安全的。我列几个常见的:

  • strtok():内部有静态指针,用 strtok_r() 替代
  • rand():全局种子,用 rand_r() 替代
  • asctime()ctime():返回静态缓冲区,用带 _r 后缀的版本
  • getenv():可能修改环境变量,不是线程安全的
  • setlocale():全局区域设置,多线程下调用会出问题

POSIX 标准后来意识到了这个问题,所以提供了很多带 _r 后缀的可重入版本。我个人建议:在多线程代码中,优先使用这些带 _r 的版本,省心又安全。

经验之谈:我曾经接手过一个遗留项目,里面大量使用了 strtok()rand()。当时为了改成线程安全,我花了整整一周时间逐个排查替换。如果一开始就注意这些细节,能省下多少功夫啊。

知识体系总览

下面这张图是我梳理的本章知识结构,方便你理解各个概念之间的关系:

线程安全与可重入函数知识体系 线程安全 定义:并发调用不产生竞态条件 可重入函数 vs 线程安全函数 实现策略:互斥锁 | 读写锁 | 无锁编程 | 线程本地存储 C标准库:strtok/rand/asctime 等需用 _r 版本

嗯,这一章的内容就到这里。线程安全是个大话题,后面我们还会深入讨论各种锁机制和并发模式。记住一句话:共享可变数据是万恶之源——尽量减少共享,实在要共享就管好锁。

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