想象你是一家繁忙餐厅的老板:后厨的厨师们不停做菜,前厅的服务员们不停上菜。如果厨师做完菜直接扯着嗓子喊服务员来取,高峰期必然乱成一锅粥;如果服务员守在厨房门口盯着,又白白浪费了接待客人的时间。于是你设计了一个“出餐口”——厨师把做好的菜放进窗口,服务员按顺序从窗口取菜。这个出餐口,就是计算机世界里大名鼎鼎的“缓冲区”,而厨师与服务员的配合问题,正是操作系统课程中的那颗璀璨明珠——生产者消费者问题。
生产者消费者问题是并发系统的“九九乘法表”。掌握它的原理与实现,你就能看懂线程池、消息队列乃至整个并发编程的骨架,这是每个 AI 工程师从“会用框架”走向“能造框架”的必经之路。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
问题定义与背景
想象你是那家餐厅的老板,厨师做菜,服务员上菜。如果出餐口只有一个很小的窗口,厨师做完菜发现窗口满了,只能端在手里干等;服务员来取菜发现窗口是空的,也只能白跑一趟。在这个看似简单的场景里,隐藏着并发编程最核心的矛盾:两类角色共享一个有限资源,既要防止“溢出”,又要避免“空转”。
在操作系统世界里,这个问题被抽象为:生产者进程(Producer)和消费者进程(Consumer)共享一个有限容量的缓冲区。生产者负责“生产”数据放入缓冲区,消费者负责从缓冲区“取出”数据。当缓冲区满时,生产者必须等待;当缓冲区空时,消费者必须等待。这就是著名的生产者消费者问题(Producer-Consumer Problem),由 Dijkstra 在 1965 年首次提出。
这个问题的本质是同步(Synchronization)与互斥(Mutual Exclusion)的双重挑战:同步是指生产者和消费者的动作必须按正确顺序发生——先有生产,后有消费;互斥是指它们不能同时修改缓冲区,否则数据会错乱。理解这双重挑战,是进入并发世界的第一道门槛。
现实世界的例子俯拾皆是:餐厅的出餐口是缓冲区,消息队列是缓冲区,线程池的任务队列也是缓冲区。理解了这个问题,你就掌握了并发编程中最基本却也最重要的协作模式。
- 同步条件生产者的“放”必须在缓冲区有空位时执行,消费者的“取”必须在缓冲区有数据时执行,这就是顺序的约束。
- 互斥条件缓冲区是共享资源,同一时刻只允许一个进程读写,否则两个厨师同时往窗口放菜会把菜打翻。
共享缓冲区设计
出餐口要有大小之分。如果窗口只能放一道菜,厨师做一道就得立刻被取走,效率极低;如果窗口能放十道菜,厨师就能连续做菜,服务员也能集中上菜——这就是缓冲区存在的意义:解耦生产与消费的节奏。
缓冲区(Buffer)是生产者和消费者之间的桥梁。它解决了两个问题:一是速度不匹配——生产者时快时慢,消费者也时忙时闲,缓冲区像一个蓄水池,吸收了两侧的波动;二是耦合关系——没有缓冲区,生产者和消费者必须直接对接,任何一方的节奏变化都会影响另一方。缓冲区的容量直接决定系统行为:容量为 1 是“严格交替”模式,容量为 N 则是“流水线”模式。
然而缓冲区引入了新的挑战:数据一致性。如果两个生产者同时向空位写入数据,或者消费者正在读取时生产者覆盖了数据,就会发生竞态条件(Race Condition)。此外还有内存可见性问题——一个线程修改了数据,另一个线程可能因为 CPU 缓存而看不到最新值。解决这些问题,正是后续要讲的锁、信号量和条件变量的用武之地。
设计共享缓冲区时,开发者需要思考的终极问题是:如何在“充分利用缓冲区容量”与“保证数据安全”之间找到平衡点。缓冲区越大,吞吐量越高,但内存开销也越大;锁的粒度越细,并发度越高,但实现复杂度也越高。
- 容量边界缓冲区可以抽象为一个计数器,记录当前存放的物品数量,这个计数器的取值范围决定了系统的状态。
- 一致性保障对缓冲区的读写必须被互斥保护,防止多个进程同时操作导致数据损坏。
信号量机制
餐厅的协作需要一套明确的规则:厨师做菜前要看窗口有没有空位,服务员取菜前要看窗口有没有菜。如果你的餐厅有一套电子叫号系统,每个号码代表一个“许可”——做菜需要“空位许可”,取菜需要“菜品许可”,这就是信号量的思想。
信号量(Semaphore)是 Dijkstra 提出的经典同步原语,本质上是一个带有资源计数的整型变量,配合两个原子操作使用:P 操作(Proberen,荷兰语“测试”,又称 wait)和 V 操作(Verhogen,“增加”,又称 signal)。P 操作将信号量减 1,如果结果小于 0 则阻塞;V 操作将信号量加 1,如果结果不大于 0 则唤醒一个阻塞的进程。
解决生产者消费者问题需要三个信号量:mutex(互斥信号量,初值 1,保护缓冲区)、empty(空位数信号量,初值为 N,表示缓冲区还有多少空位)、full(数据数信号量,初值为 0,表示缓冲区有多少数据)。生产者执行 P(empty) 申请空位,消费者执行 P(full) 申请数据,双方在执行完缓冲区操作后都执行 V 操作释放资源。
信号量机制的优点是适用范围极广,既能解决互斥也能解决同步;缺点是使用不当容易导致死锁,而且 P/V 操作分散在各处,代码可读性较差。不过,它仍是理解后续一切并发工具的基础。
- P 操作申请一个资源。信号量减 1,若结果小于 0,说明资源已被耗尽,进程进入阻塞队列等待。
- V 操作释放一个资源。信号量加 1,若结果不大于 0,说明有进程在等待,唤醒其中一个。
- 顺序保障P(empty) 必须在写入之前,P(mutex) 必须在操作缓冲区之前,顺序不能颠倒,否则可能死锁。
互斥锁实现
信号量可以控制访问,但使用起来像“手动挡”——每个操作都要记得 P 和 V。如果你的餐厅想更省心,可以给出餐口装一扇带锁的门:谁要进窗口区操作,先拿钥匙(锁),操作完归还钥匙。这就是互斥锁(Mutex Lock)。
互斥锁是并发编程中最常用的同步工具。它只有两个状态:锁定(locked)和未锁定(unlocked)。线程在进入临界区(Critical Section,即操作共享资源的代码区域)之前调用 lock() 获取锁,如果锁被占用则阻塞等待;离开临界区时调用 unlock() 释放锁。相比信号量,互斥锁的语义更简单——它只负责“互斥”,不负责“同步”。
这意味着,只靠互斥锁解决生产者消费者问题是不够的:互斥锁能阻止两个线程同时操作缓冲区,但无法解决“缓冲区满时生产者要不要等”“缓冲区空时消费者要不要等”的同步问题。因此,实践中通常用互斥锁 + 条件变量的组合来解决,或者使用二进制信号量模拟互斥锁,再用计数信号量处理同步。
使用互斥锁时,死锁是最大的陷阱。一个经典的死锁场景是:生产者持锁后执行 P(empty) 等待空位,而消费者持锁后执行 P(full) 等待数据,双方各持一把锁又互相等对方释放。预防策略是保证锁的获取顺序一致、避免持锁等待其他锁,以及使用 trylock 等非阻塞获取方式。
- 获取锁线程调用 lock(),如果锁是空闲的则成功获取,否则阻塞进入等待队列。
- 释放锁线程调用 unlock() 释放锁,如果有等待线程则唤醒一个。
条件变量
出餐口的门锁解决了“同一时间只能一个人操作”的问题,但服务员想知道“窗口有没有新菜”,总不能一直盯着窗口看吧?更聪明的办法是:窗口装一个铃铛,厨师放好菜按一下铃,服务员听到铃声再来取。这个“铃铛”,就是条件变量(Condition Variable)。
条件变量是一种让线程在特定条件下暂停和恢复的同步机制,通常与互斥锁配合使用。它提供两个核心操作:wait(cond, mutex) 让线程释放 mutex 并进入等待状态,signal(cond) 唤醒一个正在等待的线程,broadcast(cond) 唤醒所有等待的线程。线程被唤醒后,会自动重新获取 mutex。
用条件变量重写生产者消费者问题:生产者发现缓冲区已满时,调用 wait(full_cond, mutex) 释放锁并休眠;消费者取走数据后调用 signal(full_cond) 唤醒生产者。这里隐藏着一个容易踩坑的细节——虚假唤醒(Spurious Wakeup):线程可能在没有任何 signal 的情况下被唤醒。因此,必须用 while 循环检查条件,而不是 if,这就是“谓词条件”(Predicate)的重要性。
条件变量的精髓在于“等待-通知”模型:它避免了忙等待(Busy Waiting,即线程不断循环检查条件浪费 CPU),让线程在条件不满足时真正休眠,在条件满足时被及时唤醒——这正是现代操作系统“事件驱动”架构的基础。
- 等待条件使用 while 循环检查谓词,条件不满足时调用 wait(),释放 mutex 并挂起线程。
- 唤醒通知状态变化后调用 signal() 唤醒一个等待线程,该线程重新竞争 mutex 后从 wait 返回。
- 广播通知当多个消费者等待不同条件时,用 broadcast() 唤醒所有线程,让它们自行检查谓词。
高性能实现
当你的餐厅需要一天接待上千桌客人时,出餐口的每个细节都关乎营收:窗口太窄导致厨师排队,取餐流程繁琐导致服务员滞留。高性能的实现,考验的是对细节的极致打磨。
传统的互斥锁+条件变量方案虽然正确,但在高并发场景下存在性能瓶颈:锁竞争(Lock Contention)会导致线程阻塞和上下文切换(Context Switch),每次切换都有开销。为此,业界发展出了多种高性能实现方案:
- 无锁队列(Lock-Free Queue):基于 CAS(Compare-And-Swap)原子操作,多个线程可以同时尝试入队/出队,不需要阻塞等待锁。
- 环形缓冲区(Ring Buffer):用固定大小的数组模拟首尾相连的队列,通过读写指针的原子移动实现无锁生产消费,常用于音频、网络包处理。
- 批量传输(Batching):生产者攒够一批数据再放入缓冲区,消费者一次取走一批,大幅降低同步次数。
实测数据显示:在 8 核机器上,对于 100 万条数据的生产消费任务,互斥锁方案耗时约 120ms,无锁队列约 45ms,批量传输方案可压至 20ms 以内。选择哪种方案,取决于业务场景的并发度和对延迟的敏感度。
- CAS 循环无锁队列的核心是 CAS 操作——比较内存中的当前值与期望值,相等则更新为新值,整个过程是原子的。
- 指针推进环形缓冲区通过移动 head/tail 指针实现入队出队,指针的移动使用原子操作(如 fetch_add)避免竞态。
应用与调优
理解了出餐口的全部运作机制后,你终于可以设计自己餐厅的完整流程了:从后厨调度、出餐口管理到服务员排班,每一个环节都是生产者消费者思想的具体应用。
在真实系统中,生产者消费者模型无处不在:
- 线程池(Thread Pool):任务提交者是生产者,工作线程是消费者,任务队列就是缓冲区。
- 消息队列(Message Queue):如 Kafka、RabbitMQ,生产者发布消息,消费者订阅消息,中间是分布式的缓冲区。
- 日志系统:业务线程产生日志(生产者),后台线程批量写入磁盘(消费者),内存日志缓冲区平滑两者的速度差。
故障排查也有经典套路:如果消费者处理慢,缓冲区会持续增长,可能导致内存溢出;如果生产者偶尔卡顿,消费者会频繁空转等待。排查手段包括监控缓冲区水位(队列长度)、生产者与消费者的速率比、锁等待时间等指标。
调优是一场“戴着镣铐的舞蹈”:缓冲区越大,吞吐量越高但延迟也越高;并发线程越多,处理越快但锁竞争也越激烈。优秀的工程师总能像经验丰富的餐厅老板一样,根据客流(负载)灵活调整出餐口的大小和厨师团队的人数。
- 速率匹配系统稳定的前提是生产速率与消费速率大致相等,否则缓冲区要么溢出要么长期空置。
- 调优方向通过调整并发度、批量大小、缓冲区容量三个参数,找到延迟和吞吐的最优平衡点。
AI 应用场景
数据流水线中的生产消费模型
阶段:数据预处理与特征工程
解决的问题:在 AI 训练流程中,数据读取(生产者)与模型训练(消费者)的速度严重不匹配。传统的串行方式导致 GPU 大部分时间在等待数据,利用率极低。采用生产者消费者模型,用多线程或多进程异步加载数据到内存缓冲区,训练时直接从缓冲区取数据,可将 GPU 利用率从 30% 提升到 90% 以上。
具体案例:PyTorch 的 DataLoader 正是生产者消费者模型的经典实现。主进程是消费者,从共享内存队列中取 batch 喂给模型;后台 worker 进程是生产者,负责从磁盘读取图片并做数据增强,放入队列。队列容量由 num_workers 和 prefetch_factor 控制,合理设置这些参数能显著加速训练。
模型推理服务的请求队列
阶段:模型部署与在线推理
解决的问题:在 AI 推理服务中,客户端请求随时到达(生产者),推理引擎处理请求需要时间(消费者)。若没有缓冲区,突发流量会直接打垮推理服务。通过消息队列(如 Redis Stream、RabbitMQ)作为缓冲区,实现请求削峰填谷、异步处理。同时利用消费者组机制实现推理实例的水平扩展。
具体案例:某 OCR 识别服务每天处理 2000 万张图片。高峰期每秒 500 个请求,而单个推理实例只能处理每秒 100 个请求。团队将请求写入 Kafka 队列,用 5 个推理实例作为消费者组并行处理,峰值吞吐量提升 5 倍,核心服务不再因突发流量而崩溃。
Python 演示
import threadingimport timeimport randomfrom queue import Queue class Restaurant: """餐厅出餐口模拟:生产者消费者模型的完整实现""" def __init__(self, capacity=5): # 出餐口缓冲区:线程安全的队列 self.shelf = Queue(maxsize=capacity) self.produced = 0 self.consumed = 0 self.lock = threading.Lock() self.chef_ready = threading.Condition(self.lock) self.waiter_ready = threading.Condition(self.lock) def cook(self, chef_id): """厨师(生产者):做菜放入出餐口""" for _ in range(3): with self.lock: while self.shelf.full(): # 窗口已满,厨师等待空位(条件变量等待) self.chef_ready.wait() dish = f"菜-{self.produced}" self.shelf.put(dish) self.produced += 1 print(f"[厨师{chef_id}] 做了 {dish},当前窗口 {self.shelf.qsize()} 道") # 放好菜,按铃通知服务员取菜 self.waiter_ready.notify_all() time.sleep(random.uniform(0.1, 0.3)) def serve(self, waiter_id): """服务员(消费者):从出餐口取菜""" for _ in range(3): with self.lock: while self.shelf.empty(): # 窗口是空的,服务员等待菜品 self.waiter_ready.wait() dish = self.shelf.get() self.consumed += 1 print(f"[服务员{waiter_id}] 取走 {dish},剩余 {self.shelf.qsize()} 道") # 取走菜,通知厨师有空位了 self.chef_ready.notify_all() time.sleep(random.uniform(0.2, 0.5)) def run(self): """启动 2 个厨师和 2 个服务员""" threads = [] for i in range(2): threads.append(threading.Thread(target=self.cook, args=(i,))) for i in range(2): threads.append(threading.Thread(target=self.serve, args=(i,))) for t in threads: t.start() for t in threads: t.join() print(f"\n总计生产 {self.produced} 道,消费 {self.consumed} 道,数据一致!") if __name__ == "__main__": # 开一家容量为 5 的餐厅,看厨师和服务员如何完美配合 Restaurant(capacity=5).run()
threading 库完整实现生产者消费者模型,模拟 2 个厨师(生产者)和 2 个服务员(消费者)通过容量为 5 的出餐口(缓冲区)协作的过程,掌握条件变量的正确用法。self.shelf = Queue(maxsize=capacity)出餐口就是线程安全的队列,容量限制了缓冲区的最大值self.chef_ready = threading.Condition(self.lock)厨师等待“有空位”的条件变量,相当于“空位铃”while self.shelf.full():用 while 而非 if 检查条件,防止虚假唤醒导致放错菜self.chef_ready.wait()窗口满时厨师释放锁并休眠,不再占用 CPUself.waiter_ready.notify_all()放好菜后按铃通知所有服务员“有菜可取了”with self.lock:通过上下文管理器自动加锁/解锁,确保临界区的互斥访问
常见误区
误区“出餐口越大,能放的菜越多,餐厅效率肯定越高!把队列容量设得很大,生产者就不会阻塞了。”
正解缓冲区容量并非越大越好。缓冲区过大意味着内存占用高,延迟也增加——数据在生产出来后要等很久才能被消费。更关键的是,当生产速率大于消费速率时,缓冲区再大也只是一个“不断膨胀的蓄水池”,最终耗尽内存。正确做法是监控队列水位,找到容量与延迟的平衡点。
为何常见:人们的直觉是“空间越大越不容易堵”,却忽略了在速率不匹配的情况下,缓冲只是把问题后置而非解决。
误区“条件变量已经 wait 了,被唤醒就说明条件满足了,为什么还要用 while 循环再检查一遍?用 if 更简洁。”
正解条件变量的核心陷阱是虚假唤醒——线程可能在没有任何 signal 的情况下被唤醒,或者被多个等待线程同时唤醒后,条件已被其他线程改变。如果用 if,线程会在条件不满足时继续执行,导致数据错乱。用 while 是防御式编程的标准做法:被唤醒后重新检查条件,不满足就继续等待。
为何常见:初学者把“被唤醒”当作“条件满足”的保证,但这个保证在真实的调度器实现中并不存在。
误区“反正都要加锁,先申请互斥锁再检查缓冲区空位,跟先检查空位再申请锁,效果应该一样吧?”
正解顺序至关重要!如果先持有 mutex 再执行 P(empty) 等待空位,而另一个消费者持有 mutex 执行 P(full) 等待数据,双方都持锁等待对方释放资源,就形成了死锁。正确顺序是:先做同步检查(P(empty) 或 P(full))获得资源许可,再申请互斥锁进入临界区。
为何常见:没有从“锁的嵌套等待”角度思考问题,忽略了持锁等待本身就是死锁的温床。
误区“想提高消费速度,多加几个消费者线程,但每个线程操作缓冲区都要加锁,锁竞争肯定很严重。”
正解多消费者并不必然意味着高锁竞争。可以通过分片(Sharding)减少竞争——每个消费者独享一个子缓冲区;或者用批量获取让每个消费者一次取走一批数据,大幅降低获取锁的频率;更极致的方案是用无锁队列(基于 CAS)彻底消除锁阻塞。加锁只是手段,降低竞争才是目标。
为何常见:把“线程安全”与“必须加锁”划了等号,实际上原子操作、线程局部存储都是不加锁也能保证安全的手段。
面试题
Q1. 在生产者消费者问题中,缓冲区容量为 N,生产者执行 P(empty) 操作时,empty 的初始值应为多少?
- A.0
- B.1
- C.N← 正确答案
- D.N-1
答案:C。empty 信号量表示缓冲区中空位的数量,初始时缓冲区全空,空位数为 N,所以初值为 N。选项 A(0)是 full 的初值,B(1)是 mutex 的初值。把三个信号量的初值记混是初学者最常见的错误。
Q2. 下列关于互斥锁与信号量的说法,正确的是?
- A.互斥锁可以完全替代信号量解决同步问题
- B.信号量既可以实现互斥,也可以实现同步← 正确答案
- C.互斥锁有计数功能,可以允许多个线程同时进入临界区
- D.信号量只能用于互斥,不能用于同步
答案:B。信号量是更通用的同步原语——二进制信号量可做互斥,计数信号量可做同步(如 empty/full 控制生产消费节奏)。互斥锁只能保证互斥,无法表达“缓冲区满/空”这种同步条件,需要配合条件变量。因此 A、C、D 均错误。
Q3. 使用条件变量时,为什么必须用 while 循环而不是 if 来检查谓词条件?
- A.while 执行速度更快
- B.防止虚假唤醒导致条件不满足时继续执行← 正确答案
- C.if 语句在条件变量中已被禁用
- D.while 可以避免死锁
答案:B。条件变量的 wait 可能在没有任何 signal 的情况下返回(虚假唤醒),也可能多个等待线程同时被唤醒后条件已被其他线程消耗。用 while 循环在每次被唤醒后重新检查条件,不满足则继续等待,是防御式编程的标准做法。if 只检查一次,容易导致数据错乱。这不是性能问题,而是正确性问题。
Q4. 以下哪个场景体现了“批量传输(Batching)”优化思想?
- A.生产者每生产一个数据就立刻放入缓冲区
- B.消费者每次从缓冲区取出 100 条数据后才处理← 正确答案
- C.将缓冲区容量从 10 调整为 100
- D.增加消费者线程数量
答案:B。批量传输的核心是“攒够一批再处理”,降低同步操作(加锁/解锁/信号量)的固定开销。选项 B 中消费者一次取 100 条,减少了取数频率,正是批量思想。A 是单条传输,C 是调整容量,D 是提高并发度,均未体现“批量”的含义。
Q5. 在生产者消费者问题的信号量解法中,关于 P 操作执行顺序,以下说法正确的有?
- A.必须先执行 P(mutex) 再执行 P(empty)
- B.必须先执行 P(empty) 再执行 P(mutex)← 正确答案
- C.顺序颠倒可能导致死锁← 正确答案
- D.顺序任意,不影响正确性
答案:BC。经典解法要求先申请资源信号量(P(empty) 或 P(full))再申请互斥锁 P(mutex)。如果先持锁再等待资源,两个进程可能各自持锁等待对方释放资源,形成死锁。选项 A 和 D 是错误的,C 正确描述了颠倒顺序的后果。
Q6. 环形缓冲区(Ring Buffer)相比链表队列的主要优势是?
- A.可以动态扩容
- B.内存预分配、无节点分配开销,指针移动即可实现入队出队← 正确答案
- C.不需要考虑缓冲区满
- D.天然支持多生产者多消费者
答案:B。环形缓冲区使用预分配的固定数组,通过 head/tail 指针的原子移动实现入队出队,避免了链表节点的动态分配和释放开销,缓存友好,性能高。缺点是容量固定,需要额外处理满/空判断。天然支持多生产者多消费者需要配合原子操作,并非天然具备。
Q7. 关于无锁队列与互斥锁队列的对比,下列说法正确的有?
- A.无锁队列在某些场景下吞吐量优于互斥锁队列← 正确答案
- B.无锁队列完全避免了线程阻塞
- C.互斥锁队列在低竞争场景下仍有优势← 正确答案
- D.无锁队列比互斥锁队列更简单易实现
答案:AC。无锁队列基于 CAS 循环,在高并发竞争场景下减少了线程阻塞和上下文切换,吞吐量更优(A 正确),但 CAS 重试本身有开销,低竞争时互斥锁反而更高效(C 正确)。无锁队列并不完全避免阻塞(B 错),且实现复杂度远高于互斥锁(D 错)。选型要结合实际并发度。
Q8. 在线程池设计中,任务队列作为缓冲区,以下哪个指标最能反映“消费者处理速度跟不上生产者”?
- A.线程池最大线程数
- B.任务队列的持续堆积长度← 正确答案
- C.任务的平均执行时间
- D.线程的空闲时间
答案:B。当队列长度持续增长且居高不下,说明任务到达速率大于处理速率,消费者成为瓶颈。平均执行时间(C)只能反映单任务耗时,线程空闲时间(D)恰好是消费者过快的表现,最大线程数(A)是配置项而非状态指标。监控队列水位是判断积压最直观的手段。
Q9. 以下哪些措施可以减少多消费者场景下的锁竞争?
- A.分片(每个消费者独享子队列)← 正确答案
- B.批量获取数据← 正确答案
- C.增加缓冲区容量
- D.使用无锁队列← 正确答案
答案:ABD。分片让每个消费者只访问自己的子队列,消除竞争(A 对);批量取数降低加锁频率(B 对);无锁队列用 CAS 替代阻塞锁(D 对)。增加缓冲区容量(C)只影响阻塞频率,不减少锁的持有时间,不是直接的锁竞争优化手段。
Q10. 在生产者消费者模型中,互斥信号量 mutex 的初始值为 1,其含义是?
- A.缓冲区最多容纳 1 个数据
- B.同一时刻最多有 1 个进程可以操作缓冲区← 正确答案
- C.生产者每次最多生产 1 个数据
- D.消费者每次最多消费 1 个数据
答案:B。mutex 是互斥信号量,初值 1 表示缓冲区这一共享资源同一时刻只能被一个进程访问。如果改成 2,意味着两个进程可以同时写缓冲区,数据就会错乱。缓冲区容量由 empty/full 信号量控制,与 mutex 无关。