A.9 线程与线程本地存储
在计算密集型任务中,多线程能够充分释放现代多核处理器的并发潜能:在基准测试机上,统计两千万以内的素数个数,单线程执行耗时约 1.7 秒;若将计算区间均分为 16 段分配给 16 个工作线程并发处理,总耗时可锐减至 180 毫秒左右。然而,伴随高并发吞吐而来的,是复杂的数据隔离挑战——例如在错误处理流程中,每个工作线程在遇到异常时都需要记录私有的诊断信息,倘若简单地共用一个全局字符数组,各线程的写入动作将不可避免地相互踩踏,最终读取者看到的只能是其他并发线程留下的脏数据。
同一进程内的全部线程天然共享同一份虚拟地址空间,这一特性既大幅降低了线程间协同与内存交互的开销,也成为数据竞争与状态污染的根本温床:在默认的内存模型中,全局对象并非天生专属某个执行流。我们将从 Windows 平台下线程的生命周期创建、等待收割与退出码捕获切入,深入辨析系统级 CreateThread 与 C 运行时封装 _beginthreadex 在运行时环境初始化上的根本差异;通过并行求和实例度量多核并发的收益曲线与边际递减效应;随后探究线程栈的提交与保留结构、调度优先级与调试命名约定;并最终系统梳理 _Thread_local 静态声明、动态 TLS 索引以及支持生命周期清理回调的纤程本地存储(FLS),构建严密无漏的线程专属存储体系。同步原语不在这里展开,留到 A.10 与 A.11。
1. 创建线程
CreateThread 在调用进程的地址空间里新建一个线程:
HANDLE CreateThread(LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId);2
3
4
5
6
第一个参数是句柄的安全属性,传 NULL 时沿用默认安全描述符,句柄也不能被子进程继承(A.3)。第二个是栈的初始大小,传 0 用可执行文件头里的默认值,第 4 节再细说。第三个是线程函数的地址,第四个是传给它的那一个指针。dwCreationFlags 传 0 时线程立刻可运行,传 CREATE_SUSPENDED 则要另一次 ResumeThread 才开跑。最后一个参数收线程标识,不需要时传 NULL。
线程函数的形状是固定的,返回值就是退出码:
DWORD WINAPI thread_function(LPVOID param);WINAPI 在 x64 上只是修饰符,不影响调用约定;参数是一个指针(x64 上 8 字节),返回值是 32 位无符号数。一个进程里可以有多个线程同时执行同一个函数,区分它们的是各自收到的 param。CreateThread 成功时返回一个句柄,失败返回 NULL,与 CreateFileW 的 INVALID_HANDLE_VALUE 不同(A.3 讲过这种不统一)。
有一个细节从第一行代码起就要记住:线程可能比 CreateThread 返回得更早开始运行。param 指向的对象必须在创建之前就准备好,并且活到线程用完为止。第 6 节会看到违反这一条时程序怎样出错。
下面这个程序把同一道求和交给两种方式创建的线程,等它们结束再取退出码:
#include <process.h>
#include <stdio.h>
#include <windows.h>
#define LIMIT 100000
struct job {
int limit;
unsigned long long sum;
};
static DWORD WINAPI sum_with_create_thread(LPVOID param) {
struct job *job = param;
unsigned long long sum = 0;
for (int i = 1; i <= job->limit; i++) {
sum += (unsigned long long)i;
}
job->sum = sum;
return (DWORD)sum;
}
static unsigned __stdcall sum_with_beginthreadex(void *param) {
struct job *job = param;
unsigned long long sum = 0;
for (int i = 1; i <= job->limit; i++) {
sum += (unsigned long long)i;
}
job->sum = sum;
return (unsigned)sum;
}
static DWORD WINAPI sleeper(LPVOID param) {
(void)param;
Sleep(150);
return 7;
}
int main(void) {
struct job a = {.limit = LIMIT};
struct job b = {.limit = LIMIT};
HANDLE threads[2];
DWORD exit_code = 0;
HANDLE s;
threads[0] = CreateThread(NULL, 0, sum_with_create_thread, &a, 0, NULL);
if (threads[0] == NULL) {
printf("CreateThread failed: %lu\n", GetLastError());
return 1;
}
threads[1] = (HANDLE)_beginthreadex(NULL, 0, sum_with_beginthreadex, &b, 0, NULL);
if (threads[1] == NULL) {
printf("_beginthreadex failed: %lu\n", GetLastError());
CloseHandle(threads[0]);
return 1;
}
printf("CreateThread: handle %p, thread id %lu\n", (void *)threads[0],
(unsigned long)GetThreadId(threads[0]));
printf("_beginthreadex: handle %p, thread id %lu\n", (void *)threads[1],
(unsigned long)GetThreadId(threads[1]));
WaitForMultipleObjects(2, threads, TRUE, INFINITE);
GetExitCodeThread(threads[0], &exit_code);
printf("CreateThread: sum = %llu, exit code %lu (0x%08lX)\n", a.sum,
(unsigned long)exit_code, (unsigned long)exit_code);
GetExitCodeThread(threads[1], &exit_code);
printf("_beginthreadex: sum = %llu, exit code %lu (0x%08lX)\n", b.sum,
(unsigned long)exit_code, (unsigned long)exit_code);
CloseHandle(threads[0]);
CloseHandle(threads[1]);
/* 线程还没结束时,退出码是 STILL_ACTIVE,它的值恰好等于 259 */
s = CreateThread(NULL, 0, sleeper, NULL, 0, NULL);
if (s == NULL) {
printf("CreateThread failed: %lu\n", GetLastError());
return 1;
}
GetExitCodeThread(s, &exit_code);
printf("running thread: exit code %lu (STILL_ACTIVE is %lu)\n",
(unsigned long)exit_code, (unsigned long)STILL_ACTIVE);
WaitForSingleObject(s, INFINITE);
GetExitCodeThread(s, &exit_code);
printf("after wait: exit code %lu\n", (unsigned long)exit_code);
CloseHandle(s);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
完整文件在 code/系统编程/拓展A_Win32系统编程/A_9_线程与线程本地存储/create_threads.c,同目录的 Makefile 能一次编译本页的主要程序。threads_demo.c 与 dtls_probe.c 是故意编译不过和故意报警告的两个例子,用法在各自的小节里给出。
PS> gcc -std=c17 -Wall -Wextra -O2 -o create_threads.exe create_threads.c
PS> ./create_threads.exe
CreateThread: handle 00000000000000B4, thread id 28648
_beginthreadex: handle 00000000000000B8, thread id 12376
CreateThread: sum = 5000050000, exit code 705082704 (0x2A06B550)
_beginthreadex: sum = 5000050000, exit code 705082704 (0x2A06B550)
running thread: exit code 259 (STILL_ACTIVE is 259)
after wait: exit code 72
3
4
5
6
7
8
求和结果是 DWORD,五亿零五百多万这个 64 位的结果被截断成低 32 位,正好是 705082704。想从线程里带出超过 32 位的结果,要么写进 param 指向的结构体(这个程序就是这么做的),要么约定一个错误码范围,把结果放在别处。句柄值和线程 ID 每次运行都不同,前者是进程句柄表里的索引,后者由内核分配。
1.1 什么时候用 _beginthreadex
_beginthreadex 在 C 运行库(C runtime library,CRT)里,声明于 <process.h>,签名与 CreateThread 几乎一样:
uintptr_t _beginthreadex(void *security, unsigned stack_size,
unsigned (__stdcall *start_address)(void *),
void *arglist, unsigned initflag, unsigned *thrdaddr);2
3
区别有四处:线程函数用 __stdcall、返回 unsigned;返回值是 uintptr_t,要自己转换成 HANDLE;失败时返回 0 而不是 NULL(两者数值都是 0,但一个是整数、一个是指针);线程结束时由 _endthreadex 收尾,CRT 为这个线程分配的内部数据(包括 errno 的副本与 CRT 自己的每线程块)会在那时释放。
用 CRT 的线程要用 _beginthreadex 创建
Microsoft 的 CreateThread 文档写明:可执行文件中调用 C 运行库的线程应当用 _beginthreadex 与 _endthreadex 管理,而不是 CreateThread 与 ExitThread;用 CreateThread 创建的线程调用 CRT 时,CRT 可能在内存不足的情况下结束整个进程。原因是 CRT 需要在线程启动和退出时初始化、释放自己的每线程数据,_beginthreadex 在中间插了这一层。
工程上的判断很简单:C 程序里,只要线程函数会用到 CRT(printf、malloc、strtok、errno 都算),就用 _beginthreadex。它多出的几个参数和类型转换是一次性的代价。本页此后所有示例都用 _beginthreadex;CreateThread 仍然要认得,因为文档、示例代码和别的语言写的库大量使用它。
C11 在 <threads.h> 里有一套标准线程接口,第一部分 23.1 讲过。这套接口在 MinGW 上不能直接用,本机实测:
PS> gcc -std=c17 -Wall -Wextra threads_demo.c -o threads_demo.exe
threads_demo.c:4:10: fatal error: threads.h: No such file or directory
4 | #include <threads.h>
| ^~~~~~~~~~~
compilation terminated.2
3
4
5
MinGW-w64 没有提供 <threads.h>。要在 Windows 上写线程代码,可以继续用本页和后面几页的 Win32 接口,也可以用 MinGW-w64 自带的 winpthreads:编译时加 -pthread,#include <pthread.h> 就能用,本机实测可用。两种写法各有代价,前者与 Windows 的等待、TLS、线程池结合得最紧,后者与 Linux 上的代码共用一份源码。
2. 等待、退出码与结束
创建之后,主线程通常要等所有工作线程结束。等待线程用的是等待函数:线程对象在结束之前一直处于无信号状态,结束时变为有信号,等待随之返回(A.10 会把这些函数讲全,这里只用到两个)。
DWORD WaitForSingleObject(HANDLE hObject, DWORD dwMilliseconds);
DWORD WaitForMultipleObjects(DWORD nCount, const HANDLE *lpHandles,
BOOL bWaitAll, DWORD dwMilliseconds);2
3
第二个函数可以同时等多个句柄,bWaitAll 传 TRUE 表示等全部对象都有信号,INFINITE 表示不设超时。数组最多放 64 个句柄。本页所有「等 N 个线程结束」都是这一行:
WaitForMultipleObjects((DWORD)n, threads, TRUE, INFINITE);等待返回之后,用 GetExitCodeThread 取线程函数返回的值。线程还在运行时,它返回 STILL_ACTIVE,数值是 259,所以 259 不适合当作自己程序的退出码,否则主线程分不清线程是结束了还是还在跑。
句柄、线程对象与退出码
CloseHandle(hThread) 只是放弃本进程对线程对象的一个句柄,不会结束线程,也不影响线程继续运行。线程对象在内核里一直存在,直到线程结束并且所有句柄都已关闭;在那之前,随时可以用 GetExitCodeThread 或等待函数查询它的状态。线程结束后对象变为有信号,这个状态不会消失,可以反复等待,也可以反复取退出码。
这段规则与 A.8 讲过的进程对象完全一样:结束的是执行体,留下的是对象。用一张图表示一个线程对象从创建到销毁经历的状态:
本机实测,在线程还在 Sleep 时调用 CloseHandle,函数返回 1(成功),线程随后照常把自己的活干完。反过来,句柄关掉之后就不能再拿它去等待:本机把两个线程都创建好、关掉其中一个的句柄,再调 WaitForMultipleObjects,等待直接返回 WAIT_FAILED,错误码 6(句柄无效)。更隐蔽的情况是这个句柄值恰好被新创建的对象复用,那时等待会成功,等的却是另一个对象(A.3 讲过句柄复用的后果)。所以关闭句柄要等到等待、取退出码都做完以后再写,或者用 goto 统一清理。
2.1 线程的结束方式
线程有四种结束方式,选择哪一种直接影响程序能否正确清理:
| 方式 | 做法 | 评价 |
|---|---|---|
| 从线程函数返回 | return value; | 推荐,CRT 与内核都有机会收尾 |
调用 _endthreadex / ExitThread | 在线程函数中间主动退出 | 能清理,但绕过函数里剩下的清理代码 |
被别的线程 TerminateThread | 任何线程拿句柄强行结束 | 不要用 |
| 进程结束 | ExitProcess、main 返回 | 所有线程一起消失,不做逐个清理 |
_endthreadex 与 ExitThread 的区别在于前者会让 CRT 释放该线程的每线程数据,后者不会。两者都会跳过线程函数里尚未执行的代码,例如 free 与 CloseHandle,所以即使是为了「提前返回」,也应当写成 return 或 goto 到统一的清理段,而不是直接调用它们。
TerminateThread 的问题要严重得多:它不释放线程持有的锁,不回收栈,不给 DLL 发线程分离通知,线程拥有的内核对象句柄也要等到进程结束才关闭。下面这个程序把后果摆在眼前:
#include <process.h>
#include <stdio.h>
#include <windows.h>
static CRITICAL_SECTION g_cs;
static volatile LONG g_ticks;
static unsigned __stdcall worker(void *param) {
(void)param;
EnterCriticalSection(&g_cs);
printf("worker: holding the critical section\n");
for (;;) {
InterlockedIncrement(&g_ticks);
Sleep(10);
}
return 0;
}
int main(void) {
HANDLE thread;
DWORD exit_code = 0;
LONG ticks_before;
InitializeCriticalSection(&g_cs);
thread = (HANDLE)_beginthreadex(NULL, 0, worker, NULL, 0, NULL);
if (thread == NULL) {
printf("_beginthreadex failed: %lu\n", GetLastError());
DeleteCriticalSection(&g_cs);
return 1;
}
Sleep(100);
ticks_before = g_ticks;
TerminateThread(thread, 1234);
WaitForSingleObject(thread, INFINITE);
GetExitCodeThread(thread, &exit_code);
printf("after TerminateThread: exit code %lu\n", (unsigned long)exit_code);
Sleep(100);
printf("worker iterations: %ld before, %ld after\n", (long)ticks_before,
(long)g_ticks);
printf("TryEnterCriticalSection: %d (0 means still owned)\n",
TryEnterCriticalSection(&g_cs));
CloseHandle(thread);
/* 临界区对象永远拿不回来,这里不能 DeleteCriticalSection */
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
PS> gcc -std=c17 -Wall -Wextra -O2 -o terminate_demo.exe terminate_demo.c
PS> ./terminate_demo.exe
worker: holding the critical section
after TerminateThread: exit code 1234
worker iterations: 7 before, 7 after
TryEnterCriticalSection: 0 (0 means still owned)2
3
4
5
6
退出码 1234 是 TerminateThread 给的,不是线程自己返回的。g_ticks 停在第 7 次不再增加,说明线程确实死了;TryEnterCriticalSection 返回 0,说明那把临界区对象仍然记在死掉的线程名下,本进程里再也没有谁能进入它。临界区对象只能由进入它的线程离开,主线程连 DeleteCriticalSection 都不该调用,因为对象内部的等待队列还挂着东西。这就是 TerminateThread 最典型的事故现场:一个线程被强行拿走,它碰到过的所有同步对象都留在半路上。
正确的做法是给线程一个「该停了」的信号,让它自己从循环里退出。这需要一个可等待的对象,属于 A.10 第 3 节的内容;在那里,停止事件与工作信号量放在同一个 WaitForMultipleObjects 里,主线程置位停止事件,工作线程处理完手上这一件就退出。
3. 例子:多线程统计素数
线程最常见的用途是把一件耗时的计算切成几份。下面这个程序统计 2 到 2000 万之间的素数个数,把区间均分成 N 段,每个线程统计自己那一段,结果写进自己那份结构体,主线程最后汇总。
#define LIMIT 20000000ULL
#define MAX_THREADS 16
struct task {
unsigned long long lo; /* 含 */
unsigned long long hi; /* 不含 */
unsigned long long count;
};
static unsigned long long g_primes[4096]; /* sqrt(LIMIT) 以内的素数,只读共享 */
static int g_nprimes;
static int is_prime(unsigned long long n) {
if (n < 2) {
return 0;
}
for (int i = 0; i < g_nprimes; i++) {
if (g_primes[i] * g_primes[i] > n) {
return 1;
}
if (n % g_primes[i] == 0) {
return 0;
}
}
return 1;
}
static unsigned __stdcall count_primes(void *param) {
struct task *task = param;
for (unsigned long long n = task->lo; n < task->hi; n++) {
task->count += (unsigned long long)is_prime(n);
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
build_base_primes 先把 4472 以内的素数筛进 g_primes,之后每个线程只用试除这些小素数。这张表在创建线程之前就填好,之后只读,多个线程同时读它不需要任何同步。
主线程按下面的方式切分和计时:
chunk = (LIMIT - 2 + nthreads - 1) / (unsigned long long)nthreads;
for (int i = 0; i < nthreads; i++) {
tasks[i].lo = 2 + chunk * (unsigned long long)i;
tasks[i].hi = tasks[i].lo + chunk;
if (tasks[i].hi > LIMIT) {
tasks[i].hi = LIMIT;
}
tasks[i].count = 0;
threads[i] = (HANDLE)_beginthreadex(NULL, 0, count_primes, &tasks[i], 0, NULL);
if (threads[i] == NULL) {
printf("_beginthreadex failed: %lu\n", GetLastError());
return 1;
}
}
WaitForMultipleObjects((DWORD)nthreads, threads, TRUE, INFINITE);2
3
4
5
6
7
8
9
10
11
12
13
14
15
计时用 QueryPerformanceCounter:第一次取计数,工作做完再取一次,差值除以 QueryPerformanceFrequency 给的频率就是秒数。这个计数器分辨率优于微秒,只用于测时间间隔,与系统时钟无关;测绝对时间用 GetSystemTimePreciseAsFileTime(A.18 第 6.1 节)。本机频率是 10 MHz,一次调用之间差 0 个计数。
PS> gcc -std=c17 -Wall -Wextra -O2 -o primes_mt.exe primes_mt.c
PS> foreach ($n in 1,2,4,8,16) { ./primes_mt.exe $n }
1 thread(s): 1270607 primes below 20000000, 1.697 s
2 thread(s): 1270607 primes below 20000000, 1.021 s
4 thread(s): 1270607 primes below 20000000, 0.539 s
8 thread(s): 1270607 primes below 20000000, 0.238 s
16 thread(s): 1270607 primes below 20000000, 0.178 s2
3
4
5
6
7
实测机器是 16 个逻辑处理器的 Intel Core Ultra X7 358H,Windows 11 x64。四种线程数算出的素数个数都是 1270607,这说明切分没有漏段也没有重复。这一点比速度更重要,分段写错时常常表现为「线程越多结果越少」。
这台机器上重复运行几次,同一组线程数的耗时会有几个百分点的出入;下面按这一次的输出算。2 个线程快了 1.66 倍,4 个线程 3.15 倍,8 个线程 7.1 倍,16 个线程 9.5 倍。加速比比线程数少得越来越多,原因有两个:线程越多,每个线程分到的工作越少,创建、等待和汇总的固定开销占比越大;所有线程又都在读同一张素数表和相邻区间的数据,共享缓存与内存带宽会互相挤。换一台机器,这些数字全都会变,能带走的是趋势:增加线程的收益递减,加速比永远小于线程数。
线程函数里那句 task->count += ... 之所以不需要加锁,是因为每个线程写的是自己 tasks[i] 里的 count。如果 N 个线程都往同一个对象里加,结果会少掉一部分,那属于数据竞争,A.11 第一节专门演示。这里的选择是「各写各的,最后汇总」,它比加锁快得多,也是并行求和的常规写法。
线程数取多少
计算密集的任务取逻辑处理器数,I/O 密集的任务可以再多一些。Windows 的文档也是这个建议:创建与处理器数目相当的线程,其余请求排队交给它们处理。A.15 的线程池与 I/O 完成端口把这件事做成了现成的机制。
4. 线程的属性
4.1 线程 ID 与句柄
线程有两个标识:句柄与 ID。句柄是本进程句柄表里的索引,带访问权限,可以传给等待函数,也可以复制到别的进程(A.3)。线程 ID 是系统范围内的一个编号,GetCurrentThreadId 取自己的,GetThreadId(handle) 从句柄取。文档的措辞是:线程结束之前,这个 ID 在整个系统里唯一标识该线程;线程结束之后编号可以被回收给新线程。所以 ID 适合写日志、显示在调试器里,不适合当长期凭据。
printf("current thread id = %lu\n", (unsigned long)GetCurrentThreadId());4.2 栈的大小
每个线程有自己的栈,由保留区(reserve)与提交区(commit)两部分组成。保留区在虚拟地址空间里占位,提交区才计入系统的提交总量;栈按需增长,直到碰到保留区末尾那一页守护页(guard page),再往下就是栈溢出。
保留大小的默认值写在可执行文件头里,不是写死在系统里。用 objdump -p 可以直接看到:
PS> gcc -std=c17 -Wall -Wextra -O2 -o create_threads.exe create_threads.c
PS> objdump -p create_threads.exe | Select-String "SizeOfStackReserve|SizeOfStackCommit"
SizeOfStackReserve 0000000000200000
SizeOfStackCommit 00000000000010002
3
4
这是 MinGW 生成的程序,保留 0x200000 即 2 MiB,初始提交 4 KiB。同一份源码改用 MSVC 编译(cl /nologo /W4 /std:c17 create_threads.c),SizeOfStackReserve 是 0x100000,即 1 MiB。这个差别来自两份工具链的默认链接选项,与语言无关。要改默认值,MinGW 加 -Wl,--stack,1048576,MSVC 用 /STACK:1048576;本机实测改过之后 SizeOfStackReserve 变成 0x100000。
创建单个线程时也可以指定大小。CreateThread 的 dwStackSize 默认改的是提交区,加上 STACK_SIZE_PARAM_IS_A_RESERVATION 才是改保留区:
thread = CreateThread(NULL, 1024 * 1024, worker, NULL,
STACK_SIZE_PARAM_IS_A_RESERVATION, NULL);2
栈的保留区按分配粒度向上取整(64 KiB),提交区按页取整(4 KiB)。线程结束(包括正常返回)时栈被释放。要确认自己程序实际用的保留大小,看可执行文件头里的 SizeOfStackReserve 最直接;VirtualQuery 也能从栈上的一个局部对象出发,看出这段地址空间由哪些区域组成。
栈溢出在 Windows 上是一个结构化异常,进程以 0xC00000FD 结束(A.5 讲了异常如何变成退出码)。本机实测,一个递归不止的线程会让进程以 -1073741571 退出,即 0xC00000FD。把大数组放在栈上的函数在高并发下很容易撞上这个上限,改成 malloc 或者给线程指定更大的保留区都可以。
4.3 优先级与亲和性
SetThreadPriority 调整线程相对进程优先级的偏移,取值从 THREAD_PRIORITY_IDLE 到 THREAD_PRIORITY_TIME_CRITICAL,默认是 THREAD_PRIORITY_NORMAL(偏移 0)。SetThreadAffinityMask 把线程限制在指定的处理器上,本机 16 个逻辑处理器对应的掩码是 0xFFFF。本机实测把工作线程设成 THREAD_PRIORITY_BELOW_NORMAL 后,用句柄读到 -1,线程内用 GetCurrentThread 读到 -1,两者一致。
这两个函数一般程序都用不到。调高优先级会让线程抢占系统和其他程序的时间片,调低又可能让它得不到运行;绑核会破坏调度器对缓存与负载的分配。作者判断:除非在写实时采集、音视频这类有明确时限的代码,否则不要动它们;真需要时,先用测量证明瓶颈在这里。
4.4 给线程命名
调试器和崩溃转储里,一串线程 ID 很难对应到代码。Windows 10 1607 起提供 SetThreadDescription,可以给线程起一个名字,Visual Studio 的线程窗口和 WinDbg 的 ~ 命令都会显示它。这两个函数比本拓展的基线(Windows 10 1809)更早,可以直接用,但 MinGW 的头文件里没有声明,要像 A.7 那样从 kernel32 动态取地址:
typedef HRESULT(WINAPI *set_thread_description_fn)(HANDLE, PCWSTR);
typedef HRESULT(WINAPI *get_thread_description_fn)(HANDLE, PWSTR *);
g_set_thread_description =
(set_thread_description_fn)(void (*)(void))GetProcAddress(
kernel32, "SetThreadDescription");2
3
4
5
6
先转成 void (*)(void) 再转目标类型,是为了避开 GCC 的 -Wcast-function-type 警告(A.7 讲过同样的写法)。
PS> gcc -std=c17 -Wall -Wextra -O2 -o thread_name.exe thread_name.c
PS> ./thread_name.exe
main: SetThreadDescription -> 0x10000000 (SUCCEEDED: 1)
main: name is "mdr-main"
worker: SetThreadDescription -> 0x10000000 (SUCCEEDED: 1)
worker: name is "mdr-worker"2
3
4
5
6
返回值 0x10000000 值得多看一眼。它不是 S_OK(0),但最高位是 0,属于成功值,SUCCEEDED 宏判定为真。如果按 hr == S_OK 判断,这段代码在本机会被当成失败。凡是返回 HRESULT 的函数,一律用 SUCCEEDED/FAILED,这条规则在 A.5 讲过,这里是它在真实 API 上的一个例子。
GetThreadDescription 返回的字符串由系统分配,读完要用 LocalFree 释放,不是 free,也不是 CoTaskMemFree。名字随时可以改,后来的覆盖先前的;名字不要求唯一,两个线程同名也可以。给线程起名的收益主要体现在排查阶段:dump 里写着 mdr-worker 的线程,比写着 12376 的线程好认得多。
5. 线程本地存储
每个线程都需要一份自己的数据:错误消息、解析器的中间状态、日志的缩进层次。把它们放进全局对象,线程之间会互相覆盖;逐层往下传指针,接口会被这些参数塞满。线程本地存储(thread local storage,TLS)解决的正是这件事,它就是第一部分 23.5 说的线程局部存储,平台层的叫法多了「本地」两个字,指的是同一件东西。
Windows 提供三套接口:编译期的静态 TLS、运行期的动态 TLS,以及多出一个回调的纤程本地存储(fiber local storage,FLS)。
5.1 静态 TLS
C11 的 _Thread_local 在 MinGW 上可用,本机实测每个线程各有一份,互不影响:
static _Thread_local char g_error[64];
static unsigned __stdcall worker(void *param) {
int id = *(int *)param;
snprintf(g_error, sizeof g_error, "thread %d failed", id);
printf("thread %d says \"%s\"\n", id, g_error);
return 0;
}2
3
4
5
6
7
8
9
_Thread_local 的对象在每个线程里各有一份,初值在每次线程启动时按静态初始化的规则给出,访问方式与普通全局对象完全一样,不需要函数调用。它的限制是不能指定析构函数:如果每线程数据是 malloc 出来的,线程结束时没有人替你释放。
MSVC 的等价写法是 __declspec(thread),GCC 的扩展写法是 __thread。这里有一个容易踩的坑:MinGW 的 GCC 会把 __declspec(thread) 当成不认识的属性,给一条警告之后直接忽略,结果那个对象变成所有线程共享的普通全局对象。本机实测:
PS> gcc -std=c17 -Wall -Wextra -o dtls_probe.exe dtls_probe.c
dtls_probe.c:8:1: warning: 'thread' attribute directive ignored [-Wattributes]
8 | static __declspec(thread) int g_counter = 5;
| ^~~~~~
PS> ./dtls_probe.exe
worker: __declspec(thread) = 5
main: __declspec(thread) = 992
3
4
5
6
7
工作线程把值改成 99,主线程读到的也是 99。这不是 TLS,就是一个普通全局对象。如果源码要在两套编译器之间共用,用 _Thread_local:两边的 C 编译器都认标准写法。
5.2 动态 TLS
静态 TLS 的槽在编译时就定好了,适用于程序自己知道需要哪些数据的场合。库的情况不同:一个 DLL 被加载时才知道自己要不要 TLS,而它不该预先占用进程的静态 TLS 空间。动态 TLS 就是为这种场合准备的,四个函数构成全部接口:
DWORD TlsAlloc(void); /* 分配一个索引,失败返回 TLS_OUT_OF_INDEXES */
BOOL TlsSetValue(DWORD index, LPVOID value);
LPVOID TlsGetValue(DWORD index);
BOOL TlsFree(DWORD index);2
3
4
索引是进程范围的,任何一个线程分配,所有线程都能用同一个索引;槽是线程范围的,每个线程在每个索引下各有一个 LPVOID 大小的格子,线程创建时全部初始化为 NULL。所以典型用法是:初始化时 TlsAlloc 一次,把索引存进一个全局对象;每个线程要用时 TlsSetValue 把自己的指针放进去,之后 TlsGetValue 取回。一个进程至少保证有 64 个索引(TLS_MINIMUM_AVAILABLE),最多 1088 个。
有三条使用规则要记住。槽里只放得下一个指针,数据本身要另外分配,通常用 malloc。TlsGetValue 成功时也会清空线程的最后错误码,这是它与其他 Win32 函数不同的地方,所以不能只看返回值是否为 NULL 来判断失败。最重要的一条:TLS 没有析构函数,线程结束时系统把整张槽表丢掉,槽里的指针指向的内存不会自动释放。
5.3 FLS 与回调
FLS 把「线程结束时自动清理」补上了。FlsAlloc 接收一个回调函数,线程结束、纤程被删除、或者索引被 FlsFree 时,系统对每个还用着的槽调用一次回调,参数就是槽里存的值:
static VOID WINAPI fls_cleanup(PVOID value) {
free(value);
}
static DWORD g_fls_index;
static unsigned __stdcall worker(void *param) {
char *block = malloc(64);
if (block == NULL) {
return 1;
}
snprintf(block, 64, "thread %d scratch", *(int *)param);
FlsSetValue(g_fls_index, block);
/* ... 使用这块缓冲区 ... */
return 0; /* 不调用 free,线程结束时回调会做 */
}
int main(void) {
g_fls_index = FlsAlloc(fls_cleanup);
if (g_fls_index == FLS_OUT_OF_INDEXES) {
printf("FlsAlloc failed: %lu\n", GetLastError());
return 1;
}
/* ... 创建线程、等待、FlsFree ... */
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
FlsSetValue、FlsGetValue、FlsFree 与 TLS 的三个同名函数一一对应,语义也一样。回调的用途就是释放内存,参数是该线程存进去的那个值,所以它适合做「释放本线程资源」这件事,不适合在里面等别的线程。它相当于 POSIX 的 pthread_key_create 的析构函数,也相当于 C11 tss_create 的 dtor。
5.4 三种方式放在一起
下面这个程序让三个线程各自记录一条错误消息,四种存法并排比较:共享的全局缓冲区(错误做法)、静态 TLS、动态 TLS 与 FLS。每个线程写完消息后睡一会儿,再回头读自己写的那条,好让别的线程有机会覆盖共享缓冲区。
static char g_shared_error[64]; /* 所有线程共用,写错的做法 */
static _Thread_local char g_static_error[64]; /* 静态 TLS,每个线程一份 */
static DWORD g_tls_index; /* 动态 TLS 的索引,全进程共用一个 */
static DWORD g_fls_index; /* FLS 的索引,线程结束时调用登记的回调 */
static LONG g_callback_count; /* 记录回调被调用的次数 */
static VOID WINAPI fls_cleanup(PVOID value) {
InterlockedIncrement(&g_callback_count);
printf(" fls callback: thread %lu frees \"%s\"\n", GetCurrentThreadId(),
(const char *)value);
free(value);
}
static unsigned __stdcall worker(void *param) {
int id = *(int *)param;
char *tls_block;
char *fls_block;
snprintf(g_shared_error, sizeof g_shared_error, "thread %d failed", id);
snprintf(g_static_error, sizeof g_static_error, "thread %d failed", id);
tls_block = malloc(64);
fls_block = malloc(64);
snprintf(tls_block, 64, "thread %d failed", id);
TlsSetValue(g_tls_index, tls_block);
snprintf(fls_block, 64, "thread %d failed", id);
FlsSetValue(g_fls_index, fls_block);
/* 让其他线程有机会跑完,再回头读四种存放方式 */
Sleep(100 + id);
printf("thread %d: shared \"%s\", static TLS \"%s\", dynamic TLS \"%s\", "
"FLS \"%s\"\n",
id, g_shared_error, g_static_error,
(const char *)TlsGetValue(g_tls_index),
(const char *)FlsGetValue(g_fls_index));
/* 动态 TLS 的槽要自己释放,系统不会替你调用 free */
free(TlsGetValue(g_tls_index));
TlsSetValue(g_tls_index, NULL);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
PS> gcc -std=c17 -Wall -Wextra -O2 -o tls_demo.exe tls_demo.c
PS> ./tls_demo.exe
TLS index = 1, FLS index = 5
thread 0: shared "thread 0 failed", static TLS "thread 0 failed", dynamic TLS "thread 0 failed", FLS "thread 0 failed"
thread 2: shared "thread 0 failed", static TLS "thread 2 failed", dynamic TLS "thread 2 failed", FLS "thread 2 failed"
thread 1: shared "thread 0 failed", static TLS "thread 1 failed", dynamic TLS "thread 1 failed", FLS "thread 1 failed"
fls callback: thread 28792 frees "thread 2 failed"
fls callback: thread 26832 frees "thread 0 failed"
fls callback: thread 14000 frees "thread 1 failed"
main: static TLS "" (its own zero-initialized copy)
main: FLS callback ran 3 time(s)2
3
4
5
6
7
8
9
10
11
第一列三条记录全是「thread 0 failed」。共享缓冲区只有一个,谁最后写就留下谁的内容,这一次是线程 0 最后写,线程 1 和线程 2 读到的都是它;换一次运行,最后写的人可能换成别的线程,这一列的内容也就跟着变。其余三列各自读回自己的消息。主线程没有写过 g_static_error,读到的是空字符串,因为每个线程的那一份从零开始初始化,包括主线程。
三个 fls callback 行说明回调确实在线程退出时被调用了,每次带上那个线程存的值,释放的也是各自的内存。动态 TLS 的那块内存是在 worker 里手动 free 的;如果不写那两行,程序不会报错,只是每次运行漏掉三小块,长时间运行的服务里这就是内存增长。需要「线程结束自动释放」时,用 FLS,别用 TLS 加一个人工记得调用的清理函数。
三套接口的对应关系:
| 要做的事 | C11 | POSIX | Win32 |
|---|---|---|---|
| 编译期确定、每线程一份的对象 | _Thread_local | _Thread_local 或 __thread | _Thread_local(MSVC 也认 __declspec(thread)) |
| 运行时分配索引 | tss_create(&key, dtor) | pthread_key_create(&key, dtor) | TlsAlloc(),无回调 |
| 写入 | tss_set(key, p) | pthread_setspecific(key, p) | TlsSetValue(index, p) |
| 读取 | tss_get(key) | pthread_getspecific(key) | TlsGetValue(index) |
| 释放索引 | tss_delete(key) | pthread_key_delete(key) | TlsFree(index) |
| 线程退出时自动清理 | 析构函数 | 析构函数 | FlsAlloc(callback) 的回调 |
选择上的判断:编译期就知道要哪些数据、数据不大,用 _Thread_local,它最快也最简单。库代码、槽的数量在运行时才知道,用 TlsAlloc。需要在用完之后自动释放(尤其是 malloc 出来的内存、打开的文件句柄),用 FlsAlloc。同一份源码要给 Linux 用,就按 POSIX 那一列写,两边都有对应物。
6. 写错了会怎样
第 1 节说过线程可能比 CreateThread 返回得更早开始运行,也说过参数指向的对象必须活到线程用完。最常见的违反方式是把循环对象的地址直接传进去:
for (int i = 0; i < N; i++) {
threads[i] = (HANDLE)_beginthreadex(NULL, 0, worker, &i, 0, NULL);
}2
3
&i 是同一个地址。等线程真正开始读它时,主线程的循环可能已经跑完,i 停在 N;也可能正改到一半。程序照常编译,照常运行,只是每个线程打印出来的编号都不对。
for (int i = 0; i < N; i++) {
threads[i] = (HANDLE)_beginthreadex(NULL, 0, worker, &i, 0, NULL);
}2
3
struct job jobs[N];
for (int i = 0; i < N; i++) {
jobs[i].index = i;
threads[i] = (HANDLE)_beginthreadex(NULL, 0, worker, &jobs[i], 0, NULL);
}2
3
4
5
6
右边为每个线程准备了一个结构体元素,地址各不同,线程读到的 index 就是创建时的值。本机实测,左边四个线程全部打印 4:
PS> gcc -std=c17 -Wall -Wextra -O2 -o pass_index_bug.exe pass_index_bug.c
PS> ./pass_index_bug.exe
buggy worker got index 4
buggy worker got index 4
buggy worker got index 4
buggy worker got index 4
---
fixed worker got index 0
fixed worker got index 1
fixed worker got index 3
fixed worker got index 22
3
4
5
6
7
8
9
10
11
四个 4 的原因是 Sleep(20) 让创建循环先跑完:循环结束时 i 等于 4,四个线程醒来读的是同一个对象,读到的都是循环退出时的值。右边一组的顺序 0、1、3、2 每次运行都不同,那是线程启动顺序,不是错误。
这个错误还有一个变体更隐蔽:参数数组放在了 main 的局部,main 提前 return 了。进程结束时所有线程一起消失,它们可能一条结果都没写出来,而程序看起来「正常退出」。凡是让线程访问的数组、结构体,都要保证主线程等到它们用完再释放,这正是第 2 节的 WaitForMultipleObjects 的作用,它既是回收线程,也是保证数据生存期的手段。
至于多个线程同时写同一个计数器会丢结果,那是数据竞争,与参数的生存期是两个问题。A.11 第一节会用一个两线程各加一百万次的程序演示,并给出互锁函数与锁的修法。
7. 习题
7.1 巩固
(1) [1.1] create_threads.c 里两个线程的退出码都是 0x2A06B550,而不是 5000050000。为什么?如果想让主线程拿到完整的和,应该怎么做?
(2) [1.1] 程序在等待之前就调用 GetExitCodeThread 时读到 259。259 是什么值?为什么它适合(也只适合)作为「线程还在跑」的标志?
(3) [2.2] 把 CloseHandle(threads[0]) 挪到 WaitForMultipleObjects 之前,编译运行,说明 WaitForMultipleObjects 的返回值与 GetLastError() 是什么,并解释原因。
下面的程序每次运行打印的数都不一样,有时四个数全是 4。指出原因,并说明把 Sleep 去掉之后为什么输出多半还是错的。
static DWORD WINAPI worker(LPVOID param) {
Sleep(20);
printf("%d ", *(int *)param);
return 0;
}
int main(void) {
HANDLE threads[4];
for (int i = 0; i < 4; i++) {
threads[i] = CreateThread(NULL, 0, worker, &i, 0, NULL);
}
WaitForMultipleObjects(4, threads, TRUE, INFINITE);
for (int i = 0; i < 4; i++) {
CloseHandle(threads[i]);
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
分别用 -O0 和 -O2 编译,各运行五次,记录输出。再改一版:给 worker 加一个 volatile LONG *counter,让每个线程先 Sleep(20) 再把自己的编号写进一个全局数组,编号由 InterlockedIncrement 分配。运行几次,看结果与前一版的差别。
7.2 编程
写 threads_sum.c:用 _beginthreadex 创建 4 个线程,把 1 到 1000000000 均分成 4 段,每个线程求自己那一段的和,写进自己的结构体;主线程用 WaitForMultipleObjects 等待,汇总后与公式
要求:
- 线程函数返回 0,结果通过参数结构体带出,不要依赖 32 位退出码;
- 每段的上下界由主线程算好,线程函数里不再做除法;
- 打印每段的和与总和,总和必须是 500000000500000000。
验收:连续运行三次,结果一致;把线程数改成 3 和 7 再运行,总和不变。
写 thread_attrs.c,做三件事:
- 创建一个线程,用
STACK_SIZE_PARAM_IS_A_RESERVATION指定 256 KiB 的保留栈,在线程函数里从栈上的局部对象出发,用VirtualQuery走出整个分配区,打印保留大小; - 用
GetProcAddress取得SetThreadDescription,给这个线程起名mdr-attrs,主线程在等待结束后用GetThreadDescription读回并打印,记得释放; - 打印
GetCurrentThreadId()、GetThreadId(handle)与CreateThread输出参数给的线程 ID,确认三者相同。
验收:把同一分配基址的几个区域加起来,结果应当是 256 KiB(262144 字节);名字读回是 mdr-attrs;三个 ID 相同。再用默认栈创建同一个线程,比较两次的结果,后者应当在 2 MiB 附近。
写 error_slots.c:实现两个函数
void set_thread_error(const char *message);
const char *get_thread_error(void);2
要求:
- 内部用
FlsAlloc分配的索引和回调,回调里free掉消息副本;线程第一次设置错误时malloc一块新缓冲区,之后再设置就复用或重新分配; - 起 5 个工作线程,每个设置一条形如
thread %d: error %d的消息,Sleep一会儿再读回,打印结果; - 主线程在所有线程结束后打印 FLS 回调被调用的次数,必须等于线程数;
- 再写一个
tls_version.c用TlsAlloc实现同样的接口,故意不写释放代码;在两版里各维护一个InterlockedAdd64的分配字节计数和一个释放字节计数,比较两者在运行结束时的差值。
验收:error_slots 的回调次数为 5,分配与释放的字节数相等;tls_version 的释放计数为 0,分配计数是 5 块;每个线程读回的是自己那条消息;两版的输出除释放部分外一致。
写 line_count.c:在程序里生成一个约 50 MB、每行 60 到 80 字节的文本文件,然后用两种方式统计行数:
- 单线程:一次读完整个文件,数字节;
- 4 个线程:把文件按字节均分,每个线程只统计自己区间里的换行符,最后求和。
要求:
- 用
_beginthreadex创建线程,每个线程的结果写进自己的结构体; - 线程读取文件用
CreateFileW+ReadFile,每块 64 KiB(A.4); - 用
QueryPerformanceCounter分别计时,打印两种方式的行数与耗时; - 说明为什么按字节切分不会把某一行的换行符切掉,以及如果统计的是「单词数」需要额外处理什么。
验收:两种方式的行数相同;4 线程的耗时不高于单线程(在 SSD 上可能接近,说明原因)。
7.3 综合与挑战
下面这段程序本意是让 4 个线程各自把 g_sum 加 100000 次,并各记一条日志,最后打印总和与日志。它有几处缺陷:
#define N 4
#define ITERS 100000
static long long g_sum;
static char g_log[64];
static unsigned __stdcall worker(void *param) {
int *index = param;
for (int i = 0; i < ITERS; i++) {
g_sum++;
}
snprintf(g_log, sizeof g_log, "worker %d done", *index);
return 0;
}
int main(void) {
HANDLE threads[N];
for (int i = 0; i < N; i++) {
threads[i] = (HANDLE)_beginthreadex(NULL, 0, worker, &i, 0, NULL);
}
WaitForMultipleObjects(N, threads, TRUE, INFINITE);
for (int i = 0; i < N; i++) {
CloseHandle(threads[i]);
}
printf("sum = %lld, expected %d\n", g_sum, N * ITERS);
printf("log = %s\n", g_log);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
(1) [3.2] 不改代码运行三次(不加优化选项),记录 sum 与 log 的输出。找出三处缺陷,分别说明症状。
(2) [3.2] 逐个修复。改成每个线程把自己的日志写进 struct job 的元素之后,log 部分应当稳定输出每条线程自己的消息;g_sum 那一处先用互锁函数修,确认每次都是 400000。
(3) [4.3] 互锁函数属于 A.11 的内容。在此之前,先给出一个不加同步、但结果稳定的替代设计:让每个线程把自己那份计数写进结构体,主线程汇总。说明这种设计为什么不需要同步。
(4) [4.4*] 把日志缓冲区换成 _Thread_local,每个线程各记一条,主线程结束后如何把四条都打印出来?给出代码并说明为什么主线程读不到它们。
静态切分在每一段的工作量差不多时最省事,工作量不均匀时就会有的线程早早做完、有的还在跑。把 primes_mt.c 改成动态领活:准备一个 chunk_size 参数的块队列(例如每块 100000 个数),线程用 InterlockedIncrement 领下一个块号,直到块号超出范围为止。
要求:
- 命令行参数为线程数与块大小:
primes_mt_dyn.exe 4 100000; - 每个线程把自己处理的块数、统计到的素数个数写进自己的结构体;
- 与静态切分版本对比 1、2、4、8 线程的耗时,块大小取 100000 和 2000000 各测一次;
- 解释块大小对耗时的两种影响(太小则领活开销大,太大则负载不均)。
验收:两种切分算出的素数总数都是 1270607;动态版本在 8 线程下与静态版本的差距不超过 20%;能说出本机上块大小取哪个值更好。
给一批工作线程加上「合作式取消」与每线程资源自动释放。写 cancel_workers.c:
- 4 个工作线程,每个从
FlsAlloc登记的槽里取一块 1 MiB 的临时缓冲区(malloc),在循环里做一个小计算并周期性检查一个volatile LONG停止标志; - 主线程在 100 毫秒后把停止标志置 1,然后等待所有线程结束;
- 工作线程在正常结束、提前返回、出错返回三条路径上都不手动
free缓冲区,只靠 FLS 回调释放; - 打印每个线程的循环次数、FLS 回调被调用的次数与释放的字节数;
- 再写一个版本,把检查停止标志的间隔从每 1000 次改成每 1000000 次,比较主线程置位到最后一个线程退出之间的延迟(用
QueryPerformanceCounter测量)。
要求与验收:
- 不允许调用
TerminateThread; - 回调次数等于线程数,释放的总字节数等于 4 MiB;
- 检查间隔越大,取消延迟越长,用两组实测数字说明,并给出一个在「检查开销」与「响应速度」之间取平衡的间隔;
- 加分项:把
malloc与free包一层,用一对InterlockedAdd64计数器统计分配与释放的字节数,程序末尾打印两者的差,用 0 证明没有泄漏。
8. 参考资料
- Microsoft Learn,Creating Threads:https://learn.microsoft.com/en-us/windows/win32/procthread/creating-threads
- Microsoft Learn,CreateThread function:https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createthread
- Microsoft Learn,_beginthread, _beginthreadex:https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/beginthread-beginthreadex
- Microsoft Learn,Terminating a Thread:https://learn.microsoft.com/en-us/windows/win32/procthread/terminating-a-thread
- Microsoft Learn,Thread Stack Size:https://learn.microsoft.com/en-us/windows/win32/procthread/thread-stack-size
- Microsoft Learn,SetThreadDescription function:https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-setthreaddescription
- Microsoft Learn,GetThreadDescription function:https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getthreaddescription
- Microsoft Learn,GetCurrentThreadId function:https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getcurrentthreadid
- Microsoft Learn,Thread Local Storage:https://learn.microsoft.com/en-us/windows/win32/procthread/thread-local-storage
- Microsoft Learn,Using Thread Local Storage:https://learn.microsoft.com/en-us/windows/win32/procthread/using-thread-local-storage
- Microsoft Learn,TlsGetValue function:https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-tlsgetvalue
- Microsoft Learn,FlsAlloc function:https://learn.microsoft.com/en-us/windows/win32/api/fibersapi/nf-fibersapi-flsalloc
- Microsoft Learn,PFLS_CALLBACK_FUNCTION callback function:https://learn.microsoft.com/en-us/windows/win32/api/winnt/nc-winnt-pfls_callback_function
- Microsoft Learn,QueryPerformanceCounter function:https://learn.microsoft.com/en-us/windows/win32/api/profileapi/nf-profileapi-queryperformancecounter
- Johnson M. Hart,Windows System Programming,第 4 版,第 7 章。
- Pavel Yosifovich,Windows 10 System Programming, Part 1,第 5 章。
- Jeffrey Richter、Christophe Nasarre,Windows via C/C++,第 5 版,第 7、10 章。