线程同步机制(上):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. 实践建议:工控场景下的锁使用规范

最后给大家几条我在工控项目里总结的规范:

  1. 锁的粒度要适中。太粗了性能差,太细了容易死锁。我一般一个功能模块用一把锁。
  2. 优先用with语句。手动acquire/release容易漏掉release。
  3. 嵌套锁用RLock。别问为什么,问就是省心。
  4. 加锁超时机制。比如lock.acquire(timeout=5),超时了就报错,别让程序卡死。
  5. 锁的持有时间尽量短。拿到锁后赶紧干活,干完赶紧释放。别在锁里面做I/O操作。

好了,这一章的内容就到这儿。锁这东西,看着简单,用好了是利器,用不好就是定时炸弹。下一章咱们聊聊条件变量和信号量,那又是另一番天地了。

📌 课后思考
如果你有一个PLC通信程序,需要同时读取10个寄存器的值,你会用一把大锁保护所有数据,还是每个寄存器用一把小锁?为什么?