Skip to content
Published at:

第5章:线程基础

引言

进程(Process)是管理对象,本身不直接执行代码。要在 Windows 上完成任务,必须创建线程(Thread)。每个线程都是一个独立的执行路径(Execution Path),从执行角度来看,它与同一时间可能处于活动状态的其他线程无关。

使用线程的两个主要原因:

  1. 提高性能——利用多个核心并发执行
  2. 改进设计——即便单线程也可实现,但多线程常使设计更合理

线程可以执行 CPU 密集型操作、I/O 密集型操作,或等待同步原语(Synchronization Primitive),如互斥锁(Mutex)。

套接字(Sockets)、内核和逻辑处理器

在讨论线程之前,有必要理解 CPU 的硬件组成。一个典型 CPU 的逻辑组成如下:

  • 套接字(Socket):主板上的物理芯片。一个主板可以有多个插槽。
  • 核心(Core):每个插槽内包含多个核心,它们是独立的处理器单元。
  • 逻辑处理器(Logical Processor):在 Intel 处理器上,每个核心通过超线程(Hyper-Threading)技术划分为两个逻辑处理器,也称硬件线程(Hardware Thread)。

从 Windows 角度看,处理器的数量就是逻辑处理器的数量,这决定了任意时刻最多可运行的线程数。AMD 的相应技术称为同步多线程(SMT, Simultaneous Multi-Threading)。

超线程可以在 BIOS 中禁用。其潜在缺点是:共享同一核心的两个逻辑处理器也共享二级缓存(L2 Cache),可能相互"干扰",在某些场景下反而降低性能。

创建和管理线程

CreateThread 函数

Windows 中创建线程的基本函数是 CreateThread,其原型为:

cpp
HANDLE WINAPI CreateThread(
    _In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes,
    _In_ SIZE_T dwStackSize,
    _In_ LPTHREAD_START_ROUTINE lpStartAddress,
    _In_opt_ LPVOID lpParameter,
    _In_ DWORD dwCreationFlags,
    _Out_opt_ LPDWORD lpThreadId);

参数说明:

  • lpThreadAttributes:安全属性,通常为 NULL

  • dwStackSize:线程栈(Thread Stack)大小。设为 0 则使用 PE 文件头中指定的默认栈大小。

  • lpStartAddress:新线程执行的用户函数指针。必须遵循以下原型:

    cpp
    DWORD WINAPI ThreadProc(_In_ PVOID pParameter);

    线程函数必须返回一个 32 位数字作为退出代码(Exit Code)。

  • lpParameter:传递给线程函数的用户自定义值,类型为 PVOID

  • dwCreationFlags:可选值包括 CREATE_SUSPENDED(以挂起状态创建线程,需调用 ResumeThread 后才会执行)、STACK_SIZE_PARAM_IS_A_RESERVATION(改变 dwStackSize 参数的含义,将提交大小改为保留大小),或 0(立即执行)。

  • lpThreadId:输出参数,返回新线程的 ID。如果不需要,可传入 NULL

返回值是新线程的句柄(Handle),失败时返回 NULL。使用完毕后应调用 CloseHandle 关闭句柄。

基本示例

以下示例展示了创建线程、等待线程退出并获取退出码的完整流程:

cpp
DWORD WINAPI DoWork(PVOID) {
    printf("Thread ID running DoWork: %u\n", ::GetCurrentThreadId());
    ::Sleep(3000);
    return 42;
}

int main() {
    HANDLE hThread = ::CreateThread(nullptr, 0, DoWork, nullptr, 0, nullptr);
    if (!hThread) {
        printf("Failed to create thread (error=%d)\n", ::GetLastError());
        return 1;
    }
    printf("Main thread ID: %u\n", ::GetCurrentThreadId());
    ::WaitForSingleObject(hThread, INFINITE);
    DWORD result;
    ::GetExitCodeThread(hThread, &result);
    printf("Thread done. Result: %u\n", result);
    ::CloseHandle(hThread);
    return 0;
}

输出示例:

Main thread ID: 19108
Thread ID running DoWork: 23700
Thread done. Result: 42

GetExitCodeThread

GetExitCodeThread 用于获取线程函数返回的退出代码。如果对尚未退出的线程调用此函数,会返回 STILL_ACTIVE(值为 0x103,即十进制 259)。

质数计数器应用程序

本节构建一个使用多线程统计质数数量的应用程序。该应用将指定数字范围分成多个子范围,分配给多个线程分别计算,主线程等待所有工作线程退出后汇总结果。这种模式称为分治合并(Fork-Join),也称为结构化并行(Structured Parallelism)。

