讲并发编程,信号量(Semaphore)是绕不开的老朋友。1965年 Dijkstra 提出这个概念,到现在操作系统、数据库连接池、消息队列、线程池里到处都能看到它的影子。很多新手第一次接触时,容易被 P/V 操作、计数器和阻塞队列这些东西搞懵,尤其是搞不清它和普通的锁到底有什么区别。我这些年写并发代码,踩过的坑不少,把信号量的工作原理、代码写法和排查经验完整捋一遍,希望对你有用。
1. 信号量到底解决什么问题
1.1 并发场景下的资源争夺
先从一个最日常的场景说起。假设你维护一个后端服务,数据库连接池最多只有 5 个连接。如果同时有 10 个请求过来,每个线程都尝试去查数据库,连接池会被瞬间打穿,数据库直接报“连接过多”。更隐蔽的情况是多个线程同时修改同一个全局变量,比如计数器count++,CPU 上这一条指令实际会被拆成“读取、修改、写回”三步,两个线程交错执行,最后结果就少加了一次。
这两种问题本质都是并发执行时对“共享资源”的争夺。解决办法是给共享资源加一道访问控制,也就是同步原语。信号量就是其中最经典的一种:它内部维护一个计数器,表示当前还有多少“许可证”可用。线程在进入资源区之前必须先申请许可证,拿到才能进去,没有就排队等;用完之后把许可证还回去,让后面的人继续用。
这个模型比普通的互斥锁更贴近真实世界的资源池。互斥锁只能回答“能不能进来”的二值问题,信号量回答的是“还能允许几个同时进来”的数量问题。数据库连接池、线程池、限流器这类场景,天然就是“我有一批资源,希望最多同时让 N 个人使用”,信号量就是为此设计的。
1.2 信号量与互斥锁的本质区别
我把两者的差异用一个表格说清楚:
| 维度 | 互斥锁(Mutex) | 信号量(Semaphore) |
|---|---|---|
| 核心状态 | 0/1,锁定或未锁定 | 非负整数计数器 |
| 是否允许并发进入 | 同一时间只允许一个线程 | 最多允许 N 个线程 |
| 所有权 | 持有锁的线程负责解锁 | 任何线程都可以 signal,没有所有者概念 |
| 典型用途 | 保护临界区,防止数据竞争 | 资源池限流、生产者消费者协调 |
| 实现复杂度 | 相对简单 | 相对复杂,需要计数和等待队列 |
用生活例子最直观:互斥锁是公司洗手间的一把钥匙,谁拿了钥匙谁进门,用完必须交还给下一个人;信号量是路边收费停车场的空位指示牌,上面显示剩余车位,还有空位就放行一辆,停满了就拦下,等有车出来再放一辆进去。
关键点在于“有没有所有者”。互斥锁严格要求加锁和解锁由同一个线程完成,否则状态就乱了;信号量没有这个约束,线程 A 完全可以sem_post把计数加一,让线程 B 从等待中恢复。这种灵活性让它能完成很多锁做不到的协作场景,但也埋下了滥用、计数漂移的隐患,后面实操部分我会专门讲。
1.3 计数信号量与二值信号量:两种形态一次看懂
信号量按计数器取值范围分成两种形态。
计数信号量的值可以大于 1,代表“同一时间允许多个线程进入”。比如初始值设为 5,就表示最多允许 5 个线程同时持有许可证。线程池、连接池、限流器用的都是这种。
二值信号量的值只能是 0 或 1,效果看起来和互斥锁很像。很多教科书里也会用二值信号量当锁用,但这里有个容易踩的坑:两者并不等价。互斥锁带有所有权语义,能识别出“谁加的锁谁解”,还能配合优先级继承解决优先级反转;二值信号量没有这些机制,容易被一个线程signal两次把计数抬到 1,让两个线程认为都有许可证了。
所以我的习惯是:保护普通临界区,优先用互斥锁;管理资源池、协调生产消费,才用信号量。不要把二值信号量当成“更好用的锁”,它不是。
2. 核心机制拆解:P/V操作与内部实现
2.1 wait/signal到底在做什么
信号量有两个基础操作,Dijkstra 当年用荷兰语命名:P 操作(Proberen,意为“测试”)和 V 操作(Verhogen,意为“增加”)。后来各种语言里的叫法不同,sem_wait/sem_post、acquire/release、P/V,实际做的事情都一样。
P 操作(wait)的逻辑是:尝试把计数减一,如果减完小于 0,说明当前没有可用许可证,线程进入阻塞状态,排到等待队列里;如果减完大于等于 0,说明还有资源,线程继续执行。
V 操作(signal)的逻辑是:把计数加一,如果之前有线程在等待队列里,就唤醒其中一个,让它接着往下走。
这里最关键的词是“原子性”。sem_wait里的“检查计数、减一、决定是否阻塞”必须是一整步完成,中间不能被其他线程打断。否则两个线程同时读到一个计数为 1,都以为能进去,资源就被超发了。所以信号量的操作用户态是没法纯靠普通变量实现的,必须借助操作系统内核的原子指令、关闭中断或底层的 CAS 自旋来完成。
教科书里常见这样一段伪代码:
wait(S): while S.count <= 0: 线程进入休眠,放入等待队列 S.count = S.count - 1 signal(S): S.count = S.count + 1 如果有线程在等待队列里,唤醒一个注意这个伪代码里的计数器可以变成负数。负多少,就代表有多少个线程在排队等资源。这是理解信号量内部状态的一把钥匙。
2.2 一个计数信号量的完整生命周期
拿一个初始值为 3 的信号量举例,看线程们是怎么进出的:
- 初始
count = 3 - 线程 T1 执行
wait,count变成 2,T1 进入 - 线程 T2 执行
wait,count变成 1,T2 进入 - 线程 T3 执行
wait,count变成 0,T3 进入 - 线程 T4 执行
wait,count变成 -1,T4 阻塞等待 - 线程 T1 执行
signal,count变成 0,唤醒 T4 - T4 被唤醒后恢复执行,完成剩余工作
这个过程中最容易被忽略的细节是:T4 在阻塞前已经“预支”了一次计数减一,被唤醒后不需要再减一次。count = -1的含义是“有 1 个线程在等待”,当signal把它加到 0 时,等待队列里的一个线程就可以出来了。
实际编程中,POSIXsem_getvalue拿到的计数通常不会是负数,因为内核在sem_wait时遇到计数为 0 就直接进入等待,直到资源可用后再完成减一。阻塞线程的数量不会直接暴露给用户态。但理解“负数代表等待者数量”这个模型,对分析死锁和线程堆积问题非常有帮助。
2.3 生产者-消费者模型里的信号量组织
信号量最经典的实战场景是生产者-消费者模型。这里一般需要三个同步原语配合:
empty:初始值 = 缓冲区大小,表示还有多少个空位可以放数据full:初始值 = 0,表示缓冲区里有多少个数据可以取mutex:普通互斥锁,保护缓冲区的读写操作本身
生产者的流程:
wait(empty) // 先看有没有空位 wait(mutex) // 再拿缓冲区锁 写数据 signal(mutex) signal(full) // 数据变多,通知消费者消费者的流程:
wait(full) // 先看有没有数据 wait(mutex) // 再拿缓冲区锁 读数据 signal(mutex) signal(empty) // 空位变多,通知生产者为什么不能用一把互斥锁解决?因为锁没有“等待条件”的能力。消费者发现缓冲区空了,如果只拿着互斥锁干等,整个缓冲区就僵死了。信号量在这里承担了“条件等待”的角色:full会让消费者在没有数据时休眠,等生产者signal(full)再唤醒;empty让生产者没有空位时休眠,等消费者腾出位置再唤醒。
顺序也有讲究。一般来说,先检查资源信号量(empty/full),再拿互斥锁。如果反过来,先拿锁再等空位,就可能出现“持有锁却睡在信号量上”的情况,其他线程想进缓冲区也进不来,造成无谓阻塞。这种顺序我在 review 代码时看到太多次了,写的时候一定要保持清醒。
3. 不同语言/环境下的信号量实操
3.1 C语言:POSIX信号量实现生产者-消费者
在 C 语言里,POSIX 标准提供了<semaphore.h>,使用时需要链接pthread库。下面这个例子是一个简单的生产者-消费者程序,缓冲区大小 5,生产者生产 20 个数字,消费者逐个消费:
#include <stdio.h> #include <pthread.h> #include <semaphore.h> #define BUFFER_SIZE 5 #define TOTAL_ITEMS 20 int buffer[BUFFER_SIZE]; int in = 0, out = 0; sem_t empty; sem_t full; sem_t mutex; void *producer(void *arg) { for (int i = 0; i < TOTAL_ITEMS; i++) { sem_wait(&empty); sem_wait(&mutex); buffer[in] = i; in = (in + 1) % BUFFER_SIZE; printf("produce %d\n", i); sem_post(&mutex); sem_post(&full); } return NULL; } void *consumer(void *arg) { for (int i = 0; i < TOTAL_ITEMS; i++) { sem_wait(&full); sem_wait(&mutex); int item = buffer[out]; out = (out + 1) % BUFFER_SIZE; printf("consume %d\n", item); sem_post(&mutex); sem_post(&empty); } return NULL; } int main(void) { pthread_t pid, cid; sem_init(&empty, 0, BUFFER_SIZE); sem_init(&full, 0, 0); sem_init(&mutex, 0, 1); pthread_create(&pid, NULL, producer, NULL); pthread_create(&cid, NULL, consumer, NULL); pthread_join(pid, NULL); pthread_join(cid, NULL); sem_destroy(&empty); sem_destroy(&full); sem_destroy(&mutex); return 0; }编译命令是:
gcc -o producer_consumer producer_consumer.c -pthreadsem_init的第二个参数需要特别注意:传 0 表示信号量仅在当前进程内的线程间共享;传非 0 表示进程间共享,这时信号量要放在共享内存区域,否则子进程根本看不到它,排查起来非常头疼。
程序运行后,你会看到输出里的生产和消费交替出现,但不会出现“连续生产 5 个以上却没人消费”的越界情况,因为empty和full像两道闸门,把缓冲区水位严格限制住了。
3.2 Python:用信号量控制并发连接数
Python 的标准库threading自带Semaphore和BoundedSemaphore。我日常最常用的场景是给 HTTP 请求做限流:系统允许同时最多 3 个外呼请求,其余线程排队。
import threading import time import requests sem = threading.Semaphore(3) def fetch_data(url): sem.acquire() try: print(f"开始请求:{url},时间:{time.time()}") resp = requests.get(url, timeout=10) print(f"完成请求:{url},状态码:{resp.status_code}") finally: sem.release() urls = [f"https://example.com/api/{i}" for i in range(10)] threads = [threading.Thread(target=fetch_data, args=(u,)) for u in urls] for t in threads: t.start() for t in threads: t.join()这里唯一需要强调的是finally。我见过有人图省事,acquire之后忘记release,或者把release放在return之前,结果一出现异常,许可证少了一个,线程池能用的并发数从 3 慢慢变成 2、1、0,最后所有请求全部卡死。信号量不会自动帮你归还资源,任何异常分支都必须保证release被执行。
Python 里还有一个BoundedSemaphore,它比普通Semaphore多了一层保护:如果release次数超过初始值,会抛出ValueError。这能帮你尽早发现“释放次数超过获取次数”的编码错误,推荐在开发阶段使用。
3.3 Java 和 Go 里的信号量形态
Java 在java.util.concurrent包里有现成的Semaphore类,支持公平模式:
Semaphore sem = new Semaphore(3, true); sem.acquire(); // 可响应中断 try { // 执行受限资源操作 } finally { sem.release(); }第二个参数true表示公平调度:先等待的线程先获得许可证。默认是非公平的,吞吐量更高,但可能出现线程长时间抢不到许可证的饥饿问题。对数据库连接池这类请求延迟敏感的场景,我会倾向开公平模式,虽然性能略低,但行为更好预测。
Go 语言没有直接叫Semaphore的类型,但它的 channel 天然就能模拟信号量。用带缓冲的 channel,容量就是信号量初始值:
sem := make(chan struct{}, 3) // 获取许可证 sem <- struct{}{} defer func() { <-sem }()我实际写的时候会把这段封装成一个函数,避免忘记归还:
func withLimit(sem chan struct{}, fn func()) { sem <- struct{}{} defer func() { <-sem }() fn() }3.4 怎么选:信号量、互斥锁、条件变量还是Channel
很多人问:并发同步到底该选哪个?我个人的选型逻辑是这样的:
| 需求 | 推荐工具 | 理由 |
|---|---|---|
| 让 N 个用户同时使用资源池 | 计数信号量 | 天生就是许可证模型 |
| 保护一个共享变量或临界区 | 互斥锁 | 语义简单,支持所有者检查 |
| 等待某个业务条件成立 | 条件变量 / Condition | 可以和锁配合,等多久都行 |
| 协程之间的消息传递 | Channel | 直接传递数据,携带业务上下文 |
| 多线程限流 | 信号量 / 令牌桶 | 计数器直观,接入成本低 |
信号量的优势是表达能力强,计数、排队、唤醒都有现成的支持;缺点是语义太灵活,容易被人当锁用,用错了就出问题。条件变量和 Channel 语义更明确,但代码结构相对复杂。项目里我的原则是:能用一个原语解决,就不叠两个;谁语义最贴近业务场景,就优先用谁。
4. 实战中的坑:死锁、优先级反转与排查实录
4.1 多信号量嵌套造成的死锁
信号量没有自动死锁检测机制,一旦多个线程对多个信号量加锁顺序不一致,就可能死锁。经典例子:
- 线程 A:先
wait(s1),再wait(s2) - 线程 B:先
wait(s2),再wait(s1)
如果 A 拿到 s1 后等待 s2,而 B 拿到 s2 后等待 s1,两边都永远等不到对方手里的资源,程序就挂死了。
我踩过最隐蔽的一次是业务在三个模块里分别加了信号量,模块顺序没对齐,代码在 100 万请求量级下跑了很久才触发一次。排查时发现两个线程都在sem_wait上卡住,gdb一看调用栈,锁的顺序反了。
解决办法没那么神秘:所有线程获取多个信号量时,必须按同一个全局顺序来。比如规定 s1 永远先于 s2,谁违反了谁就有问题。另一个防线是使用超时等待接口,比如 C 的sem_timedwait、Java 的tryAcquire(timeout),拿不到就放弃或重试,至少不会让线程无限期卡死。
4.2 信号量计数漂移:泄漏与过度释放
计数漂移是信号量项目里最常见的慢性病。表现有两种:
一种是泄漏,也就是wait的次数多于release。常出现在异常路径上:某个函数中途抛出异常,或者走了return提前退出,release没执行,许可证永久丢了一个。反复发生之后,可用计数越来越少,最终所有线程阻塞在wait上。现象是系统没有报错,但并发能力慢慢归零,请求排队时间无限拉长。
另一种是过度释放,也就是release的次数多于wait。计数被抬得比初始值还高,资源池实际只有 3 个连接,却被放进来 5 个线程,数据库被打崩。C 的sem_post、Java 的release对此都不设防,Python 的BoundedSemaphore才能拦住一部分。
我的排查经验是:给信号量操作多做一层审计日志,记录 acquire/release 前后的计数变化,再加上断言或者监控告警。把“资源使用峰值”“并发请求数”和“信号量计数”画在同一张图上,计数漂移一眼就能看出来,比盯着代码逐行找 release 快得多。
4.3 优先级反转:现象与对策
优先级反转在普通应用层不太常见,但在实时系统和嵌入式领域是必须认真对待的问题。场景是这样的:
- 低优先级线程 L 拿了信号量进入临界区
- 高优先级线程 H 也在等同一个信号量,进入阻塞
- 中优先级线程 M 不需要信号量,但它一直在运行,把 L 挤得无法执行
- 结果 H 明明优先级最高,却要等 M 和 L 都完事才能跑
互斥锁可以通过PTHREAD_PRIO_INHERIT开启优先级继承,让持有锁的线程临时提升优先级,尽快执行完并释放锁。信号量在 POSIX 标准里没有这样的自动继承机制,遇到强实时场景,用信号量做临界区保护是危险的。
如果项目评估下来优先级翻转可能影响功能,我的建议是:别拿信号量硬扛,改用带优先级继承的互斥锁,或者采用优先级天花板协议,让锁的优先级直接抬到所有可能竞争这个资源的线程的最高值。应用层普通的 web 服务一般不涉及这个问题,但如果你在写机器人控制、嵌入式固件、音视频调度,一定提前把这条写进设计评审检查单里。
4.4 问题定位与工具建议
信号量的调试比普通业务代码难一个数量级,因为你很难直接看到“当前有几个线程在等”。我总结过一套排查路径:
- 先用
strace或gdb查看线程卡在哪个sem_wait上,拿到阻塞点的调用栈 - 再用
sem_getvalue打印当前计数,看看是不是已经到 0 或负值,判断是资源耗尽还是计数漂移 - 对 C/C++ 程序,上
valgrind --tool=helgrind,它能检测出锁顺序冲突和信号量使用不匹配 - 对 Java 程序,直接
jstack抓线程转储,看哪些线程停在Semaphore.acquire,统计数量是不是和预期一致 - 对 Python 程序,用
faulthandler或者第三方库dumpsys抓线程堆栈
还有一个容易被忽略的点:sem_getvalue拿到的计数值本身是不稳定的,因为在你读到值的那一瞬间,其他线程可能已经在做sem_wait或sem_post了。它只能作为定性参考,不能拿来做强一致的判断。真要看并发状态,还是得靠业务日志里的关联 ID 和计数审计。
4.5 常见问题速查表
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
线程全部卡在sem_wait | 某个异常分支没有执行release,计数泄漏到 0 | 找所有持有信号量的函数,确认finally是否覆盖全部路径 |
| 并发数超过设定上限 | 过度release,或初始化值设置错误 | 审查每一条release调用,开发阶段用BoundedSemaphore或断言兜底 |
| 程序偶尔死锁,重启后恢复 | 多个信号量加锁顺序不一致 | gdb看卡在哪个wait,统一全局加锁顺序,必要时用超时等待 |
| 高优先级任务长时间得不到调度 | 优先级反转 | 评估是否在临界区里用了信号量,考虑换优先级继承互斥锁 |
| 计数变化和预期不符 | 多线程同时操作,计数读取非原子 | 结合业务日志和审计计数,不要轻信单次sem_getvalue |
信号量用了这么多年还在各种底层系统里屹立不倒,说明它确实不可替代。它给你的不是一个简单的锁,而是一套“资源许可”的思维框架。真把这个框架想透了,连接池、限流器、生产者消费者、流量控制这些场景,写起来都会顺手很多。我自己每次排查完信号量问题,都会把日志和堆栈截图存一份,过阵子回头看,能少走不少弯路。