不知道你有没有遇到过这种灵异事件:开了十个线程去抓数据,逻辑写得挺小心,结果数据不是少几条,就是偶尔重复;又或者模块明明限制了并发数,高峰期服务还是被打穿,连接数一路往上飙。排查了半天,锁也加了,队列也做了,问题依然在。这时候你十有八九是缺了一个叫信号量的东西。
信号量(Semaphore)是操作系统里最老牌的并发同步原语之一,核心作用用一句话说清:让多个线程或进程对有限资源做到有条不紊地使用。它不只是加锁,还能做流量控制、连接池管理、生产者消费者模型这一大票事情,属于并发编程绕不过去的基本功。这篇文章我会从一个最简单的排队场景讲起,把信号量的原理、P/V 两个核心操作、三种主流语言里面的实际写法,以及工程项目里踩过的坑,全部过一遍。适合刚上手多线程编程、对锁和信号量分不太清的人,也适合写了好几年业务代码、想系统补一下并发基础的老开发。
1. 信号量到底是什么:从排队打饭说起
想象一下食堂只有三个窗口,来了二十个人。硬件资源就这么点,窗口只能同时服务三个人,剩下十七个人要么站着等,要么先去干点别的。信号量干的事情就是给这个场景配一个计数器:计数器等于 3 的时候,说明三个窗口都空着;每进来一个人打饭,计数器减一;谁打完了,计数器加一,同时叫醒一个排队的人。就这么简单,本质上是一个计数器加一个等待队列。
和锁最本质的差别也在这一层。锁关注的是“互斥”,同一时刻只允许一个人进临界区;信号量关注的是“数量”,它天生就能表达“允许多少个访问”。所以信号量的计数可以大于 1,这意味着它处理的不只是竞争问题,更重要的是容量问题。
1.1 一个计数器的自我修养
信号量内部就两个成员:一个整数计数器,一个等待队列。理解了这两个成员,就理解了信号量的一切。
- 计数器表示当前还有多少个可用的资源。资源可以是空闲连接、剩余容量、可以进入的线程额度,任何你能量化成“坑位”的东西都行。
- 等待队列放着那些因为拿不到资源而被挂起的线程,等有人释放资源时,再从队列里挑一个唤醒。
操作系统保证“检查计数、修改计数”这两个动作是原子的。这个原子性特别关键。比如计数器是 3,两个线程同时申请资源,如果没有原子性,它们可能同时读到 3、同时减一,结果以为有 6 个资源。信号量在实现上会关中断或者用硬件原子指令,从内核层面保证这点。这也是为什么用户态代码不能自己折腾一个变量假装是信号量——你做不到在检查与修改之间不被别人插队。
另外有个细节新手容易懵:有些实现里,计数器减出来的负数表示有多少线程正在等待。计数值变成 -2,意味着资源已经耗尽,还有两个线程排队。看监控日志的时候看到负数不用慌,这恰恰说明一切正常,队列在起作用。
1.2 二值信号量与计数信号量
信号量最常见的两种形态:
- 二值信号量:计数只能取 0 或 1。它解决的问题和互斥锁很像,但语义并不一样。互斥锁有“所有者”概念,谁加的锁,谁负责解;信号量没有,谁都可以释放。
- 计数信号量:计数大于 1,允许多个线程同时访问有限资源。连接池、限流器、任务队列容量都是典型场景。
用停车场类比最好理解。普通互斥锁是只有一个车位的停车场,车进去,门就锁死;二值信号量也是这个观感,但细节上不同。计数信号量就是有 N 个车位的停车场,入口显示屏上写着剩余车位,车进一辆减一,出一辆加一,满员时新车在外面排队。
1.3 信号量适合解决的经典场景
我工作中用得最多的几个场景:
- 连接池:数据库连接、外部服务连接都是稀缺资源,不能无限创建,用信号量把并发连接数钉死。
- API 限流:第三方接口的并发承受能力有限,比如最多允许 5 个请求同时打过去,信号量天然适合。
- 生产者消费者:缓冲区有空位才能放数据、有数据才能取数据,“空位数”和“数据量”各用一个信号量控制。
- 任务闸门:批量任务要等所有子任务跑完再汇总,可以初始化信号量为任务总数,每个任务结束释放一次,主任务集中获取 N 次。
信号量不是万能的。它不适合保护共享数据结构,因为缺少所有权和重入检查,误用容易出隐蔽死锁。下面这章把两个核心操作讲透,你自然就知道哪里能用、哪里不能。
2. 两个核心操作:P/V 背后的设计与原理
信号量有两个核心操作,不同语言里名字不同,底层思想完全一致:一个是申请资源,一个是释放资源。操作系统教材里习惯叫 P 操作和 V 操作,大学课堂上的经典命名。P 大致对应“探查并占用”,V 大致对应“增加并唤醒”。理解这两个操作,比背任何 API 都管用。
2.1 P 操作:资源不够就先排队
P 操作也叫 wait、down、acquire。它的逻辑是:
void P(sem_t *sem) { // 一直尝试,直到拿到资源 while (1) { if (sem->count > 0) { sem->count--; break; } // 没资源,把自己挂进等待队列 sleep_on(sem->wait_queue); } }这是示意逻辑,真实实现里检查和修改必须原子完成。重点在于:为什么是 while 而不是 if?因为线程被唤醒之后,并不意味着一定拿到了资源。可能多个线程同时被唤醒,资源已经被别人抢走,所以必须重新检查条件。这就是教科书里常说的“唤醒不一定代表成功,需要自旋式地再次确认”。很多自己手写同步逻辑的人在这里翻车,用 if 判断一次,醒过来直接往下走,结果资源早就没了。
用健身房储物柜类比:每个柜子是资源,你到前台问“有没有空柜子”,有就插卡占用,没有就在旁边等着。这中间不能有另一个人同时问你、同时把最后一个柜子抢走。听起来很简单,但并发环境下必须靠原子操作才能保证公平和正确。
2.2 V 操作:归还资源,叫醒一个等待者
V 操作也叫 signal、up、release。逻辑更简单:
void V(sem_t *sem) { sem->count++; if (sem->wait_queue is not empty) { wake_up_one(sem->wait_queue); } }释放资源就是把计数器加一,然后从等待队列里叫醒一个线程。注意这里的“叫醒”只是让它进入可运行状态,不代表它立刻就能上 CPU 跑。调度器可能让它再等一会儿,所以被唤醒的线程回到 P 操作里还是要重新检查计数器。
V 操作有个容易被忽略但极其重要的特性:它不需要由“持有者”调用。互斥锁要求谁锁谁开,信号量没有这个限制。任何线程都可以释放信号量资源,哪怕它从来没用过这个资源。这不是缺陷,反而是生产者和消费者模型能成立的关键:生产者占用“空位”,消费者负责归还“空位”,两者根本不需要知道对方是谁。
2.3 为什么说信号量没有“所有者”
这是信号量和互斥锁之间最深的一道分水岭。
互斥锁内部记录了持有者线程的 ID,可以做到重入检查:同一个线程重复加锁不会死锁。信号量只是一个计数器,它不关心资源是被谁拿走的,也不关心释放的人是不是当初拿走的人。所以:
- 用二值信号量模拟互斥锁时,如果线程 A 加锁后异常退出,线程 B 理论上可以帮忙调用 release,把计数恢复成 1。看起来是“修复”了死锁,实际上临界区可能被两个线程同时进入,事故更大。
- 信号量的所有者在应用层是不存在的,设计的时候必须自己约定:谁负责释放,释放几次。各语言的标准库提供了互斥锁,所以保护临界区的首选一定是锁,而不是信号量。
一句话总结:锁管“能不能进”,信号量管“能进几个”。搞混了,代码就会在诡异的时间点出问题。
2.4 各语言里的“同名不同姓”速查表
同一个概念,不同语言的名字五花八门,刚切换语言时很容易晕。记住这张表:
| 操作 | 通用叫法 | C 语言 | Python | Go | Java |
|---|---|---|---|---|---|
| 申请资源 | P / wait / down | sem_wait | acquire | channel 发送 | acquire |
| 释放资源 | V / signal / up | sem_post | release | channel 接收 | release |
| 非阻塞申请 | trywait | sem_trywait | acquire(blocking=False) | select + default | tryAcquire |
| 超时申请 | timed wait | sem_timedwait | acquire(timeout=秒) | select + time.After | tryAcquire(超时) |
看到没有,C 里叫 wait/post,Python 和 Java 里叫 acquire/release,Go 里甚至没有专门的信号量类型,而是用带缓冲的 channel 实现。换语言时别被 API 名字带偏,认准背后是“申请/释放”这对操作就行。
3. 实操:三种常用语言里的信号量写法
光讲原理不够,我直接给出三套实战可用的代码,覆盖不同的开发场景。你先复制跑通,再回头对照原理看,印象会深很多。
3.1 C 语言的 POSIX 信号量:一个生产者消费者示例
C 语言用的是 POSIX 信号量,头文件semaphore.h。下面这个例子维持一个容量为 4 的环形缓冲区,一个生产者线程往里面放数据,一个消费者线程往外面取数据。空位信号量empty初始化为 4,数据信号量full初始化为 0。
#include <stdio.h> #include <pthread.h> #include <semaphore.h> #include <unistd.h> #define CAPACITY 4 sem_t empty, full; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; int buf[CAPACITY]; int head = 0, tail = 0; void *producer(void *arg) { for (int i = 0; i < 10; i++) { sem_wait(&empty); // 申请一个空位 pthread_mutex_lock(&lock); buf[tail] = i; tail = (tail + 1) % CAPACITY; pthread_mutex_unlock(&lock); sem_post(&full); // 数据量加一 usleep(100000); } return NULL; } void *consumer(void *arg) { for (int i = 0; i < 10; i++) { sem_wait(&full); // 申请一个数据 pthread_mutex_lock(&lock); int val = buf[head]; head = (head + 1) % CAPACITY; pthread_mutex_unlock(&lock); sem_post(&empty); // 空位加一 printf("got %d\n", val); usleep(200000); } return NULL; } int main() { sem_init(&empty, 0, CAPACITY); sem_init(&full, 0, 0); pthread_t pt, ct; pthread_create(&pt, NULL, producer, NULL); pthread_create(&ct, NULL, consumer, NULL); pthread_join(pt, NULL); pthread_join(ct, NULL); sem_destroy(&empty); sem_destroy(&full); return 0; }编译时记得加-pthread。sem_init的第二个参数 0 表示线程间共享,如果改成非 0,信号量就可以用于进程间同步,但前提是该内存区域必须能被两个进程同时看到,通常是共享内存。这个参数很多人初始化时随手填 1,进程间同步时发现两个进程各搞各的,就是这个原因。
还有一个 C 语言特有的坑:sem_wait可能被信号打断,返回 -1 并设置 errno 为 EINTR。严谨的代码要循环重试,或者用sem_wait的 GNU 扩展自动重试。生产环境不要裸调一次就放心。
3.2 Python 的 threading.Semaphore:限流五秒上手
Python 的写法简洁得多。下面是一个限制最多 3 个并发请求的限流器:
import threading import time import random sem = threading.Semaphore(3) def request(url): with sem: print(f"start {url}") time.sleep(random.uniform(0.2, 1.0)) print(f"done {url}") threads = [threading.Thread(target=request, args=(i,)) for i in range(5)] for t in threads: t.start() for t in threads: t.join()关键点在with sem,它会自动执行 acquire 和 release,就算中间抛了异常也会正确释放。我强烈建议能用with就不要手动写 try/finally,少写一行,少一个事故。
一个进阶建议:线上代码优先用threading.BoundedSemaphore而不是Semaphore。普通信号量允许 release 任意多次,计数可能涨到初始值以上,这通常意味着逻辑 bug;BoundedSemaphore 会在计数超过初始值时抛出 ValueError,让错误尽早暴露。这个细节很多老手都不知道,但你一旦遇到过计数器暴涨把限流变成放行的坑,就会回来把它写进规范里。
3.3 Go 的 channel:没有信号量类型,却有信号量的魂
Go 语言没有专门的 semaphore 类型,但带缓冲的 channel 完全可以充当计数信号量。缓冲大小就是资源总量,往 channel 里发一个值相当于 acquire,从 channel 里收一个值相当于 release。
package main import ( "fmt" "sync" "time" ) func main() { ch := make(chan struct{}, 3) // 最大 3 个并发 var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(id int) { defer wg.Done() ch <- struct{}{} // 获取资源 defer func() { <-ch }() // 释放资源 fmt.Println("start", id) time.Sleep(300 * time.Millisecond) fmt.Println("done", id) }(i) } wg.Wait() }用struct{}而不是int或bool,是因为空结构体不占内存,纯粹占位。想实现非阻塞获取,用 select 加 default:
select { case ch <- struct{}{}: // 拿到了 default: // 没拿到,落入这里的逻辑 }注意发送ch <- struct{}{}一定要放在 worker goroutine 里。如果写成主协程先发 3 个、再创建 goroutine,很容易变成主协程自己把自己阻塞,代码还特别难查。知道的人看起来很好笑,第一次踩的人都懵很久。
3.4 Java 的 Semaphore:公平模式与超时获取
Java 的java.util.concurrent.Semaphore功能最全,几个细节非常实用。构造时传第二个参数true,表示公平模式,先申请的先拿到资源,避免线程饥饿。默认非公平模式性能更好,但有概率让某些线程长时间吃不到资源。
import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; public class SemaphoreDemo { public static void main(String[] args) { Semaphore sem = new Semaphore(3, true); for (int i = 0; i < 6; i++) { new Thread(() -> { boolean acquired = false; try { acquired = sem.tryAcquire(500, TimeUnit.MILLISECONDS); if (!acquired) { System.out.println("timeout, drop task"); return; } System.out.println("running"); Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (acquired) { sem.release(); } } }).start(); } } }tryAcquire带超时时间,是线上防雪崩的利器。宁可超时丢弃一部分任务,也不要让所有线程无限期挂在信号量上。finally 里释放是铁律,Java 的 InterruptedException 在等待期间随时可能抛出来,如果不在 finally 处理,信号量被永久占住,后面所有任务只能排队等到天荒地老。
4. 工程案例:用信号量管住有限资源
前面是入门样例,这一章我直接讲两个真实场景的设计思路,你会发现信号量的实际价值比教科书大得多。
4.1 一个连接池的借用与归还
假设我要做一个数据库连接池,最大连接数 3。连接对象存在一个列表里,信号量负责控制可用的“坑位”。
初始化: idle = [conn1, conn2, conn3] sem = Semaphore(3) 借连接: sem.acquire() 从 idle 里取一个连接 还连接: 把连接放回 idle sem.release()这与前面代码的原理一模一样,只是资源从整数换成了真实对象。设计时要注意几点:
- 借出连接后要记录借用超时时间,超时了要回收并释放信号量,防止连接泄漏。
- idle 列表要加锁保护,因为它会被并发修改。信号量管数量,锁管安全,两者是配合关系,不是替代关系。
- 压测时观察信号量计数的波动,可以判断连接池是否够用。如果计数长期为 0,且大量请求在等待,就该调大最大连接数或者优化单连接耗时。
4.2 任务闸门:等所有子任务跑完再继续
还有一个场景是“任务闸门”,比如一个批量导出任务,要把 10 个子任务全部跑完才能合并结果。很多人第一反应是用 sleep 轮询,低效且容易误判。
用信号量的思路是:初始化一个计数信号量为 0,每个子任务结束时 release 一次,主任务 acquire 10 次,次数够了就继续。
sem = Semaphore(0) 每个子任务结束后: sem.release() 主任务: for i in range(10): sem.acquire() 合并结果这里有一个关键点:信号量初始值是 0。这不是错误,是“事件通知”用法。每次 release 相当于一次完成事件,acquire 相当于等待一次完成事件。很多语言里还有更高级的 CountDownLatch、WaitGroup,本质都是同一个思想的改良版。理解了信号量的计数语义,再看这些工具就一片透明。
4.3 与互斥锁、条件变量怎么选
我在技术评审里经常被问“这场景到底用锁还是用信号量”。我的选型标准非常固定:
| 同步原语 | 核心语义 | 推荐场景 | 典型风险 |
|---|---|---|---|
| 互斥锁 | 互斥访问 | 保护共享变量、临界区 | 死锁、重入问题 |
| 条件变量 | 等待某个条件成立 | 生产者消费者中等待“有数据” | 虚假唤醒、忘记唤醒 |
| 信号量 | 控制可用数量 | 限流、资源池、任务闸门 | 忘记释放、无所有者 |
| 屏障/闸门 | 等待 N 个任务到达 | 多阶段并行计算 | 提前超时误判 |
我的原则是三个“不”:
- 临界区是改共享变量,用锁,不硬凑信号量。
- 要等待某个具体条件成立,用条件变量或 channel,信号量表达条件语义很费劲。
- 要做并发数限制、资源池、流量控制,优先信号量,别用锁加全局计数的方式硬扛。
工具本身没有高下之分,选型的依据永远是你要表达的语义。
5. 常见问题与排查技巧实录
信号量相关的问题,一旦出 bug,多半是“看起来卡住了”或者“并发数失控”。下面这些场景都是我真实遇到过或者帮别人排过的,整理成速查表,遇到类似症状可以直接对照。
5.1 信号量相关的经典事故
事故一:忘记 release,服务慢慢卡死
现象:线上请求越来越慢,线程全部堆积,数据库连接数掉到 0。看线程栈,大片线程停在 semaphore 的 acquire 方法上。原因多半是某个异常分支里没有执行 release。排查时除了看代码,还要看信号量的当前计数值:如果长期是 0,且没有一个 release 来源,基本就是泄漏。
事故二:release 写错位置,计数器失控
普通信号量允许无限 release,某天把 release 放在了循环外面或者重复调用,计数器一路飙升,限流形同虚设,系统被瞬间打爆。Python 用 BoundedSemaphore 能挡住这种问题,Java 和 C 就得靠 code review 和自己小心了。
事故三:多个信号量互相等待死锁
线程 A 持有信号量 S1,等待 S2;线程 B 持有 S2,等待 S1。这种死锁比锁死锁更难发现,因为信号量的“持有”语义很隐晦。建议多个信号量的获取顺序全局一致,或者改用带超时的获取方法,让死锁变成超时报错。
事故四:把二值信号量当互斥锁用
前面已经强调过,信号量没有所有者,线程 B 可以释放线程 A 持有的资源。代码里一旦出现“某个线程释放另一个线程获取的信号量”这种操作,基本等于挖坑。最怕的是异常兜底逻辑里顺手调一下 release,看起来是在“救火”,实际把互斥性彻底破坏了。
事故五:跨进程共享信号量时初始化参数不对
进程间同步时,sem_init的第二个参数必须传非 0,并且信号量要放在两个进程都能访问的共享内存里。很多人只改了初始化参数,忘记处理内存映射,导致 A 进程的修改 B 进程完全看不见,诡异问题层出不穷。
5.2 排查手段:从现场抓到根因
遇到信号量问题,我一般按这个顺序查:
- 看当前计数值。监控系统里直接看信号量计数,如果长期为 0 或者异常巨大,问题范围立刻缩窄。
- 抓线程栈。看有多少线程阻塞在 acquire 上、它们的调用栈长什么样,能快速定位是谁在申请资源、为什么没人释放。
- 看系统调用。Linux 下可以用跟踪工具观察 futex 相关系统调用,确认线程是在正常排队还是真的死锁。
- 放大复现。写一个压测脚本,把关键操作循环一万次,打印每次 acquire 前后的计数值,通常很快就能看到哪一步把计数改坏了。
5.3 我的四个实操习惯
这些坑踩多了,我现在写并发代码有几条固定习惯。第一,默认选择带边界检查的信号量,比如 Python 的 BoundedSemaphore,发现乱 release 立刻炸出声,宁可报错也不要沉默地放大故障。第二,获取资源尽量用带超时的版本,不裸等,哪怕超时后只是记一条日志,也比全员挂起强。第三,release 一定放在 finally 或 defer 里,绝不散落在正常路径中,异常路径容易忘。第四,上线前把信号量初始值和实时计数接入监控,告警阈值设成 0,长时间归零就要警惕。
信号量这东西,原理一句话能说完,但在生产环境里能不能用得稳,全靠这些细节。我见过太多因为少写一个 release 引发的线上事故,也见过用信号量把并发控制做得极其漂亮的系统。希望这篇能帮你把这个老牌原语真正吃透,下次写并发代码时,第一步想到的不再只是锁,而是先问自己一个问题:这里到底要互斥,还是要控量?想清楚这个,技术选型就不会出错。