数据结构

cpp
struct PrimesData {
    int From, To;
    int Count;
};

每个线程的 PrimesData 实例存储其负责的数字范围(FromTo)和计数结果(Count)。

主函数逻辑

主函数接收三个命令行参数:起始数字 from、终止数字 to、线程数量 threads。为简化实现,线程数限制为 64,因为 WaitForMultipleObjects 一次最多等待 64 个句柄。

CalcAllPrimes 函数

该函数使用 GetTickCount64 记录开始时间,然后为每个线程分配 PrimesData 实例和句柄:

cpp
int chunk = (to - from + 1) / threads;
for (int i = 0; i < threads; i++) {
    auto& d = data[i];
    d.From = i * chunk;
    d.To = i == threads - 1 ? to : (i + 1) * chunk - 1;
    DWORD tid;
    handles[i] = ::CreateThread(nullptr, 0, CalcPrimes, &d, 0, &tid);
    assert(handles[i]);
    printf("Thread %d created. TID=%u\n", i + 1, tid);
}

关键逻辑:最后一个线程负责处理剩余部分(从 (threads - 1) * chunkto),确保每个数字都被覆盖。

CalcPrimes 线程函数

cpp
DWORD WINAPI CalcPrimes(PVOID param) {
    auto data = static_cast<PrimesData*>(param);
    int from = data->From, to = data->To;
    int count = 0;
    for (int i = from; i <= to; i++)
        if (IsPrime(i)) count++;
    data->Count = count;
    return count;
}

每个线程遍历自己负责的范围,对每个数字调用 IsPrime 检查是否为质数,将结果存入共享数据结构的 Count 字段。

IsPrime 函数

IsPrime 函数检查一个数字是否为质数。基本算法:遍历从 2 到 sqrt(n) 的整数,如果存在整除因子则该数不是质数。

等待与结果收集

所有线程创建完毕后,主线程调用 WaitForMultipleObjects 等待所有线程退出:

cpp
WaitForMultipleObjects(threads, handles.get(), TRUE, INFINITE);

之后使用 GetThreadTimes 获取每个线程的内核时间(Kernel Time)和用户时间(User Time),并汇总所有线程的质数计数。

运行质数计数器

在 16 个逻辑处理器的系统上,对范围 3 ~ 20,000,000 进行测试,结果如下:

线程数耗时(毫秒)
19218
25984
43141
81766
161188
201109

