线程同步之互斥锁:从原理到实战

多线程编程中,最让人头疼的问题是什么?我个人觉得,就是多个线程同时访问共享数据。你想想看,两个线程同时往一个银行账户里存钱,结果钱数对不上,这谁受得了?

互斥锁,说白了就是一把「门锁」。一次只让一个线程进去干活,其他人在门外等着。今天我就带你把这把锁的原理、用法和坑都摸清楚。

互斥锁的原理:为什么需要它?

先看一个经典问题。两个线程各自对全局变量 counter 执行 100 万次自增。你猜结果是多少?

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

int counter = 0;

void* worker(void* arg) {
    for (int i = 0; i < 1000000; 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;
}

运行几次你会发现,结果每次都不一样,而且很少是 200 万。为什么会这样?

因为 counter++ 在底层是三条指令:

  1. 从内存读取 counter 到寄存器
  2. 寄存器加 1
  3. 写回内存

两个线程可能同时执行到第 1 步,都读到同一个值,然后各自加 1 写回。结果只加了 1 次,而不是 2 次。这就是典型的「竞态条件」。

核心概念:互斥锁保证同一时刻只有一个线程能进入临界区(访问共享资源的代码段)。其他线程必须等待,直到锁被释放。

pthread_mutex_t 的使用:四步走

在 POSIX 线程库中,互斥锁用 pthread_mutex_t 类型表示。使用流程就四步,我习惯把它记成「创建-加锁-解锁-销毁」。

1. 初始化互斥锁

两种方式:

// 静态初始化(推荐)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 动态初始化
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);  // NULL 表示使用默认属性

静态初始化简单高效,我一般都用它。动态初始化适合需要自定义属性(比如递归锁)的场景。

2. 加锁

pthread_mutex_lock(&mutex);
// 临界区代码
// 只有当前线程能执行到这里
pthread_mutex_unlock(&mutex);

注意:lock() 是阻塞的。如果锁已被占用,线程会一直等下去。如果你不想等,可以用 pthread_mutex_trylock(),它立即返回,成功返回 0,失败返回 EBUSY。

3. 解锁

解锁必须由加锁的线程执行。别的线程不能帮你解锁,这是规矩。

4. 销毁

pthread_mutex_destroy(&mutex);

动态初始化的锁需要销毁,释放资源。静态初始化的锁理论上不需要,但调用一下也没坏处。

完整示例:用互斥锁修复计数器问题

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

int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* worker(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&mutex);
        counter++;
        pthread_mutex_unlock(&mutex);
    }
    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);  // 稳定输出 2000000
    pthread_mutex_destroy(&mutex);
    return 0;
}

运行结果永远是 2000000。嗯,这才对嘛。

小技巧:加锁和解锁之间的代码要尽量短。只保护真正需要保护的共享资源,别把无关操作也包进去。我在项目中见过有人把整个函数都锁住,结果性能惨不忍睹。

死锁的避免:最头疼的问题

死锁是什么?两个线程互相等待对方释放锁,结果谁也动不了。程序就卡死了。

经典的死锁场景:

// 线程 A
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2);  // 等待 lock2

// 线程 B
pthread_mutex_lock(&lock2);
pthread_mutex_lock(&lock1);  // 等待 lock1

两个线程各持有一把锁,又都在等对方手里的锁。死锁了。

我曾经踩过的坑

有一次我写一个日志系统,主线程写日志要锁住缓冲区,后台线程刷盘也要锁住同一个缓冲区。结果主线程在锁住缓冲区后调用了刷盘函数,而刷盘函数又试图锁缓冲区... 嗯,自己把自己锁死了。这就是「自死锁」。

后来我改用递归锁 PTHREAD_MUTEX_RECURSIVE_NP 解决了。但说实话,递归锁是「治标不治本」,最好还是从设计上避免。

避免死锁的黄金法则

法则 说明
固定加锁顺序 所有线程按相同顺序获取锁。比如都先锁 lock1 再锁 lock2
使用 trylock 获取不到锁就放弃,避免死等
锁的粒度要小 尽量只锁必要的数据,减少锁的持有时间
避免嵌套锁 能不嵌套就别嵌套,一个锁能解决的问题别用两个

警告:死锁的排查非常痛苦。程序卡死了,没有崩溃信息,没有错误日志。我建议你在开发阶段就加上锁的调试信息,比如每次加锁解锁都打印日志。虽然会影响性能,但能救命。

知识体系总览

下面这张图总结了互斥锁的核心知识点,我把它画成了流程图,方便你理解整个逻辑:

互斥锁知识体系 pthread_mutex_t 原理:互斥访问 使用:四步法 死锁:避免策略 竞态条件 → 数据不一致 原子性:读-改-写不可分割 初始化 → 加锁 → 解锁 → 销毁 静态/动态初始化两种方式 固定加锁顺序 使用 trylock 避免死等 缩小锁粒度,减少嵌套 核心:锁住共享资源,释放要快

避坑指南:我踩过的那些雷

  • 忘记解锁:函数中间 return 了,锁没释放。其他线程永远等下去。我建议用 goto 统一出口,或者用 RAII 风格封装。
  • 锁的粒度太大:把整个循环都锁住,结果多线程退化成单线程,性能还不如不用锁。
  • 信号处理函数中加锁:信号处理函数中调用 pthread_mutex_lock 可能导致死锁。因为信号可能在线程持有锁时到达。
  • 忘记初始化:静态初始化的锁没问题,动态初始化的锁如果忘了调用 pthread_mutex_init,行为是未定义的。

个人习惯:我写多线程代码时,会在每个加锁解锁的地方加上注释,标明保护的是哪个共享变量。比如 // lock: protects counter。这样半年后回来看代码,还能一眼看懂。

互斥锁是线程同步的基础工具,但也不是万能的。读写锁、条件变量、信号量各有各的适用场景。不过先把互斥锁用熟练了,后面的路就好走了。

可可精品资料库