☰
Windows信号量机制详解:从原理到多线程限流实战
2026/10/21 12:14:53 网站建设 项目流程

先说一个大实话:Windows 下想管好多线程和共享资源,很多人第一反应就是锁、临界区、事件,信号量反而成了“听说过但一直没搞明白”的那个。我最早接触信号量,是在一个服务端项目里排查“服务偶尔卡死”的问题,折腾半天发现根因不是死锁,而是对信号量的计数语义理解偏了。从那天起我才真正意识到,信号量这个东西的用法比大多数人以为的宽得多,也坑得多。

这篇内容我打算把 Windows 信号量讲透:它和锁、事件到底有什么区别,内部模型长什么样,核心 API 怎么用,经典场景怎么落地,以及我实际踩过的一个“限流变卡死”的坑。如果你在用 C/C++ 写 Windows 多线程程序,或者正准备面试操作系统并发这一块,这篇应该能帮你省不少时间。

1. Windows信号量是怎样一种“带计数的闸机”

1.1 一个场景:为什么只有锁不够用

假设你写了一个下载模块,里面有 20 个线程同时往磁盘写文件。如果你只给每个文件加锁,逻辑上没错,但你控制不了“同一时刻到底有多少个线程在真正干活”。锁保证的是互斥,说白了就是“只能一个人进”,可很多业务场景要的不是“一个人进”,而是“最多放 N 个人进来,其他人门口等着”。

这就是信号量和普通锁最本质的差异:锁强调的是排他性,信号量强调的是容量限制。信号量内部维护了一个计数器,每次有线程进入就减一,每次有线程出来就加一,计数一旦归零,后面的线程就只能在等待队列里排队。把它理解为景区门口的闸机最形象——闸机本身不关心你是谁,只关心“还能放几个人进去”。

围绕这个核心模型,Windows 把信号量做了一个比较厚实的封装,既有老牌的内核对象,也有轻量级实现。下面我从原理到形态给你拆开讲。

1.2 计数器加等待队列:信号量的核心模型

信号量的概念最早是荷兰计算机科学家提出的,用在操作系统进程调度里。Windows 实现它的方式并不神秘:一个非负整数计数器、一个等待队列、两个原子操作:等待(wait)和释放(release)。

等一个信号量时,系统先把计数器减一,如果减完之后大于等于零,线程就直接通过;如果减完之后变成负数,线程就被挂到等待队列里,进入阻塞状态。释放时计数器加一,然后系统从等待队列里唤醒一个线程继续执行。

这里有个容易误解的地方:计数器的值变成负数,不代表出错了,而是“有多少个线程正在排队”的统计。比如初始计数是 3,五个线程同时来等,计数器会变成 -2,意思就是 3 个线程进去了,2 个线程在门口候着。真正决定“允许多少个线程同时进来”的,是创建信号量时指定的最大计数,而不是初始计数。

Windows 的信号量是一个内核对象,也就是说它不仅仅能在进程内用,还能跨进程共享。你给它起个名字,另一个进程用同样的名字打开,就能拿到同一个内核对象的句柄。这一点和临界区这种纯用户态对象有本质区别,后面我会专门对比。

1.3 内核信号量与轻量级信号量:两种形态的取舍

Windows 从 Vista 开始提供了两类信号量:一类是传统的内核信号量,通过 CreateSemaphore 创建,句柄可以被命名、可以被跨进程等待;另一类是用户态的轻量级信号量,叫 PSSemaphore,没有内核对象那么“重”,但只能在单个进程内部使用。

做个表格对比一下,会更直观:

对比项内核信号量(CreateSemaphore)轻量级信号量(PSSemaphore)
创建方式CreateSemaphore / CreateSemaphoreExPSCreateSemaphore
跨进程共享支持,可按名称共享不支持,仅限本进程
等待接口WaitForSingleObject / WaitForMultipleObjectsPsWaitForSemaphore
开销相对较高,每次等待释放都要进内核较低,通常驻留用户态
适用场景多进程协作、进程间资源计数进程内高频计数场景,比如游戏引擎内部

我自己的习惯是:如果只是进程内做资源限流,能用轻量级就尽量用轻量级;一旦涉及跨进程(比如多个进程同时消费同一个任务队列),直接上内核信号量,省得自己维护文件锁。性能上,在绝大多数业务场景里两者的差异根本感知不到,真正要关心的反而是 API 语义有没有用对。

