线程同步机制:Lock锁、RLock与死锁
各位同学,今天我们来聊聊多线程编程里最核心、也最容易踩坑的话题——线程同步。说白了,就是多个线程抢资源的时候,怎么保证不出乱子。
我在早年做支付系统的时候,就吃过线程不同步的亏。两个线程同时扣款,结果余额变成了负数。嗯,那场面,至今难忘。所以今天的内容,我建议你认真看。
一、为什么需要Lock锁?
先想一个问题:两个线程同时修改同一个变量,会发生什么?
我举个例子。假设有个全局变量 counter = 0,两个线程各自对它执行100万次 counter += 1。你猜最终结果是多少?
理论上应该是200万。但实际跑起来,可能只有180万,甚至更少。
为什么会这样?因为 counter += 1 不是原子操作。它拆成了三步:
- 读取 counter 的值
- 计算 counter + 1
- 写回 counter
两个线程同时执行,就可能出现「读取-计算-写回」的交叉。比如线程A读到0,线程B也读到0,各自加1后写回,结果还是1。明明加了两次,却只加了1。这就是经典的「竞态条件」。
二、Lock锁的基本用法
Python的 threading.Lock 是最基础的同步工具。用法很简单:
import threading
lock = threading.Lock()
counter = 0
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
这里 acquire() 和 release() 必须成对出现。我习惯用 with 语句来写,更安全:
def increment():
global counter
for _ in range(1000000):
with lock:
counter += 1
用 with 的好处是,即使中间抛异常,锁也会自动释放。你想想看,要是忘了写 release(),其他线程就永远拿不到锁了——这就是死锁的雏形。
三、RLock可重入锁
Lock 有个限制:同一个线程不能多次 acquire() 同一个锁。否则会把自己锁死。
举个例子:
lock = threading.Lock()
lock.acquire()
lock.acquire() # 这里会阻塞,死锁!
这在递归调用或嵌套锁的场景下特别常见。比如:
def func1():
with lock:
func2()
def func2():
with lock: # 同一个线程再次申请同一个锁
print("hello")
用普通 Lock,func2 永远等不到锁释放。这时候就需要 RLock(可重入锁)。
RLock 允许同一个线程多次 acquire(),内部维护了一个计数器。每次 acquire() 加1,release() 减1,只有减到0时,其他线程才能获取。
rlock = threading.RLock()
def func1():
with rlock:
func2()
def func2():
with rlock: # 同一个线程,没问题
print("hello")
简单总结一下区别:
| 特性 | Lock | RLock |
|---|---|---|
| 同一线程多次 acquire | 阻塞(死锁) | 允许 |
| 内部实现 | 二进制信号量 | 计数器+所有者 |
| 适用场景 | 简单互斥 | 递归/嵌套调用 |
四、死锁的产生
死锁,说白了就是「两个线程互相等对方释放锁,结果谁也动不了」。
经典场景是这样的:
lock_a = threading.Lock()
lock_b = threading.Lock()
def thread1():
with lock_a:
time.sleep(0.1)
with lock_b:
print("thread1 done")
def thread2():
with lock_b:
time.sleep(0.1)
with lock_a:
print("thread2 done")
线程1持有lock_a,等lock_b。线程2持有lock_b,等lock_a。两个线程都在等对方释放,程序就永远卡住了。
五、如何避免死锁
避免死锁,有几种常用策略:
- 固定锁顺序:所有线程按相同顺序获取锁。比如都先拿lock_a再拿lock_b。
- 超时机制:用
acquire(timeout)设置超时,拿不到就放弃。 - 锁分级:给锁分配等级,只能从低等级往高等级获取。
我比较推荐第一种,简单有效。比如上面的例子,只要把thread2改成先拿lock_a再拿lock_b,死锁就解了。
超时机制也很实用:
if lock_a.acquire(timeout=5):
try:
if lock_b.acquire(timeout=5):
try:
# 业务逻辑
finally:
lock_b.release()
finally:
lock_a.release()
else:
print("获取锁超时,做降级处理")
嗯,这里要注意:超时后一定要做降级处理,不能直接忽略。否则数据可能不一致。
六、知识体系总览
下面这张图,帮你理清本章的核心脉络:
七、总结
今天的内容,说白了就三件事:
- Lock:最基本的互斥锁,适合简单场景
- RLock:可重入锁,适合递归或嵌套调用
- 死锁:两个线程互相等待,必须通过固定顺序或超时来避免
我个人建议,在不确定的场景下,优先用RLock。虽然性能略低一点,但能避免很多隐晦的死锁问题。你想想看,一个线上bug排查的成本,可比这点性能开销大多了。
好了,今天就到这里。记住一句话:锁是工具,不是目的。能用简单锁解决的问题,别搞复杂了。
可可精品资料库