观察结论

  • 执行时间的改善并非线性。线程数从 1 增加到 2 时,时间并未减半(阿姆达尔定律 Amdahl's Law 的体现)。
  • 使用比逻辑处理器数量更多的线程(20 > 16)反而进一步减少了执行时间。

后者看似违反直觉,原因是工作分配不均匀:随着数字增大,平方根函数的输出与输入成反比,导致后面的线程(负责更大数字范围)工作量更大。较早完成的线程释放处理器,使得额外的线程能获得处理器继续推进工作。

但这种策略存在极限——上下文切换(Context Switch)开销和页面错误(Page Fault)最终会使过多线程反而降低性能。

终止线程

线程有以下三种终止方式:

1. 线程函数返回(最佳选择)

线程函数正常返回是推荐的做法。返回值即线程的退出代码。这是最干净、最安全的终止方式。

2. 调用 ExitThread(应避免)

cpp
VOID WINAPI ExitThread(DWORD dwExitCode);

此函数不会返回到调用者,因此不会调用线程栈上对象的 C++ 析构函数,可能导致资源泄漏。但它会以 DLL_THREAD_DETACH 原因调用所有 DLL 的 DllMain 函数。除非有非常特殊的原因,否则不应使用。

3. 通过 TerminateThread 被终止(通常是最坏的选择)

cpp
BOOL WINAPI TerminateThread(HANDLE hThread, DWORD dwExitCode);

此函数可由另一线程发起,需要对被终止线程具有 THREAD_TERMINATE 访问掩码。

TerminateThread 的严重问题:

  • 无法确定目标线程执行到了哪条指令——哪些操作已完成、哪些未完成,可能导致应用状态不一致。
  • 不会DLL_THREAD_DETACH 原因调用 DLL 的 DllMain,导致 DLL 无法执行清理代码。
  • 被终止线程持有的临界区(Critical Section)等资源无法释放。

除非极少数极端情况(例如进程即将终止且别无选择),否则应避免使用 TerminateThread

另外,OpenThread 函数可用于获取任意线程的句柄,用法与 OpenProcess 类似,需要指定线程 ID 和期望的访问掩码。

线程栈

局部变量(Local Variable)和函数返回地址(Return Address)存储在线程栈(Thread Stack)中。每个线程都有自己独立的栈。影响线程栈的两个关键值:

  • 保留内存大小(Reserved Size):栈的最大大小,即栈可以增长到的最大空间。
  • 初始已提交内存大小(Initial Committed Size):栈创建时即可直接使用的内存大小。

Windows 内存管理器对栈采用按需提交(Lazy Commit)的优化策略:创建线程时先提交较小的内存;当栈增长超过已提交大小,访问下一页会触发 PAGE_GUARD(保护页)异常。内存管理器捕获此异常后提交额外的物理页面,并将保护页下移一页(栈向低地址增长)。保护页的最小值为 12KB(3 页)。

线程函数结束时,保护页自动移除。

CreateThreaddwStackSize 参数设为 0 时,从 PE 头获取默认栈大小。使用 dumpbin /headers 工具可以查看。例如 Windows 记事本(notepad.exe)的默认提交大小为 0x11000(68KB),保留大小为 0x80000(512KB)。

在 Visual Studio 中,可通过项目属性的 "Linker/System" 节点更改默认栈大小,对应链接器选项 /STACK

此外,SetThreadStackGuarantee 函数可以设置线程栈保证可用的最小大小,确保在极端情况下仍有足够的栈空间执行关键代码(如异常处理)。

线程名称

从 Windows 10 和 Windows Server 2016 开始,可以通过 SetThreadDescription API 为线程设置名称/描述:

cpp
HRESULT SetThreadDescription(
    _In_ HANDLE hThread,
    _In_ PCWSTR lpThreadDescription);

使用此函数时,线程句柄需具有 THREAD_SET_LIMITED_INFORMATION 访问掩码。线程名称仅存储在线程内核对象中,用作调试辅助工具——无法通过名称查找线程。

在 Visual Studio 2019 及以上版本的调试器中,"线程(Threads)"窗口中会显示通过此 API 设置的名称,方便调试时区分各个线程。

对应的反向函数 GetThreadDescription 可获取线程描述:

cpp
PWSTR name;
if (SUCCEEDED(::GetThreadDescription(::GetCurrentThread(), &name))) {
    printf("Name: %ws\n", name);
    ::LocalFree(name);
}

调用成功后,返回的字符串需要调用 LocalFree 释放内存。

C++ 标准库呢?

C++11 标准引入了 std::thread 及其配套的线程机制(std::mutexstd::condition_variable 等)。使用 C++ 标准库的最大好处是跨平台性——同一份代码可在 Windows、Linux、macOS 等多个平台上编译运行。

然而,其缺点也很明显:可定制性很少。C++ 标准库不支持以下特性:

  • 线程优先级(Thread Priority)
  • 线程亲缘性(Thread Affinity)
  • CPU 集(CPU Sets)
  • 堆栈大小控制(Stack Size Control)
  • 其他 Windows 特有的线程属性

只有直接使用 Windows API 才能实现这种程度的精细控制。因此,在需要深度操作系统级控制的场景下,Windows 原生 API 仍然是必要的选择。

练习

  1. 创建基于 WTL 对话框的应用程序,在指定数字范围内计算质数。使用单独线程执行计算,避免阻塞 UI 线程。
  2. 在上述对话框中添加"取消"按钮,支持在计算过程中取消当前操作。
  3. 创建控制台应用,使用多线程并发计算曼德勃罗集(Mandelbrot Set)。将总行数除以线程数分配行范围,结果存入二维数组(0 表示属于该集合,1 表示不属于)。
  4. 扩展上述应用,将输出写入 BMP 或 PPM 格式文件以便可视化查看计算结果。
  5. 创建 WTL 应用程序,使用多线程计算曼德勃罗集同时不冻结 UI。添加平移/缩放功能,并按需重新计算。

总结

本章涵盖了 Windows 下线程创建和管理的基础知识:

  • 线程是 Windows 中的最小执行单元,进程本身不直接执行代码。
  • 使用 CreateThread 创建线程,通过 WaitForSingleObject / WaitForMultipleObjects 等待线程退出。
  • Fork-Join 模式是实现多线程并行计算的基本范式。
  • 线程终止应通过线程函数正常返回,避免使用 ExitThreadTerminateThread
  • 线程栈通过保护页机制按需增长,栈大小可从 PE 头配置。
  • Windows 10+ 提供了 SetThreadDescription 用于设置线程名称辅助调试。
  • C++ 标准库提供跨平台线程支持,但在精细控制方面不如 Windows 原生 API。

下一章将讨论线程调度(Thread Scheduling)及其相关属性,如优先级和亲缘性。