1295 字
3 分钟
信号量(Semaphore)
一、为什么需要信号量
想象一个场景:你的服务同时打开 50 个数据库连接,数据库服务器直接报错「too many connections」。你加了个连接池,但池子本身也需要限制——总不能让 1000 个请求同时抢 10 个连接。这就是信号量要解决的问题:控制同时做某件事的线程数量。
没有信号量会怎样?最常见的情况是资源耗尽。数据库连接、文件句柄、外部 API 配额——这些都是有限的。如果不对并发访问加以限制,系统在高负载下会雪崩:线程争抢资源导致超时,超时触发重试,重试加剧争抢,恶性循环。
更隐蔽的问题是「惊群效应」。100 个线程同时等待一个连接释放,连接归还时 100 个线程全被唤醒,但只有 1 个能拿到,剩下 99 个又回去睡觉。信号量通过维护一个计数器和等待队列,精确地唤醒一个等待者,避免了这种无谓的竞争。
二、现实类比
带剩余车位显示的停车场。入口大屏显示「剩余车位 3」,车进去一辆,数字减一;车出来一辆,数字加一。显示 0 时,后面的车必须在门口排队等。信号量就是那个数字牌——它不关心谁在停车,只关心还有几个空位。
三、核心思想
信号量本质上是一个带两个原子操作的计数器:
acquire:计数器减一,如果已经为零则阻塞等待release:计数器加一,如果有等待者则唤醒一个
sequenceDiagram
participant T1 as 任务 1
participant S as 信号量 (max=2)
participant T2 as 任务 2
participant T3 as 任务 3
T1->>S: acquire (计数: 2→1)
T2->>S: acquire (计数: 1→0)
T3->>S: acquire (阻塞 — 计数=0)
T1->>S: release (计数: 0→1)
S->>T3: 唤醒 (计数: 1→0)
关键数据结构只有一个计数器加一个等待队列,复杂度如下:
| 操作 | 复杂度 | 说明 |
|---|---|---|
acquire | O(1) | 有许可时直接递减;计数为零时阻塞 |
release | O(1) | 递增计数器,唤醒一个等待者 |
| 公平性 | 取决于实现 | FIFO 或任意顺序 |
| 空间 | O(1) + O(等待者数) | 计数器 + 等待队列 |
四、变体与对比
| 模式 | 关系 | 区别 |
|---|---|---|
| 互斥锁(Mutex) | 信号量 max=1 时行为类似互斥锁 | 互斥锁有所有权语义——只有获取者才能释放;信号量是匿名计数器,任何线程都能 release |
| 限流器(Rate Limiter) | 都限制访问 | 限流器控制时间维度的吞吐量(每秒 N 次);信号量控制并发数量(同时 N 个) |
| 对象池(Object Pool) | 池大小本质上是一个信号量 | 对象池额外管理资源的创建、复用和销毁;信号量只管计数 |
| 背压(Backpressure) | 信号量可以实现背压 | 背压是一种流控思想,信号量是具体实现手段 |
Note
最大值为 1 的信号量虽然行为类似互斥锁,但不应该替代互斥锁。互斥锁的所有权语义能防止其他线程意外释放,还支持优先级继承——信号量做不到这些。
五、多语言实现
Go:用带缓冲的 channel 模拟信号量
Go 没有原生的信号量类型,但带缓冲的 channel 天然具备信号量的语义——写入即获取,读出即释放。
func processWithLimit(items []string, maxConcurrent int) { // 带缓冲 channel 充当信号量 sem := make(chan struct{}, maxConcurrent) var wg sync.WaitGroup
for _, item := range items { wg.Add(1) sem <- struct{}{} // acquire:缓冲区满时阻塞 go func(s string) { defer wg.Done() defer func() { <-sem }() // release:读出释放一个位置 process(s) }(item) } wg.Wait()}channel 方式的优势在于与 select 自然组合,可以同时处理超时和取消:
select {case sem <- struct{}{}: // 获取成功case <-ctx.Done(): // 上下文已取消}TypeScript:基于 Promise 的异步信号量
class Semaphore { private queue: (() => void)[] = []; private count: number;
constructor(private max: number) { this.count = max; }
async acquire(): Promise<void> { if (this.count > 0) { this.count--; return; } // 计数器为零,排队等待 return new Promise<void>((resolve) => this.queue.push(resolve)); }
release(): void { const next = this.queue.shift(); if (next) { next(); // 唤醒等待者,计数器不变 } else { this.count++; // 没有等待者,恢复计数 } }}
// 配合 try/finally 确保释放async function withSemaphore<T>(sem: Semaphore, fn: () => Promise<T>): Promise<T> { await sem.acquire(); try { return await fn(); } finally { sem.release(); }}六、生产验证
- Linux 内核 — semaphore.h#L15-L55:
struct semaphore提供内核级计数信号量,down()获取、up()释放,用于设备驱动访问控制 - Go 标准库 — semaphore.go#L28-L107:
Weighted结构体支持加权获取,Acquire阻塞直到权重可用或上下文取消 - Java Semaphore —
java.util.concurrent.Semaphore:支持公平(FIFO)和非公平两种模式,是 Java 并发编程的基础工具
七、小结
何时使用:
- 限流——限制并发 API 调用、数据库连接数
- 资源池——控制对固定数量资源的访问
- 背压——防止压垮下游服务
- 节流——限制并发文件 I/O、网络请求
何时不用:
- 互斥——如果需要独占访问(max=1),用 mutex 更安全
- 简单计数——不需要阻塞就用原子计数器
- 顺序敏感——如果处理顺序重要,用有界队列代替
- 资源复用——信号量只管并发数,不管资源生命周期;需要连接池而非裸信号量
八、参考资料
- Dijkstra 原始论文 - 1965 年,信号量的发明者 Edsger Dijkstra 的奠基论文
- Go semaphore 源码 - Weighted 信号量的完整实现
- Linux 内核信号量 - 内核级 down/up 操作
- Java Semaphore 文档 - 公平/非公平信号量说明
- Mars Pathfinder 优先级反转 - 信号量与优先级反转的经典案例
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
相关文章 智能推荐






