4. 线程同步机制(上):Lock锁的基本使用、可重入锁RLock、死锁的产生与预防策略
多线程编程里,最让人头疼的问题是什么?
我个人觉得,不是性能不够,而是数据乱了套。你想想看,两个线程同时往一个银行账户里存钱,结果账目对不上——这种问题一旦出现,排查起来简直要命。
所以,线程同步机制就派上用场了。说白了,就是给共享资源加把锁,同一时间只允许一个线程进去操作。
4.1 Lock锁:最基础的同步工具
Python 的 threading.Lock 是最简单的锁。它只有两个方法:acquire() 和 release()。
核心原则: 谁拿到锁,谁就拥有访问权。其他线程必须等着。
来看个例子。假设我们有个计数器,两个线程各加 100 万次:
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(1_000_000):
lock.acquire()
counter += 1
lock.release()
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start()
t2.start()
t1.join()
t2.join()
print(f"最终结果: {counter}") # 输出 2000000
如果不加锁,你大概率会得到 199 万左右的结果。为什么会这样?因为 counter += 1 不是原子操作,它分三步:读取、加一、写回。两个线程同时读取到同一个值,加完写回去,就丢了一次更新。
我的习惯: 用 with lock: 上下文管理器来替代手动 acquire/release。这样即使中间抛异常,锁也会自动释放,省心很多。
def increment():
global counter
for _ in range(1_000_000):
with lock:
counter += 1
4.2 可重入锁 RLock:同一个线程能重复拿锁
普通 Lock 有个限制:同一个线程不能连续两次 acquire。否则会死锁——自己把自己锁死了。
但有时候,我们确实需要在一个线程里多次获取同一把锁。比如函数 A 调函数 B,两个函数都需要锁:
lock = threading.Lock()
def func_b():
with lock:
print("执行 B")
def func_a():
with lock:
print("执行 A")
func_b() # 这里会死锁!
t = threading.Thread(target=func_a)
t.start()
运行这段代码,程序会卡住。因为 func_a 拿到了锁,还没释放就去调 func_b,而 func_b 也要拿同一把锁——但锁还在 func_a 手里。
这时候就需要 RLock(可重入锁) 了。它允许同一个线程多次 acquire,内部维护一个计数器,每次 acquire 加一,release 减一,减到零才真正释放。
rlock = threading.RLock()
def func_b():
with rlock:
print("执行 B")
def func_a():
with rlock:
print("执行 A")
func_b() # 没问题,同一个线程可以重入
t = threading.Thread(target=func_a)
t.start()
注意: RLock 虽然方便,但不要滥用。我在项目中见过有人把 RLock 当普通锁用,结果代码里 acquire 和 release 次数对不上,排查了半天。能用 Lock 的地方,优先用 Lock。
4.3 死锁:最隐蔽的坑
死锁,就是两个或多个线程互相等待对方释放资源,结果谁也动不了。
经典的死锁场景是这样的:
lock_a = threading.Lock()
lock_b = threading.Lock()
def thread_1():
with lock_a:
print("线程1 拿到锁A")
import time; time.sleep(0.1)
with lock_b:
print("线程1 拿到锁B")
def thread_2():
with lock_b:
print("线程2 拿到锁B")
import time; time.sleep(0.1)
with lock_a:
print("线程2 拿到锁A")
t1 = threading.Thread(target=thread_1)
t2 = threading.Thread(target=thread_2)
t1.start()
t2.start()
运行后,两个线程各拿一把锁,然后都在等对方释放——程序就卡死了。
我曾经踩过的坑: 在一个支付系统里,订单锁和账户锁互相嵌套,结果线上出现了死锁,导致一批订单卡住无法处理。后来加了超时机制和日志,才定位到问题。
4.4 预防死锁的几种策略
死锁虽然讨厌,但也不是没办法预防。我总结了几个实用策略:
- 固定锁顺序:所有线程都按同样的顺序获取锁。比如先拿 A 再拿 B,就不会出现循环等待。
- 使用超时:
lock.acquire(timeout=5),拿不到就放弃,避免无限等待。 - 尽量少用嵌套锁:能用一个锁解决的问题,别用两个。
- 使用 try-finally 或 with 语句:确保锁一定会释放。
来看一个固定锁顺序的例子:
def thread_safe(lock_a, lock_b):
# 约定:总是先拿 lock_a,再拿 lock_b
with lock_a:
with lock_b:
print("安全执行")
这样不管有多少线程,都不会出现循环等待。
4.5 知识体系图
下面这张图,帮你理清本章的核心逻辑:
4.6 本章小结
嗯,这一章的内容其实就三件事:
- Lock 是最基础的锁,适合简单场景。记得用 with 语句,别手动 acquire/release。
- RLock 解决同一个线程重复拿锁的问题,但别滥用。
- 死锁 是并发编程的头号杀手,预防比事后排查重要得多。
我个人觉得,理解这些概念最好的方式就是动手写代码。把上面的例子跑一遍,再故意制造个死锁看看效果——相信我,踩过坑之后,你一辈子都忘不了。
一个小建议: 刚开始用锁的时候,尽量保持简单。一个函数里只拿一把锁,拿完就放。等熟练了再考虑复杂的嵌套场景。