同步与互斥:锁、信号量、条件变量

并发编程里的「坑」,十有八九出在共享数据上。

多线程同时读写一个变量,结果不可预测,这就是并发安全问题。怎么解决?

说白了,就是给这块共享数据加一把锁,同一时间只让一个线程碰它。

但锁也分很多种,用不对反而更慢。我见过太多团队把并发程序写成了串行排队——锁加粗了,性能也没了。

这节我们聊聊三种核心工具:互斥锁、信号量、条件变量。

搞清楚它们什么时候用、怎么用,你写并发代码才能心中有数。

1. 互斥锁:最简单的节奏控制器

互斥锁(Mutex)是并发编程的入门工具。它的任务就一个:保证临界区代码互斥执行。

嗯,临界区就是读写共享资源的那段代码。你想想看,两个线程同时写一个 Map,结果数据全乱了。这时候就需要锁。

核心原则:锁的粒度要尽量小,只保护真正需要保护的数据。

我在项目中遇到过一个问题:有人把整个方法都上了锁,性能直线下降。实际上,只有那三行修改共享变量的代码需要保护。改完之后,吞吐量翻了一倍。

使用姿势

import threading

lock = threading.Lock()

def deposit(amount):
    lock.acquire()
    try:
        # 临界区:只保护这一行
        balance += amount
    finally:
        lock.release()

上面这段代码看似简单,但有个关键点:一定要用 try-finally 释放锁。我之前线上出过一个事故,某段代码抛了异常,锁没释放,整个服务卡死了。那次教训太深刻了。

锁的常见陷阱

问题 表现 怎么避免
死锁 两个线程互相等对方释放锁 规定锁的获取顺序,或使用 try_lock 超时
锁争用过高 CPU 大部分时间在等锁 缩小临界区,使用读写锁分离
忘记释放 线程卡死,服务无响应 用上下文管理器(with 语句)

小技巧:Python 里用 with lock: 比手动 acquire/release 更安全。Java 里推荐用 ReentrantLock 搭配 try-finally,或者直接上 synchronized 关键字。

2. 信号量:控制并发访问数的水龙头

互斥锁只让一个线程进去。那如果我想允许最多 5 个线程同时访问呢?

这就需要信号量(Semaphore)了。你可以把它想象成一个水龙头,设置好水量,一次只能放那么多水。

我习惯把信号量用在连接池、限流等场景。比如数据库连接池最多开 10 个连接,信号量就能精确控制并发数。

信号量 vs 互斥锁

特性 互斥锁 信号量
允许进入的线程数 1 个 N 个(由初始值决定)
典型场景 保护共享数据 资源池、限流
释放方式 必须由持有锁的线程释放 任何线程都可以 release
# 信号量示例:控制数据库连接
from threading import Semaphore

# 最多允许 5 个线程同时获取连接
pool_semaphore = Semaphore(5)

def get_connection():
    pool_semaphore.acquire()
    try:
        # 获取连接
        conn = database.get_conn()
        return conn
    finally:
        # 一定要在 finally 中释放
        pool_semaphore.release()

注意事项:我曾经在一次重构中,不小心把信号量的 release 写在了 try 块里。并发一上来,有线程抛异常,信号量直接「漏」了,导致实际连接数超预期,数据库被打崩了。所以记得,release 必须放在 finally 里。

3. 条件变量:通知模式的引擎

互斥锁解决的是互斥问题,条件变量解决的是等待-通知问题。

举个例子:生产者往队列放数据,消费者从队列取数据。队列空时,消费者要等着;队列满时,生产者要等着。这种场景用互斥锁硬搞会很别扭——你会写一堆 while 循环轮询,CPU 全部空转。

条件变量(Condition)就是干这个的。它让线程优雅地等待某个条件成立,条件满足时再被唤醒。

import threading

condition = threading.Condition()
queue = []

def producer():
    with condition:
        while len(queue) >= 10:
            condition.wait()  # 队列满了,等待
        queue.append('item')
        condition.notify()    # 通知消费者

def consumer():
    with condition:
        while len(queue) == 0:
            condition.wait()  # 队列空了,等待
        item = queue.pop()
        condition.notify()    # 通知生产者

这里要注意一个细节:条件变量必须配合互斥锁使用。wait 方法会自动释放锁,等被唤醒后重新获取锁。这机制很巧妙,但也很容易出错。

条件变量的三个关键点

  1. 用 while 而不是 if 检查条件。线程被唤醒后,条件可能又被其他线程改了。用 while 确保条件真的满足。
  2. 在持有锁的情况下调用 wait / notify。否则会抛出异常。
  3. notify 和 notifyAll 的区别。notify 只唤醒一个等待线程,notifyAll 唤醒所有。我个人习惯在不确定的时候用 notifyAll,避免「信号丢失」。

记牢了:条件变量的使用口诀——「持锁检查,while 循环,wait 释放,notify 通知」。

4. 三者的知识图谱

到这里,三种并发工具的核心概念都讲完了。它们虽然功能不同,但共同目标都是协调多线程的有序执行。

我总结了一张关系图,帮你快速建立认知框架:

并发控制三剑客 互斥锁 (Mutex) 同一时间只允许一个线程进入 信号量 (Semaphore) 控制最多 N 个线程并发访问 条件变量 (Condition) 线程等待与通知机制 记住这三者的关系 互斥锁 → 解决「谁可以进去」 信号量 → 解决「一次可以进去几个人」 条件变量 → 解决「什么时候可以进去」

5. 如何选择?

你可能会问:这三个工具看着都挺像,实际项目里到底怎么选?

嗯,我总结了三条经验:

  • 保护共享资源,用互斥锁。比如计数器、缓存、银行余额。
  • 控制并发数量,用信号量。比如连接池实例数、API 限流阈值。
  • 线程间协作,用条件变量。比如生产者-消费者、资源就绪通知。

实用建议:如果你不确定该用哪个,先从互斥锁开始。大多数并发问题用互斥锁配合简单轮询就能解决。等性能满足不了时,再考虑升级成信号量或条件变量。

最后再说一句:工具是死的,场景是活的。写代码的时候多想想——你的线程到底在等什么。想清楚这个,选锁就不会出错。