4. 线程同步机制(上):Lock锁的基本使用、可重入锁RLock、死锁的产生与预防策略

多线程编程里,最让人头疼的问题是什么?

我个人觉得,不是性能不够,而是数据乱了套。你想想看,两个线程同时往一个银行账户里存钱,结果账目对不上——这种问题一旦出现,排查起来简直要命。

所以,线程同步机制就派上用场了。说白了,就是给共享资源加把锁,同一时间只允许一个线程进去操作。

4.1 Lock锁:最基础的同步工具

Python 的 threading.Lock 是最简单的锁。它只有两个方法:acquire() 和 release()。

核心原则: 谁拿到锁,谁就拥有访问权。其他线程必须等着。

来看个例子。假设我们有个计数器,两个线程各加 100 万次:

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(1_000_000):
        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(f"最终结果: {counter}")  # 输出 2000000

如果不加锁,你大概率会得到 199 万左右的结果。为什么会这样?因为 counter += 1 不是原子操作,它分三步:读取、加一、写回。两个线程同时读取到同一个值,加完写回去,就丢了一次更新。

我的习惯: 用 with lock: 上下文管理器来替代手动 acquire/release。这样即使中间抛异常,锁也会自动释放,省心很多。

def increment():
    global counter
    for _ in range(1_000_000):
        with lock:
            counter += 1

4.2 可重入锁 RLock:同一个线程能重复拿锁

普通 Lock 有个限制:同一个线程不能连续两次 acquire。否则会死锁——自己把自己锁死了。

但有时候,我们确实需要在一个线程里多次获取同一把锁。比如函数 A 调函数 B,两个函数都需要锁:

lock = threading.Lock()

def func_b():
    with lock:
        print("执行 B")

def func_a():
    with lock:
        print("执行 A")
        func_b()  # 这里会死锁!

t = threading.Thread(target=func_a)
t.start()

运行这段代码,程序会卡住。因为 func_a 拿到了锁,还没释放就去调 func_b,而 func_b 也要拿同一把锁——但锁还在 func_a 手里。

这时候就需要 RLock(可重入锁) 了。它允许同一个线程多次 acquire,内部维护一个计数器,每次 acquire 加一,release 减一,减到零才真正释放。

rlock = threading.RLock()

def func_b():
    with rlock:
        print("执行 B")

def func_a():
    with rlock:
        print("执行 A")
        func_b()  # 没问题,同一个线程可以重入

t = threading.Thread(target=func_a)
t.start()

注意: RLock 虽然方便,但不要滥用。我在项目中见过有人把 RLock 当普通锁用,结果代码里 acquire 和 release 次数对不上,排查了半天。能用 Lock 的地方,优先用 Lock。

4.3 死锁:最隐蔽的坑

死锁,就是两个或多个线程互相等待对方释放资源,结果谁也动不了。

经典的死锁场景是这样的:

lock_a = threading.Lock()
lock_b = threading.Lock()

def thread_1():
    with lock_a:
        print("线程1 拿到锁A")
        import time; time.sleep(0.1)
        with lock_b:
            print("线程1 拿到锁B")

def thread_2():
    with lock_b:
        print("线程2 拿到锁B")
        import time; time.sleep(0.1)
        with lock_a:
            print("线程2 拿到锁A")

t1 = threading.Thread(target=thread_1)
t2 = threading.Thread(target=thread_2)
t1.start()
t2.start()

运行后,两个线程各拿一把锁,然后都在等对方释放——程序就卡死了。

我曾经踩过的坑: 在一个支付系统里,订单锁和账户锁互相嵌套,结果线上出现了死锁,导致一批订单卡住无法处理。后来加了超时机制和日志,才定位到问题。

4.4 预防死锁的几种策略

死锁虽然讨厌,但也不是没办法预防。我总结了几个实用策略:

  1. 固定锁顺序:所有线程都按同样的顺序获取锁。比如先拿 A 再拿 B,就不会出现循环等待。
  2. 使用超时:lock.acquire(timeout=5),拿不到就放弃,避免无限等待。
  3. 尽量少用嵌套锁:能用一个锁解决的问题,别用两个。
  4. 使用 try-finally 或 with 语句:确保锁一定会释放。

来看一个固定锁顺序的例子:

def thread_safe(lock_a, lock_b):
    # 约定:总是先拿 lock_a,再拿 lock_b
    with lock_a:
        with lock_b:
            print("安全执行")

这样不管有多少线程,都不会出现循环等待。

4.5 知识体系图

下面这张图,帮你理清本章的核心逻辑:

线程同步机制(上)知识体系 线程同步机制 Lock 基本锁 RLock 可重入锁 死锁问题 acquire() / release() with 上下文管理器 同一线程可重入 内部计数器管理 循环等待资源 预防:固定顺序/超时 核心目标:保证共享数据的一致性

4.6 本章小结

嗯,这一章的内容其实就三件事:

  • Lock 是最基础的锁,适合简单场景。记得用 with 语句,别手动 acquire/release。
  • RLock 解决同一个线程重复拿锁的问题,但别滥用。
  • 死锁 是并发编程的头号杀手,预防比事后排查重要得多。

我个人觉得,理解这些概念最好的方式就是动手写代码。把上面的例子跑一遍,再故意制造个死锁看看效果——相信我,踩过坑之后,你一辈子都忘不了。

一个小建议: 刚开始用锁的时候,尽量保持简单。一个函数里只拿一把锁,拿完就放。等熟练了再考虑复杂的嵌套场景。

可可精品资料库