一直觉得线程同步是Windows编程里最容易"看着明白、写着拉胯"的一章。互斥量、临界区、信号量这些概念背得滚瓜烂熟,真到项目里要协调多个线程的工作节奏,经常还是不知道怎么下手。尤其是事件(Event),我见过不少同事把它当成"升级版的锁"来用,结果要么线程卡死,要么该醒的醒不过来,调试到怀疑人生。
这篇笔记不打算把MSDN抄一遍,而是按我自己的学习路径来写:先搞清楚事件和锁的本质区别,再讲清楚自动重置和手动重置到底怎么选,然后用几个真实场景把API串起来,最后把我踩过的坑和排查思路原原本本摆出来。适合刚啃完临界区和互斥量、想在多线程实战里把事件用明白的读者,也适合写过一阵子但仍对等待逻辑心里没底的开发者。
1. 事件与其他同步原语的本质区别:它不是锁,是"铃"
Windows提供了一大堆线程同步工具:临界区、互斥量、信号量、事件,还有后来的条件变量。新手最容易犯的错误,就是把它们归成同一类东西,觉得都是"让线程排队"的。这个理解在临界区和互斥量上确实成立,但事件完全不是这个逻辑。
事件(Event)本质上是一个状态标志,不是一道门。它的核心作用是"通知":一个线程负责把事件状态改成"有信号",其他线程等在"无信号"上,一旦收到信号就继续往下走。这就好比一个闹铃——你要等的不是"能不能通过"的许可,而是"时机到了"的提醒。
这个区别非常关键,直接决定了使用方式:
- 锁类原语(临界区、互斥量):保护共享资源,同一时刻只允许一个线程进去操作。操作完必须释放,否则别人永远进不来。
- 信号量:控制同时访问的线程数量,像一个带计数器的门卫。
- 事件:纯粹负责发信号通知。它不保护任何数据,也不限制并发数量,它只回答一个问题——"我要等的那个时刻,到了没有?"
我在项目里见过最典型的误用:两个线程共享一个缓冲区,开发者用事件当锁使,线程A写数据前SetEvent,线程B读数据前WaitForSingleObject。第一次跑看起来还行,第二次就出诡异Bug——因为事件被设置了之后不会自动撤销(如果是手动重置事件),B线程Wait直接通过,根本起不到"等一下再读"的作用。
1.1 自动重置与手动重置:这是个根本性抉择
CreateEvent的第二个参数bManualReset,几乎决定了这个事件会怎么影响你整个程序的节奏。这个参数名字容易让人误解,它不是"是不是手动触发"的意思,而是事件被触发后,WaitForSingleObject返回时,事件的状态要不要自动清掉。
自动重置事件(bManualReset = FALSE):某线程SetEvent后,只要有一个等在WaitForSingleObject上的线程被唤醒,事件状态立刻自动变回"无信号"。其他线程再Wait就继续等待。这个语义可以拆成四个字:一个萝卜一个坑。
手动重置事件(bManualReset = TRUE):SetEvent之后,事件一直保持"有信号"状态,哪怕是好几个线程依次Wait都能立刻通过。直到有人主动调用ResetEvent,它才会重新变回"无信号"。说白了就是:一响响一片,手动才关。
如果你需要"多个工作线程同时开工"这种场景,比如主线程发一个信号,所有干活线程一起往下走,那就要用手动重置事件。如果你需要的是"两个线程轮流交替执行"这种严格的接力赛,那必然要用自动重置事件。
1.2 初始状态给不留信号,直接影响第一次等待
CreateEvent的第三个参数bInitialState,是很多人写代码时顺手填FALSE但其实没想清楚的地方。
如果填TRUE,事件创建出来就处于"有信号"状态。这意味着如果某线程立刻去Wait,它会直接通过,不会阻塞。这个特性可以被刻意利用,也可能成为隐蔽的Bug源头——比如你希望线程启动后先原地等待,结果因为初始状态给成了TRUE,线程'嗖'地一下就窜出去了,时序全乱。
我的习惯是:除了一种情况外都填FALSE。那唯一的情况是:你要模拟"一开始就有消息可处理"的初始条件,或者某个标志位本身就默认是"已就绪"。大多数同步场景,老老实实从FALSE开始,让程序的控制流从第一行代码起就保持在你的预期之内。
2. 核心API的语义拆解:CreateEvent、SetEvent、ResetEvent、CloseHandle
很多人学API只记参数表,不记返回值和语义细节。这在写业务代码时问题不大,一写到底层一点的逻辑就露馅。我建议把每个API当成一句"带返回值的承诺"来理解。
2.1 CreateEvent:事件刚出生时的全套配置
HANDLE CreateEvent( LPSECURITY_ATTRIBUTES lpEventAttributes, // 安全描述符,传NULL即可 BOOL bManualReset, // FALSE=自动重置,TRUE=手动重置 BOOL bInitialState, // FALSE=无信号,TRUE=有信号 LPCSTR lpName // 事件的名字,跨进程用,不跨进程就NULL );四个参数里,第二个和第三个才是真正的逻辑核心。第一个几乎永远传NULL,第四个名字参数则是跨进程同步的唯一途径。
有个细节值得注意:CreateEvent返回的句柄不能和CloseHandle随便配合。如果你传了lpName去创建一个命名事件,之后GetLastError有可能返回ERROR_ALREADY_EXISTS,说明这个事件之前已经被创建过,你拿到的是打开操作的结果而非新建结果。这时候如果代码路径不同,你可能会在逻辑上做出错误判断。跨进程场景下尤其要想清楚:到底是"创建者"还是"打开者",身份不同,对事件生命周期的责任也完全不同。
2.2 SetEvent与ResetEvent:触发与熄灭的时机陷阱
BOOL SetEvent(HANDLE hEvent); BOOL ResetEvent(HANDLE hEvent);SetEvent的语义是"把事件状态置为有信号"。对于自动重置事件,这个"有信号"是瞬间的——它只够唤醒一个等待线程,然后立刻被消耗掉。对于手动重置事件,"有信号"是持久的,直到ResetEvent被调用。
这里必须提一个很多初学者完全没意识到的坑:自动重置事件下,SetEvent不一定保证你的线程能等到。
考虑这个时序:线程A执行SetEvent,同一瞬间线程B恰好在执行WaitForSingleObject。如果B是"先Wait再被唤醒",没问题。但如果是"你先判断了某个标志,发现条件不满足,正准备去Wait",而SetEvent恰好发生在这个间隙里,那么B会进入等待之后,事件却已经变成了无信号——因为A的信号被"消耗"了,但没有任何线程等在Wait上。这就是经典的事件信号丢失(Lost Wakeup)。
所以正确做法是:先保证线程已经进入等待状态,再由另一方发信号。这句话说起来简单,做起来需要靠其他同步原语配合,后续实战部分会专门演示。
ResetEvent的坑则相反——手动重置事件容易"忘记关"。你把事件Set了,所有线程哗啦一下全过去了,但如果没有人调用ResetEvent,这个事件会一直保持有信号。下次你想再用它做同步,所有线程会直接穿透,程序运行得像开了加速器一样,Bug特别难查。我的习惯是:手动重置事件每次用完之后,在确定没人在等的那一刻立刻Reset,宁可多Reset一次,也不要让它悬空。
2.3 WaitForSingleObject的返回值:超时不是终点
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);返回值只有三种可能:WAIT_OBJECT_0(等到了)、WAIT_TIMEOUT(超时)、WAIT_FAILED(出错,比如句柄非法)。WAIT_ABANDONED是互斥量专属,事件不会出现。
我见过最多的低质量代码是这样的:
WaitForSingleObject(hEvent, INFINITE); // 直接往下走INFINITE等待只适用于两种情况:一是程序定义里没有任何超时概念,二是其他线程一定会发信号且程序必须依赖这个信号才能继续。但绝大多数生产场景都不是这样。更稳健的写法是给一个合理的超时,然后判断:
DWORD ret = WaitForSingleObject(hEvent, 3000); if (ret == WAIT_TIMEOUT) { // 处理超时:记录日志、清理状态、决定重试还是退出 } else if (ret == WAIT_OBJECT_0) { // 正常唤醒 }有人觉得超时是个麻烦事,因为多了一条要处理的路径。但恰恰是这个"麻烦路径",让你在程序卡死的时候能看见现场,而不是对着一个黑窗口发呆。我做调试时,只要怀疑线程卡住,第一件事就是把所有INFINITE等待都改成带超时版本,加上日志。这招帮我抓出过至少三次隐藏极深的死锁。
3. 四类经典同步场景的实战代码骨架
事件这个东西,光看API永远学不会。我不按API顺序讲,而是按场景讲,每个场景直接给可落地的代码骨架,附带为什么要这么写的逻辑分析。
3.1 单生产者单消费者的"接力赛":自动重置事件的正统用法
最经典的场景:一个线程产生数据放进某个槽位,另一个线程负责取走处理。两个线程必须严格交替,不能同时操作同一个槽位。
// 全局句柄 HANDLE g_hEventBufferReady; // 数据就绪信号 // 生产者线程 DWORD WINAPI ProducerThread(LPVOID lpParam) { for (int i = 0; i < 100; i++) { // 这里把数据写进共享槽位(单生产单消费,槽位只有一个时不需要加锁) g_sharedData = i; // 通知消费者:数据好了,可以来取了 SetEvent(g_hEventBufferReady); } return 0; } // 消费者线程 DWORD WINAPI ConsumerThread(LPVOID lpParam) { for (int i = 0; i < 100; i++) { WaitForSingleObject(g_hEventBufferReady, INFINITE); // 此时槽位里的数据一定是"新产生的",可以安全读取 printf("get: %d\n", g_sharedData); } return 0; }注意我特意没加锁,因为单生产者单消费者 + 单槽位的模型下,数据竞争可以被严格的时序避免。生产者在SetEvent之前已经写完数据,消费者在Wait返回的那一刻只会在数据写完的瞬间之后才读。这正是事件的核心价值——它能把两个线程的执行节奏卡到"我写完你才能读"。
如果你在这个场景下错误地用了手动重置事件,那消费者第一次Wait返回后事件还是亮的,第二次Wait直接通过,你会读到同一个旧数据两次,整个消费逻辑就错乱了。反过来,如果这个场景你非要加锁,代码会变得又绕又容易出错,完全没必要。
3.2 多线程同时开工的"总动员":手动重置事件的价值
有个场景在程序启动时经常出现:主线程做了大量准备,准备好了之后要让所有工作线程同时开始干活。每个线程的工作是独立的,不需要互相等待,唯一的要求是"等我准备完"。
如果这里用自动重置事件,那么SetEvent只能唤醒一个线程,其他线程还在蒙头睡。正确做法是手动重置事件 + 所有线程Wait同一事件。
HANDLE g_hEventStart; // 手动重置事件,初始无信号 DWORD WINAPI WorkerThread(LPVOID lpParam) { // 所有工作线程都等同一个信号 WaitForSingleObject(g_hEventStart, INFINITE); // 收到信号后,所有线程同时开工 DoWork(); return 0; } // 主线程 int main() { g_hEventStart = CreateEvent(NULL, TRUE, FALSE, NULL); // 手动重置,初始无信号 // 创建10个工作线程 for (int i = 0; i < 10; i++) { CreateThread(NULL, 0, WorkerThread, NULL, 0, NULL); } // 做准备工作... DoPrepare(); // 一声令下,全部开工 SetEvent(g_hEventStart); // 所有线程干完再继续(这里可以用WaitForMultipleObjects等待线程句柄) WaitForMultipleObjects(10, g_hThreads, TRUE, INFINITE); return 0; }这段代码里最关键的选择就是bManualReset = TRUE。如果换成FALSE,10个线程只有1个能被唤醒,剩下的9个永远卡死在等待里——这是新手最容易犯的隐蔽错误,因为程序不会立刻崩溃,只是功能上"只有一条线程在工作",日志和性能分析才能看出来。
3.3 生产者队列的"按批处理":事件加锁的经典配合
实际项目中很少是简单的单槽位。生产者和消费者共享一个队列时,你需要做两件事:保证队列操作本身的线程安全(用锁),同时让消费者在队列为空时睡大觉(用事件)。
这个配合逻辑很重要:事件本身不保护数据,锁保护数据,事件负责"什么时候该检查数据"。
std::queue<int> g_queue; CRITICAL_SECTION g_csQueue; // 保护队列的临界区 HANDLE g_hEventData; // 自动重置事件,表示"队列里有数据了" DWORD WINAPI ProducerThread(LPVOID lpParam) { while (true) { // 产生数据 int item = Produce(); // 入队:锁保护 EnterCriticalSection(&g_csQueue); g_queue.push(item); LeaveCriticalSection(&g_csQueue); // 通知消费者来取 SetEvent(g_hEventData); } return 0; } DWORD WINAPI ConsumerThread(LPVOID lpParam) { while (true) { // 先等数据信号 WaitForSingleObject(g_hEventData, INFINITE); // 这里还要再等锁,因为可能有其他生产者也在写队列 EnterCriticalSection(&g_csQueue); if (!g_queue.empty()) { int item = g_queue.front(); g_queue.pop(); LeaveCriticalSection(&g_csQueue); Process(item); } else { LeaveCriticalSection(&g_csQueue); } } return 0; }这个模式里的event有点微妙:它不一定和队列里的数据一一对应。可能一次SetEvent对应了两个入队操作,也可能信号来了但队列被另一个消费者取空了。所以消费者Wait返回后必须再检查一次队列是否为空,绝不能在信号触发后默认队列一定有货。
这个"信号可能多余但队列可能为空"的复杂性,就是为什么很多现代框架直接用条件变量替代了这个组合。但Windows原生开发里,事件加临界区组合依然是可靠且常见的方案,只要你理解了信号和数据的弱绑定关系。
3.4 跨进程事件同步:命名事件与OpenEvent
事件是少数几个不需要额外IPC机制就能跨进程同步的内核对象。原理很简单:进程A创建一个命名事件,进程B用同一个名字打开同一个内核对象,两个进程里的线程就能通过SetEvent和Wait互相通知。
// 进程A(创建者) HANDLE g_hEvent = CreateEvent(NULL, FALSE, FALSE, L"Global\\MyAppSyncEvent"); // 进程B(使用者) HANDLE g_hEvent = OpenEvent(EVENT_ALL_ACCESS, FALSE, L"Global\\MyAppSyncEvent"); if (g_hEvent == NULL) { // 打开失败,可能事件还没创建 }跨进程使用时有三个我亲测踩过的坑:
坑一:命名空间前缀。Global\和Local\前缀会影响事件在不同会话/权限下的可见性。服务程序创建的命名事件,普通用户进程用Global\才能打开;不加前缀默认为Session\,在会话隔离的环境下不同终端登录根本看不到对方。桌面程序之间相互通知,不加前缀通常没问题;一旦牵扯服务、系统级程序,Global前缀几乎必加。
坑二:权限问题。进程B如果没有权限打开进程A创建的事件,OpenEvent会返回NULL。跨用户、跨会话场景下需要调整安全描述符,这已经不是普通业务代码能搞定的范围了。我的建议是:如果跨进程事件需要处理UAC、服务权限这些,先评估现有的IPC框架(管道、共享内存、消息队列)是否更合适,别硬扛。
坑三:创建打开的顺序。如果进程B先启动,它OpenEvent时事件还不存在,返回NULL。一个经典的解决方案是:进程B用CreateEvent而不是OpenEvent来"打开"命名事件。如果事件已存在,CreateEvent会返回已有句柄,并且GetLastError返回ERROR_ALREADY_EXISTS。这样无论谁先启动都能拿到句柄:
HANDLE g_hEvent = CreateEvent(NULL, FALSE, FALSE, L"Global\\MyAppSyncEvent"); if (GetLastError() == ERROR_ALREADY_EXISTS) { // 事件是别人创建的,但句柄照样能用 }4. 高频踩坑实录:信号丢失、死锁与复位遗忘的完整排查链路
这一节是我最想写的部分。前几节的内容几乎所有教材和文档都会讲,但踩坑经历才是真正从实践中得来的。每个坑我都会按"现象 → 排查过程 → 根因 → 解决"的方式完整展开,让你以后遇到类似问题时能直接复现我的排查思路。
4.1 信号丢失:生产者发射了,消费者错过了
这是我在一个模拟项目X里遇到的真问题。业务模型是:网络模块负责收包,解析模块负责处理。网络线程收完一个包,SetEvent发信号;解析线程等待并处理。逻辑听起来无懈可击,但运行几分钟后,解析线程就再也不处理数据了,程序像死了一样。
排查过程:我先是给两个线程都加了日志,看事件触发的时刻和解析线程Wait的进入时刻。结果发现一个很有意思的现象:日志显示SetEvent已经执行了,但解析线程的Wait一次都没返回过。也就是说信号发了,但没唤醒任何人。
再细看日志,让我找到了根本原因:某些时刻,网络线程收到两个包的时间间隔非常短,第一次SetEvent唤醒了解析线程,解析线程还没处理完第一个包,网络线程又SetEvent了第二次。但这个时候解析线程根本不在Wait上,它在处理数据。对于自动重置事件,第二次SetEvent的信号没人消费,就永远丢失了。
根因:自动重置事件在"没人等待时被Set"后,事件状态立刻变回无信号,这个信号就彻底没了。等解析线程处理完上一个包,回来执行Wait时,它等的是一个永远等不到的信号。
解决方案:生产者的信号丢失,必须靠额外的状态标志位补上。经典做法是:在SetEvent前,把共享数据里的"待处理数量"加一(需要锁保护),然后SetEvent后消费者Wait返回后检查待处理数量,不为零就继续处理,直到清零才再次Wait。这本质上回到了第3.3节的"信号与数据弱绑定"模型。
是个很深刻的教训:事件信号不是消息队列,它不会累积。指望每次生产事件都能被精确消费,本身就是错误的设计假设。
4.2 手动重置事件忘记Reset:程序运行像开了加速器
另一个项目里,我用手动重置事件做多线程任务分发。现象非常有意思:程序刚启动时一切正常,跑着跑着所有线程的执行频率突然狂飙,日志刷新快到肉眼跟不上的程度。
排查过程:我一开始怀疑是循环里没加延时,反复检查业务逻辑没发现问题。后来把事件状态单独打印出来看,发现它一直是"有信号"状态。顺藤摸瓜找到了源头:某次逻辑分支里,我调用了SetEvent,但整个分支流程提前return了,跳过了后面的ResetEvent调用。
根因:手动重置事件被设置后,如果没人重置,它会一直保持有信号。所有等待线程的Wait都会立即通过,相当于同步机制完全被旁路了。你的程序不会崩溃,只是所有靠事件控制的节奏全部失效,表现就是运行得像脱缰野马。
解决方案:手动重置事件的使用要养成两个习惯:
- 尽量缩短有信号状态的窗口期。SetEvent后,在下一个能确定所有等待线程都通过了检查点之后,立刻ResetEvent。
- 在逻辑分支多的代码里,用RAII风格封装:构造时关联事件,析构时如果事件处于有信号状态就Reset,保证提前return也不会忘。
养成习惯之后,我再没有因为这个问题翻过车。
4.3 死锁:事件与锁的等待顺序不一致
一个相对隐蔽的死锁场景:线程A持有某把锁,等一个事件;线程B持有另一个事件(在SetEvent之前需要拿到同一把锁),结果两边互相等,直接死锁。
排查过程:现象是程序完全卡死,用调试器挂上去,查看所有线程的调用栈,发现线程A停在一个WaitForSingleObject上,线程B停在一个EnterCriticalSection上。再往上翻代码,才看清了交叉等待的路径。
根因:事件同步永远不要持锁等待。你在等事件的时候,等于告诉系统"我这把锁不松,别人也别想用这把锁"。如果那个"别人"恰好就是发信号的人,死锁必然发生。
解决方案:核心原则是——等事件之前释放所有锁,等到了之后重新拿锁。这和第3.3节的写法是一致的,建议严格遵守。拿锁 → 改共享状态 → 放锁 → 等待事件,这样的顺序能最大程度避免交叉等待。
4.4 虚假唤醒:某些场景下Wait返回但条件不成立
事件不像条件变量那样有系统级的虚假唤醒(spurious wakeup)问题,但逻辑上的虚假唤醒照样存在。比如多个消费者等待同一个自动重置事件,事件被Set一次只唤醒一个消费者,这个消费者进入处理逻辑后却发现数据被别的线程拿走了,这时候它不能简单地重新等待——因为它不知道自己是不是唯一被唤醒的那个。
这个问题的本质是:事件只通知"可能有活干",不保证"一定有活干"。所以消费者被唤醒后的第一件事永远是检查条件,不满足就继续等待。我把这个检查称为"守卫条件",它必须和事件配合使用。
// 消费者 while (IsQueueEmpty()) { WaitForSingleObject(hEvent, INFINITE); // 注意:用while不用if }注意这个while。如果只用if,线程被唤醒后发现队列为空,继续往下走会处理到空数据。我的习惯是永远用while循环包住等待,而不是if,这样可以多一层保护——哪怕发生了信号丢失导致的异常唤醒,检查条件兜底。
5. 如何根据场景正确选择同步原语:对照表与决策思路
我经常收到类似提问:"事件和互斥量有什么区别?""我这个场景到底该用事件还是信号量?"
这些问题背后其实是对同步原语的定位没理清。我给出一个自己的决策公式:先想清楚你要解决的是"资源争抢"还是"节奏协调"。
- 如果是多个线程抢一个共享资源(同一份数据、同一个设备),用临界区(单进程内)或互斥量(跨进程)。
- 如果是控制最多N个线程同时访问某资源,用信号量。
- 如果是"做完某件事再继续""等到时机再动手"这类流程协调,用事件。
- 如果是复杂条件判断(队列非空、计数达到阈值)加等待,用条件变量(或事件加守卫条件的组合)。
| 场景 | 推荐原语 | 原因 |
|---|---|---|
| 保护计数器、链表、普通变量 | 临界区 | 开销最小,单进程内效率最高 |
| 跨进程保护共享资源 | 互斥量 | 命名互斥量可跨进程,且具备线程所有权 |
| 控制最多N个线程并发访问 | 信号量 | 带计数器,天然支持资源池化 |
| 单生产者单消费者接力 | 自动重置事件 | 严格交替,信号一次性消费 |
| 多线程同时开工 | 手动重置事件 | 信号广播给所有等待线程 |
| 复杂条件等待(队列等) | 条件变量,或事件+临界区 | 信号与条件需要动态匹配 |
在旁边补充一条经验:性能敏感的循环同步,优先考虑临界区和事件组合;需要复杂条件判断才考虑条件变量。Windows的条件变量在Vista之后可用,和老代码的兼容性不如事件组合,但语义上更贴合"先查条件再等待"的场景。如果你在维护一套老系统,事件加锁的方案往往改动最小、最稳妥。
还有一个容易忽略的点:事件的开销比临界区大。因为事件涉及内核对象,每次Wait和Set都伴随着用户态到内核态的切换。如果一个循环里频繁SetEvent + WaitForSingleObject,性能损耗会明显高于纯临界区方案。我测过一个极端场景:百万次事件同步比百万次临界区分段操作慢了接近一个数量级。所以在循环内部"每次迭代都要同步"的情况下,建议重新审视设计——能不能批量处理来减少同步次数。
6. 实战进阶级:如何用事件优雅地处理线程池优雅关闭
最后一个场景是我认为事件最有价值的应用之一:线程池的优雅关闭。这也是初学线程同步时很难设计好的问题。
最简单的关法:主线程直接TerminateThread。这是灾难性的,线程持有的锁、打开的句柄、正在写的数据全都进入未知状态。正确的关闭方式是:通知所有工作线程"别干活了,收拾一下自己的东西退出",然后等它们全部退出。
事件在这里扮演两个角色:
第一个角色:让工作线程感知到"程序要关了"。用两个手动重置事件:一个控制"正常运行",一个控制"应该退出"。
第二个角色:等所有工作线程结束。这不是事件干的活,而是用WaitForMultipleObjects等待线程句柄。
HANDLE g_hEventExit; // 手动重置,初始无信号,控制退出 HANDLE g_hEventWork; // 手动重置,初始无信号,控制开工 DWORD WINAPI WorkerThread(LPVOID lpParam) { WaitForSingleObject(g_hEventWork, INFINITE); // 等开工指令 while (true) { // 每次循环先检查退出信号 if (WaitForSingleObject(g_hEventExit, 0) == WAIT_OBJECT_0) { break; // 收到退出指令,优雅退出 } // 干活 DoSomething(); // 稍微让出CPU,避免忙等待 Sleep(10); } // 清理资源 Cleanup(); return 0; } // 主线程关闭流程 void Shutdown() { SetEvent(g_hEventExit); // 1. 通知所有线程退出 // 2. 等待所有工作线程结束 WaitForMultipleObjects(g_nThreadCount, g_hThreads, TRUE, INFINITE); // 3. 关闭句柄 CloseHandle(g_hEventExit); CloseHandle(g_hEventWork); }这里的巧妙之处在于用WaitForSingleObject(g_hEventExit, 0)来"探测"退出信号。超时设为0,如果事件有信号就立即返回WAIT_OBJECT_0,否则立即返回WAIT_TIMEOUT。这是一种非阻塞的轮询检查,既能让工作线程在空闲时感知退出事件,又不会让线程在Wait上卡死导致无法响应退出。
注意轮询的时候加了Sleep(10),这是刻意为之的。如果循环里没有任何等待或睡眠,线程会疯狂空转,吃掉一整颗CPU核心。Sleep(10)把检查频率控制在100Hz左右,对退出响应时间的要求来说完全够用。
还有一个小细节:要不要把g_hEventExit也用手动重置事件?如果要,一旦设置就永久保持有信号,所有工作线程都会在下一轮循环退出,这正是我们想要的。如果你用自动重置事件,可能会遇到"主线程Set了两次,某线程还没检查过一次就浪费了一个信号"的情况,退出不完全。我强烈建议在这个场景使用手动重置事件,语义最稳妥。
说实话,线程同步这部分内容我没有写过比事件更"看着简单、用着复杂"的API。它的API形状非常简单,总共就那么三四个函数,但真正的难度全在设计判断上:用自动重置还是手动重置?信号发了没人等怎么办?等到了之后要不要再检查条件?这些问题不亲自踩一遍,光看文档很难有体感。
按照我个人经验,建议你拿到一个同步场景时,先用一句话描述清楚"谁在等谁、等到之后做什么"再动手写代码。描述不清楚就说明你的线程模型还没梳理明白,这时候怎么写都会出Bug。事件只是个工具,能不能用好,取决于你对线程之间协作关系的理解深度。
最后再用一个小技巧收尾:写代码的时候,把每个WaitForSingleObject都当成一条"协议"来看——它左边是等待方,右边是通知方,中间是"信号的含义"。把这个协议写进注释里,哪怕三个月后回来看代码,也能一眼看懂当初的设计意图。这个习惯帮我省下了大量"这行代码在等什么"的翻旧账时间。