2. 信号量管理线程与资源的三种典型打法

2.1 限流:最多 N 个线程同时访问共享资源

信号量最经典的应用就是限流。比如你的程序里有一段调用第三方服务的逻辑,这个服务只允许同时 5 个请求进来,你开 20 个 worker 线程处理任务,怎么保证并发不超?最省事的办法就是创建一个最大计数为 5 的信号量,每个线程在发起请求前等信号量,请求结束后释放信号量。

代码写出来大概是这样:

// 初始计数5,最大计数5,匿名信号量 HANDLE g_hSem = CreateSemaphore(NULL, 5, 5, NULL); if (g_hSem == NULL) { // 创建失败处理 } // worker线程内,每次调用第三方服务前 DWORD waitResult = WaitForSingleObject(g_hSem, INFINITE); if (waitResult == WAIT_OBJECT_0) { // 拿到“通行证”,可以发起请求 CallRemoteService(); // 请求结束,释放通行证 ReleaseSemaphore(g_hSem, 1, NULL); }

这里有两个细节需要提醒。第一,初始计数和最大计数都设为 5,表示“一开始 5 个空位都在”。如果你把初始计数设成 3,最大计数设成 5,那前三个线程能直接通过,第四个开始排队,但是整体并发上限仍然是 5,只是初始可用额度少了。第二,一定要保证 ReleaseSemaphore 在每次获取之后都被调用,最好用 RAII 封装,否则任何一个提前 return 的路径都会导致计数泄漏。计数一旦泄漏,时间长了并发上限就越来越低,程序会“莫名其妙变慢”。

2.2 生产者-消费者的计数穿插

生产者-消费者模型里,信号量有两种非常自然的用法:一个信号量记录“缓冲区里有多少个空闲位置”,另一个信号量记录“缓冲区里有多少个已填充数据”。

一开始 I 号信号量等于缓冲区大小,比如 100;E 号信号量等于 0。生产者往缓冲区放数据前,先等 I 号信号量,放完后释放 E 号信号量;消费者正好反过来。这样生产者和消费者之间不需要显式加锁沟通缓冲区剩余量,信号量的计数本身就把信息传过去了。

我第一次用这种方式写项目时,最直观的感受是:代码比用事件加锁的组合要干净很多。事件只能告诉别人“有货了”,但告诉不了别人“有几个货”;信号量天生就携带数量信息。这也是为什么你会看到很多任务队列实现里,阻塞等待队列的任务数时直接选信号量而不选事件。

2.3 句柄管理与资源闭环

信号量作为内核对象,用完之后必须 CloseHandle,不然就是句柄泄漏。你可能觉得这是废话,但我确实碰到过线上服务句柄数缓慢上涨的问题,排查到最后发现是某个异常分支把 ReleaseSemaphore 和 CloseHandle 都写漏了。

建议所有用到信号量的地方都按这三个步骤走:创建后立刻确认句柄有效;每次等待后按返回值分流处理;每个等待点都有配套的释放点。听起来简单,实际操作里最容易漏的就是“提前 return”和“异常跳转”这两个位置。你可以把整个获取、释放逻辑封装成一个类,析构函数里做释放,保证栈展开时一定会走释放流程。

3. 关键API的使用逻辑与参数陷阱

3.1 CreateSemaphore:三个参数决定三种行为

Windows 创建内核信号量的核心函数是 CreateSemaphore,原型是:

HANDLE CreateSemaphore( LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, LONG lInitialCount, LONG lMaximumCount, LPCSTR lpName );

第一个参数传 NULL 用默认安全属性,第三个参数传名字可以在跨进程时被其他进程打开。真正有陷阱的是第二个和第三个参数:初始计数的取值范围是 0 到最大计数之间,不能大于最大计数;最大计数必须大于 0。

如果你创建的是命名信号量,本地安全策略或权限不够,CreateSemaphore 可能失败,报错码是 ERROR_ACCESS_DENIED。注意一下:命名信号量如果已经存在,CreateSemaphore 并不会失败,而是直接返回已有对象的句柄,GetLastError 会变成 ERROR_ALREADY_EXISTS。很多人在这里栽过跟头,误以为每次调用都会生成新对象。

