3. 线程同步与锁:Lock锁的基本使用、可重入锁RLock、死锁的产生与避免
说到多线程,有个问题绕不开——数据竞争。说白了,就是多个线程同时抢着修改同一份数据,结果数据乱套了。
我刚开始写多线程程序时,就吃过这个亏。一个计数器,两个线程各加100万次,结果跑出来不是200万,而是199万8千多。当时我还以为是Python的bug,查了半天……嗯,后来才明白,这是典型的线程不安全。
怎么解决?用锁。
3.1 Lock锁:最基础的同步工具
Python的threading.Lock是最简单的锁。它只有两个方法:acquire()和release()。一个线程拿到锁,其他线程就得等着。
看个例子:
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(1000000):
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(counter) # 稳稳的2000000
加了锁之后,结果就对了。为什么?因为counter += 1这个操作,在CPU层面其实是三步:读、加、写。不加锁的话,两个线程可能同时读到同一个值,然后各自加1再写回去——结果只加了1次。这就是所谓的竞态条件。
核心要点:锁的本质是让一段代码变成「原子操作」——要么不执行,要么一口气执行完,中间不能被其他线程打断。
我个人习惯用with语句来管理锁,省得忘记release():
def increment():
global counter
for _ in range(1000000):
with lock:
counter += 1
这样写,锁会自动释放。哪怕中间抛了异常,锁也会被释放。我建议你养成这个习惯。
3.2 可重入锁RLock:同一个线程能重复拿锁
普通锁有个限制:同一个线程不能连续acquire()两次。否则会死锁——自己把自己锁死了。
举个例子:
lock = threading.Lock()
lock.acquire()
lock.acquire() # 这里就卡死了!线程在等自己释放锁
为什么会这样?因为普通锁不记录「是谁拿的锁」。它只知道「锁被拿了」,但不知道是被谁拿的。所以第二次acquire()时,它发现锁已经被占用了,就老老实实等着——结果等的是自己。
RLock(可重入锁)解决了这个问题。它记录了「持有锁的线程」和「重入次数」:
rlock = threading.RLock()
rlock.acquire()
rlock.acquire() # 没问题,重入次数变成2
rlock.release() # 重入次数变回1
rlock.release() # 重入次数变回0,锁真正释放
我在项目中遇到过这样一个场景:一个递归函数需要加锁,如果用普通锁,递归到第二层就死锁了。换成RLock,问题迎刃而解。
小技巧:如果你不确定用Lock还是RLock,优先用RLock。它更安全,性能损失也几乎可以忽略。我个人的代码里,90%的情况都用RLock。
3.3 死锁:最让人头疼的问题
死锁,说白了就是「你等我,我等你,谁也别想动」。两个线程各自拿着一把锁,又都在等对方手里的锁——这就僵住了。
看个经典例子:
import threading
lock_a = threading.Lock()
lock_b = threading.Lock()
def worker_1():
with lock_a:
print("worker_1 拿到锁A")
with lock_b:
print("worker_1 拿到锁B")
def worker_2():
with lock_b:
print("worker_2 拿到锁B")
with lock_a:
print("worker_2 拿到锁A")
t1 = threading.Thread(target=worker_1)
t2 = threading.Thread(target=worker_2)
t1.start()
t2.start()
这段代码跑起来,大概率会卡住。worker_1拿了A等B,worker_2拿了B等A——死锁了。
警告:死锁的程序不会报错,也不会崩溃,就是卡在那里不动。CPU占用率可能为0,也可能很高(如果线程在忙等)。排查起来很头疼。
3.4 如何避免死锁?
我曾经在一个线上服务里排查过死锁问题。那个服务每隔几天就卡死一次,重启就好。查了两天才发现,是三个线程互相等锁。从那以后,我总结了几条经验:
- 固定锁的顺序——所有线程都按同样的顺序拿锁。比如都先拿A再拿B,就不会出现循环等待。
- 使用超时机制——
acquire(timeout=5),拿不到锁就放弃,别死等。 - 尽量少用多把锁——能用一把锁解决的问题,别用两把。
- 用RLock代替Lock——避免自己锁自己的情况。
其中第一条最实用。你想想看,如果所有线程都约定好「先拿A再拿B」,那worker_2就不会先拿B了,死锁自然消失。
def worker_2():
with lock_a: # 先拿A
print("worker_2 拿到锁A")
with lock_b: # 再拿B
print("worker_2 拿到锁B")
就这么简单的一改,死锁就没了。
3.5 本章知识体系
下面这张图,帮你理清本章的核心逻辑:
这张图把本章的三个核心知识点串起来了。左边是Lock,中间是RLock,右边是死锁。你顺着箭头看,就能理清它们之间的关系。
最后说一句:锁不是银弹。锁用多了,性能会下降,甚至可能比单线程还慢。我见过有人给每一行代码都加锁——那还不如不用多线程。合理的做法是:只给真正需要保护的共享资源加锁,锁的粒度越小越好。
本章小结:
- Lock是最基本的锁,适合简单场景
- RLock支持重入,适合递归或嵌套调用
- 死锁的根源是循环等待,固定锁顺序是最有效的避免方法
- 用
with管理锁,省心又安全
可可精品资料库