Python3 多线程进阶锁工具

🎉摘要:本文深入解析Python threading模块中的四种核心锁机制:互斥锁(Lock)、可重入锁(RLock)、信号量(Semaphore)和条件变量(Condition)。通过实战代码示例,详细讲解每种锁的应用场景、正确用法、常见陷阱(如死锁)以及性能优化技巧,帮助开发者根据具体业务需求选择正确的锁工具,并编写出安全、高效的多线程并发代码。

多线程编程中,共享数据是万恶之源。Python 的 threading 模块提供了一套锁工具,核心目的只有一个:让并发代码串行执行,使其运行结果与串行代码一致。

本章不会重复讲解线程创建和 join() 的基础知识,而是聚焦在四种锁上 —— Lock、RLock、Semaphore、Condition。读完本章,你应该能根据业务场景选对锁,并且能一眼看出别人的代码哪里该加锁、哪里锁多了。

Lock:互斥锁

threading.Lock 是一个独占锁,同一时间只有一个线程能持有它。如果其他线程试图 acquire(加锁),会被阻塞直到锁被释放。

为什么不用锁会出错

先来看一个经典竞争条件(Race Condition):

import threading
import time

counter = 0

def increment():
    global counter
    for _ in range(100000):
        # 读取 -> 修改 -> 写入,这三步不是原子的
        current = counter
        time.sleep(0.0000001)  # 强制让出 CPU,放大竞争概率
        counter = current + 1

threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()

print(f"预期值: 1000000, 实际值: {counter}")

运行后结果大概率会小于 1000000。原因是 counter += 1 在字节码层面被拆成了多条指令,线程 A 读到 5 还没写回,线程 B 也读到 5,最终只加了 1 次。

Lock 的正确用法

在多线程基础章节我们介绍了通过 lock.acquire() 和 lock.release() 实现加锁和释放锁。下面示例演示通过 with lock 进行加锁/释放锁:

import threading

# 创建锁
lock = threading.Lock()
counter = 0

def safe_increment():
    global counter
    for _ in range(100000):
        # 使用锁来保护对共享资源的访问
        with lock:
            counter += 1

