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 本章知识体系

下面这张图帮你梳理本章的核心逻辑:

线程同步之锁:核心知识体系 锁(Lock) Lock vs RLock 不可重入 vs 可重入 with语句管理 自动acquire/release 锁粒度控制 粗粒度 vs 细粒度 死锁预防与检测 固定顺序、超时、检测工具 Lock:自己锁自己 RLock:计数器机制 递归函数场景 推荐默认用RLock 异常安全 自动释放 性能 vs 复杂度 先粗后细原则 转账案例 账户级锁 固定加锁顺序 破坏循环等待 超时机制 避免无限等待 核心原则:正确性优先,性能其次,安全第一

嗯,锁这个话题就聊到这儿。记住一句话:锁是手段,不是目的。我们的目标是保证数据安全的同时,尽量提高并发性能。下一节咱们聊聊更高级的同步工具——条件变量和信号量,到时候你会发现,有些场景用锁反而笨重了。