跳转至

信号量与条件变量

互斥锁回答“谁可以修改共享状态”,但很多并发问题还要回答“状态不满足时怎样等待”。信号量和条件变量提供了两种不同表达方式。

为什么只有锁不够

消费者获取锁后发现队列为空。它不能一直持锁循环等待,否则生产者永远无法获取锁并加入元素;若解锁后反复轮询,又浪费 CPU 并增加延迟。

正确模式应是:

持锁检查条件
  -> 条件不满足时,原子地释放锁并睡眠
  -> 状态改变者在同一协议中发出通知
  -> 被唤醒后重新获取锁并再次检查

“原子地释放锁并进入等待”避免通知恰好发生在解锁与睡眠之间而丢失。

信号量

信号量维护一个非负计数和等待队列,可抽象为两个原子操作:

wait(S):
    若资源可用则把计数减一
    否则阻塞直到能够完成减一

post(S):
    把计数加一
    必要时唤醒一个等待者

计数信号量适合表示 \(N\) 个同类资源;初值为 1 时可表达互斥,但通常专用互斥锁具有更清楚的所有权和调试语义。

最小资源池例子

系统只有 3 个数据库连接。将信号量初值设为 3:

from threading import Semaphore

slots = Semaphore(3)


def handle_request() -> None:
    with slots:
        use_one_database_connection()

信号量保证最多 3 个请求同时进入资源区,但不负责选择具体连接,也不保护连接池内部数据结构;这些仍需单独协议。

二元信号量与互斥锁的区别

二元信号量计数只在 0 和 1 之间变化,看似等价于锁,但语义有所不同:

  • 互斥锁通常有所有者,谁加锁谁解锁。
  • 信号量常用于一个执行流等待、另一个执行流通知,不要求同一主体完成 wait 和 post。
  • 互斥锁实现可能支持优先级继承、递归检测或所有者调试。

表达资源计数或事件时用信号量,表达临界区所有权时优先使用互斥锁,意图更清楚。

条件变量

条件变量本身不保存业务条件。条件存在于受互斥锁保护的共享状态中,条件变量只是等待和通知机制。

标准模式:

pthread_mutex_lock(&mutex);
while (!predicate()) {
    pthread_cond_wait(&condition, &mutex);
}
use_state();
pthread_mutex_unlock(&mutex);

pthread_cond_wait 概念上原子完成三件事:释放互斥锁、进入等待、被唤醒后重新获取互斥锁。返回时调用者再次持有锁。

为什么必须用 while

不能写成:

if (!predicate()) {
    pthread_cond_wait(&condition, &mutex);
}

原因包括:

  • 可能发生虚假唤醒。
  • 多个等待者被唤醒后,另一个线程先获取锁并消耗了条件。
  • 通知只说明“状态可能变化”,不保证当前线程获得锁时条件仍成立。

因此条件变量的正确含义是:

等待一个重新检查谓词的机会,而不是接收“条件已经永久成立”的承诺。

有界缓冲区:最小完整例子

下面的 Python 代码使用一个锁和两个条件变量。not_emptynot_full 共享同一把锁,因为它们描述同一个队列状态。

from collections import deque
from threading import Condition, Lock


class BoundedQueue:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.items = deque()
        self.lock = Lock()
        self.not_empty = Condition(self.lock)
        self.not_full = Condition(self.lock)

    def put(self, item) -> None:
        with self.not_full:
            while len(self.items) == self.capacity:
                self.not_full.wait()
            self.items.append(item)
            self.not_empty.notify()

    def get(self):
        with self.not_empty:
            while not self.items:
                self.not_empty.wait()
            item = self.items.popleft()
            self.not_full.notify()
            return item

不变量是:

\[ 0\le |items|\le capacity. \]

生产者等待“非满”,入队后通知“可能非空”;消费者等待“非空”,出队后通知“可能非满”。

notify 还是 notify_all

  • notify 唤醒一个等待者,减少惊群,但必须确认一个状态变化只需一个等待者继续。
  • notify_all 让所有等待者重新竞争和检查,协议更保守,但可能造成大量无效唤醒。

若不同等待者使用不同谓词,共享一个条件变量时,单次通知可能唤醒“不合适”的等待者。可以拆分条件变量,或在正确性优先时广播后让所有线程重检。

信号量版本的有界缓冲区

也可以使用:

  • empty:初值为容量,表示空槽数量。
  • full:初值为 0,表示已有元素数量。
  • mutex:保护队列数据结构。

生产者:

wait(empty)
lock(mutex)
enqueue
unlock(mutex)
post(full)

消费者:

wait(full)
lock(mutex)
dequeue
unlock(mutex)
post(empty)

操作顺序很重要。若生产者先锁住队列再等待空槽,队列满时它会持锁睡眠,消费者无法获得锁腾出空槽。

丢失唤醒怎样产生

错误协议常见于:等待者无锁检查条件,然后准备睡眠;通知者在两步之间修改状态并通知,此时还没有实际等待者;随后等待者睡下,再也收不到已经过去的通知。

条件变量通过“持锁检查 + 原子释放并等待”闭合这个窗口。通知者也应在同一锁协议下修改谓词,使状态变化与等待检查形成清楚顺序。

屏障与一次性事件

屏障

屏障让一组线程都到达某阶段后再共同继续。若共有 \(N\) 个线程,前 \(N-1\) 个到达者等待,第 \(N\) 个到达者推进代数并唤醒其他线程。

可重用屏障需要记录“第几轮”,否则上一轮迟到的唤醒可能干扰下一轮等待。

一次性事件

一次性事件在设置后保持已触发状态,后来者无需等待。它和条件变量通知不同:条件变量通知本身不保存,保存的是受保护状态。用布尔标志加条件变量可以实现事件语义。

超时与取消

实际等待通常需要超时、取消和关闭协议。仅给 wait 加超时还不够,还要定义:

  • 超时后资源是否可能已经到达?
  • 取消时如何从等待队列移除?
  • 队列关闭后生产者和消费者得到什么结果?
  • 唤醒与销毁同步对象如何避免竞争?

生命周期经常比基本同步算法更难,接口设计必须明确所有者和关闭状态。

权衡与限制

信号量紧凑地表达资源数量,但复杂协议中计数含义容易隐蔽。条件变量把业务谓词显式留在共享状态中,更便于检查不变量,却要求每个等待者正确使用循环和同一把锁。

高层并发库中的通道、阻塞队列、future 和任务组往往把这些模式封装起来。使用高层抽象可以减少错误,但仍需理解背压、关闭和取消最终落到什么同步语义上。

自测

  1. 条件变量为什么必须与互斥锁配合?
  2. 条件变量通知是否会永久保存到未来等待者?
  3. 为什么有界缓冲区需要分别表示“非空”和“非满”?
  4. 计数信号量初值为 5 表示什么?
  5. 生产者为什么不能持有队列锁等待空槽?
参考思路

锁保证谓词检查与状态更新一致,等待操作原子释放锁。通知不保存,业务状态才保存条件。生产者和消费者等待方向不同。初值 5 表示最多五份可并发获取资源。持锁等待会阻止消费者改变使其继续的条件。