「用 C 编写设备控制用的常驻进程」「要给一个用了 20 年的 C 应用加上线程」「一直用 TerminateThread 来停止线程,但进程偶尔会整个卡死」── C 语言中的多线程,是语言支援最少的世界。没有异常、没有 RAII、也没有模板,同步是否正确,完全取决于 API 的选择与调用纪律。
本文是多线程实务系列的C 语言篇。面向用 C × Win32 API 编写代码的开发者,将多线程设计的原则 ── 停止滥造线程、减少共享可变状态、锁的纪律、把停止方式放在最前面设计 ── 落实到 Win32 的具体工具上,从线程的创建方式(_beginthreadex)、同步对象的选择、不使用 TerminateThread 的停止设计,到 DllMain 的限制,基于截至 2026 年 8 月的一手资料进行梳理。本文写得可以单独阅读。把同样的原则在其他语言中展开的文章还有「.NET篇」「C++篇」「Java篇」。
1. 先说结论
- 创建线程不要用
CreateThread,而要用_beginthreadex。如果调用 CRT 的线程用 CreateThread 创建,CRT 在内存不足时可能会终止整个进程。12 - 进程内的锁默认用 SRW 锁,只有需要递归获取时才用 CRITICAL_SECTION。在进程内互斥中使用 Mutex,是一种始终伴随内核切换的「常见错误」。3
- 单个变量的更新要用 Interlocked 系列函数。
volatile既不保证原子性也不保证顺序。大多数 Interlocked 函数都带有完整的内存屏障。4 - 等待要用条件变量(
SleepConditionVariableCS系列)或事件 + 等待函数。用Sleep做轮询循环,会同时浪费 CPU 与响应性。5 - 不使用
TerminateThread。这是会破坏锁・堆・DLL 状态的危险函数,是代码分析警告 C6258 的检测对象。停止应该以「停止事件 +WaitForMultipleObjects」的协作式停止来设计。67 - 短任务的并行化不要自建线程,而要投给 Windows 线程池(
CreateThreadpoolWork)。不要用ExitThread/TerminateThread结束线程池中的线程。89 - 不要在 DllMain 中创建・同步线程或等待线程结束。因为它是在持有加载器锁的状态下被调用的,是死锁的温床。10
- C11 的
<threads.h>从 VS 2022 17.8 起可用,但<stdatomic.h>仍是 experimental。如果代码只面向 Windows,Win32 API 的写法更加现实。11
2. 为什么多线程很难 ── 竞态条件与死锁
多线程带来的问题,不论语言为何,归根结底只有两种。
竞态条件(race condition)是这样一类 bug:结果会因为多个线程到达特定代码的先后顺序不同而发生变化。最经典的例子是共享计数器 —— count++ 这一个表达式,在机器码层面会被拆成「读取 → 相加 → 写回」三个步骤。如果两个线程同时进入这三个步骤,一方的相加结果就会被另一方的写回覆盖并消失。4 每次执行的结果都可能不同,也无法预测会得到哪种结果。
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:死锁中的循环等待。等待的箭头一旦构成一个环,环中的所有线程就会永远停止
这两者都依赖于时序,在开发机上可能几万次执行才会命中一次的执行顺序组合,在核心数与时序都不同的客户机器上却可能天天发生。「一挂上调试器就不复现」也是因为观测本身改变了时序,这是竞态 bug 的典型表现。因此,本文的所有原则都指向同一个方向 ── 在「正确地同步」之前,先「减少需要同步的地方」。
2.1. C 独有的前提 ── 语言不会替你守住任何东西
这一系列原则在 C 语言中,由于语言本身没有「强制遵守的机制」,就必须以纪律的形式明文化。
第一,用结构来保证释放。因为没有相当于 C++ RAII 的机制,锁的释放、句柄的 CloseHandle,要靠把函数出口统一为一处的 goto cleanup 模式,或者「获取与释放成对书写」的编码规范来守住。新增一个提前 return 导致锁泄漏,是 C 语言中的典型事故。
第二,数据竞争的处理方式与 C++ 相同。经过适当对齐的 32 位变量的简单读写,在 Windows 上确实是原子的,但除此之外 ── 64 位变量(32 位 Windows)、复合操作、多个变量之间的一致性 ── 什么都得不到保证。12 那些「碰巧能跑」的代码,会因为编译器或优化级别的变化而崩溃。
第三,明确所有权。「这个缓冲区由哪个线程写入、从何时起归谁所有」,把这些内容明确写在函数注释里的文化,在 C 语言的多线程中,其效果丝毫不亚于同步原语的选择。
3. 线程的创建方式 ── 只用 _beginthreadex
3.1. 为什么不能用 CreateThread
Win32 的原生 API 是 CreateThread,但官方指南是 调用 CRT(C 运行时)函数的线程要用 _beginthreadex 创建。_beginthreadex 会先初始化 CRT 供每个线程使用的内部数据,然后再启动线程。用 CreateThread 创建的线程如果调用了 CRT 函数,在内存不足的情况下,CRT 有可能会终止整个进程。12 printf、malloc、strtok 都属于 CRT,所以在实务中可以说「用 C 编写的线程,一律使用 _beginthreadex」。
_beginthread(不带 ex)也要避免使用。它有一个陷阱:如果创建的线程提早结束,返回的句柄就会失效(可能指向另一个线程),而能把句柄传给同步 API 的 _beginthreadex 更加安全。_beginthreadex 返回的句柄,由调用方负责用 CloseHandle 关闭。13
#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 线程池(Vista 以后提供的线程池 API)。用 CreateThreadpoolWork 创建的工作对象,通过 SubmitThreadpoolWork 投递后,线程池的工作线程就会并行执行回调。8 线程数量的管理可以交给 OS,线程创建・销毁的开销也随之消失。「不要自己创建线程」这一原则,在 C 语言中的答案就是它。
使用线程池时的纪律,官方也有明确规定:不要用 TerminateThread / ExitThread 来结束池中的线程,回调中创建的状态(TLS、线程优先级等)要在返回前恢复,等待句柄要保留到线程池用完为止,等等。9 还有一点实务上的注意事项:线程池限制的只是工作线程的数量,通过 SubmitThreadpoolWork 投递、尚未执行的回调可以无限堆积。在投递持续超过处理能力的常驻结构中,请在应用侧用信号量之类的手段限制入场,或使用带容量上限的队列,让投递方在队列满时等待或拒绝(这是一种把过载转移到内存之外的背压机制,与第 5 章的队列设计是同一个原则)。
4. 把共享可变状态最小化 ── 拆分・只读・传递
竞争只有在「多个线程」与「被共享的可变数据」同时具备时才会发生。在选择同步原语(下一章)之前,先考虑能否从根本上减少共享。手段大致分为三类。
拆分。在并行汇总时,不要让各线程直接写入共享计数器,而是在每个线程各自的局部变量(或按线程分配的缓冲区)中生成小计,结束时只用一次 InterlockedAdd 之类的操作合并。对共享数据的写入从「每次迭代一次」减少到「每个线程一次」,同步开销与竞争窗口都会成数量级地缩小。2.1 节中「这个缓冲区归哪个线程所有」的所有权明文化,直接就是拆分的设计图。
只读化。启动时构建、之后不再改写的配置与表类数据,初始化完成后,不论多少个线程读取都是安全的。要么「在所有线程启动前就完成初始化」,要么如果需要延迟初始化,就用 Win32 的一次性初始化(InitOnceExecuteOnce),用代码明确划出「从何时起变为只读」的边界。3
传递。线程之间的数据流动,不要让两侧都去碰共享变量,而应该归拢到生产者/消费者队列中。在 C 语言中的实现,第 5 章的条件变量(有限循环缓冲区 + SleepConditionVariableCS)本身就是官方给出的实现示例,带容量上限的缓冲区,还能自然地形成「生产超过消费时生产方就等待」的背压机制。5
5. 同步对象的选择与锁的纪律
Win32 的同步原语种类繁多,选错了既会损害性能,也会损害正确性。下面把官方指南汇总成一张图。3
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 函数 | 进程内(若为共享内存也可跨进程) | 无需加锁的原子操作 | 计数器・标志・指针替换4 |
补充一点表格与流程图之外的内容。事件、信号量、Mutex 这类内核对象,只要以无名方式创建,同样可以正常用于进程内同步(第 6 章的停止事件正是无名事件)。内核对象并不等于「专供跨进程使用」。而且「无名 = 绝对只限进程内」也不成立:只要让句柄被子进程继承,或者用 DuplicateHandle 复制到其他进程,即使没有名字,同一个内核对象也能被多个进程使用。准确的理解是,命名只是让进程间能够重新打开同一对象的代表性手段之一。图3的分支所展示的要点,是「进程内的锁不要选择内核对象」,进程内的通知(事件)或同时数限制(信号量),无名内核对象依然是正确答案。
Interlocked 系列对应 .NET 篇的 Interlocked 类、C++ 篇的 std::atomic。InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange 会对单个变量进行不可分割的操作,而且大多数函数都带有完整的内存屏障,因此同时也能获得顺序上的保证。4 「加了 volatile 就没问题」是一种既不保证原子性也不保证顺序的误解(参见 FAQ)。另一个前提条件是对齐(alignment)。Interlocked 函数的目标变量必须对齐到自然边界(32 位值需 4 字节边界,64 位值需 8 字节边界),不对齐的话行为就无法预测。12 不要把 #pragma pack 过的结构体,或者直接映射通信格式的缓冲区上的字段作为 Interlocked 的操作对象。计数器与标志请限定为普通声明(即由编译器负责对齐)的变量。此外,用 InterlockedExchangePointer 等函数进行的指针替换,还有它特有的注意事项:不可分割的只是替换动作本身,替换之后旧内存块的生命周期没有任何人来保障。如果读者刚加载完旧指针,写者就立刻替换并 free 掉它,那就是访问已释放的内存。用指针替换来更新共享数据的设计,只有在配合锁、引用计数等回收协议的情况下才能成立(拿不准的话,用 SRW 锁来保护是稳妥的做法)。
等待方面有条件变量。用 InitializeConditionVariable 创建,消费方用 SleepConditionVariableCS(与 CRITICAL_SECTION 搭配)进入睡眠,生产方用 WakeConditionVariable 唤醒 ── 有限缓冲区的生产者/消费者队列的官方实现示例正是这种形式。5 一个重要的做法是:唤醒后必须在锁内重新确认条件(队列是否非空),如果为假就回到等待循环。条件变量存在无通知就唤醒的伪唤醒(spurious wakeup),而且醒来时可能已经有别的消费者先一步拿走了元素,所以「被唤醒」不等于「条件成立」。这是在 C 语言中搭建与 .NET 篇 4.3 节的通道、C++ 篇第 4 章的 BlockingQueue 相同结构的工具。如果要与 SRW 锁搭配使用,就用 SleepConditionVariableSRW。
5.1. 锁的纪律 ── 无论选哪种都不变的三条原则
就算原语选对了,如果没有使用上的纪律,也无法防止竞争。
- 一对一确定「哪个数据由哪把锁守护」。为每一组想要保护的可变数据对应一把锁(SRW 锁或 CRITICAL_SECTION),并在触碰这些数据的所有位置都获取同一把锁。在头文件注释中明确写上「这个结构体由
g_lockFoo守护」的做法,在 C 语言中尤其有效。 - 持锁期间不要做耗时的事、不要做外部的事。持锁期间只应该做被保护数据的读写。持锁不放地进行文件 I/O、网络访问、回调调用,不仅会延长持锁时间,还可能因为被调用方试图获取另一把锁,而构成图2那样的循环等待。
- 固定多把锁的获取顺序。在需要获取两把以上锁的地方,规定所有线程都按同一个顺序(锁层级)获取。顺序颠倒(lock order inversion)会导致难以调试的死锁,应该定义层级并始终遵守 ── 这一点在 DLL 最佳实践文档中已有明文规定。10
6. 停止方式的设计 ── 不使用 TerminateThread
6.1. TerminateThread 会破坏什么
TerminateThread 会在完全不让目标线程执行任何用户模式代码的情况下将其消灭。官方文档列举的后果十分严重:如果目标线程持有临界区,临界区就永远不会被释放;如果正在进行堆操作,堆锁就会一直被握住(此后所有调用 malloc 的线程都会挂起);如果正在操作 DLL 的全局状态,DLL 的状态就会被破坏。官方对它的定位是「只应在最极端情况下使用的危险函数」,代码分析也会以警告 C6258 检测出它的使用。67
在调查「进程偶尔会整个卡死」的应用时发现 TerminateThread,这在实务中是相当常见的场景。一旦发现,它就是需要修复的对象。
6.2. 正确的做法:停止事件 + WaitForMultipleObjects
C 语言中协作式停止的定式,是创建一个手动重置的停止事件,让每个工作线程同时等待「工作信号」与「停止信号」。警告 C6258 的文档本身,也把这种做法(创建事件,让每个线程用 WaitForSingleObject 监视该事件并自行结束)作为正确的结束方式来说明。7
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 时不应继续释放共享资源 */
}
停止方的汇合之所以逐个使用 WaitForSingleObject,是有理由的。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 就能让所有工作线程都看到);把停止事件放在等待数组的最前面(当多个对象同时处于信号状态时,停止会被优先处理);停止方必须先等待线程句柄汇合,然后再关闭句柄。
这里补充两点适用范围上的注意事项。第一,这种「事件 + 全部排空」的形式适合单个工作线程的结构。自动重置事件无论调用多少次 SetEvent,都只能表达「有一个信号状态」(连续的信号会合并),如果工作线程有多个,就只会有一个被唤醒,把突发的一批任务串行处理掉。如果由多个工作线程分担同一个队列,工作信号就应该换成信号量,每积压一件就用 ReleaseSemaphore(hSem, 1, NULL) 增加计数。信号量等待成功会消耗一次计数,因此能得到「积压了多少件,就有多少个正在等待的工作线程各醒来一次」这种正确的对应关系(这种用途和图3表格中「限制资源池的同时访问数」一样,属于信号量的守备范围)。不过换成信号量之后,消费方也要相应改成「等待成功一次 = 从队列只处理一件」。如果原样保留上面示例中的全部排空循环,一次等待明明只消耗了一个许可,却会把整个队列清空,结果就是剩余的许可会让其他工作线程对着空队列醒来、生产方的 ReleaseSemaphore 因超出上限而失败,账目由此错乱。遵守「一个许可 = 一件工作」的对应关系,是信号量方式成立的前提。第二,要让停止路径也贯穿到单件处理本身。如果 ProcessNextItem 内部有较长的阻塞等待,就应该把停止事件也传进去做叠加等待,或者附加一个有限的超时。仅仅在各条目之间做检查,仍然会留下「因为一件处理不完,关闭流程就永远等待」这个漏洞。这与 .NET 篇的 StopAsync、C++ 篇的 jthread + join 所说的是同一件事。
在阻塞式 I/O(管道・套接字・串口)上等待的线程无法回来查看事件,因此 I/O 一侧也需要设计成 OVERLAPPED + 事件的叠加等待,或者用 CancelIoEx 把 I/O 唤起(串口通信的具体例子请参见「串口通信应用的陷阱 - 直到重连与日志设计」)。
7. DllMain 与加载器锁 ── 编写 DLL 时的雷区
用 C 编写的共享组件常常会做成 DLL,而这里存在加载器锁这一特有的限制。DllMain 是在 OS 的加载器持有加载器锁的状态下被调用的,因此在其中做以下事情,会成为死锁或崩溃的原因。10
- 与其他线程同步(获取锁・等待线程结束)
- 调用
LoadLibrary/FreeLibrary(不论是直接还是间接调用) - 创建线程(如果伴随同步则很危险)或调用
ExitThread
「在 DLL 卸载时,于 DllMain 中等待工作线程结束」看起来相当正确,实际上却是典型的死锁(正在结束的线程为了投递 DLL_THREAD_DETACH 而尝试获取加载器锁,双方就此互相等待)。带有线程的 DLL,应该公开像 MyLib_Init / MyLib_Shutdown 这样显式的初始化・终止函数,把线程的启动与汇合放在那里进行。DllMain 理想的样子,是接近空壳的桩函数。10
8. C11 线程这一选项 ── 现状梳理
如果「想写不依赖 Win32、可移植的 C 代码」,可选的方案是 C11 的 <threads.h>(thrd_create / mtx_lock / cnd_wait)和 <stdatomic.h>。根据官方的合规性表,MSVC 的支持情况是:<threads.h> 从 Visual Studio 2022 17.8 起支持(需要 /std:c11 及对应的 SDK),而 <stdatomic.h> 仍属于 experimental,需要 /experimental:c11atomics 选项。11
如果需要与 Linux 共享代码,C11 线程(或 pthread 封装)就有其价值;但如果代码库仅面向 Windows,本文介绍的 Win32 风格在资料量・既有经验・调试便利性上都更有优势。不论选择哪一种,到目前为止的设计原则(减少共享、锁与数据的对应关系、协作式停止)都不会改变。
9. 验证与调试 ── 以「无法复现」为前提做好准备
竞态类 bug 很难指望靠测试发现,因为普通的测试会把「碰巧没有发生竞争」的那次执行计为成功。准备工作要从三个层面来考虑。
第一道防线是设计。在评审时用表格确认「哪些是被共享的可变数据」「各自由哪把锁守护(对应 5.1 节的对照表)」「锁的获取顺序是否唯一」「停止事件是否能传达到所有工作线程」。如果一个设计写不出这张表,就算能运行,也还没有完成。
第二,让异常变得可观测。不要在等待处一律使用无条件的 INFINITE,而要在关键位置附加超时,并把超时记录到日志中,这样就能把永远的挂起变成一个可检测的失败。在挂起现场,要采集转储并检查所有线程的调用栈,看看彼此的锁等待是否形成了循环。针对 DLL 相关的错误,官方推荐用 Application Verifier 进行检查。10 转储与日志的整备,在「Windows 应用崩溃时保留日志与转储的设计」中有讨论。
第三,用负载去晃动它。用比核心数更多的线程长时间运行、让处理顺序随机化、插入人为延迟等压力测试手段,是让开发机更容易「抽中」竞争的现实做法。也不要忘记在经过优化的发布版本上、在高负载下进行复现测试。
10. 总结 ── C 语言版检查清单
- 线程创建是否全部使用
_beginthreadex(有没有混入CreateThread/_beginthread) - 线程句柄是否先汇合(
WaitForSingleObject)再CloseHandle - 是否为短生命周期的任务滥造自建线程(能否投给线程池 API)
- 进程内互斥是否用的是 SRW 锁 / CRITICAL_SECTION(有没有误用 Mutex)
- 共享计数器・标志是否已改为 Interlocked 系列,而不是依赖
volatile - 是否还残留
Sleep轮询(是否已替换为条件变量・事件等待) - 是否完全没有使用
TerminateThread(强制终止其他线程)。工作线程是否通过从线程函数return结束,而不是调用ExitThread(这样 CRT 的收尾才能正确经由_endthreadex执行) - 所有工作线程是否都有「停止事件 +
WaitForMultipleObjects」的停止路径,阻塞式 I/O 中的线程是否也能被唤醒 - 锁・句柄的释放是否在所有 return 路径上都能得到保证(
goto cleanup的纪律) - 是否没有在
DllMain中创建・同步线程或等待线程结束
没有语言层面的支援,作为代价,C 语言多线程的质量就直接体现在 API 的选择与纪律上。把 _beginthreadex・SRW 锁・Interlocked・停止事件 ── 这四件套设为默认选择,即使用 C 语言,也能做出远离「偶尔卡死」的设计。
相关文章
- 多线程实务最佳实践 .NET 篇 ── 在增加线程之前应先确定的事
- 多线程实务最佳实践 C++ 篇 ── 用 RAII 和 jthread 从结构上消除事故
- 多线程实务最佳实践 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, 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
-
Microsoft Learn, Warning C6258. 关于代码分析警告 C6258 会检测 TerminateThread 的使用;TerminateThread 无法进行恰当的线程清理;以及文档中给出的正确结束方式 ── 用 CreateEvent 创建事件,让每个线程用 WaitForSingleObject 监视事件状态,一旦变为信号状态就自行结束执行。 ↩ ↩2 ↩3
-
Microsoft Learn, CreateThreadpoolWork function. 关于用 CreateThreadpoolWork 创建工作对象,每次调用 SubmitThreadpoolWork 时线程池的工作线程都会执行该回调;可以通过回调环境(TP_CALLBACK_ENVIRON)指定执行环境;以及该 API 从 Windows Vista 起可用。 ↩ ↩2
-
Microsoft Learn, Thread Pools. 关于线程池适用于大量异步执行短任务的应用,或频繁创建短生命周期线程的应用;Vista 重新设计后的新线程池 API 的构成要素;最佳实践包括不要用 TerminateThread 结束线程池中的线程、也不要在回调中调用 ExitThread;回调中创建的状态要在返回前清理;以及等待句柄要保留到线程池用完为止。 ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices. 关于 DllMain 是在持有加载器锁的状态下被调用的,因而对可调用的 API 有重大限制;在 DllMain 内与其他线程同步会招致死锁;调用 LoadLibrary 属于禁止事项;DLL 卸载时如果在 DllMain 内等待线程结束,会与正在结束的线程投递 DLL_THREAD_DETACH 时所需的加载器锁互相等待而死锁;理想的 DllMain 应接近空壳,初始化应尽量延后;以及应该定义锁层级,把加载器锁置于最高层。 ↩ ↩2 ↩3 ↩4 ↩5
-
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 的使用区分。
多线程实务最佳实践 Java 篇 ── 虚拟线程时代的准则
Java 多线程的惯例是不直接创建线程,而是交给 ExecutorService 与虚拟线程来处理。本文整理 synchronized 与 ReentrantLock 的取舍、通过中断实现的协作式停止、ConcurrentHashMap 的原子操作,直至 Swing 的 E...
多线程实务最佳实践 .NET 篇 ── 在增加线程之前应先确定的事
针对 .NET/C# 整理「立了线程之后,偶尔崩溃・卡死」的防范设计准则。内容涵盖不自行创建线程而改用 Task、减少共享可变状态、锁的纪律、基于 CancellationToken 的停止设计,直至 UI 线程的处理方式。
卷影复制服务(VSS)的原理与实务 ── 使用中文件为何能够备份
使用中的文件明明会因共享冲突而无法复制,备份软件为什么却能正常备份?本文将解说卷影复制服务(VSS)中请求者・编写器・提供程序的角色分工、写时复制的原理、vssadmin 的实务操作,以及差异区域的陷阱。
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
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。