3. 线程同步之互斥锁

多线程编程里,最让人头疼的问题是什么?
我个人觉得,就是「数据打架」。
两个线程同时写一个变量,结果谁也不知道最终值是多少。
嗯,这就是竞态条件。解决它,最基础的工具就是互斥锁。

3.1 互斥锁的原理

互斥锁,说白了就是一把「门锁」。
线程想进临界区,得先拿到钥匙。
拿到钥匙的线程进去干活,其他线程只能在门外等着。
等里面的线程出来,把钥匙一扔,下一个线程才能进去。

这个机制,在操作系统层面叫「原子操作」。
锁的获取和释放,必须是不可分割的。
你想想看,如果两个线程同时检查锁状态,都以为锁是空闲的,那就乱套了。

我在项目中遇到过一个问题:
一个日志模块,多个线程同时写文件。
没加锁的时候,日志文件里经常出现乱码,甚至两行日志混在一起。
加了互斥锁之后,问题立刻消失。

核心要点:

  • 互斥锁保证同一时刻只有一个线程进入临界区
  • 锁的获取和释放是原子操作
  • 其他线程在锁释放前会被阻塞

3.2 pthread_mutex_t 的使用

在 POSIX 线程库中,互斥锁的类型是 pthread_mutex_t
使用起来其实很简单,就四个步骤:

  1. 定义锁变量
  2. 初始化锁
  3. 加锁
  4. 解锁
  5. 销毁锁

来看一个完整的例子:

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

pthread_mutex_t lock;
int shared_data = 0;

void* worker(void* arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&lock);
        shared_data++;
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    
    // 初始化互斥锁
    pthread_mutex_init(&lock, NULL);
    
    pthread_create(&t1, NULL, worker, NULL);
    pthread_create(&t2, NULL, worker, NULL);
    
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    
    printf("最终结果: %d\n", shared_data);
    
    // 销毁锁
    pthread_mutex_destroy(&lock);
    
    return 0;
}

这段代码里,shared_data++ 就是临界区。
如果不加锁,两个线程同时执行 shared_data++,结果肯定小于 200000。
加了锁,结果永远是 200000。

我的习惯:

初始化锁的时候,我一般用 PTHREAD_MUTEX_INITIALIZER 宏,省去显式调用 pthread_mutex_init
但如果是动态分配的锁,还是得用 pthread_mutex_init

3.3 死锁的产生与预防

死锁,是互斥锁使用中最容易踩的坑。
我曾经在一个网络服务器项目里,调试了整整两天,才发现是死锁问题。
两个线程各自持有一把锁,又都在等对方释放锁,结果谁也动不了。

死锁产生的四个必要条件:

条件 说明
互斥 资源一次只能被一个线程占用
持有并等待 线程持有资源的同时,还在等待其他资源
不可剥夺 资源只能由持有者主动释放
循环等待 多个线程形成等待环路

预防死锁,我总结了三条经验:

  • 固定锁顺序:所有线程都按相同的顺序加锁。比如先锁 A,再锁 B,不要有的线程先锁 B 再锁 A。
  • 使用 trylock:用 pthread_mutex_trylock 尝试加锁,如果失败就释放已有锁,避免死等。
  • 缩小锁范围:只在真正需要保护的数据上加锁,不要锁住整个函数。

避坑指南:

我曾经在回调函数里加锁,结果回调函数又被另一个锁保护,形成了嵌套锁。
这种场景下,一定要用 pthread_mutexattr_settype 设置递归锁,否则会死锁。

3.4 锁的粒度控制

锁的粒度,说白了就是锁保护的数据范围。
粒度太粗,并发性能差;粒度太细,容易死锁,而且锁开销大。

我举个例子:
一个银行账户系统,有存款、取款、查询三个操作。
如果整个系统只用一把大锁,那查询操作也得等存款操作完成,显然不合理。

合理的做法是:

  • 每个账户一把锁(细粒度)
  • 或者按操作类型分锁(中等粒度)
  • 或者整个系统一把锁(粗粒度,适合低并发场景)

选择锁的粒度,要看实际场景:

粒度 优点 缺点 适用场景
粗粒度 实现简单,不易死锁 并发性能差 低并发、数据量小
中等粒度 性能与复杂度平衡 需要仔细设计 大多数业务系统
细粒度 并发性能高 容易死锁,实现复杂 高并发、大数据量

我的建议:

刚开始写多线程程序时,先用粗粒度锁。
等程序跑通了,再用性能分析工具看看瓶颈在哪。
不要一开始就追求细粒度,那是给自己挖坑。

下面这张图,展示了互斥锁的核心知识体系:

互斥锁 原理 原子操作 临界区保护 线程阻塞 pthread_mutex_t 初始化/销毁 lock/unlock trylock 死锁 四个必要条件 固定锁顺序 trylock 预防 锁粒度 粗/中/细粒度

嗯,互斥锁的内容就这些。
说白了,它就是多线程编程的「基本功」。
用好了,程序稳定高效;用不好,各种诡异问题等着你。

我个人觉得,理解互斥锁的关键在于:
想清楚「哪些数据需要保护」和「保护多久」。
这两个问题想明白了,锁就用对了。

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