互斥锁
互斥锁,说白了就是一把「全局钥匙」。
谁拿到这把钥匙,谁就能进临界区干活。
其他人?只能在门外等着。
我刚开始做多线程时,觉得这东西简单。后来被死锁坑过几次,才明白——越简单的东西,越容易翻车。
基本原理
互斥锁的核心逻辑就两条:
- 加锁(lock):尝试获取锁。如果没人持有,你就拿到;如果有人拿着,你就阻塞等待。
- 解锁(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 操作,粗粒度锁就是灾难。
学会用工具压测——没有数据,一切都是猜测。