threads = [threading.Thread(target=safe_increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()

print(f"Result: {counter}")  # 稳定输出 1000000

注意,with lock: 是 lock.acquire() 和 lock.release() 的语法糖,即使在临界区抛出异常,锁也能保证被释放。如果你手写 try...finally,代码会冗长不少:

lock.acquire()
try:
    counter += 1
finally:
    lock.release()

锁的粒度:粗锁 vs 细锁

如果锁的范围越大,线程并行度越低(例如直接将整个方法锁住,那么整个方法一次只能被一个线程执行,这将很大程度降低线程的并行执行度)。如果范围太小,又容易遗漏。看一个转账的例子:

import threading

class Account:
    def __init__(self, balance):
        self.balance = balance
        self.lock = threading.Lock() # 每个 Account 实例一把锁

def bad_transfer(a: Account, b: Account, amount):
    """错误:分别加锁,中间状态可能被其他线程读取"""
    with a.lock:
        a.balance -= amount
    with b.lock:
        b.balance += amount

def good_transfer(a: Account, b: Account, amount):
    """正确:对涉及的所有账户同时加锁,原子完成"""
    # 底粒度锁,并发现更好
    # 按 id 排序加锁,避免死锁
    first, second = (a, b) if id(a) < id(b) else (b, a)
    # 同时持有转账两个账户的锁才能进行转账操作
    with first.lock:
        with second.lock:
            a.balance -= amount
            b.balance += amount

alice = Account(1000)
bob = Account(1000)

for _ in range(100):
    t1 = threading.Thread(target=good_transfer, args=(alice, bob, 1))
    t2 = threading.Thread(target=good_transfer, args=(bob, alice, 1))
    t1.start(); t2.start()
    t1.join(); t2.join()

print(f"Alice: {alice.balance}, Bob: {bob.balance}")  # 都是 1000

查看上面代码,你可能存在如下疑问:

为什么要使用 first, second = (a, b) if id(a) < id(b) else (b, a) 来确定锁的顺序。这样写 不行吗?

def good_transfer2(a: Account, b: Account, amount):
    """正确:对涉及的所有账户同时加锁,原子完成"""
    with a.lock:
        with b.lock:
            a.balance -= amount
            b.balance += amount

如果能保证两个账户之间的转账是串行(执行账户 1 到账户 2,在执行账户 2 到账户 1,...),一笔完成后才会进行下一笔(在正式环境不可能),没有该代码也不会出现问题。如果不能保证,假如同一时刻有两个线程同时执行账户 1 和账户 2 之间的转账,就会出现问题,如下:

线程1:账户1 → 账户2
持有账户1的锁,等待账户2的锁...

线程2:账户2 → 账户1
持有账户2的锁,等待账户1的锁...

这时,死锁出现了,线程 1 等待账户 2 的锁,但被线程 2 持有了;线程 2 等待账户 1 的锁,被线程 1 持有了,互补谦让,一直等待下去。

你可能对 good_transfer() 函数有点不理解,为什么不是这样?

# 粗粒度锁,并发性低
GLOBAL_LOCK = Lock()
def transfer(a, b, amount):
    with GLOBAL_LOCK:
        a.balance -= amount
        b.balance += amount

这种全局单一锁确实非常简单,但是不推荐使用。为什么?整个系统同一时间只能执行一笔转账。账户 1 转账户 2、账户 3 转账户 4,完全不相关,也要排队串行;用户多、转账频繁时,并发直接卡死,性能极低。

为了提升并发能力,我们为每个 Account 实例自带一把专属锁,只锁住当前操作的两个账户,无关账户互不干扰。

T1:账户 A ↔ B 转账,占用 A、B 锁

T2:账户 C ↔ D 转账,占用 C、D 锁

两者无资源交集,可以同时并行执行,并发能力大幅提升。

注意了:

(1)锁必须覆盖整个事务:读取、计算、写入缺一不可。

(2)多个锁按固定顺序获取:不按顺序加锁,两个线程互相等待对方释放,就会形成死锁。

RLock:可重入锁

RLock(Reentrant Lock)允许同一个线程多次 acquire(上锁),内部维护一个持有计数,只有计数归零时才真正释放。它解决的是“锁内调用锁”的问题。

RLock 的引入场景

假设你有一个线程安全的类,每个方法都加了锁。方法 A 调用方法 B,如果用的是普通 Lock,就会把自己锁死:

import threading

class BadCounter:
    def __init__(self):
        self.lock = threading.Lock() # 持有的锁
        self.value = 0

    def add(self, n):
        # 加锁
        with self.lock:
            self.value += n

    def double_add(self, n):
        # 加锁
        with self.lock:
            self.add(n)  # 再次请求同一把锁,死锁!
            self.add(n)

counter = BadCounter()

def worker():
    try:
        print("worker() 开始执行...")
        counter.double_add(5) # 死锁,永远不会执行后续的代码了
        print("worker() 结束执行")
    except Exception as e:
        print(f"Error: {e}")

# 实际上这里会永远阻塞,不是抛出异常,因为 acquire 是阻塞的
t = threading.Thread(target=worker)
t.start()
t.join()

此时,我们可以把 Lock 换成 RLock 就能解决:

import threading

class SafeCounter:
    def __init__(self):
        self.lock = threading.RLock()  # 换成可重入锁
        self.value = 0

    def add(self, n):
        # 加锁
        with self.lock:
            self.value += n

    def double_add(self, n):
        # 加锁
        with self.lock:
            self.add(n)  # 同一线程再次 acquire,计数 +1
            self.add(n)

counter = SafeCounter()

def worker():
    counter.double_add(5)
    print(f"Value: {counter.value}")

t = threading.Thread(target=worker)
t.start()
t.join()  # 正常结束,输出 10

RLock 与递归函数

另一个常见场景是递归算法需要线程安全:

import threading

# 创建可重入锁 RLock
rlock = threading.RLock()

def traverse(node, depth=0):
    """递归遍历二叉树,累加所有节点值,使用RLock保证线程安全"""
    # 同一线程递归再次进入with会重复acquire,RLock靠计数不会阻塞
    with rlock:
        # 当前节点为空,递归终止,返回0不参与求和
        if node is None:
            return 0
        # 递归遍历左子树:再次调用traverse,会再次执行with rlock加锁
        # 因为是同一个线程持有锁,RLock计数+1,不会死锁
        left = traverse(node.get('left'), depth + 1)
        # 递归遍历右子树,同理重复上锁
        right = traverse(node.get('right'), depth + 1)
        # 当前节点值 + 左子树总和 + 右子树总和,向上返回
        return node.get('value', 0) + left + right

# 模拟二叉树字典结构
tree = {
    'value': 1,
    'left': {'value': 2, 'left': {'value': 4}, 'right': {'value': 5}},
    'right': {'value': 3, 'left': {'value': 6}, 'right': {'value': 7}}
}

result = traverse(tree)
print(f"Tree sum: {result}")  # 输出 28

一个陷阱:RLock 不能跨线程释放

RLock 的持有者信息是线程绑定的。如果线程 A 加了锁,线程 B 试图释放,会抛出 RuntimeError。这个设计是对的,否则锁的语义就崩塌了。例如:

import threading

rlock = threading.RLock()

def thread_a():
    # 线程A加锁
    rlock.acquire()
    print("A acquired")

def thread_b():
    # 线程B释放锁
    try:
        rlock.release()  # RuntimeError: cannot release un-acquired lock
    except RuntimeError as e:
        print(f"B failed: {e}")

t1 = threading.Thread(target=thread_a)
t1.start()
t1.join()

t2 = threading.Thread(target=thread_b)
t2.start()
t2.join()

# 运行结果:
# A acquired
# B failed: cannot release un-acquired lock

记住了,RLock 只能在同一个线程中进行加锁和释放锁,不要跨线程。

Semaphore:信号量,控制并发配额

Semaphore 可以看成一把有 N 个钥匙的锁。最多允许 N 个线程同时进入临界区,第 N+1 个线程必须等待有人退出。它的典型用途是限流,比如限制同时下载的线程数、限制数据库连接池的使用数量。

基础用法:限流下载器

用法很简单,和 lock 类似,创建一个信号量,然后通过 with 进行使用,例如:

import threading
import time
import random

# 创建信号量
# 最多允许 3 个线程同时下载
sem = threading.Semaphore(3)

def download(url: str):
    # 信号量使用
    with sem:
        print(f"[{threading.current_thread().name}] 开始下载 {url}")
        time.sleep(random.uniform(1, 3))  # 模拟下载
        print(f"[{threading.current_thread().name}] 完成下载 {url}")

# 准备10个URL
urls = [f"https://example.com/file{i}.zip" for i in range(10)]

# 创建10个线程去下载,一个URL一个线程
threads = [
    threading.Thread(target=download, args=(url,), name=f"Worker-{i}")
    for i, url in enumerate(urls)
]

for t in threads: t.start()
for t in threads: t.join()
print("全部任务完成")

运行后你会看到最多只有 3 个 "开始下载" 同时打印,其他线程在排队。这与线程池不同:Semaphore 不管理线程生命周期,只限制同时执行的代码段数量。如果你需要复用线程,应该用 concurrent.futures.ThreadPoolExecutor(max_workers=3)。

信号量做资源池

Semaphore 也常用来实现简单的连接池或插槽池,例如:

import threading
import time

class ConnectionPool:
    def __init__(self, max_connections=5):
        self._semaphore = threading.Semaphore(max_connections)
        self._available = max_connections
        self._lock = threading.Lock()

    def acquire(self):
        """加锁"""
        self._semaphore.acquire()
        with self._lock:
            self._available -= 1
            print(f"连接已获取,剩余可用: {self._available}")
        return self  # 简化返回 self,实际应返回连接对象

    def release(self):
        """释放锁"""
        with self._lock:
            self._available += 1
            print(f"连接已释放,剩余可用: {self._available}")
        self._semaphore.release()

    # 进入 with 代码块,自动执行 __enter__() 
    def __enter__(self):
        self.acquire()
        return self

    # 离开 with 代码块(正常走完 / 报错异常),自动执行 __exit__()
    def __exit__(self, *args):
        self.release()

pool = ConnectionPool(3)

def worker(name):
    with pool:
        print(f"[{name}] 使用连接中...")
        time.sleep(1)
        print(pool)
        print(f"[{name}] 使用完毕")

for i in range(5):
    t = threading.Thread(target=worker, args=(f"Thread-{i}",))
    t.start()

注意,上面示例使用了 with 进入和退出的魔法函数来实现获取连接和释放连接的操作。

BoundedSemaphore:防止信号量溢出

标准 Semaphore 有一个隐患:release() 可以被无限制调用,导致计数超过初始值。如果代码里有 bug 多调了几次 release,信号量就形同虚设了。例如:

sem = Semaphore(2)
sem.acquire()  # count=1
sem.release()  # count=2
sem.release()  # 多调用一次!count=3

上述代码,现在计数器变成 3,超过初始化的 2。再来 3 个线程 acquire() 全部能拿到许可,原本限制最多 2 人,现在变成 3 人同时执行,限流逻辑直接失效。

BoundedSemaphore 会在 release() 导致计数超过初始值时抛出 ValueError,帮助你尽早发现错误:

import threading

sem = threading.BoundedSemaphore(2)
sem.acquire()
sem.acquire()
sem.release()
sem.release()
sem.release()  # ValueError: Semaphore released too many times

注意:在需要严格配额控制的场景下,优先使用 BoundedSemaphore。

Condition:条件变量,精确的等待与通知

Condition 是锁的增强版,它内部持有一把锁(默认 RLock),并额外提供 wait()、notify() 和 notify_all() 三个方法。核心场景是生产者-消费者:消费者发现队列空了就等待,生产者放入数据后通知消费者醒来。

生产者-消费者模型

下面使用 Condition 来实现生产者-消费者模型,队列大小为 3,如下:

import threading
import time
import random

class Queue:
    def __init__(self, maxsize=10):
        # 最大大小
        self.maxsize = maxsize
        # 数据
        self.items = []
        # 条件变量
        self.cond = threading.Condition()

    def put(self, item):
        """放入数据到队列"""
        with self.cond:
            # 队列满时等待,直到有空位
            while len(self.items) >= self.maxsize:
                print(f"[Producer] 队列已满,等待...")
                self.cond.wait() # 等待队列被消费
            self.items.append(item)
            print(f"[Producer] 放入 {item},队列: {self.items}")
            self.cond.notify_all()  # 通知可能在等待的消费者

    def get(self):
        """从队列取值"""
        with self.cond:
            # 队列空时等待,直到有数据
            while len(self.items) == 0:
                print(f"[Consumer] 队列已空,等待...")
                self.cond.wait() # 等待队列被放入数据
            item = self.items.pop(0)
            print(f"[Consumer] 取出 {item},队列: {self.items}")
            self.cond.notify_all()  # 通知可能在等待的生产者
            return item

# 创建队列,最大大小为3
queue = Queue(maxsize=3)

def producer():
    for i in range(10):
        time.sleep(random.uniform(0.1, 0.5))
        queue.put(f"item-{i}") # 放值到队列

def consumer():
    for _ in range(10):
        time.sleep(random.uniform(0.3, 0.7))
        queue.get() # 从队列取值

# 生产者
p = threading.Thread(target=producer, name="Producer")
# 消费者
c = threading.Thread(target=consumer, name="Consumer")
p.start()
c.start()
p.join()
c.join() # 主线程等待生产者和消费者完成
print("Done")

注意了:

  • wait() 必须在持有锁的情况下调用,调用后它会自动释放锁并阻塞线程,被唤醒后自动重新获取锁。

  • 用 while 而不是 if 判断条件。因为 notify() 唤醒后,条件可能又不满足了(虚假唤醒或被其他线程抢先),必须再次检查。

  • notify() 唤醒一个等待线程,notify_all() 唤醒所有。如果不确定该唤醒几个,用 notify_all() 更安全。

超时等待与轮询

wait() 函数支持超时,避免永久阻塞,例如:

with cond:
    if not cond.wait(timeout=5.0):  # 5 秒内未被唤醒,返回 False
        print("等待超时,继续处理备选逻辑")

这比用 time.sleep() 做轮询优雅得多。轮询浪费 CPU,而 wait() 会让线程进入阻塞状态,不占用调度资源。

与 queue.Queue 的关系

Python 标准库的 queue.Queue 内部已经用 Condition 实现了线程安全的生产者-消费者模型。实际项目中,优先用 queue.Queue,而不是自己手写 Condition。

自己写 Condition 适合以下情况:

  • 队列逻辑有特殊判定规则(如"当队列中总金额超过 1000 时才通知处理");

  • 需要多个条件变量协同(如"队列为空且系统未暂停")。

四种锁的对比与选型

下表从语义、使用场景和注意点简单说说上面介绍的各种锁的差异:

锁类型核心语义适用场景注意点
Lock独占,一个线程持有单一临界区、计数器、标志位保护不能重入,跨方法调用时小心死锁
RLock可重入,同线程多次 acquire类内部方法互相调用、递归算法不能跨线程释放,性能略低于 Lock
Semaphore最多 N 个线程同时进入限流、连接池、资源配额控制用 BoundedSemaphore 防溢出;注意与线程池的区别
Condition等待 + 通知 的精确控制生产者-消费者、状态变化事件必须配合 while 使用;wait() 自动释放/获取锁

什么时候不该用锁?

锁不是万能药。Python 的 GIL(Global Interpreter Lock)已经保护了字节码执行的原子性,对于单条字节码操作(如列表的 append、字典的 pop)实际上不需要额外加锁。但 GIL 会在 I/O 操作和固定数量的字节码指令后释放,所以跨多条语句的复合操作仍然需要锁。

另一个方向是无锁编程。对于简单的计数器,可以用 queue.Queue 做任务分发,或者把状态收敛到单线程(如 asyncio 的事件循环)。锁越少,死锁概率越低,调试越轻松。

死锁

死锁是多线程编程的噩梦,而且死锁通常需要在特定条件才会触发,这就给发现死锁增加了难度。死锁也很危险,代码上线后直接死锁,导致功能不可用,甚至整个服务都会崩溃。后果还是很严重,在测试环境不一定能够发现。

典型的死锁场景

线程 1 拿 A 等 B,线程 2 拿 B 等 A,循环等待,死锁发生。例如:

import threading

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

def thread_1():
    with lock_a:
        print("Thread-1 got A")
        threading.Event().wait(0.1)  # 故意让出时间片
        with lock_b: # 等待锁B
            print("Thread-1 got B")

def thread_2():
    with lock_b:
        print("Thread-2 got B")
        threading.Event().wait(0.1)
        with lock_a: # 等待锁A
            print("Thread-2 got A")

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

# 程序永远卡在这里
t1.join()
t2.join()

你可能会问,有没有什么办法避免死锁,答案是肯定的。但是不能完全避免,但是可以降低死锁出现的概率。

避免死锁的四条军规

一定要记牢了:

(1)全局排序:所有锁按固定顺序获取(比如按 id(lock) 从小到大)。前面转账例子已经展示。

(2)超时机制:lock.acquire(timeout=5.0) 在 5 秒后放弃,打印日志并回滚。

(3)减少锁的持有时间:不要在锁里调外部接口、读写文件,只保留最小必要操作。

(4)能用一个锁,不用两个:锁的数量和死锁概率成正比。

性能与调试

锁的性能开销

锁的获取和释放需要操作系统内核介入(futex 系统调用),通常耗时在微秒级别。对于高并发、细粒度操作,锁竞争会成为瓶颈。此时考虑:

  • 把数据分区(每个线程操作自己的分片,最后合并)。

  • 使用 threading.local() 做线程本地存储,避免共享。

  • 在合适的场景下用 multiprocessing(多进程处理,后续介绍)绕过 GIL。

打印线程状态

调试多线程时,threading.enumerate() 和 threading.current_thread() 很有用:

import threading

def show_threads():
    for t in threading.enumerate():
        print(f"  {t.name} (daemon={t.daemon}, alive={t.is_alive()})")

# 在关键位置插入调用,观察线程是否卡在锁上

更高级的调试可以用 faulthandler 模块在程序崩溃时 dump 所有线程的 traceback,或者直接用 py-spy / gdb attach 到进程查看锁的持有者。

注意了,多线程代码的正确性不能靠 "看起来没问题" 来保证。写出一段并发代码只是开始,在压力测试下跑 10 万次、100 万次、... 不崩溃,才算真正过关。

说说我的看法
全部评论(
没有评论
关于
本网站专注于 Java、数据库(MySQL、Oracle)、Linux、软件架构及大数据等多领域技术知识分享。涵盖丰富的原创与精选技术文章,助力技术传播与交流。无论是技术新手渴望入门,还是资深开发者寻求进阶,这里都能为您提供深度见解与实用经验,让复杂编码变得轻松易懂,携手共赴技术提升新高度。如有侵权,请来信告知:hxstrive@outlook.com
其他应用
公众号