Python 中 Lock 和 RLock 有什么区别?如何保证线程安全?
简化版
Lock 是互斥锁,同一时刻只允许一个线程进入临界区;RLock 是可重入锁,同一个线程可以重复获取同一把锁,但也要释放相同次数。线程安全的核心是识别共享可变状态,并用锁、队列或不可变设计避免并发修改冲突。
详细版
典型线程安全问题:
counter = 0
def incr():
global counter
for _ in range(100000):
counter += 1
counter += 1 看似一行,但包含读取、计算、写回多个步骤,多线程交错执行可能丢失更新。
使用 Lock:
import threading
counter = 0
lock = threading.Lock()
def incr():
global counter
for _ in range(100000):
with lock:
counter += 1
RLock 适合递归调用或同一线程在调用链中重复进入受同一把锁保护的代码:
lock = threading.RLock()
def outer():
with lock:
inner()
def inner():
with lock:
do_something()
面试重点:
- 锁保护的是共享可变状态;
- 临界区越小越好;
Lock更简单,优先使用;- 确实需要同线程重复获取时才用
RLock; - GIL 不等于业务代码天然线程安全。
完整版教学
一、线程安全问题来自共享可变状态
如果多个线程只读同一份不可变数据,通常问题不大。真正危险的是多个线程同时修改共享对象:
- 修改全局计数器;
- 更新同一个字典;
- 向同一个列表维护复杂状态;
- 读改写同一份配置;
- 多线程操作同一个业务对象。
线程安全不是“用了线程就不安全”,而是“共享可变状态没有同步就不安全”。
二、为什么 GIL 不能替代锁
CPython 的 GIL 确实限制同一时刻执行 Python 字节码的线程数量,但它不保证一段业务逻辑整体原子。
例如:
counter += 1
逻辑上包含:
- 读取
counter; - 计算加一;
- 写回
counter。
线程 A 读到 10,线程 B 也读到 10,两个线程都写回 11,本该加两次,结果只加了一次。GIL 不能让这整个读改写过程自动成为业务原子操作。
三、Lock 的使用方式
Lock 用来保护临界区:
lock = threading.Lock()
with lock:
update_shared_state()
推荐用 with,因为它能在异常时自动释放锁。如果手动:
lock.acquire()
try:
update_shared_state()
finally:
lock.release()
也要保证 finally 释放,否则异常会导致锁永远不释放,其他线程全部卡住。
四、RLock 为什么叫可重入锁
普通 Lock 被同一个线程再次获取也会阻塞自己:
lock = threading.Lock()
def outer():
with lock:
inner()
def inner():
with lock:
pass
如果 outer() 已经拿到 lock,再调用 inner(),inner() 还想拿同一把普通锁,就会自己等自己,形成死锁。
RLock 记录持有锁的线程和重入次数。同一个线程可以重复获取,但必须释放相同次数。它适合递归、嵌套调用、对象方法之间互相调用且共享同一把锁的场景。
五、临界区不要写太大
锁会降低并发度。临界区应该只包住必须同步的部分:
data = slow_io()
with lock:
shared.append(data)
不要把慢 I/O、网络请求、耗时计算都放在锁里,否则其他线程会长时间等待,甚至造成级联阻塞。
六、除了锁,还有哪些线程安全思路
锁不是唯一答案:
- 用
queue.Queue在线程间传递数据; - 减少共享状态,每个线程处理自己的局部数据;
- 使用不可变对象;
- 使用线程池统一管理任务;
- 把并发写入转成单线程消费模型。
面试回答如果只说“加锁”,显得比较浅;如果能说出“减少共享、缩小临界区、优先用 Queue”,就更像工程经验。
七、常见误区与追问
| 工具或做法 | 解决点 | 注意事项 |
|---|---|---|
Lock | 互斥访问临界区 | 同一线程重复获取会卡住 |
RLock | 支持同一线程重入 | 必须获取几次就释放几次 |
with lock | 自动释放锁 | 推荐替代手写 acquire/release |
| 缩小临界区 | 降低等待时间 | 不要持锁做慢 I/O |
- 误区:GIL 可以替代业务锁。 GIL 不保证多步业务操作原子,涉及共享可变状态时仍然需要显式同步。
- 误区:RLock 比 Lock 更高级,所以应该默认用 RLock。
RLock适合递归调用或同一线程嵌套获取同一把锁,普通互斥用Lock更简单。 - 误区:只要用了锁就一定线程安全。 锁必须保护所有访问共享状态的路径;有的路径绕过锁,仍会产生竞态。
- 追问:为什么推荐 with lock? 它能在异常发生时自动释放锁,避免忘记
release()导致其他线程永久等待。 - 追问:临界区应该包含哪些代码? 只包含必须保持一致的共享状态读写,把网络请求、磁盘 I/O、复杂计算尽量移到锁外。
- 追问:如何解释线程安全? 多线程并发调用时,对象或函数仍能保持正确不变量,不产生数据竞争和不可预期中间状态。
记忆钩子:锁保护的是“不变量”,不是保护某一行代码;先找共享状态,再决定锁的范围。
八、加强记忆
线程安全的敌人是共享可变状态,锁的作用是保护临界区。Lock 是普通互斥锁,优先使用;RLock 允许同一线程重复进入,适合嵌套调用但别滥用。GIL 不是业务锁,不能替你保证复合操作的正确性。