13、并发与线程安全:数据竞争、死锁、线程安全函数、互斥锁的正确使用

多线程编程,说白了就是让程序同时干好几件事。听起来很爽,对吧?但我在项目中见过太多人栽在这上面。数据竞争、死锁、莫名其妙的崩溃……嗯,今天我们就来把这些坑一个个填平。

13.1 数据竞争:最隐蔽的敌人

数据竞争是什么?简单说就是两个线程同时读写同一个变量,而且至少有一个是写操作。结果就是——你永远不知道读出来的是什么鬼东西。

核心问题:多个线程访问共享数据,没有同步机制保护。

举个例子,你想想看:

// 危险!数据竞争
int counter = 0;

void* thread_func(void* arg) {
    for (int i = 0; i < 100000; i++) {
        counter++;  // 这不是原子操作!
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread_func, NULL);
    pthread_create(&t2, NULL, thread_func, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %d\n", counter);  // 结果不是200000!
    return 0;
}

为什么会这样?因为 counter++ 在底层是三步操作:读、加、写。两个线程可能同时读到同一个值,然后各自加1写回去——结果只加了1次。我在项目中遇到过类似问题,排查了整整两天才发现是这里出了问题。

避坑指南:我曾经以为加个 volatile 就能解决,结果发现 volatile 只保证不优化,不保证原子性。该用锁还得用锁。

13.2 死锁:线程们的“互相等待”

死锁就是两个线程互相持有对方需要的资源,谁也不肯放手。就像两个人过独木桥,你让我先过,我让你先过,结果谁都没法过。

经典的死锁场景:

// 死锁示例
pthread_mutex_t lock_a, lock_b;

void* thread1(void* arg) {
    pthread_mutex_lock(&lock_a);
    sleep(1);  // 故意制造时间差
    pthread_mutex_lock(&lock_b);  // 等lock_b,但被thread2占着
    // 干活...
    pthread_mutex_unlock(&lock_b);
    pthread_mutex_unlock(&lock_a);
    return NULL;
}

void* thread2(void* arg) {
    pthread_mutex_lock(&lock_b);
    sleep(1);
    pthread_mutex_lock(&lock_a);  // 等lock_a,但被thread1占着
    // 干活...
    pthread_mutex_unlock(&lock_a);
    pthread_mutex_unlock(&lock_b);
    return NULL;
}

死锁的四个必要条件,我建议你记一下:

条件 说明
互斥 资源一次只能被一个线程占用
持有并等待 线程占着资源不放手,同时等别的资源
不可剥夺 资源不能被强制拿走
循环等待 形成等待环路

个人习惯:我写代码时,所有线程获取锁的顺序必须一致。比如都先拿 lock_a 再拿 lock_b,这样就不会有循环等待了。

13.3 线程安全函数:哪些能用,哪些不能用

不是所有函数都能在多线程里随便用的。标准库里有不少函数不是线程安全的。

常见的非线程安全函数:

  • strtok() — 内部有静态缓冲区
  • rand() — 全局状态
  • ctime() / asctime() — 返回静态字符串
  • gethostbyname() — 内部静态数据

对应的线程安全版本:

  • strtok_r() — 传入自己的上下文
  • rand_r() — 传入种子指针
  • ctime_r() / asctime_r() — 传入缓冲区
  • getaddrinfo() — 可重入设计

判断标准:看函数是不是可重入的(reentrant)。可重入函数不依赖全局或静态数据,所有状态都由调用者传入。

13.4 互斥锁的正确使用

互斥锁(mutex)是最基本的同步工具。但用不对,反而会出问题。

基本用法:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* safe_thread_func(void* arg) {
    pthread_mutex_lock(&mutex);
    // 临界区:只有当前线程能进来
    // 操作共享数据...
    pthread_mutex_unlock(&mutex);
    return NULL;
}

几个关键点:

  • 锁的粒度要合适:锁太大,性能差;锁太小,容易出bug。我一般先粗后细,性能不够再优化。
  • 避免在锁内调用外部函数:你不知道那个函数会不会也去拿锁,万一形成死锁就麻烦了。
  • 检查返回值pthread_mutex_lock() 可能失败,虽然概率低,但严谨的代码应该检查。

避坑指南:我曾经在锁内调用了 malloc(),结果 malloc 内部也有锁,而且和另一个线程形成了死锁。从那以后,我坚持锁内只做最必要的事。

13.5 知识体系总览

下面这张图,是我梳理的并发与线程安全的核心逻辑。你把它理解了,基本就能应对大部分场景。

并发与线程安全 数据竞争 多个线程同时读写 共享变量 死锁 互相等待资源 循环依赖 线程安全函数 可重入设计 避免全局状态 互斥锁 正确加锁/解锁 粒度控制 解决方案 使用互斥锁保护 原子操作 解决方案 使用_r版本函数 传入上下文 核心原则:共享数据必须加锁,加锁顺序必须一致

13.6 实战建议

说了这么多,最后给你几条我自己的经验:

  1. 能用局部变量就别用全局变量:减少共享数据,从源头避免问题。
  2. 锁的持有时间越短越好:只锁必要的代码行,别锁整个函数。
  3. 优先使用高级同步原语:比如读写锁、条件变量,比裸用互斥锁更安全。
  4. 测试要覆盖并发场景:单线程跑得欢,多线程就崩,这种案例我见得太多了。

个人习惯:我每次写完多线程代码,都会用 ThreadSanitizer 跑一遍。它能自动检测数据竞争,省了我不少排查时间。

并发编程没有银弹。但只要你理解了数据竞争、死锁的本质,掌握了互斥锁的正确用法,再配合线程安全函数的选择,大部分问题都能迎刃而解。嗯,今天就到这里。

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