Windows线程同步这个系列写到第23篇,终于轮到信号量(Semaphore)了。说实话,我刚学线程同步的时候,觉得信号量是几个经典同步对象里最“好懂”的一个——不就是计数器嘛。直到后来在某项目里写一个并发下载任务,因为初始计数和最大计数理解反了,线上同一时间涌进来几十个线程,把资源占满,我才意识到越是看起来简单的对象,越容易在细节上翻车。这篇笔记把我从官方文档读到、再自己写代码验证过的东西整理了一遍,包括信号量的三个核心API用法、生产者消费者队列的完整实现、几个高频踩坑点,以及进阶的闸门同步玩法。正好在学Windows编程的同学可以拿去做参考,已经接触过互斥量和事件的读者,也能通过这篇把并发原语的知识补完整。
1. 一次多线程抢购翻车后,我重新理解了信号量
1.1 抢票场景里的并发扣减问题
先说一个我实际调试过的例子。某商城项目里有一个秒杀接口,逻辑很简单:前端发起请求,后端开线程去扣减库存。仓库里只剩10件商品,可同时来了20个请求,线程各自判断“库存大于0就减1”。如果不加任何同步,最后的扣减结果大概率不是0,甚至会出现负数。问题在于“判断库存”和“减1”这两步不是一个原子操作:线程A读到库存是1,线程B也读到1,A减成0,B也减成0,结果库存变0但实际卖出2件。
这种场景用互斥量(Mutex)可以把代码块锁住,保证同一时间只有一个线程进入扣减逻辑。但如果业务本身需要限制的是“同时最多只有N个线程进入某段代码”,互斥量就不太对症了——互斥量只能锁1个,做不到“放行一组”。这时候信号量就派上用场:它是一个带上限的计数器,初始化时就规定好最大并发数,每次有线程进入就减1,减到0以后其他线程只能排队等待,直到有线程出来把计数加回去。
1.2 信号量的心智模型:有限停车位
我给同事讲信号量的时候,最喜欢用停车场来类比。假设商场停车场有20个车位,入口栏杆上挂着一块计数牌,显示“剩余20”。每进去一辆车,计数牌减1;每出去一辆车,计数牌加1。计数牌显示0的时候,后续的车只能排队,等有车出去腾出车位才能进。
信号量就是这块计数牌,只不过数字由操作系统帮我们维护,而且线程进出的时候不用掏钥匙,只需要调用等待和释放两个函数。这里有个关键认知:停车场里停着的车之间是没有关系的,车A和车B互不认识,信号量也不关心哪辆车占了哪个车位。它只负责一件事——确保任意时刻场内车辆数不超过20。这就解释了为什么信号量与互斥量有本质区别:互斥量是“锁”,强调独占;信号量是“计数器”,强调上限。如果某段代码同时只允许一个线程进入,用互斥量更合适;如果允许N个线程同时进入、但N不能无限大,信号量就是最自然的选择。
1.3 三种内核同步对象的适用边界
既然说到互斥量、事件、信号量这三个内核对象,我顺便把它们的差异整理一下,方便对照选择:
| 同步对象 | 核心特征 | 典型场景 | 不适用场景 |
|---|---|---|---|
| 互斥量(Mutex) | 独占访问,同一时刻只允许一个线程持有 | 保护全局变量、写共享内存 | 想限制“最多N个并发”时不适合 |
| 事件(Event) | 信号开关,有触发和未触发两种状态 | 线程A做完某件事,通知线程B开工 | 需要计数统计的场景不适合 |
| 信号量(Semaphore) | 资源计数器,0到最大值之间变化 | 限制并发连接数、生产者消费者队列 | 需要严格互斥锁定的场景不适合 |
理解这个边界之后,遇到具体问题先别急着敲代码,先问自己一句:“我要限制的是‘只能一个进’还是‘最多N个进’?”前者用互斥量,后者用信号量。这个选择题做对了,后面写代码会顺手很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信号量三件套API:Create、Wait、Release 的参数细节
2.1 CreateSemaphore:四个参数一个都不能含糊
创建信号量的函数签名是:
c复制HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, // 安全属性,一般传NULL
LONG lInitialCount, // 初始计数
LONG lMaximumCount, // 最大计数
LPCSTR lpName // 对象名称,匿名传NULL
);
我第一次用的时候,以为初始计数是“当前占用的数量”,结果全反了。这里的初始计数和最大计数指的是“剩余可用资源数量”和“最多剩余资源数量”,所以初始计数通常等于最大计数,表示一开始所有资源都空闲;如果初始计数是0,则表示一开始没有任何可用资源,所有线程都得排队。
有个细节初学容易忽略:lInitialCount 必须大于或等于0,小于或等于 lMaximumCount,lMaximumCount 必须大于0。比如 CreateSemaphore(NULL, 0, 5, NULL) 表示最大并发5、当前资源为0;CreateSemaphore(NULL, 5, 5, NULL) 表示最大并发5、当前还有5个资源可用。这两种写法含义完全不同,后面第4章我会说设错之后会出现什么诡异现象。
创建返回的句柄可以传给等待函数。这里补一句经验:句柄是内核对象句柄,用完一定要 CloseHandle,否则每次创建都会泄漏句柄,长时间运行的程序句柄数会一路涨上去,最后进程无端崩溃。
2.2 WaitForSingleObject:等待并消耗一个计数
线程想“占用一个资源”的时候,调用等待函数:
c复制DWORD dwWaitResult = WaitForSingleObject(hSemaphore, INFINITE);
如果信号量计数大于0,这个调用会立即返回 WAIT_OBJECT_0,同时计数减1;如果计数已经是0,线程就会进入等待状态,直到其他线程释放计数,或者等待超时。
我建议在代码里别把返回值直接忽略,尤其在使用带超时参数的等待时:
c复制DWORD dwWaitResult = WaitForSingleObject(hSemaphore, 3000);
switch (dwWaitResult) {
case WAIT_OBJECT_0:
// 成功拿到信号量,计数已减1
break;
case WAIT_TIMEOUT:
// 3秒没等到,说明资源一直没被释放
break;
case WAIT_FAILED:
// 句柄无效或参数错误
break;
}
WAIT_TIMEOUT 这个分支很关键。我见过不少代码只在 WAIT_OBJECT_0 里干正事,忽略 WAIT_TIMEOUT,结果超时之后线程照样往下执行,相当于没拿到资源却硬闯临界区,数据又乱了。等到的时候一定要 return 或者跳到错误处理,不要继续往下走。
2.3 ReleaseSemaphore:释放并唤醒等待线程
释放计数的函数是:
c复制BOOL ReleaseSemaphore(
HANDLE hSemaphore,
LONG lReleaseCount, // 要增加的计数数量
LPLONG lpPreviousCount // 接收释放前的旧计数,不需要传NULL
);
lReleaseCount 通常传1,代表释放一个资源。如果一次要释放多个资源,可以传大于1的值,但必须保证“当前计数 + 释放数量 <= 最大计数”,否则函数返回 FALSE,错误码是 ERROR_TOO_MANY_POSTS。
lpPreviousCount 这个参数挺有意思,它回填的是“释放之前信号量还剩多少计数”。这个值在调试的时候非常有用,我写测试程序时经常顺手打印出来,观察计数曲线的变化,确认等待和释放是否按预期成对出现。生产环境代码可以不关心它,传 NULL 就行。
2.4 跨线程、跨进程共享句柄的两种方式
信号量句柄在同一进程内的多个线程之间可以直接使用,因为线程共享进程句柄表。如果要在进程之间同步,有两种做法。
第一种:创建时指定名字。比如 CreateSemaphore(NULL, 1, 1, L"Global\\MyNamedSemaphore")。进程A创建了命名信号量,进程B用同样的名字再调 CreateSemaphore,系统不会创建一个新对象,而是返回同一个内核对象的句柄。此时可以通过 GetLastError() 判断是否为已存在对象——如果返回 ERROR_ALREADY_EXISTS,说明是“加入者”而不是“创建者”。这个判断在只有一个进程负责初始化、其他进程只负责等待的场景里很实用。
第二种:通过继承句柄。CreateSemaphore 的第一个参数传 &sa,把 bInheritHandle 设为 TRUE,然后创建子进程时子进程就能继承这个句柄。这种方式在我的实践中用得少,因为命名方式更直观。但要注意命名信号量会被系统中所有进程看到,名字要起得有辨识度,避免和别的程序撞车。
3. 实战:生产者-消费者队列,信号量是幕后调度员
3.1 为什么这个场景信号量比事件更优雅
生产者-消费者问题几乎是并发编程的必修课:一个线程往缓冲区放数据,一个线程从缓冲区取数据,缓冲区有大小限制。这个场景如果用事件来做,需要处理“缓冲区空”“缓冲区满”“有新数据”“有空位”好几个信号,状态一多,事件本身就变得很难维护。
信号量处理这个问题天然对路:用两个信号量,一个记录“空位数”,一个记录“数据数”。空位数从缓冲区大小开始,数据数从0开始。生产者每次放入一个数据,就消耗一个空位、产出一个数据;消费者正好反过来,消耗一个数据、产出一个空位。两个信号量的计数在孩子玩具跷跷板的两头,一头往下另一头就往上,所以缓冲区永远不会越界。
3.2 数据结构与全局对象设计
假设缓冲区大小是8,用环形队列实现:
c复制#define BUFFER_SIZE 8
HANDLE g_hSemEmpty; // 空位信号量,初始=8,最大=8
HANDLE g_hSemFull; // 数据信号量,初始=0,最大=8
HANDLE g_hMutex; // 保护队列索引的互斥锁
int g_buffer[BUFFER_SIZE];
int g_head = 0;
int g_tail = 0;
这里还有一个关键点:信号量只负责计数,不负责保护队列索引本身。多个生产者同时写 g_buffer[g_tail] 的时候,g_tail 的修改必须互斥,否则两个线程会写到同一个格子。所以还需要一个互斥量保护队列读写。信号量和互斥量在这里是合作关系,各管一摊。
初始化代码:
c复制g_hSemEmpty = CreateSemaphore(NULL, BUFFER_SIZE, BUFFER_SIZE, NULL);
g_hSemFull = CreateSemaphore(NULL, 0, BUFFER_SIZE, NULL);
g_hMutex = CreateMutex(NULL, FALSE, NULL);
3.3 生产者线程核心代码
c复制DWORD WINAPI ProducerThread(LPVOID param) {
for (int i = 0; i < 20; i++) {
// 先等一个空位,这个等待会消耗空位信号量计数
WaitForSingleObject(g_hSemEmpty, INFINITE);
// 拿到空位后,再锁互斥量,往队列里写
WaitForSingleObject(g_hMutex, INFINITE);
g_buffer[g_tail] = i;
g_tail = (g_tail + 1) % BUFFER_SIZE;
ReleaseMutex(g_hMutex);
// 写入完成,数据号增加1,唤醒等待中的消费者
ReleaseSemaphore(g_hSemFull, 1, NULL);
printf("[producer] produced %d\n", i);
}
return 0;
}
注意这里的等待顺序:先等信号量,再等互斥量。这个顺序不能反过来。如果先持有互斥量再等空位信号量,而缓冲区是满的,生产者就会一直举着锁等空位;消费者此时想取数据,却发现要先拿互斥量才能操作队列,而互斥量被生产者占着,于是两边互相死等。经典死锁。
3.4 消费者线程核心代码
c复制DWORD WINAPI ConsumerThread(LPVOID param) {
for (int i = 0; i < 20; i++) {
// 先等数据,有数据才继续
WaitForSingleObject(g_hSemFull, INFINITE);
// 再锁互斥量,从队列里取
WaitForSingleObject(g_hMutex, INFINITE);
int data = g_buffer[g_head];
g_head = (g_head + 1) % BUFFER_SIZE;
ReleaseMutex(g_hMutex);
// 取走一个数据,空位增加1
ReleaseSemaphore(g_hSemEmpty, 1, NULL);
printf("[consumer] consumed %d\n", data);
}
return 0;
}
从日志里能看到生产者生产速度比消费者快时,g_hSemFull 的计数会一路涨到接近8,然后生产者被 g_hSemEmpty 挡住,处于等待状态;等消费者取走数据释放空位,生产者才继续。这个“背压”效果是信号量计数天然带来的,不需要额外写复杂的状态判断。
3.5 运行效果与手写验证
我把生产者和消费者各开2个线程实测过。2个生产者、2个消费者,跑20轮,输出里数据不会重复也不会遗漏。我最开始没有加 g_hMutex,只靠两个信号量,结果偶发出现同一个数据被打印两次的情况,原因就是多个消费者同时读 g_head 取到了同一个格子。加锁之后问题消失。这个测试也说明了一个经验:信号量管“有没有资源”,互斥量管“资源谁在动”,两者缺一不可。
4. 信号量使用中的五个高频翻车点与排查方法
4.1 把初始计数和最大计数设反,整个程序逻辑颠倒
前文提到 CreateSemaphore(NULL, 0, 5, NULL) 和 CreateSemaphore(NULL, 5, 5, NULL) 的区别。第二个参数是当前可用资源数,第三个参数是最大允许资源数。我在某下载器项目里把两个参数写反过:CreateSemaphore(NULL, 5, 0, NULL),这直接就是一个非法调用,创建返回 NULL。当时代码里没有检查返回值,程序跑起来所有等待线程全部报错。
排查方式很简单,创建后立刻判断句柄是否为空:
c复制HANDLE hSem = CreateSemaphore(NULL, 5, 5, NULL);
if (hSem == NULL) {
DWORD err = GetLastError();
// 在这里打日志,别往下走
}
这样第一道防线就有了。还有一个容易错的点是第三参数最大计数必须大于0,否则系统会直接拒绝创建,返回 ERROR_INVALID_PARAMETER。
4.2 ReleaseSemaphore 溢出导致“幽灵计数”
溢出这个问题,我用一个具体场景说。假设信号量最大计数是5,当前计数是4,某线程错误地调用了 ReleaseSemaphore(hSem, 2, NULL),想释放2个资源。此时4+2=6,超过最大计数5,操作系统不会截断成5,而是直接让调用失败,返回 FALSE,错误码 ERROR_TOO_MANY_POSTS。我可以老实说:第一版我根本没处理这个返回值,程序运行一段时间后,下游任务数量对不上,排查了很久。
这类问题之所以“阴”,是因为它不一定每次都触发,只有当计数恰好在临界点才会失败。我的习惯是在所有 ReleaseSemaphore 后面写一行断言:
c复制BOOL ok = ReleaseSemaphore(hSem, 1, NULL);
assert(ok);
Debug 版本跑断言,Release 版本至少打一条日志。别嫌麻烦,这种错误一旦发生,定位成本远高于写断言的时间。
4.3 把信号量当成“互斥锁”使用,导致资源被钻空子
初学者容易以为信号量也能实现互斥,最多把最大计数设为1不就行了?逻辑上确实可以,但语义上有区别。互斥量有一个“所有者”概念:哪个线程锁了就必须哪个线程解锁;信号量没有这个概念,任何线程只要拿到句柄,都可以随便释放计数。
这意味着如果一个线程执行了 WaitForSingleObject(hSem, INFINITE),另一个完全不相关的线程也能调 ReleaseSemaphore 把这个计数加回去,当前线程的“独占”就被别人莫名放开了。在严格互斥的场景里,这是安全隐患。所以我的经验是:代码里读到“同时只允许一个线程访问某资源”这种描述,直接选互斥量或临界区,不要用信号量图省事。
4.4 WAIT_TIMEOUT 被忽略,线程在没拿到资源时继续跑
这块我在第2章提过,但值得单独展开一个翻车案例。某日志采集程序,线程序号有上限,用信号量控制。某个线程调用 WaitForSingleObject(hSem, 5000) 后,直接把返回值扔了,然后开始写文件。当时磁盘很忙,信号量5秒内没释放出来,线程没等到资源却照样执行了写入逻辑,结果多个线程同时操作同一个文件,日志内容乱掉。事后在代码里找到那行被忽略的返回值,改成:
c复制DWORD r = WaitForSingleObject(hSem, 5000);
if (r != WAIT_OBJECT_0) {
// 超时或失败,绝不能继续往下走
return r;
}
从此这类问题再没复发。
4.5 命名信号量在跨进程场景里的“已存在即忽略参数”陷阱
使用命名信号量时有个隐藏行为:如果指定名称的信号量已经存在,CreateSemaphore 返回已有对象的句柄,同时 lInitialCount 和 lMaximumCount 参数会被忽略。也就是说,一个进程想通过重新创建把计数重置是做不到的,必须用 ReleaseSemaphore 手动调整。
这个坑在自动化测试里尤其常见。测试用例跑完一轮,希望下一个用例把信号量恢复到初始状态,直接重新调用 CreateSemaphore 却发现计数还是旧值。正确做法是先 ReleaseSemaphore 把计数加回去,或者干脆在测试结束时把当前计数读取出来然后补足:
c复制LONG oldCount = 0;
while (oldCount < target) {
ReleaseSemaphore(hSem, 1, &oldCount);
}
另外,GetLastError() 返回 ERROR_ALREADY_EXISTS 是一个可用信息,它可以告诉程序员当前进程是“创建者”还是“加入者”,在需要区分角色的协议里很有用。
5. 进阶用法:闸门同步与并发流量控制
5.1 用信号量让N个线程同时起跑
有时候我们希望多个线程在同一个指令下“齐步走”,而不是各跑各的。这种模式叫闸门(Gate)。事件对象可以做,但事件要么触发、要么不触发,控制多个线程同时放行时,如果线程数量是动态的,事件调 SetEvent 只能保证当前正等待的线程被唤醒,如果有一个线程晚到,事件已经被消费掉,它就没赶上这班车。
用信号量做闸门更灵活。假设有8个工作线程,先把初始计数设为0,让所有线程在信号量上等待。主线程要放行时,调 ReleaseSemaphore(hGate, 8, NULL),一次性把计数加到8,8个线程全部被唤醒。如果只放行前3个,可以只 ReleaseSemaphore(hGate, 3, NULL),后5个继续等。这种“按需放行”是事件对象不太好做的。
c复制HANDLE hGate = CreateSemaphore(NULL, 0, 8, NULL);
// 每个工作线程:
WaitForSingleObject(hGate, INFINITE);
// 主线程某个时刻:
ReleaseSemaphore(hGate, 8, NULL);
这里注意最大计数必须设成8以上,因为释放的计数总量和最大计数有关。如果最大计数只设了1,即使调 ReleaseSemaphore(hGate, 8, NULL) 也会因为溢出返回失败,闸门打不开。
5.2 限制同时运行的并发任务数量
信号量最贴近日常用途的就是流量控制。比如一个爬虫程序要抓取一批页面,如果一次性开50个线程,网络连接和内存都可能扛不住。我通常的做法是维护一个“并发额度”信号量,最大计数设为8,每个任务线程开始前先等待一个计数:
c复制HANDLE hConcurrency = CreateSemaphore(NULL, 8, 8, NULL);
DWORD WINAPI TaskThread(LPVOID param) {
// 如果当前已有8个任务在跑,这里会阻塞
WaitForSingleObject(hConcurrency, INFINITE);
// 执行业务逻辑
DoWork();
// 无论成功失败,最后必须归还计数
ReleaseSemaphore(hConcurrency, 1, NULL);
return 0;
}
这种模式的好处是:线程池可以开很多,但真正并发执行的任务始终不会超过8个,任务完成的瞬间计数归还,排队的任务立刻补位。相比“一开始就固定创建8个线程”的做法,动态任务队列配合固定并发额度更灵活,任务数可以远远大于线程池大小。
5.3 三个组合使用的心得
把这些东西串起来之后,我有几条长期有效的心得。
第一,等待顺序一旦涉及多个同步对象,必须全局一致,而且“先信号量后锁”基本是通用解法。信号量解决“是否有资源”,锁解决“资源谁访问”,把锁放在信号量之后,能减少持有锁等待资源的时长。
第二,信号量的计数和代码路径必须严格成对。每有一个地方 WaitForSingleObject 成功,就必然有一个对应的 ReleaseSemaphore,而且要在同一个函数或同一个生命周期管理逻辑里闭合。我在长期维护的项目里见过信号量计数漂移,最后定位到原因就是某个提前 return 分支漏了释放调用,导致后续所有任务都饿死。
第三,调试时多观察计数。“计数涨不上去”通常能快速判断是释放少了还是等待多了;配合 lpPreviousCount 打印日志,几轮运行下来规律就出来了。我在自己写测试时专门写了个小函数输出信号量当前计数:
c复制void DumpSemaphore(const char* tag, HANDLE hSem) {
LONG count = 0;
// 通过ReleaseSemaphore释放0个来读取当前计数是行不通的,
// 实际调试时我用WaitForSingleObject+ReleaseSemaphore组合来探测,
// 更省事的做法是在每次业务操作里把lpPreviousCount直接打出来。
}
这个函数只是示意,正确的做法还是在每次 ReleaseSemaphore 时拿到 lpPreviousCount,或者用一个原子变量在业务层面对计数做影子记录。真要我推荐,影子记录最省心——它不依赖系统调用,调试信息更直观。影子变量虽然不算内核对象的一部分,但在排查逻辑问题时非常够用。
从第一次写信号量代码到现在,我对它的态度从“这玩意真简单”变成了“越用越小心”。它表面上只是个计数器,可一旦和互斥锁、事件排在一起用,各种交互细节就冒出来了。这篇笔记里的代码和踩坑经历,都是我在实际项目里跑过的,希望它能帮你少走几段弯路。如果你也在 Windows 下写并发程序,建议把生产者消费者这个例子自己从头敲一遍,再试着把闸门和流量控制两个模式改造成自己的工具函数,跑通几次之后,信号量基本就变成顺手的东西了。
