前一篇文章介绍completion,专门在等待场景下替换semaphore。后面要介绍的mutex,则在锁的场景下作为semaphore的替代品。为何semaphore会出现这种不待见的情况。
在并发编程中,信号量(Semaphore)是一个经典的同步机制,由 Dijkstra 在 1965 年提出,在操作系统教材中占据了重要篇幅。然而,在 Linux 内核的实际演进中,信号量的使用场景却被不断压缩,大量原本使用信号量的代码被 Mutex、Completions、Kref 等更专门的机制取代。
为什么会这样?信号量究竟 “不好用” 在哪里?
信号量本质上是一个计数器,围绕它定义了 P(尝试)和 V(增加)两种原子操作。理论上,它能做的事情很多:
值为 1:充当互斥锁(Mutex)
值为 N(N>1):控制对 N 个资源实例的访问
值为 0:实现线程间的等待 / 通知
但问题在于,这三种用法截然不同,用的却是同一套 API。阅读代码时,你无法从类型上判断一个信号量到底是用来保护临界区的互斥锁,还是用来控制连接池的计数器,还是用来做线程同步。
这种语义上的模糊,直接影响了代码的可读性和可维护性。工程师必须通过上下文推理才能理解信号量的实际用途,这为后续的修改和审查埋下了隐患。
这是信号量架构层面最致命的问题。信号量本身不记录 “是谁” 执行了 P 操作(获取了资源),这意味着:
线程 A 获取了信号量,但线程 B 可以随意执行 V 操作将其释放。这在 Mutex 中是不可想象的 —— 只有锁的持有者才能解锁。这种特性让资源管理的正确性完全依赖于程序员的自觉,编译器或运行时都无法提供保护。
当发生死锁时,Mutex 可以明确告诉你 “进程 X 持有了锁 Y”,而信号量只能告诉你 “当前资源计数为 0”。你无法得知谁占用了资源,排查问题只能依靠日志和猜测,效率极低。
在实时系统中,为解决优先级反转问题,内核需要知道锁的持有者是谁,以便临时提升其优先级。信号量因为没有持有者信息,无法可靠地支持这一机制。这也是在追求确定性的场景中,信号量逐渐被冷落的重要原因。
用信号量来管理 N 个资源,同样存在风险。假设一个连接池有 10 个可用连接,用初始值为 10 的信号量来管理。如果某个线程在使用完连接后,因编程错误调用了两次 V 操作,信号量值会变成 11。
系统不会报错,但它 “凭空创造” 了一个不存在的连接。这类 Bug 可能潜伏数月,直到系统负载增高时才暴露为难以追踪的诡异故障。
这类问题本质上是信号量缺乏对状态边界进行约束的能力。而在现代的引用计数机制(如 Linux 内核的 refcount_t)中,溢出和非法操作都会被检测并阻止。
Mutex 的优势正是通过 “不灵活” 换来的 —— 它放弃了对计数和同步的职责,专注于互斥访问,从而在正确性、可调试性和性能上都赢得了显著提升。
在 Linux 内核的演进中,信号量的地位已被一系列更专用的机制所取代:
如今,Linux 内核中的传统信号量(struct semaphore)仅在少数特殊场景中保留使用,例如与旧驱动保持兼容,或在 Mutex 机制尚未就绪的早期启动阶段。
信号量是一个经典的计算机科学概念,但它更像一把 “瑞士军刀”—— 功能多样,却难以在具体场景中做到极致。
现代工程实践告诉我们:一个工具只做好一件事,才是构建大规模、高可靠系统的基础。从信号量到 Mutex、Completions 等专用原语的演进,正是这一理念在操作系统内核层面的体现。
写并发代码时,选择合适的工具,往往比精通一个万能工具更重要。