更新记录(仅首版,2026年08月02日 发布)
- 首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175914)
以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。
Go Komura(2026)。《多线程实战最佳实践 C 语言篇——以 Win32 API 的方式安全编写》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/multithreading-best-practices-c/
- DOI(已登记存档)
- 10.5281/zenodo.22175914
- DOI(上次登记版本)
- 10.5281/zenodo.22175915
“用 C 写的常驻进程,只有退出时会卡住。”“想给老旧的设备控制应用加线程。”“现在用 TerminateThread 停线程,但有时整个进程都不动了。”在用 C 写多线程的现场,比起把处理并排跑起来,共享数据由谁守护、怎么等待、怎么结束才是真正的问题。
C 里没有像 C++ 的 RAII 那样、离开作用域就自动释放锁和句柄的机制。也没有异常和模板可以借力,因此光是选好 API 还不够,必须把获取、释放、等待结束的纪律落到代码结构里。
本文是多线程实战最佳实践系列的 C 语言篇。面向使用 C 和 Win32 API 的开发者,把避免滥造线程、减少共享可变状态、定好锁的纪律、先设计停止方式这几条原则,落到具体的 API 上。
本文涉及的内容是:用 _beginthreadex 创建线程、同步对象的选择、不依赖 TerminateThread 的协作式停止,以及 DllMain 的限制。说明所依据的信息时点为 2026 年 8 月。只读本篇也能看懂,此外还有用别的语言讲同一套原则的“.NET 篇”“C++ 篇”“Java 篇”。
按遇到的问题查阅
| 遇到的问题 | 首先要确认的事 | 阅读位置 |
|---|---|---|
| 想增加线程 / 想大量运行短任务 | 自建线程与线程池的分工 | 线程的创建方式 |
| 计数器对不上 / 共享数据被破坏 | 减少共享的设计,以及锁与原子操作的对应 | 减少共享状态、同步的选法 |
| 加了锁反而卡死 | 要保护的数据、持锁期间的处理、获取顺序 | 锁的纪律 |
| 退出时卡住 / 正在使用强制终止 | 是否把停止请求与结束确认分开,等待期间能否停止 | 协作式停止 |
| 明明用事件唤醒了,工作却分配不均 | 单工作线程与多工作线程在通知方式上的差异 | 停止示例的适用范围 |
| DLL 卸载时卡住 | 启动、停止、汇合是否都放在 DllMain 之外 | DllMain 的限制 |
| 想和 Linux 共用代码 / 无法复现故障 | 可移植性的要求,以及设计评审、日志、压力测试 | C11 这个选项、验证与调试 |
第一次做设计时,请用第 2 章掌握 C 的前提,用第 3~5 章确定执行单位和共享数据,再用第 6 章确认停止路径。检查既有代码时,最后的检查清单也能派上用场。
1. 先说结论
按任务性质选择执行的场所
会调用 CRT 的自建线程,用 _beginthreadex 创建。用 CreateThread 创建的线程如果调用 CRT,内存不足时 CRT 可能会终止进程。要把大量短任务并行化,就不要反复创建自建线程,而应使用 Windows 线程池的 CreateThreadpoolWork。123
不能用 ExitThread 或 TerminateThread 结束线程池的线程。正确的用法是:把作为回调借来的执行场所收拾干净,再还回去。4
把共享数据的保护与等待汇合分开
进程内的锁默认选 SRW 锁,需要递归获取时才选 CRITICAL_SECTION。高频的进程内互斥如果用 Mutex,就要背上内核切换的开销。5
单个变量的更新使用 Interlocked 系列函数。只加 volatile 并不能确保原子性和必要的同步。大多数 Interlocked 函数都带有完整的内存屏障。等待汇合使用条件变量,或者事件加等待函数,不要用 Sleep 轮询白白消耗 CPU 和响应性。67
停止方式与释放顺序,要在启动前定好
不使用 TerminateThread,而要设计基于停止事件和 WaitForMultipleObjects 的协作式停止。强制终止有破坏锁、堆、DLL 状态的风险,也是代码分析警告 C6258 的检出对象。不要只发出停止请求就去释放资源,而要确认线程已经结束之后再关闭句柄。89
不在 DllMain 里创建线程、做同步或等待线程结束。要在加载器锁之外准备显式的初始化函数和终止函数。10
需要可移植性时,C11 也是一个选项。按 2026 年 8 月时点的梳理,<threads.h> 从 VS 2022 17.8 起可用,<stdatomic.h> 仍是 experimental。仅面向 Windows 的代码,以本文使用 Win32 API 的方针为基本。11
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 23 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 多线程为什么难——竞态条件与死锁
首先要掌握的,是结果随执行顺序而变的竞态条件,以及等待成环后谁都无法前进的死锁。在记 API 之前,先把这两者区分清楚。
竞态条件(race condition)是指多个线程到达某段代码的先后顺序不同,结果就会改变的缺陷。
以共享计数器的 count++ 为例,即使只是一个表达式,没有同步时也不能保证“读取 → 相加 → 写回”会不可分地执行。两个线程读到同一个值,各自相加再写回,其中一次相加就会丢失。6 图 1 展示的就是本想从 10 加两次、结果却是 11 的执行顺序。
sequenceDiagram
participant A as 线程 A
participant M as 共享变量 count
participant B as 线程 B
Note over M: count = 10
A->>M: 读取(10)
B->>M: 读取(10)
A->>A: 在本地相加(11)
B->>B: 在本地相加(11)
A->>M: 写回(11)
B->>M: 写回(11)
Note over M: 加了两次 count 却是 11<br/>线程 A 的相加丢失了
图 1:共享计数器上相加丢失的典型竞态条件。count++ 的三个步骤之间被别的线程插入时,后写回的一方会覆盖先写回的结果
死锁是两个线程互相等待对方持有的锁,谁都无法继续前进的状态。线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1——仅此一点,两者就会永远停住。
flowchart LR
A["线程 A<br/>持有锁 1"] -->|"等待锁 2 被释放"| B["线程 B<br/>持有锁 2"]
B -->|"等待锁 1 被释放"| A
图 2:死锁的循环等待。等待的箭头一旦连成环,环中的所有线程就会永远停住
这类缺陷依赖时序。开发机上几乎不出现的执行顺序,在核心数和负载都不同的客户现场可能反复出现。挂上调试器就复现不了,也是因为观测本身改变了时序。
因此,后面的所有原则都从“减少需要同步的地方”开始,而不是先谈“正确地同步”。
2.1. C 特有的前提——语言什么都不替你保证
在 C 里,这组原则没有语言层面的“强制机制”,所以必须作为纪律明文写下来。
释放要连函数出口一起设计
因为没有与 C++ 的 RAII 对应的机制,锁的释放和句柄的 CloseHandle 都要自己保证。可以采用把函数出口收敛到一处的 goto cleanup 模式,或者规定获取与释放成对书写。要点在于:即使中途新增一个提前 return,释放也不会被漏掉。
即使简单读写是原子的,同步也要另外做
必须避免数据竞争这一点和 C++ 一样。在 Windows 上,经过适当对齐的 32 位变量的简单读写是原子的,但仅凭这一点并不保证访问的同步,也不保证前后内存操作的顺序。32 位 Windows 上的 64 位变量、读出后相加这类复合操作、多个变量之间的一致性,都不能和简单读写同等看待。12
不要把“碰巧能跑”当作依据,而要用锁或 Interlocked 明确写出必要的同步。即使编译器或优化级别变了,这条纪律依然需要。
所有权要写到交接的那一刻
在函数注释里写明“这个缓冲区由哪个线程写”“从什么时候起归谁所有”。所有权的纪律,和同步原语的选择同样重要。后面讲的分割处理和队列,也是先定下这条边界才能安全使用。
3. 线程的创建方式——只选 _beginthreadex
3.1. CreateThread 不行的理由
Win32 的原生 API 是 CreateThread,但官方指引是:调用 CRT(C 运行时)函数的线程要用 _beginthreadex 创建。_beginthreadex 会先初始化 CRT 为每个线程使用的内部数据,然后再开始执行。2
用 CreateThread 创建的线程如果调用 CRT 函数,内存不足时 CRT 可能会终止进程。1 printf、malloc、strtok 也都属于 CRT,所以实用上可以把“用 C 写的自建线程一律用 _beginthreadex”当作基本做法。
等待并关闭句柄,都是创建方的责任
_beginthread(不带 ex)也要避开。因为线程提前结束时,返回的句柄会失效,可能指向别的线程。应选择可以把句柄交给同步 API 的 _beginthreadex,由调用方等待其结束后再 CloseHandle。13
下面是展示创建与汇合流程的片段。WorkerContext 的定义、ctx 的初始化和失败处理都假定由应用一侧准备,它不是可以单独编译的完整程序。创建失败时不要继续进入等待;创建成功时,也要把工作线程使用的 ctx 一直保持有效,直到确认线程结束为止。
#include <process.h>
static unsigned __stdcall WorkerMain(void* arg)
{
WorkerContext* ctx = (WorkerContext*)arg;
/* ... 第 5 章、第 6 章的等待循环 ... */
return 0;
}
HANDLE hThread = (HANDLE)_beginthreadex(
NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* 失败处理 */ }
/* ...发出停止请求之后... */
WaitForSingleObject(hThread, INFINITE); /* 汇合 */
CloseHandle(hThread);
3.2. 短任务交给 Windows 线程池
如果是“想投入大量小任务”“反复创建又销毁短命线程”的情况,就使用 Windows 线程池。在 Windows Vista 之后的 API 中,用 CreateThreadpoolWork 创建工作对象,再用 SubmitThreadpoolWork 投入。回调由池中的工作线程执行,因此线程数的管理可以交给操作系统,不必为每件工作创建和销毁自建线程。3
借来的线程,状态要在返回前复原
不要用 TerminateThread / ExitThread 结束池中的线程;在回调里改动的 TLS、线程优先级等,要在返回前复原;等待句柄要一直保持有效,直到线程池用完为止——这些都是官方规定的纪律。4
只管理线程数,压不住投入量
即使把线程数的管理交给线程池,也防不住“未执行的回调无限堆积”这种设计。在投入持续超过处理速度的常驻进程里,要在应用一侧设置信号量之类的准入限制,或者带容量上限的队列。
还要定好队列满时是让投入方等待,还是直接拒绝投入。这是为了不把过载转嫁成内存消耗的背压,与第 5 章的有限缓冲区是同一种思路。
4. 把共享可变状态降到最低——分割、只读、交接
在选择同步原语之前,先想一想能不能减少多个线程接触同一份可变数据的地方。手段有三种:分割、改为只读、用队列交接。
按线程分割,最后再汇总
在并行汇总中,各线程不必每次都往共享计数器写入,而是在局部变量或专用缓冲区里做小计。最后只用 InterlockedAdd 之类汇总一次,就能把对共享数据的写入从“每次迭代一次”减少到“每个线程一次”。
这是既能降低同步开销、又能减少争用机会的做法。2.1 节定下的“这个缓冲区由哪个线程所有”,本身就构成了分割的设计。
初始化完成之后就只读
启动时构建、之后不再改写的配置和表,可以在初始化完成后由多个线程读取。不要让边界含混,要么在所有线程启动之前完成初始化,要么在延迟初始化时使用 InitOnceExecuteOnce。5
重要的不是“自以为是只读的”,而是在代码中明确写出初始化何时结束、从何时起不再改写。
把两边都动的共享变量改为用队列交接
线程之间的数据流动,与其让两边都去操作共享变量,不如收拢到生产者/消费者队列上。在 C 和 Win32 中,有把有限循环缓冲区和 SleepConditionVariableCS 组合起来的官方实现示例。7
只要给容量设上限,生产超过消费时生产方就会等待,这本身就是自然的背压。
5. 同步对象的选法与锁的纪律
Win32 的同步原语种类繁多,选错了会同时损害性能和正确性。这里把官方的指引汇总到一张图里。5
flowchart TB
S{"是否要跨进程<br/>同步?"} -->|"是"| Q2{"用途是什么?"}
Q2 -->|"互斥"| MTX["命名 Mutex"]
Q2 -->|"限制同时访问数"| SEM["命名信号量"]
Q2 -->|"事情发生的通知"| EVT["命名事件"]
S -->|"否(进程内)"| Q3{"是否需要同一线程<br/>递归获取?"}
Q3 -->|"是"| CS["CRITICAL_SECTION"]
Q3 -->|"否"| Q4{"是否是重视可移植性的<br/>C++ 代码?"}
Q4 -->|"是"| STD["std::mutex /<br/>std::shared_mutex"]
Q4 -->|"否"| SRW["SRW 锁(默认选项)"]
图 3:Win32 同步原语的选法。第一个分支是“是否跨进程”,要点在于不跨进程时就不要选内核对象(Mutex)
| 原语 | 作用范围 | 特点 | 适用场合 |
|---|---|---|---|
| SRW 锁 | 进程内 | 快(通常在用户模式内完成)、指针大小、不可递归 | 新代码的默认选择。用 AcquireSRWLockShared 还能做读取共享 |
| CRITICAL_SECTION | 进程内 | 快(自旋之后进入内核等待)、可递归 | 需要同一线程递归获取时 |
| Mutex | 进程内 / 进程间 | 始终是内核对象,速度慢 | 跨进程的互斥(命名),以及与 WaitForMultipleObjects 并用 |
| 信号量 | 进程内 / 进程间 | 内核对象 | 限制对资源池的同时访问数 |
| 事件 | 进程内 / 进程间 | 内核对象 | “发生了某件事”的通知(不用于保护数据) |
| Interlocked 函数 | 进程内(共享内存的话也可跨进程) | 无需加锁的原子操作 | 计数器、标志、指针替换6 |
内核对象并不是跨进程专用
图 3 想说明的要点是:进程内的锁,不要没有理由地选 Mutex。进程内的通知可以用事件,同时访问数的限制可以用信号量。第 6 章的停止事件,也是没有名字的内核对象。
另外,没有名字并不等于必然局限在进程内。通过句柄继承或 DuplicateHandle 复制,同一个对象也可以被多个进程使用。命名只是让别的进程能重新打开同一个对象的代表性手段之一。
Interlocked 要把操作、对齐、生命期分开考虑
让对单个变量的操作不可分
InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange 是把对单个变量的操作不可分地完成的函数。它们对应 .NET 篇的 Interlocked 类、C++ 篇的 std::atomic,而且大多数函数还带有完整的内存屏障。6
不要以为只加一个 volatile 就能得到原子性和必要的同步。要让多个变量整体保持一致时,就用 SRW 锁或 CRITICAL_SECTION 来保护。
保证目标变量的对齐
Interlocked 的操作对象必须对齐到自然边界。32 位值是 4 字节边界,64 位值是 8 字节边界。未对齐时的行为无法预测。12
不要把 #pragma pack 过的结构体、或者直接映射通信格式的缓冲区中的字段当作操作对象,计数器和标志要作为由编译器适当对齐的普通变量来准备。
指针替换并不保护旧数据的生命期
InterlockedExchangePointer 等函数保证不可分的,只是指针替换这一动作本身。读取方刚取到旧指针,写入方就完成替换并把旧的内存块 free 掉,读取方就会访问已释放的内存。
替换和回收是两个不同的问题。需要有一套让读取方用完之前不回收旧内存块的步骤,比如加锁,或者安全的引用计数管理。拿不准时,就用 SRW 锁来保护。
条件变量要在醒来之后再确认条件
在有限缓冲区的生产者/消费者队列里使用条件变量。用 InitializeConditionVariable 初始化,消费方用与 CRITICAL_SECTION 组合的 SleepConditionVariableCS 等待,生产方用 WakeConditionVariable 唤醒——这就是官方实现示例的形态。7 与 SRW 锁组合时则使用 SleepConditionVariableSRW。
重要的是醒来之后要在锁内重新确认条件,条件为假就再次等待,形成一个循环。既有没被通知就醒来的虚假唤醒,也有醒来时元素已经被别的消费者取走的情况。“被唤醒了”和“队列非空”不是一回事。
这是在 C 里搭出与 .NET 篇 4.3 的通道、C++ 篇第 4 章的 BlockingQueue 相同结构的工具。
5.1. 锁的纪律——不论选哪个都不变的三条原则
即使原语选对了,没有使用上的纪律也防不住竞争。
1. 固定数据与锁的对应关系
为每一组要保护的可变数据,对应一个 SRW 锁或 CRITICAL_SECTION。在接触这些数据的所有地方都取同一把锁。
在 C 里,在头文件中写上“这个结构体由 g_lockFoo 守护”的做法尤其管用。评审时也要把这种对应关系列成表来确认。
2. 持锁期间不做耗时处理和外部调用
锁里面只做所保护数据的读写。持锁期间的文件 I/O、网络通信、回调调用都会拉长持有时间。如果被调用方又去获取另一把锁,还可能造出图 2 那样的循环等待。
3. 固定多把锁的获取顺序
要取两把以上的锁时,就定义一个锁层级,让所有线程遵循同一个顺序。DLL 最佳实践文档也说明了获取顺序反转会招致死锁,以及必须始终遵循层级。10
6. 停止方式的设计——不使用 TerminateThread
6.1. TerminateThread 会破坏什么
TerminateThread 会不让目标线程执行用户模式的收尾工作就把它结束掉。它当时持有的状态,未必会以安全的形式被清理干净。8
| 被终止时所处的时机 | 可能发生的事 |
|---|---|
| 正持有临界区 | 锁不会被释放,其他线程会一直等下去 |
| 正在从堆中分配内存 | 堆锁残留,之后的内存分配会停住 |
| 正在操作 DLL 的全局状态 | DLL 内部的状态被破坏 |
官方把它定位为“只应在最极端情况下使用的危险函数”,代码分析也会作为警告 C6258 检出。89
在调查“偶尔整个进程就卡住”的应用时翻出 TerminateThread,在实务中是真的很常见的场面。一旦发现,它就是要修的对象。
6.2. 正确的形式:停止事件 + WaitForMultipleObjects
在协作式停止中,停止方不是去把线程抹掉,而是发出停止请求,让工作线程自己收拾干净再结束。定式是创建一个手动重置的停止事件,让工作线程同时等待工作的信号和停止的信号。C6258 的官方资料也介绍了监视事件并自行结束的方法。9
请把发出停止请求 → 工作线程收尾 → 确认线程结束 → 释放句柄和共享资源当作彼此分开的阶段来处理。仅仅把停止事件置为有信号状态,并不等于线程已经结束。
下面这段代码同样是用于说明的片段。事件的创建与失败确认、队列的互斥控制,以及 ProcessNextItem、LogLastError、Cleanup 的实现都省略了。请按“事件和队列在等待与处理期间不会被销毁”,以及下面要讲的面向单工作线程的工作通知这一前提来阅读。
HANDLE hStopEvent; /* CreateEvent(NULL, TRUE, FALSE, NULL): 手动重置 */
HANDLE hWorkEvent; /* CreateEvent(NULL, FALSE, FALSE, NULL): 自动重置。
接收时自动回到无信号状态(改成手动重置的话,一旦置位
之后等待就会直接穿过去,变成对着空队列反复打转的
忙等循环) */
static unsigned __stdcall WorkerMain(void* arg)
{
HANDLE waits[2] = { hStopEvent, hWorkEvent };
for (;;) {
DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (r == WAIT_FAILED) { /* 句柄无效等。放着不管就会全速空转 */
LogLastError(); /* 记录 GetLastError() 之后脱出 */
break;
}
if (r == WAIT_OBJECT_0) /* 停止请求 */
break;
if (r == WAIT_OBJECT_0 + 1) { /* 有工作 */
/* 也把停止事件传给 ProcessNextItem:单件内部要长时间等待时,
若在那里观测不到停止,整个关机就会被这一件工作扣作人质 */
while (ProcessNextItem(hStopEvent)) { /* 从队列处理一件。空则返回 FALSE */
/* 排空期间也要确认停止请求。忽略这一点的话,只要工作
还在不断堆积,就永远停不下来(停止被饿死) */
if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
break;
}
}
}
Cleanup(); /* 自己的收尾自己做 */
return 0; /* 自己结束 */
}
BOOL StopWorkers(HANDLE* threads, DWORD count)
{
BOOL ok = TRUE;
if (!SetEvent(hStopEvent)) { /* 停止请求没送到的话, */
LogLastError(); /* 就不能进入无限期的汇合 */
return FALSE;
}
/* 所有线程的停止请求已经一起立起来了 */
for (DWORD i = 0; i < count; i++) {
if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
CloseHandle(threads[i]); /* 只关闭确认汇合成功的那一个 */
} else {
LogLastError(); /* WAIT_FAILED: 句柄无效等 */
ok = FALSE; /* 不报告“全部已停止” */
}
}
return ok; /* 为 FALSE 时不能继续去释放共享资源 */
}
汇合要确认等待成功之后再往前走
StopWorkers 首先确认 SetEvent 是否成功。停止请求都没送达,就不应该进入无限期的汇合。对每个线程也一样,只关闭 WaitForSingleObject 返回 WAIT_OBJECT_0 的那些句柄,等待失败时不报告“全员已停止”。返回值为 FALSE 时就不去释放共享资源——这才是正确的用法。
之所以把结束等待拆成一个一个来做,是因为 WaitForMultipleObjects 一次能等待的句柄数有 MAXIMUM_WAIT_OBJECTS(64 个) 的上限。数组超过上限时,等待可能返回 WAIT_FAILED。如果只是等所有线程结束,逐个等待的循环就不受这个数组长度的限制。
flowchart TB
OWNER["停止方"] -->|"SetEvent(hStopEvent)"| SE["停止事件<br/>(手动重置,全员可见)"]
SE --> W1["工作线程 1<br/>用 WaitForMultipleObjects<br/>同时等待停止与工作"]
SE --> W2["工作线程 2<br/>用 WaitForMultipleObjects<br/>同时等待停止与工作"]
W1 --> C1["收拾干净后自己 return"]
W2 --> C2["收拾干净后自己 return"]
C1 --> J["停止方等待线程句柄完成汇合<br/>到这一步才能说“已停止”"]
C2 --> J
图 4:停止事件模式。停止用手动重置事件的话,一次 SetEvent 就能让所有等待中的工作线程一起醒来。结束的方式由各线程自己决定,汇合完成才算停止
遵守停止事件的设置与顺序
停止事件要设为手动重置,让一次 SetEvent 对所有工作线程都可见。它和工作通知的职责不同。
另外,在 WaitForMultipleObjects 的等待数组里要把停止事件放在最前面。像本示例这样 bWaitAll 为 FALSE 的等待中,多个对象同时有信号时,下标较小的句柄优先。最后,停止方要确认线程句柄完成汇合之后,再关闭该句柄。
单工作线程:用事件唤醒,把队列排空
上面那种“用自动重置事件唤醒,把队列全部排空”的形态,是面向单个工作线程的结构。自动重置事件即使 SetEvent 多次,也不会记住工作的件数。有信号状态只有一个,连续的信号会合并成一次。
把这种形态原样用到多个工作线程上,可能只有一个线程醒来,串行地处理完一整批工作。
多工作线程:让一个信号量许可对应一件工作
如果要让多个工作线程分担队列,就把工作的信号换成信号量。生产方每放入一件就用 ReleaseSemaphore(hSem, 1, NULL) 增加一个许可,消费方每次等待成功只取出一件。因为等待成功会消耗掉一个许可,许可数与件数的对应关系就能保持住。
此时不能保留示例里那个全部排空的循环。只消耗了一个许可却把队列清空的话,剩下的许可会让别的工作线程对着空队列醒来,或者让生产方的 ReleaseSemaphore 因超过上限而失败。前提是“一个许可 = 一件工作”。
即使单件处理很长,也要能观测到停止
只在两件工作之间确认停止是不够的。如果 ProcessNextItem 内部要做长时间的阻塞等待,就要把停止事件也传进去一起等待,或者设一个有限的超时。
否则,一件工作迟迟不结束,关机就会一直等下去。.NET 篇的 StopAsync、C++ 篇的 jthread 与 join 也是同样的思路。
阻塞 I/O 也要准备 I/O 一侧的停止路径
在管道、套接字、串口的 I/O 上等待的线程,就那样是回不到检查停止事件的地方的。需要在 I/O 一侧也准备结束等待的路径,包括 OVERLAPPED 与事件的叠加等待,以及用 CancelIoEx 取消 I/O。
具体例子在“串口通信应用的陷阱”中讨论。
7. DllMain 与加载器锁——写 DLL 时的雷区
用 C 写的共享部件常常做成 DLL,而那里有加载器锁这一特有的限制。DllMain 是由操作系统的加载器在持有加载器锁的状态下调用的,因此在其中做下面这些事,就会成为死锁或崩溃的原因。10
- 与其他线程同步(获取锁、等待线程结束)
- 调用
LoadLibrary/FreeLibrary(无论直接还是间接) - 创建线程(伴随同步就危险)或调用
ExitThread
在 DllMain 之外准备显式的终止函数
“DLL 卸载时,在 DllMain 里等待工作线程结束”看上去像是对的,其实是典型的死锁。因为正在结束的线程也需要加载器锁来投递 DLL_THREAD_DETACH,于是和 DllMain 互相等待。10
带线程的 DLL 应该公开 MyLib_Init / MyLib_Shutdown 这样的初始化函数和终止函数,把线程的启动和汇合放在 DllMain 之外。调用方在终止函数里确认已经停止之后再卸载。DllMain 本身则设计成尽可能接近空壳的存根。10
8. C11 线程这个选项——现状梳理
如果不想依赖 Win32,想和其他操作系统共用代码,C11 的 <threads.h> 和 <stdatomic.h> 就是选项。把 2026 年 8 月时点 MSVC 的支持情况分开看,结果如下。11
| 功能 | 在 MSVC 中的定位 | 需要确认的条件 |
|---|---|---|
<threads.h>(thrd_create / mtx_lock / cnd_wait) |
Visual Studio 2022 17.8 起支持 | /std:c11 和对应的 Windows SDK |
<stdatomic.h> |
experimental | /experimental:c11atomics 选项 |
重要的是,不要把线程和原子操作当成处于同一支持阶段。
如果与 Linux 共用代码是硬性要求,C11 线程(或者 pthread 包装层)就有价值;但如果是仅面向 Windows 的代码库,本文的 Win32 风格在资料量、实绩和调试便利性上更有优势。无论选哪一种,到这里为止的设计原则(减少共享、数据与锁的对应、协作式停止)都不会变。
9. 验证与调试——按“复现不了”的前提来准备
普通测试通过了,也不能断言没有竞争。因为还存在“只是碰巧没有走到有问题的执行顺序”的可能。要用确认设计、让异常可观测、用压力晃动执行顺序这三层来准备。
设计评审:让数据、锁、停止路径一一对应
把共享的可变数据、守护它的锁、多把锁的获取顺序、停止事件能送达的工作线程列成表来确认。这是检查 5.1 节的纪律是否真的落到了代码上的工作。
在这张对应表写不出来的状态下,就不能拿“现在能跑”当作设计完成的依据。
观测:用超时、日志、转储留下等待发生的位置
不要把所有等待都写成无条件的 INFINITE,而要在关键处设置超时,并把超时记入日志。这是为了把原本只会一直等下去的状态,变成可以调查的失败。不过,发生超时并不等于确认线程已经结束,仅凭这一点还不能去释放共享资源。
发生挂起时要采集转储,查看所有线程的栈,确认锁的等待有没有成环。对 DLL 周边的错误,还可以使用官方推荐的 Application Verifier。10 日志和转储的准备,请参考“Windows 应用崩溃时留下日志和转储的设计”。
压力测试:试出开发机上难以出现的执行顺序
用多于核心数的线程长时间运行、把处理顺序随机化、插入人工延迟,用这样的压力测试增加缺陷显现的机会。也要确认经过优化的发布版构建与高负载的组合。
压力测试不能代替设计评审。把三层结合起来,才能更接近“一旦复现就能追出原因”的状态。
10. 总结——C 语言版检查清单
- 线程创建是否全都用
_beginthreadex(有没有混进CreateThread/_beginthread) - 线程句柄是否先汇合(
WaitForSingleObject)再CloseHandle - 有没有为短命的工作滥造自建线程(能不能交给线程池 API)
- 进程内的互斥是否用的是 SRW 锁 / CRITICAL_SECTION(有没有误用 Mutex)
- 共享计数器和标志是否不再依赖
volatile,而改用 Interlocked 系列 - 有没有残留的
Sleep轮询(是否已换成条件变量、事件等待) TerminateThread(强制终止其他线程)是否一处都没有。工作线程是否不是调用ExitThread,而是从线程函数return结束(这样 CRT 的收尾会经由_endthreadex正确执行)- 停止事件 +
WaitForMultipleObjects的停止路径是否覆盖了所有工作线程,阻塞 I/O 中的线程能否被唤醒 - 锁和句柄的释放是否在所有返回路径上都有保证(
goto cleanup的纪律) - 有没有在
DllMain里创建线程、做同步或等待线程结束
没有语言层面的支援,C 的多线程就直接由 API 的选择和纪律决定质量。_beginthreadex、SRW 锁、Interlocked、停止事件——把这四件套定为默认,用 C 也能做出远离“偶尔卡住”的设计。
相关文章
- 多线程实战最佳实践 .NET 篇
- 多线程实战最佳实践 C++ 篇
- 多线程实战最佳实践 Java 篇
- 共享内存的陷阱与实战最佳实践
- 在 Windows 上应优先使用事件等待而不是 Sleep(1) 的理由
- 串口通信应用的陷阱
- Windows 应用崩溃时留下日志和转储的设计
相关咨询领域
小村软件有限公司承接用 C 编写的常驻进程、设备控制应用和 DLL 的多线程设计评审,因 TerminateThread 或锁泄漏导致的挂起、崩溃的原因调查(转储分析),以及为遗留 C 代码增加线程的技术咨询。
参考链接
-
Microsoft Learn, CreateThread function。关于调用 CRT 的可执行文件中的线程应该用 _beginthreadex / _endthreadex 而不是 CreateThread / ExitThread 来管理;以及用 CreateThread 创建的线程调用 CRT 时,在低内存状态下 CRT 可能会终止进程。 ↩ ↩2
-
Microsoft Learn, Multithreading with C and Win32。关于调用 CRT 库的程序应该用 _beginthread / _beginthreadex 启动线程而不用 Win32 的 CreateThread / ExitThread;_beginthread 系列会初始化 CRT 的每线程变量;以及 SuspendThread 挂起正在访问 CRT 内部数据结构的线程可能招致死锁。 ↩ ↩2
-
Microsoft Learn, CreateThreadpoolWork function。关于用 CreateThreadpoolWork 创建工作对象,每调用一次 SubmitThreadpoolWork 池中的工作线程就执行一次回调;可以用回调环境(TP_CALLBACK_ENVIRON)指定执行环境;以及它从 Windows Vista 起可用。 ↩ ↩2
-
Microsoft Learn, Thread Pools。关于线程池适用于大量异步执行短任务的应用和频繁创建短命线程的应用;Vista 中重新设计的新线程池 API 的组成要素;以及作为最佳实践,不要用 TerminateThread 结束池中的线程、不要在回调中调用 ExitThread、要在返回前清理回调中建立的状态、要让等待句柄在线程池用完之前保持有效。 ↩ ↩2
-
Microsoft Learn, About Synchronization。关于 Win32 同步原语的选择指引:SRW 锁是新代码的默认选择,只有指针大小且通常在用户模式内完成;CRITICAL_SECTION 用于需要递归获取的场合;Mutex 始终是内核对象,用于跨进程的命名同步以及与 WaitForMultipleObjects 并用;在进程内同步中使用 Mutex 是在高频操作下大幅变慢的“常见错误”;以及信号量用于限制资源池的同时访问数、事件用于通知。 ↩ ↩2 ↩3
-
Microsoft Learn, Interlocked Variable Access。关于 Interlocked 函数会同步多个线程共享的变量的访问并让操作不可分地完成;InterlockedIncrement / Decrement 把读取、相加、写回捆成一次原子操作,没有同步时两个线程的同时自增可能丢失一次;InterlockedExchange / InterlockedCompareExchange 等函数群;共享内存上的变量也能在不同进程的线程之间使用;以及大多数 Interlocked 函数提供完整内存屏障,并可用 Acquire / Release 版本选择顺序语义。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Using Condition Variables。关于用 CRITICAL_SECTION 保护的有限循环缓冲区实现生产者/消费者队列的示例;用 InitializeConditionVariable 创建条件变量,消费者用 SleepConditionVariableCS 等待、用 WakeConditionVariable 唤醒对方的结构;以及条件变量从 Windows Vista 起受支持。 ↩ ↩2 ↩3
-
Microsoft Learn, TerminateThread function。关于 TerminateThread 会在完全不让目标线程执行用户模式代码的情况下将其结束;目标持有临界区时不会被释放;正在从堆分配内存时堆锁不会被释放;kernel32 的状态和 DLL 的全局状态可能被破坏;以及它是“只应在最极端情况下使用的危险函数”,除非完全掌握并控制目标线程可能执行的代码,否则不应调用。 ↩ ↩2 ↩3
-
Microsoft Learn, Warning C6258。关于代码分析警告 C6258 会检出 TerminateThread 的使用;TerminateThread 无法做到恰当的线程清理;以及作为正确的结束方法,给出了用 CreateEvent 创建事件、各线程用 WaitForSingleObject 监视事件状态、变为有信号状态后自己结束执行的步骤。 ↩ ↩2 ↩3
-
Microsoft Learn, Dynamic-Link Library Best Practices。关于 DllMain 是在持有加载器锁时被调用的,因此可调用的 API 有严重限制;在 DllMain 内与其他线程同步会招致死锁;调用 LoadLibrary 属于禁止事项;DLL 卸载时在 DllMain 内等待线程结束,会与结束线程的 DLL_THREAD_DETACH 投递互相等待而死锁的结构;理想的 DllMain 是接近空壳的存根,初始化应尽量延迟;以及应该定义锁层级并把加载器锁放在最上层。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version。关于 C 标准库功能的支持表中,C11 线程(threads.h)在 Visual Studio 2022 17.8 得到支持;stdatomic.h 按 experimental 对待(/experimental:c11atomics 选项);以及 C11 / C17 的编译器支持需要 Visual Studio 2019 16.8 以上和对应的 Windows SDK。 ↩ ↩2
-
Microsoft Learn, Interlocked Variable Access。关于经过适当对齐的 32 位变量的简单读写是原子的,但不保证访问的同步(顺序);64 位变量的简单读写在 64 位 Windows 上是原子的,在 32 位 Windows 上没有保证;以及其他大小的变量在任何平台上都不保证原子性。 ↩ ↩2
-
Microsoft Learn, _beginthread, _beginthreadex。关于 _beginthreadex 比 _beginthread 更安全的理由:用 _beginthread 创建的线程提前结束时,已返回的句柄会失效并可能指向别的线程;_beginthreadex 的句柄需要调用方用 CloseHandle 关闭,因而有效性有保证;用 _beginthreadex 可以把句柄交给同步 API;线程函数采用 __stdcall 约定并返回线程退出码;以及需要链接多线程版 CRT。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
多线程实战最佳实践 C++ 篇——用 RAII 和 jthread 从结构上杜绝问题
C++ 的多线程里数据竞争就是未定义行为。本文梳理 std::thread 析构函数的陷阱、jthread 与 stop_token 的停止设计、scoped_lock 的死锁规避、atomic 的正确定位,直到与 Win32 同步 API 的区分使用。
多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事
面向 .NET/C# 梳理防止“线程一开就偶尔崩溃、偶尔卡死”的设计做法,涵盖不自己创建线程而依托 Task、减少共享可变状态、加锁的纪律、用 CancellationToken 设计停止流程,直到 UI 线程的处理方式。
睡眠恢复后就出故障的应用——电源事件机制与扛得住恢复的业务应用设计
打开笔记本电脑时业务应用的通信已经断开——原因是设计没有考虑睡眠。本文依据一手资料讲解 WM_POWERBROADCAST 的通知流程、Modern Standby 的行为、断开与重连的设计、睡眠抑制以及调查命令。
DllMain 与加载器锁——“DLL 初始化里什么都别做”的真正原因
为什么不能在 DllMain 里调用 LoadLibrary 或与线程同步。本文依据一手资料,从串行化全部 DLL 通知的加载器锁机制,讲到死锁成立的典型场景、延迟初始化等正确设计,以及挂起的调查步骤。
“无响应”的真相——Windows 如何判定应用卡死,以及不会卡死的设计
Windows 的“无响应”是操作系统在窗口 5 秒未取出消息时作出判定、并换成幽灵窗口的机制。本文讲解判定的内部动作、卡死的常见原因、把繁重处理移出 UI 线程的设计,以及挂起的调查步骤。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
支持包含常驻处理、设备联动、运行日志与可维护结构的 Windows 桌面应用程序。
技术咨询 & 设计评审
协助梳理设计方向、架构边界、生命周期责任,以及既有 Windows 资产的处理方式。
常见问题
汇总了咨询这一主题时常见的问题。
- CreateThread 和 _beginthreadex 应该用哪一个?
- 如果线程会调用 C 运行时库(CRT)的函数,就用 _beginthreadex。_beginthreadex 会先初始化 CRT 为每个线程准备的内部数据,然后再启动线程。官方文档明确写明,用 CreateThread 创建的线程如果调用 CRT 函数,内存不足时 CRT 可能会终止进程。实际上,用 C 编写的应用里的线程几乎必然会在某处调用 CRT 函数(printf、malloc、strtok 等),所以记成“一律用 _beginthreadex”也没有问题。另外,_beginthread(不带 ex)有个陷阱:线程提前结束时,返回的句柄会失效,可能指向别的线程,因此同样应该选 _beginthreadex。
- 不能用 TerminateThread 停止线程吗?
- 不能。TerminateThread 会在完全不让目标线程执行任何用户模式代码的情况下把它抹掉,因此该线程如果持有临界区,临界区就不会被释放;如果正在从堆中分配内存,堆锁就会一直被握住;如果正在操作 DLL 的全局状态,DLL 的状态就会被破坏。官方文档明确把它定位为“只应在最极端情况下使用的危险函数”,代码分析也会作为警告 C6258 检出。正确的停止方式是创建一个停止用事件,各线程用 WaitForSingleObject / WaitForMultipleObjects 监视该事件,自己收拾干净之后再结束,也就是协作式停止。
- 进程内的互斥我一直在用 Mutex,这有什么问题吗?
- 能跑,但性能损失很大。Win32 的 Mutex 始终是内核对象,每次获取和释放都会发生一次内核模式切换。如果只是进程内的互斥,能在用户模式内完成(仅在发生争用时才落到内核等待)的 SRW 锁或 CRITICAL_SECTION 要快得多,官方文档也明确把进程内同步使用 Mutex 写成“常见的错误”。Mutex 的用武之地,是需要以命名对象跨进程互斥的场合,以及需要用 WaitForMultipleObjects 与其他内核对象一起等待的场合。
- C11 的 threads.h 和 stdatomic.h 能在 Windows 上使用吗?
- 在 MSVC 中,C11 线程(threads.h)从 Visual Studio 2022 17.8 起得到支持(需要 /std:c11 和对应的 Windows SDK)。而 stdatomic.h 仍按 experimental 对待,需要 /experimental:c11atomics 选项(依据 2026 年 8 月时点的官方支持表)。在最优先考虑可移植性时,它可以成为一个选项,但如果是仅面向 Windows 的代码库,从实绩和资料量来看,用 Win32 API(_beginthreadex、SRW 锁、条件变量、Interlocked)来写更现实。
- 给共享标志加上 volatile 就安全了吗?
- 不会。C 的 volatile 只是抑制编译器的优化(例如缓存到寄存器),既不保证操作的原子性,也不保证处理器之间的内存顺序。经过适当对齐的 32 位变量的简单读写在 Windows 上本身就是原子的,但“读取、相加、写回”会被拆开,与前后内存操作的顺序也没有规定。共享计数器和标志的更新请使用 Interlocked 系列函数。大多数 Interlocked 函数都带有完整的内存屏障,因此也能同时得到顺序上的保证。要把多个变量放在一起保护时,就用 SRW 锁或 CRITICAL_SECTION。