3. 线程同步之锁:Lock与RLock的区别、with语句管理锁、锁的粒度控制、死锁的预防与检测
聊到多线程,锁是绕不开的话题。很多初学者觉得锁就是加锁、解锁,没什么难的。但我在项目中踩过不少坑,才明白锁这东西,用好了是利器,用不好就是灾难。今天咱们就掰开揉碎,把Python里的Lock、RLock、with语句、锁粒度,还有死锁这些事儿说清楚。
3.1 Lock与RLock:一个能重入,一个不能
先说说最基本的threading.Lock。它就像一个厕所门锁——谁抢到谁进去,别人只能等着。但有个问题:同一个线程如果已经拿到了锁,想再拿一次,会怎样?
Lock会死锁自己:同一个线程连续两次acquire()同一个Lock,第二次调用会阻塞,因为锁已经被自己占着。这就好比你自己进了厕所,还想再拧一下门锁进去——门锁着,你进不去,但你又占着锁不放,死循环了。
import threading
lock = threading.Lock()
lock.acquire()
print("第一次拿到锁")
# 下面这行会死锁!
lock.acquire() # 线程自己把自己锁死了
print("这行永远不会执行")
我刚开始学多线程时,就犯过这个错。写了一个递归函数,每次递归都去acquire同一个锁,结果程序直接卡死。排查了半天才意识到是锁不可重入的问题。
RLock(可重入锁)就是来解决这个问题的。它允许同一个线程多次acquire,内部维护了一个计数器。每次acquire加1,release减1,只有减到0时,其他线程才能拿到锁。
import threading
rlock = threading.RLock()
rlock.acquire()
print("第一次拿到锁")
rlock.acquire() # 不会阻塞
print("第二次拿到锁,没问题")
rlock.release()
rlock.release() # 释放两次,计数器归零
我的建议:如果你不确定会不会出现嵌套加锁的场景,直接用RLock。虽然性能上比Lock稍微慢一丢丢,但安全第一。我在写工具类、库函数时,习惯默认用RLock,省得调用方不小心嵌套调用导致死锁。
3.2 with语句管理锁:告别忘记释放的噩梦
你想想看,手动acquire和release,最怕什么?怕中间抛出异常,导致release没执行。锁没释放,其他线程就永远等下去了。这种bug特别难查,因为不是必现的,得看运气。
Python的with语句就是救星。锁对象实现了上下文管理器协议,进入with块时自动acquire,退出时自动release,哪怕中间抛异常也会释放。
import threading
lock = threading.Lock()
# 手动管理,容易忘release
lock.acquire()
try:
# 业务逻辑
pass
finally:
lock.release()
# 用with,简洁又安全
with lock:
# 业务逻辑
pass # 退出with自动释放
我个人习惯:能用with就别手动acquire/release。代码少写几行是小事,关键是少了一个出bug的入口。我曾经在一个生产环境的问题排查中,发现就是某处异常路径没走到release,导致整个服务线程池慢慢耗尽。从那以后,我团队里就定了个规矩:锁必须用with管理,代码review时重点检查。
注意:with语句只适用于锁的简单加解锁场景。如果你需要超时控制(比如acquire(timeout=5)),或者非阻塞尝试加锁(acquire(blocking=False)),with就帮不上忙了,得手动处理。
3.3 锁的粒度控制:粗了性能差,细了容易死锁
锁的粒度,说白了就是锁保护的数据范围有多大。这是个权衡的艺术。
| 粒度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 粗粒度(大锁) | 实现简单,不易死锁 | 并发性能差,串行化严重 | 低频操作、数据一致性要求极高 |
| 细粒度(小锁) | 并发性能好,吞吐量高 | 实现复杂,容易死锁 | 高频操作、热点数据 |
举个例子。假设你有一个银行账户系统,里面有多个账户。粗粒度做法:整个系统一把大锁,转账时锁住所有账户。细粒度做法:每个账户一把锁,转账时只锁涉及的两个账户。
import threading
# 粗粒度:全局大锁
global_lock = threading.Lock()
def transfer_global(from_acc, to_acc, amount):
with global_lock:
# 锁住所有操作
from_acc.balance -= amount
to_acc.balance += amount
# 细粒度:每个账户一把锁
def transfer_fine(from_acc, to_acc, amount):
# 注意:要按固定顺序加锁,避免死锁
first = min(id(from_acc), id(to_acc))
second = max(id(from_acc), id(to_acc))
with first.lock:
with second.lock:
from_acc.balance -= amount
to_acc.balance += amount
我在项目中遇到过这样的场景:一个高频交易系统,最初用粗粒度锁,压测时发现吞吐量上不去。后来改成细粒度锁,性能提升了5倍。但代价是代码复杂度翻倍,而且出现了两次死锁——都是因为加锁顺序不一致导致的。
我的经验法则:先粗后细。初期用粗粒度锁保证正确性,性能瓶颈出现时再逐步细化。不要一开始就追求极致性能,那往往是过度设计。
3.4 死锁的预防与检测
死锁,就是两个或多个线程互相等待对方释放锁,结果谁也动不了。经典的死锁四个必要条件:互斥、持有并等待、不可剥夺、循环等待。只要破坏其中一个,就能预防死锁。
预防方法一:固定加锁顺序
这是最常用的方法。所有线程都按相同的顺序获取锁,就不会出现循环等待。比如上面转账的例子,按账户ID排序后加锁。
# 错误:可能死锁
def transfer_wrong(a, b, amount):
with a.lock:
with b.lock: # 如果另一个线程是b.lock -> a.lock,就死锁了
pass
# 正确:固定顺序
def transfer_right(a, b, amount):
if id(a) < id(b):
with a.lock:
with b.lock:
pass
else:
with b.lock:
with a.lock:
pass
预防方法二:使用超时
给acquire加个超时时间,拿不到锁就放弃,避免无限等待。
import threading
lock1 = threading.Lock()
lock2 = threading.Lock()
def worker():
if lock1.acquire(timeout=5):
try:
if lock2.acquire(timeout=5):
try:
# 业务逻辑
pass
finally:
lock2.release()
finally:
lock1.release()
else:
print("获取锁超时,做其他处理")
死锁检测
Python标准库没有内置的死锁检测器,但我们可以用threading模块的_active和_limbo来辅助排查。更实用的方法是:
- 开启
PYTHONDEADLOCKDEBUG环境变量(Python 3.12+) - 使用第三方工具如
objgraph分析锁的持有关系 - 在代码中埋点,记录每个线程获取和释放锁的时间戳
我曾经踩过的坑:一个后台服务运行了三天后突然卡死,所有请求都超时。上去一看,线程堆栈显示四个线程两两死锁。原因是有个新来的同事在代码里加了一把新锁,但没有遵守我们约定的加锁顺序。从那以后,我们团队在CI流程里加了一个静态检查规则,自动检测加锁顺序是否一致。
3.5 本章知识体系
下面这张图帮你梳理本章的核心逻辑:
嗯,锁这个话题就聊到这儿。记住一句话:锁是手段,不是目的。我们的目标是保证数据安全的同时,尽量提高并发性能。下一节咱们聊聊更高级的同步工具——条件变量和信号量,到时候你会发现,有些场景用锁反而笨重了。