同步与互斥:锁、信号量、条件变量
并发编程里的「坑」,十有八九出在共享数据上。
多线程同时读写一个变量,结果不可预测,这就是并发安全问题。怎么解决?
说白了,就是给这块共享数据加一把锁,同一时间只让一个线程碰它。
但锁也分很多种,用不对反而更慢。我见过太多团队把并发程序写成了串行排队——锁加粗了,性能也没了。
这节我们聊聊三种核心工具:互斥锁、信号量、条件变量。
搞清楚它们什么时候用、怎么用,你写并发代码才能心中有数。
1. 互斥锁:最简单的节奏控制器
互斥锁(Mutex)是并发编程的入门工具。它的任务就一个:保证临界区代码互斥执行。
嗯,临界区就是读写共享资源的那段代码。你想想看,两个线程同时写一个 Map,结果数据全乱了。这时候就需要锁。
核心原则:锁的粒度要尽量小,只保护真正需要保护的数据。
我在项目中遇到过一个问题:有人把整个方法都上了锁,性能直线下降。实际上,只有那三行修改共享变量的代码需要保护。改完之后,吞吐量翻了一倍。
使用姿势
import threading
lock = threading.Lock()
def deposit(amount):
lock.acquire()
try:
# 临界区:只保护这一行
balance += amount
finally:
lock.release()
上面这段代码看似简单,但有个关键点:一定要用 try-finally 释放锁。我之前线上出过一个事故,某段代码抛了异常,锁没释放,整个服务卡死了。那次教训太深刻了。
锁的常见陷阱
| 问题 | 表现 | 怎么避免 |
|---|---|---|
| 死锁 | 两个线程互相等对方释放锁 | 规定锁的获取顺序,或使用 try_lock 超时 |
| 锁争用过高 | CPU 大部分时间在等锁 | 缩小临界区,使用读写锁分离 |
| 忘记释放 | 线程卡死,服务无响应 | 用上下文管理器(with 语句) |
小技巧:Python 里用 with lock: 比手动 acquire/release 更安全。Java 里推荐用 ReentrantLock 搭配 try-finally,或者直接上 synchronized 关键字。
2. 信号量:控制并发访问数的水龙头
互斥锁只让一个线程进去。那如果我想允许最多 5 个线程同时访问呢?
这就需要信号量(Semaphore)了。你可以把它想象成一个水龙头,设置好水量,一次只能放那么多水。
我习惯把信号量用在连接池、限流等场景。比如数据库连接池最多开 10 个连接,信号量就能精确控制并发数。
信号量 vs 互斥锁
| 特性 | 互斥锁 | 信号量 |
|---|---|---|
| 允许进入的线程数 | 1 个 | N 个(由初始值决定) |
| 典型场景 | 保护共享数据 | 资源池、限流 |
| 释放方式 | 必须由持有锁的线程释放 | 任何线程都可以 release |
# 信号量示例:控制数据库连接
from threading import Semaphore
# 最多允许 5 个线程同时获取连接
pool_semaphore = Semaphore(5)
def get_connection():
pool_semaphore.acquire()
try:
# 获取连接
conn = database.get_conn()
return conn
finally:
# 一定要在 finally 中释放
pool_semaphore.release()
注意事项:我曾经在一次重构中,不小心把信号量的 release 写在了 try 块里。并发一上来,有线程抛异常,信号量直接「漏」了,导致实际连接数超预期,数据库被打崩了。所以记得,release 必须放在 finally 里。
3. 条件变量:通知模式的引擎
互斥锁解决的是互斥问题,条件变量解决的是等待-通知问题。
举个例子:生产者往队列放数据,消费者从队列取数据。队列空时,消费者要等着;队列满时,生产者要等着。这种场景用互斥锁硬搞会很别扭——你会写一堆 while 循环轮询,CPU 全部空转。
条件变量(Condition)就是干这个的。它让线程优雅地等待某个条件成立,条件满足时再被唤醒。
import threading
condition = threading.Condition()
queue = []
def producer():
with condition:
while len(queue) >= 10:
condition.wait() # 队列满了,等待
queue.append('item')
condition.notify() # 通知消费者
def consumer():
with condition:
while len(queue) == 0:
condition.wait() # 队列空了,等待
item = queue.pop()
condition.notify() # 通知生产者
这里要注意一个细节:条件变量必须配合互斥锁使用。wait 方法会自动释放锁,等被唤醒后重新获取锁。这机制很巧妙,但也很容易出错。
条件变量的三个关键点
- 用 while 而不是 if 检查条件。线程被唤醒后,条件可能又被其他线程改了。用 while 确保条件真的满足。
- 在持有锁的情况下调用 wait / notify。否则会抛出异常。
- notify 和 notifyAll 的区别。notify 只唤醒一个等待线程,notifyAll 唤醒所有。我个人习惯在不确定的时候用 notifyAll,避免「信号丢失」。
记牢了:条件变量的使用口诀——「持锁检查,while 循环,wait 释放,notify 通知」。
4. 三者的知识图谱
到这里,三种并发工具的核心概念都讲完了。它们虽然功能不同,但共同目标都是协调多线程的有序执行。
我总结了一张关系图,帮你快速建立认知框架:
5. 如何选择?
你可能会问:这三个工具看着都挺像,实际项目里到底怎么选?
嗯,我总结了三条经验:
- 保护共享资源,用互斥锁。比如计数器、缓存、银行余额。
- 控制并发数量,用信号量。比如连接池实例数、API 限流阈值。
- 线程间协作,用条件变量。比如生产者-消费者、资源就绪通知。
实用建议:如果你不确定该用哪个,先从互斥锁开始。大多数并发问题用互斥锁配合简单轮询就能解决。等性能满足不了时,再考虑升级成信号量或条件变量。
最后再说一句:工具是死的,场景是活的。写代码的时候多想想——你的线程到底在等什么。想清楚这个,选锁就不会出错。