互斥锁

互斥锁,说白了就是一把「全局钥匙」。

谁拿到这把钥匙,谁就能进临界区干活。

其他人?只能在门外等着。

我刚开始做多线程时,觉得这东西简单。后来被死锁坑过几次,才明白——越简单的东西,越容易翻车。

基本原理

互斥锁的核心逻辑就两条:

  • 加锁(lock):尝试获取锁。如果没人持有,你就拿到;如果有人拿着,你就阻塞等待。
  • 解锁(unlock):释放锁。这时候等着的线程会被唤醒一个。

你想想看,这不就是个「先到先得」的排队机制么?

但有个细节很多人忽略:解锁必须在同一个线程里执行。谁锁的,谁解。跨线程解锁?行为未定义。

下面这张图能帮你直观理解互斥锁的工作流程:

互斥锁核心流程 尝试获取锁 锁是否空闲? (没有人持有) 是 持有锁,进入临界区 执行临界区代码 释放锁(unlock) 否 线程阻塞等待 锁释放后唤醒 ⚠ 关键约束 谁加锁,谁解锁 跨线程解锁 → 未定义行为

Linux 下的 pthread_mutex

在 C/C++ 里,最经典的实现就是 POSIX 线程库的 pthread_mutex_t。

我个人的习惯是:拆成三步走。

// 第一步:定义变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 第二步:加锁
pthread_mutex_lock(&mutex);
// 临界区代码
pthread_mutex_unlock(&mutex);

// 第三步(可选):销毁
pthread_mutex_destroy(&mutex);

看着简单对吧?但有三个地方容易踩坑。

我曾经见过一个线上事故:

某团队在信号处理回调里加锁,忘了处理 EAGAIN 错误。结果锁被信号处理函数意外持有,主线程直接饿死。从此以后,我在所有非阻塞场景一律用 pthread_mutex_trylock,返回 EAGAIN 直接跳过。

另外,pthread_mutex_t 还支持几种类型属性:

类型 行为 适用场景
PTHREAD_MUTEX_NORMAL 不加额外校验,死锁就死锁 性能优先的单线程加锁
PTHREAD_MUTEX_ERRORCHECK 重复加锁返回 EDEADLK 开发和调试阶段
PTHREAD_MUTEX_RECURSIVE 允许同一线程重复加锁 递归函数内需要锁的场景

实际项目里,我建议 DEBUG 模式用 ERRORCHECK 类型。

等发布到生产环境再切回 NORMAL。

这样既能发现问题,又不影响性能。

语言内置锁

说实话,现在大家写业务代码,很少直接撸 pthread_mutex 了。

语言层面的内置锁更香。Java 的 synchronized,Go 的 sync.Mutex,Python 的 threading.Lock,底层大多还是 pthread_mutex,但封装得更友好。

Java 的 synchronized:

public class Counter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}

Go 的 sync.Mutex:

type Counter struct {
    mu    sync.Mutex
    count int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

Python 的 threading.Lock:

import threading

class Counter:
    def __init__(self):
        self.lock = threading.Lock()
        self.count = 0
    
    def increment(self):
        with self.lock:
            self.count += 1

你会发现,不管哪个语言,模式都一样:

  • 锁住资源
  • 操作共享数据
  • 释放锁

Go 的 defer 解锁是个好习惯。

函数无论正常返回还是 panic,锁都能释放。

我以前在 C 项目里就吃过「提前 return 忘记解锁」的亏。

锁的粒度和性能权衡

这是面试常考,也是实战最容易出问题的地方。

锁的粒度,说白了就是「锁的范围有多大」。

粒度 描述 典型场景 性能特点
粗粒度 一个大锁保护全部共享数据 全局 map、单例模式 实现简单,并发度低
细粒度 多个小锁分别保护不同数据 分段锁、读写分离 实现复杂,并发度高
无锁 CAS、原子操作代替锁 计数器、队列头指针 最高性能,但限制多

什么时候该用粗粒度?

  • 共享数据相互关联,拆不开
  • 临界区执行时间极短(微秒级)
  • 并发线程数少,锁竞争不激烈

什么时候该拆成细粒度?

  • 不同线程访问不同数据段(比如 Hash 表的不同桶)
  • 读多写少(这种场景读写锁更合适)
  • 临界区执行时间长

我曾经重构过一个支付服务:

一开始用一把大锁保护所有账户。QPS 一到 2000 就锁竞争爆炸。后来拆成账户级锁,QPS 直接冲到 5 万。

但代价是代码复杂度翻倍,还得处理死锁和锁顺序问题。

嗯,这里要提醒一句:细粒度锁千万别搞出死锁,这个坑我们下一段会说。

最后说个我个人的经验法则:

锁的粒度,不是越细越好。如果你的临界区只有一行赋值语句,粗粒度锁的开销完全可以接受。反之,如果临界区有几十毫秒的 I/O 操作,粗粒度锁就是灾难。

学会用工具压测——没有数据,一切都是猜测。