线程同步机制:Lock锁的使用、RLock可重入锁、死锁的产生与避免
说到多线程文件读写,有个问题绕不开——线程同步。
我刚开始写多线程程序时,觉得这玩意儿挺简单。不就是开几个线程一起干活嘛?结果一跑,数据全乱套了。两个线程同时往一个文件里写,你写一半我插一脚,最后文件里全是乱码。
嗯,这就是典型的竞态条件。说白了,就是多个线程同时操作共享资源,互相干扰。
1. Lock锁:最基础的同步工具
Python 的 threading.Lock 是最基础的锁。它的用法很简单:
import threading
lock = threading.Lock()
def write_file(data):
lock.acquire() # 获取锁
try:
# 这里是临界区,一次只能一个线程进来
with open('data.txt', 'a') as f:
f.write(data + '\n')
finally:
lock.release() # 释放锁
我在项目中遇到过一个问题:有个同事忘了写 finally 块里的 release()。结果程序跑着跑着就卡死了。为什么?因为某个线程获取锁后抛了异常,锁没释放,其他线程永远等不到。
with 语句来管理锁,省心又安全。
lock = threading.Lock()
def write_file(data):
with lock: # 自动获取和释放锁
with open('data.txt', 'a') as f:
f.write(data + '\n')
这样写,就算里面抛异常,锁也会自动释放。我个人强烈推荐这种方式。
2. RLock可重入锁:同一个线程能重复获取
有时候,一个函数里调用了另一个函数,两个函数都需要同一把锁。用普通的 Lock 会怎样?
lock = threading.Lock()
def func_a():
with lock:
func_b() # 这里会死锁!
def func_b():
with lock: # 同一个线程想再次获取锁,但锁已经被自己占着
print('hello')
为什么会这样?因为 Lock 是不可重入的。同一个线程获取锁后,没释放之前再获取一次,就会把自己卡死。
这时候就需要 RLock(可重入锁)了:
rlock = threading.RLock()
def func_a():
with rlock:
func_b() # 没问题,RLock允许同一个线程重复获取
def func_b():
with rlock: # 同一个线程可以再次获取
print('hello')
RLock 内部维护了一个计数器。同一个线程每 acquire 一次,计数器加1;每 release 一次,计数器减1。只有计数器归零了,其他线程才能获取锁。
RLock 更安全。
3. 死锁的产生:两个线程互相等待
死锁是线程同步里最头疼的问题。我来讲个真实案例。
有一次,我负责一个日志系统。两个线程分别写不同的文件,但偶尔需要交换文件句柄。代码大概长这样:
lock_a = threading.Lock()
lock_b = threading.Lock()
def thread_1():
with lock_a:
time.sleep(0.1) # 模拟一些操作
with lock_b: # 等待lock_b
print('thread_1 done')
def thread_2():
with lock_b:
time.sleep(0.1) # 模拟一些操作
with lock_a: # 等待lock_a
print('thread_2 done')
跑起来后,程序卡死了。为什么?
- 线程1获取了
lock_a,等待lock_b - 线程2获取了
lock_b,等待lock_a - 两个线程都在等对方释放锁,谁也不让谁
这就是经典的死锁。
4. 死锁的避免:四个方法
我曾经被死锁坑过好几次,后来总结了几条经验:
方法一:固定锁的获取顺序
让所有线程都按同样的顺序获取锁。比如都先拿 lock_a 再拿 lock_b:
def thread_1():
with lock_a:
with lock_b:
print('thread_1 done')
def thread_2():
with lock_a: # 同样先拿lock_a
with lock_b:
print('thread_2 done')
这样就不会出现循环等待了。
方法二:使用超时机制
acquire() 可以设置超时时间。拿不到锁就放弃,别死等:
if lock_a.acquire(timeout=5): # 最多等5秒
try:
if lock_b.acquire(timeout=5):
try:
# 处理业务
pass
finally:
lock_b.release()
finally:
lock_a.release()
else:
print('获取锁超时,做其他处理')
方法三:尽量少用多把锁
能用一把锁解决的问题,就别用两把。锁越少,死锁概率越低。
方法四:使用 with 语句
前面说过,with 能保证锁一定会释放,避免因为异常导致锁没释放而引发死锁。
知识体系总览
下面这张图,把本章的核心逻辑串起来了:
这张图把三个核心概念串起来了。你想想看,Lock 是最基础的,RLock 是它的升级版,而死锁则是使用不当的后果。底部的四个方法,就是我们的护身符。
- 用
with管理锁,别手动acquire/release - 嵌套锁场景优先用
RLock - 多把锁时固定获取顺序,避免死锁
- 加超时机制,别让线程死等
好了,线程同步这块就聊到这儿。记住一句话:锁是工具,不是目的。我们的目标是让多线程安全高效地读写文件,别为了用锁而用锁。