3.2 WaitForSingleObject 的返回值别只盯着成功看

等待信号量通常是 WaitForSingleObject,它返回 WAIT_OBJECT_0 时表示拿到了信号量。但 WAIT_TIMEOUT 也很常见,尤其是你给了超时时间后,此时绝对不能再往下执行“已经拿到通行证”的逻辑。

还有一点容易忽略:信号量不会出现 WAIT_ABANDONED 状态。互斥量如果被线程持有期间线程挂了,系统会返回 WAIT_ABANDONED,帮你识别“互斥对象被遗弃了”;信号量没有这种机制,它的内核对象生命周期和线程没有绑定关系。这个特性在日常调试时非常有用——如果你发现等待返回异常状态,优先怀疑的应该是句柄关闭时序,而不是对象被遗弃。

如果你需要同时等待多个信号量,可以用 WaitForMultipleObjects。注意它的默认行为是等待“所有对象都被触发”,还是“任一对象被触发”,由第三个参数 fWaitAll 决定。实际操作里,我建议把同时等多个信号量的场景拆开,尽量改成一次只等一个,因为多个信号量同时等时很容易由于超时时间设置不合理导致排队线程饥饿。

3.3 ReleaseSemaphore:计数“加法”比想象中更容易出错

释放信号量的函数是 ReleaseSemaphore,它有两个容易出错的点。

第一,释放数量不能超过最大计数。假如最大计数是 5,当前计数是 4,你一次 ReleaseSemaphore(hSem, 3, NULL),这个调用会失败,返回 FALSE,计数保持不变。系统不给你“超额放行”的机会,这是好事,但如果你用的是循环释放,就要小心循环次数和剩余空间之间的关系,别在最后一轮踩到返回 FALSE。

第二,第三个参数 lPreviousCount 可以用来获取释放前的计数。这个参数很有用,比如你需要判断“当前是不是已经没资源了”,可以先读这个值,不用额外引入共享变量。我们项目里做监控告警时,就靠它判断资源池的使用水位,省掉了一次额外的原子操作。

