线程同步机制(上):Lock锁的使用、RLock可重入锁、死锁的产生与预防
各位同学,咱们今天聊点硬核的——线程同步。说实话,我见过太多工控项目因为锁没用好,现场跑着跑着就卡死了。那种感觉,就像你盯着PLC的指示灯,它就是不给你想要的信号,急死人。
先问大家一个问题:为什么需要锁?
你想想看,多线程就像多个工人同时操作一台机器。如果没人管,A工人刚把零件放上去,B工人就拧螺丝,C工人又过来刷漆——这不出乱子才怪。锁的作用,就是给每个工人发个牌子:谁拿到牌子谁干活,其他人等着。
1. Lock锁:最基础的互斥机制
Python里的threading.Lock,说白了就是一把最简单的锁。只有两种状态:锁定和未锁定。
import threading
lock = threading.Lock()
shared_data = 0
def worker():
global shared_data
for _ in range(100000):
lock.acquire()
shared_data += 1
lock.release()
threads = [threading.Thread(target=worker) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print(f"最终结果: {shared_data}")
这段代码看着简单,但我得提醒你:acquire()和release()必须成对出现。我在项目中遇到过有人忘记release(),结果整个线程卡死,PLC通信中断了半小时才被发现。嗯,那是个教训。
我曾经在调试一个西门子PLC的通信程序时,因为一个异常分支没有释放锁,导致所有读取线程全部阻塞。现场工程师打电话说数据不动了,我远程一看,锁还攥在手里没松开。从那以后,我养成了一个习惯:能用with语句就别手动acquire/release。
用with语句改写一下:
def worker():
global shared_data
for _ in range(100000):
with lock:
shared_data += 1
这样就算代码里抛出异常,锁也会自动释放。省心多了。
2. RLock可重入锁:同一个线程能重复获取
Lock有个问题:同一个线程不能连续两次acquire()。你试试看:
lock = threading.Lock()
lock.acquire()
lock.acquire() # 这里会死锁!自己把自己锁死了
为什么会这样?因为Lock不记录"谁持有锁"。它只知道锁被占用了,但不知道是被自己占用的。第二次acquire时,它发现锁没释放,就老老实实等着——结果永远等不到。
这时候就需要RLock(可重入锁)了。
rlock = threading.RLock()
rlock.acquire()
rlock.acquire() # 没问题,同一个线程可以重入
rlock.release()
rlock.release() # 释放两次
RLock内部维护了一个计数器和一个持有者线程ID。每次acquire,计数器+1;每次release,计数器-1。只有计数器归零了,其他线程才能拿到锁。
我个人习惯在递归函数或者嵌套调用场景下用RLock。比如你写一个PLC数据采集函数,里面调用了另一个需要同步的函数,用RLock就省心很多。
如果你不确定用Lock还是RLock,先问自己一个问题:这个锁会不会在同一个线程里被多次获取? 会的话,用RLock;不会的话,用Lock性能更好。
3. 死锁的产生:两个线程互相等待
死锁是工控编程里最头疼的问题之一。我给大家画个场景:
- 线程A持有锁1,等待锁2
- 线程B持有锁2,等待锁1
两个人都攥着对方想要的东西,谁也不撒手。结果就是——程序卡死,PLC通信中断,现场报警灯狂闪。
import threading
lock1 = threading.Lock()
lock2 = threading.Lock()
def worker_a():
with lock1:
print("A 拿到锁1")
import time; time.sleep(0.1)
with lock2:
print("A 拿到锁2")
def worker_b():
with lock2:
print("B 拿到锁2")
import time; time.sleep(0.1)
with lock1:
print("B 拿到锁1")
t1 = threading.Thread(target=worker_a)
t2 = threading.Thread(target=worker_b)
t1.start()
t2.start()
t1.join()
t2.join()
运行这段代码,大概率会卡住。A和B各拿一把锁,然后互相等对方释放——这就是典型的死锁。
4. 死锁的预防:四个必要条件
死锁的发生需要同时满足四个条件:
| 条件 | 说明 | 预防思路 |
|---|---|---|
| 互斥 | 资源一次只能被一个线程使用 | 无法避免,锁本身就是互斥的 |
| 持有并等待 | 线程持有资源的同时等待其他资源 | 一次性申请所有资源 |
| 不可剥夺 | 资源只能由持有者主动释放 | 设置超时,超时后强制释放 |
| 循环等待 | 存在循环的等待链 | 规定锁的获取顺序 |
实际项目中,我最常用的预防手段是规定锁的获取顺序。比如所有线程都先拿锁1再拿锁2,就不会出现循环等待。
在工控项目中,我建议所有线程按照全局统一的顺序获取锁。比如:先获取通信锁,再获取数据锁,最后获取界面锁。顺序一旦定下来,就不要改。
5. 我在PLC通信项目中遇到的死锁案例
讲个真实案例吧。几年前我做一个三菱PLC的通信项目,需要同时采集多个寄存器的数据。我设计了两个线程:
- 采集线程:负责从PLC读取数据,写入共享缓冲区
- 处理线程:负责从缓冲区读取数据,进行逻辑运算
当时我用了两把锁:buffer_lock保护缓冲区,plc_lock保护PLC通信端口。采集线程先拿plc_lock再拿buffer_lock,处理线程先拿buffer_lock再拿plc_lock——你看出问题了吗?
没错,这就是典型的循环等待死锁。程序跑了大概两个小时,突然卡死了。现场工程师重启了三次,每次都是两小时后准时卡死。
排查过程很痛苦。我远程登录上去,用pstack看了线程堆栈,发现两个线程都在等锁。改起来倒简单:统一锁的顺序,都先拿plc_lock再拿buffer_lock。改完后再也没出过问题。
我曾经犯过的错,希望大家别再犯:
1. 不要在不同线程里用不同的锁顺序
2. 不要在一个锁的持有范围内调用可能阻塞的外部函数
3. 能用超时就用超时,别死等
4. 调试时加锁日志,记录谁在什么时候拿了什么锁
6. 实践建议:工控场景下的锁使用规范
最后给大家几条我在工控项目里总结的规范:
- 锁的粒度要适中。太粗了性能差,太细了容易死锁。我一般一个功能模块用一把锁。
- 优先用with语句。手动acquire/release容易漏掉release。
- 嵌套锁用RLock。别问为什么,问就是省心。
- 加锁超时机制。比如
lock.acquire(timeout=5),超时了就报错,别让程序卡死。 - 锁的持有时间尽量短。拿到锁后赶紧干活,干完赶紧释放。别在锁里面做I/O操作。
好了,这一章的内容就到这儿。锁这东西,看着简单,用好了是利器,用不好就是定时炸弹。下一章咱们聊聊条件变量和信号量,那又是另一番天地了。
如果你有一个PLC通信程序,需要同时读取10个寄存器的值,你会用一把大锁保护所有数据,还是每个寄存器用一把小锁?为什么?