LONG prevCount = 0; BOOL ok = ReleaseSemaphore(g_hSem, 1, &prevCount); if (ok && prevCount == 0) { // 说明释放之前信号量计数已经归零 // 这里可以做“从空转非空”的额外处理 }

4. 一次真实排查:并发限流下服务“卡死”的根因

4.1 现象:请求越积越多,服务像被冻住

有段时间我们某个内部的批量任务系统 CPU 不高、内存也有余量,但吞吐量突然掉到接近零,新任务一直排队。从现象上看很像死锁,但线程池监控显示 worker 线程并没有互相持有锁等待,更像所有人都在等同一个东西。

我第一反应是检查每个 worker 的调用栈。抓下来的栈里,大量线程阻塞在 WaitForSingleObject 上,等待的句柄指向同一个内核信号量。这个信号量是用来给“写数据库”操作限流的,最大计数设的是 8。理论上最多 8 个线程在写库,其余等待,不该出现“卡死”的感觉。

4.2 排查链路:从线程栈一步步追踪到计数泄漏

顺着 WaitForSingleObject 的调用点往上看,我发现每个业务线程在写完数据库之后确实有 ReleaseSemaphore,但数据库访问被封装在一个基础库里,这个库内部有一个异常预处理流程:部分 SQL 执行失败时,它会在写日志后提前返回,而提前返回的路径上漏掉了 ReleaseSemaphore。

理解一下这个过程:高并发时段,一批 SQL 超时失败,每个失败任务都拿着信号量计数不放手,可线程本身已经返回去接新任务了。于是一个信号量被越占越多,最多 8 个配额,失败几次就全占完了。后续线程全部阻塞在等待上,表现为“任务不执行但线程也没闲着”。

为了验证这个假设,我临时在工作线程里加了日志,打印每次 Wait 之前的当前累计成功数、失败数和 Release 调用数。结果十分明显:Release 数比 Wait 成功数少了将近三分之一,而且少掉的这部分恰好和 SQL 失败次数吻合。

4.3 修复:用 RAII 封住所有出口

根因确认后,修复方案很直接:把“获取信号量 + 数据库调用 + 释放信号量”改成一个完整的 RAII 作用域。获取信号量对象包装成一个局部对象,析构函数里统一做 ReleaseSemaphore,这样不管是正常结束、异常捕获、还是中途 return,释放逻辑都会在栈展开时执行。

同时我在 ReleaseSemaphore 调用之后加了一层断言式检查,用 lPreviousCount 判断释放前的计数是否等于最大计数,如果是,说明逻辑上出现了“没有获取就释放”的迹象,直接写错误日志并终止该任务后续流程。

4.4 复盘:哪些经验可以前置

事后复盘,这个坑本质上不是信号量的问题,而是“资源获取后必须释放”这个铁律被函数内部的提前返回违反了。如果你也打算用信号量做限流,有几件事值得提前做:

  • 把获取和释放封装在同一个类或函数范围内,杜绝跨着函数边界做释放;
  • 每次 Release 都检查返回值,释放失败说明你的计数状态已经和预期不符;
  • 在开发阶段给信号量加个数量的监控日志,比如每 10 秒打印一次当前计数,方便问题发生时回溯;
  • 不要试图用线程数估算当前信号量计数,二者的关系在异常分支里会被完全打乱。

你一定觉得这些都是老生常谈,可很多线上问题恰恰就出在“老生常谈”没被严格落实。

5. 信号量、互斥量、事件与临界区:到底怎么选

5.1 四种同步原语的核心差异

Windows 下常见的同步原语有临界区(CRITICAL_SECTION)、互斥量(Mutex)、事件(Event)和信号量(Semaphore)。它们各有各的设计目标,混用才会出问题。

  • 临界区:进程内排他访问,性能最好,但无法跨进程。
  • 互斥量:跨进程排他访问,适合“只能一个进程拿到的资源”,也支持递归获取。
  • 事件:纯粹的通知机制,没有“权限计数”,适合“有货了通知你”这种一次性醒信。
  • 信号量:既有限流能力,又有计数信息,适合“多个额度”的场景。

一句话总结就是:排他选互斥量或临界区,通知选事件,限流选信号量。如果只允许一个线程进入,你还想用信号量,那也成立,但没必要,语义上不如互斥量表达得清楚。

5.2 现代 C++ 的 counting_semaphore 是否能替代

C++20 标准库引入了<semaphore>,提供 counting_semaphore 和 binary_semaphore。在最新的 MSVC 工具链里,这个类型已经可用,底层在 Windows 上通常会映射到轻量级信号量实现。

#include <semaphore> #include <thread> #include <vector> std::counting_semaphore<5> g_sem(5); // 最多5个并发 void Worker() { g_sem.acquire(); // 执行需要限流的逻辑 g_sem.release(); }

如果项目已经全面转 C++20,我倾向于直接用标准库,因为可读性和跨平台性都比 Windows API 强。但在老项目里、或需要命名信号量跨进程共享的场景,Windows API 还是不可替代的。两套并存时,建议你明确约定:进程内新代码一律用标准库信号量,跨进程代码才允许碰内核对象。

5.3 我在实际选型时的一些判断习惯

先说结论:能用 RAII 封装解决的,就别裸用 API;能用标准库表达语义的,就别自己包一层;确定是“只允许一个线程”的场景,直接用互斥量比强行套信号量更优雅。

我在实际项目里最常见的信号量用法就两个:一个是控制线程池对某个下游服务的并发请求数,另一个是给任务队列做缓冲水位控制。前者用最大计数直接限制并发度,后者用两个信号量分别管理“空位”和“数据”。

如果你刚接触信号量,我建议拿一个最简单的场景练手:限制并发线程数不超过 4,跑 50 个任务,线程里打印当前线程 ID 和任务编号,观察任务执行顺序和并发轨迹。跑通之后再去碰生产代码,会顺手非常多。

最后再补充一个小技巧:所有创建出来的信号量内核对象,在退出前都要及时 CloseHandle。跨进程共享的命名信号量,最后持有句柄的进程关闭后,内核对象才会真正销毁。提前关闭句柄不会立刻破坏其他进程正在进行的等待,但会让你的程序失去对该信号的后续管理能力。这两年我见过不少“偶发找不到信号量”的问题,追到底都是过早关闭句柄惹的祸。信号量是个好东西,但用之前,真的要先把它的计数语义和资源生命周期想清楚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询