Win32 线程池 API ── 用 CreateThreadpoolWork 实现不自建线程的并发

· · Windows, 多线程, C++, Windows 开发, Win32 API, 性能改善

「每个客户端一条 CreateThread。」「定时器一条。」「等事件一条。」——在原生 Windows 代码里,线程往往会这样增生。每条线程都消耗栈和内核对象,创建和销毁也有成本。工作很细,线程却重——线程池就是操作系统为吸收这一错配而提供的。

.NET 的 ThreadPoolTask.Run 的便利广为人知,但其实Win32 原生也有设计良好的、操作系统标准的线程池 API。在 Windows Vista 全面重新设计之后,这套 API 是原生并发的基础:可以用统一的回调机制处理工作、定时器、等待和异步 I/O。面向用 C/C++ 编写 Windows 应用、服务和 DLL 的开发者,本文根据一手资料讲解这套 API 的结构、用法,以及容易踩的坑。

1. 先说结论

  • 大量发出短命工作,以及替换只负责等待的线程,线程池优于自建 CreateThread把线程管理交给操作系统,可以减少线程数和上下文切换。1
  • 该用的是新 API(CreateThreadpoolWork 系列)。Vista 的重新设计使其比旧 API(QueueUserWorkItem 系列)更简单、更可靠、性能更高,还可以在一个进程内创建多个独立的池。12
  • 对象有四种。向其提交工作的 work;按时刻或周期触发的 timer;内核对象有信号时触发的 wait;异步 I/O 完成时触发的 io。它们都搭乘同一套回调机制。3
  • 关闭是「先等待,再关闭」。WaitForThreadpoolWorkCallbacks 系列等待完成,或经清理组批量处理——不把正在运行的回调丢下,这一纪律是必需的。4
  • 回调内部:不要长时间阻塞(若会,用 CallbackMayRunLong);不要同步等待同一池上的完成;不要弄脏线程状态。这三条是铁则。56
  • 从 DLL 使用:留意卸载竞态。在显式关闭函数中等待完成,并了解 FreeLibraryWhenCallbackReturns 等专用 API。3

2. 为何用池,何时用池

线程池的想法很简单。不是按工作创建线程,而是把工作(回调)扔给操作系统管理的一组工作线程。工作线程依次执行工作,操作系统按负载调整数量。

官方文档列出了池划算的具体应用类型。1

  • 大量并行发出小型工作项的应用(搜索、网络 I/O 等)
  • 频繁创建和拆除短命线程的应用
  • 在后台并行处理独立工作的应用
  • 持有专门等待内核对象或事件的线程的应用

最后一项容易被忽略。若有五条线程只为「事件有信号时再跑」而睡眠,就可以换成池上的五个 wait 对象,等待被汇总到池的等待线程上。

反过来,也有不适合池的工作。需要改变线程优先级、需要 COM STA、在进程整个生存期内一直运行——需要线程上「个性」的工作放在专用线程上。工作线程是共享资源;是借来的。

在专用线程与池之间选择先检查是否需要优先级或 STA 等线程个性,以及是否长时间运行;两者都不符合的短命、大量或等待型工作才放到线程池需要优先级或 STA 等个性?留在专用线程上会长时间运行吗?放到线程池短命工作、等待、定时器、I/O 完成

图 1: 可以放到池上的工作只有「不需要个性且很快结束」的。其他一切仍像以前一样留在专用线程上。

还有一段历史要钉死。线程池 API 有两代。自 Windows 2000 延续下来的旧 API(QueueUserWorkItemRegisterWaitForSingleObject 等),以及 Vista 全面重新设计的新 APICreateThreadpoolWork 系列)。新 API 统一了工作线程种类,提供专用持久线程、同一进程内多个池、清理组等,官方文档明文写它「更简单、更可靠、性能更好、更灵活」。1 旧 API 还有「一旦入队就无法取消工作」等结构性约束。2 此后本文只讲新 API。

旧线程池 API 与新 API 的对应旧 API 的 QueueUserWorkItem 对应新 API 的 work 对象,定时器队列对应 timer,注册等待对应 wait,BindIoCompletionCallback 对应 ioQueueUserWorkItemworkTimer queuestimerRegistered waitswaitBindIoCompletionCallbackio

图 2: 从旧 API 的迁移目标是一一对应。盘点既有代码可以从这对应对应开始。

3. 四种对象 ── work、timer、wait 与 io

新 API 的中心是四种回调触发条件不同的对象。3

对象 创建函数 回调何时触发
work CreateThreadpoolWork 用 SubmitThreadpoolWork 提交时
timer CreateThreadpoolTimer 指定的时刻或周期到达时
wait CreateThreadpoolWait 内核对象变为有信号时
io CreateThreadpoolIo 关联句柄上的异步 I/O 完成时
线程池的四种对象与回调机制work 在显式提交时触发,timer 按时间触发,wait 在内核对象有信号时触发,io 在异步 I/O 完成时触发;它们都在同一组工作线程上作为回调运行哪种对象?work(提交时)定时器、等待还是 io?timer(时刻 / 周期)等待还是 io?wait(有信号时)io(I/O 完成)工作线程运行回调

图 3: 触发条件不同,但四种都统一为「同一池上的工作线程运行回调」这一机制。

这一统一是实务上的长处。不必把周期处理、事件响应、I/O 完成处理各自写在专用线程上,可以把它们对齐到一种回调风格。定时器被收进整个池的单一定时器队列,等待被汇总到少量等待线程上——进程里「只睡觉」的线程消失了。1

用 wait 对象替换只负责等待的线程过去按事件各睡一条的等待专用线程变成 wait 对象,被汇总到池的等待线程上,因此只在有信号时运行回调5 条各自睡眠的等待专用线程消耗 5 个栈和 5 条线程5 个 wait 对象汇总到池的等待线程只在有信号时运行回调

图 4: 「只睡觉等待」的线程可以变成 wait 对象后去掉。这是池迁移的清晰第一步。

4. 基本模式 ── 用 work 对象走一圈

我们用最常用的 work 对象把规矩走一遍。4

VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
                           PVOID context, PTP_WORK work)
{
    // The context is fixed at creation time. Per-item data is passed through a synchronised queue
    WORK_QUEUE* queue = (WORK_QUEUE*)context;
    ITEM* item = Dequeue(queue);        // Take one item under exclusive control
    ProcessItem(item);
}

// 1) Create (bind the callback to the shared context = the queue)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* Failure handling with GetLastError */ }

// 2) Submit once for each item you enqueue (keep the item count and the submit count in step)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);

// 3) Stop the submitter, then wait for completion (TRUE also attempts to cancel work that has not yet started)
WaitForThreadpoolWorkCallbacks(work, FALSE);

// 4) Close
CloseThreadpoolWork(work);

有两点要记住。第一,可以对同一个 work 对象多次 SubmitThreadpoolWork。每次提交都会运行回调(可并行)。7 不过,传给回调的上下文在创建时就固定了,因此用一个 work 对象写「同一种工作的 N 项」时,要像上面代码那样把同步队列放进上下文,每次提交取一项(按项创建 work 对象的设计也可以)。第二,关闭前务必等待完成。对象上还留着运行中或已入队的回调就关闭,或释放回调引用的内存,就是直接的释放后使用。为了让这次等待成为安全关闭,先停止提交者是前提——若另一条线程还能在等待的同时并行 Submit,等待之后的提交会与 Close 竞态。把 WaitForThreadpoolWorkCallbacks 的第二个参数设为 TRUE,还会尝试取消尚未开始的提交。

work 对象的生命周期用 CreateThreadpoolWork 创建;用 SubmitThreadpoolWork 提交,回调并行运行。关闭时先停止新提交,用 WaitForThreadpoolWorkCallbacks 等待所有回调完成,再用 CloseThreadpoolWork 关闭用 CreateThreadpoolWork 创建用 SubmitThreadpoolWork 提交(可重复)回调并行运行停止新提交用 WaitForThreadpoolWorkCallbacks 等待完成用 CloseThreadpoolWork 关闭

图 5: 关闭顺序是「停止提交 → 等待完成 → 关闭」。跳过任何一步都会得到释放后使用或竞态。

默认情况下,回调运行在进程默认池上。许多用途这就够了。下一章是想拆分池时的内容。

5. 自定义池与清理组

拆分池。可以用 CreateThreadpool 创建独立的池,用 SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum 设置线程数的上下限。8 典型用途是隔离。为了让「可能较慢的批处理类工作」不吃掉「需要立即响应的工作」的工作线程,拆开池,给各自线程预算。

用回调环境绑定。工作在哪个池上运行,通过初始化 TP_CALLBACK_ENVIRON(回调环境)、用 SetThreadpoolCallbackPool 指向该池,并把它作为 CreateThreadpoolWork 等的第三个参数来指定。9

用清理组收拢。在创建许多对象的模块里,关闭处理容易变成「全部等待、全部关闭」的念经。若用 CreateThreadpoolCleanupGroup 创建组,并经回调环境把各对象挂上去,一次 CloseThreadpoolCleanupGroupMembers 就能对每个成员对象一并完成等待与释放34

经回调环境绑定配置回调环境指向自定义池和清理组;用该环境创建的 work 和 timer 在该池上运行,对清理组的批量操作收拢完成等待与释放回调环境(TP_CALLBACK_ENVIRON)自定义池(控制线程数)清理组创建 work / timer / wait / io 时传入一次完成等待与释放

图 6: 回调环境是在对象创建时注入「在哪个池上运行、由谁清理」的机制。

6. 陷阱 ── 回调内部的纪律

几乎所有线程池缺陷都来自「在借来的线程上为所欲为」。

长时间阻塞。池按回调迅速返回来调整线程数。在默认设置下做长时间工作或长时间等待,会推迟其他回调的执行。可能跑很久的回调应使用 CallbackMayRunLong 声明「这会跑很久」(池把它当作增加线程的提示),或一开始就送到专用线程。注意,当无法为其他回调准备好工作线程时,CallbackMayRunLong 返回 FALSE。若不检查返回值继续阻塞,仍会堵住池,因此为 FALSE 时要落到不阻塞的一侧——拆分工作、送到专用线程等。5

同步等待同一池上的完成。在回调 A 内部用 WaitForThreadpoolWorkCallbacks 等等待提交到同一池的工作 B 完成,这种形态在每个工作线程都变成「等待另一个工作线程」的瞬间,就会变成池饥饿死锁。把工作之间的依赖改写成不是等待,而是「从 B 的完成回调提交下一个」的延续。

池饥饿死锁的结构若每条工作线程都同步等待提交到同一池的其他工作完成,就没有空闲工作线程去运行那些工作,所有人永远等待工作线程 1:等待工作 X 完成工作 X 和 Y 等待运行工作线程 2:等待工作 Y 完成没有空闲工作线程去运行它们所有人永远等待(饥饿死锁)

图 7: 若从工作线程内部同步等待另一条工作线程,就没有人去运行被等待的工作。

弄脏线程状态。工作线程会被下一次回调复用。改变线程优先级、COM 初始化状态、留在 TLS 里的值、忘了离开的锁——任何一项都会变成对下一次(无关)回调的污染。「扔给池的函数不得依赖线程的个性」从旧 API 时代就是官方警告。6 清理有专用机制;例如 LeaveCriticalSectionWhenCallbackReturns 可以请池「这个回调返回时释放这把锁」。3

与 DLL 卸载的竞态。若包含代码的 DLL 在回调运行时被卸载,就会访问违例。基本形态是在 DLL 的关闭函数里彻底等待完成;FreeLibraryWhenCallbackReturns 是为「这个回调是最后一项工作,结束时希望连同自己一起释放 DLL」这种情况提供的。不过这个 API 只是「正在运行的回调返回时放开一个引用」;它并不防止回调开始之前的卸载。成对使用:提交前用 GetModuleHandleEx 自己拿一个模块引用,再让回调用这个 API 放开该引用。3 而且不得在 DllMain 里做这次完成等待——正如「DllMain 与加载器锁」所说,在 DllMain 里等待另一条线程是死锁模式。

为 DLL 卸载与回调的竞态做准备回调运行期间卸载 DLL 会变成访问违例,因此基本形态是在显式关闭函数中等待完成再关闭;当最后一次回调自己释放 DLL 时,使用 FreeLibraryWhenCallbackReturns回调期间卸载访问违例等待,然后关闭安全卸载在关闭函数中FreeLibraryWhenCallbackReturns最后一次回调释放 DLL不要在 DllMain 里

图 8: 基本形态是在显式关闭函数中做「等待,然后关闭」。在 DllMain 里等待会招来另一种死锁。

已提交工作内部的异常与崩溃。工作线程上未处理的异常会带走进程。在回调入口全面捕获异常并记录,采用和专用线程的线程函数相同的策略。

7. 与标准库和 .NET 的关系 ── 写在哪一层

最后整理它和其他工具的位置。

  • 若 C++ 的 std::async / std::thread 够用,它们是第一候选。可移植,代码短,连 future 的语义也由标准定好。10
  • 直接使用 Win32 线程池的理由是:(1) 想要包含 timer、wait、io 的统一回调机制,(2) 想要拆分池或控制线程数,或 (3) 不希望在 DLL 或 COM 组件里自持线程。
  • 在 .NET 一侧ThreadPoolTask 扮演同样角色,I/O 完成与 IOCP 绑定。这一地下室结构见「IOCP 与 .NET 线程池」。
决定用哪一层的工具来写若标准 C++ 的 async 或 thread 够用就用那些;需要定时器、等待与 I/O 完成的整合、拆分池或控制线程数、或不希望在 DLL 或 COM 组件里自持线程时,直接使用 Win32 线程池timer、wait 与 io 的整合拆分池 / 数量控制避免 DLL 内自持线程标准 C++ 工具够用吗?std::async / std::thread需要什么?Win32 线程池

图 9: 拿不准时从标准库开始;这套 API 的上场,是在它表达不了的需求出现时。

也就是说,这套 API 是在你已决定「写原生」时的并发基础。现实的清理是分阶段的:作为从自建 CreateThread 增生的迁移目标,先引入 work 对象,再把只负责等待的线程换成 wait、把定时器线程换成 timer。

8. 小结

  • 大量发出短命工作,以及清理只负责等待和只负责定时的线程,用操作系统标准线程池而不是自建线程。用的是 Vista 以后的新 API。
  • 中心是 work、timer、wait、io 四种对象。触发条件不同;统一在同一组工作线程和同一种回调风格上。
  • 规矩是「创建 → 提交 → 等待完成 → 关闭」。多次提交并行运行。清理组可以批量关闭处理。
  • 回调的三条铁则:不要长时间阻塞(若会,用 CallbackMayRunLong);不要在同一池上同步等待;不要弄脏线程状态。
  • 从 DLL 使用:留意卸载竞态。在显式关闭函数中等待完成;不要在 DllMain 里做。
  • 标准 C++ 或 .NET 够用的地方,用那些。这套 API 的上场,是需要 timer/wait/io 整合或池控制时。

线程池 API 在 Win32 API 里属于较新、设计较好的一侧。一旦完成从「创建线程」到「扔回调」的思路转换,原生代码里的并发就会好写得多。

相关文章

相关咨询领域

小村软件有限公司承接从线程增生的原生代码迁到线程池的迁移设计、C++ 应用与 DLL 中并发处理的设计评审,以及由池饥饿或回调引起的挂起与崩溃的原因调查。欢迎从盘点既有代码开始咨询。

参考链接

  1. Microsoft Learn, Thread Pools. 关于线程池是代表应用高效执行异步回调的一组工作线程;适合的应用类型(大量并行发出小型工作项、频繁创建和拆除短命线程、并行处理独立工作、对内核对象的独占等待等);以及 Vista 的全面重新设计(统一工作线程种类、单一定时器队列、专用持久线程、清理组、同一进程内多个池,以及新 API)。  2 3 4 5

  2. Microsoft Learn, Thread Pooling. 关于较旧线程池 API 的结构(QueueUserWorkItem、定时器队列、注册等待、BindIoCompletionCallback);一旦入队就无法取消工作;以及 Vista 引入的新线程池 API 被表述为更简单,在可靠性、性能和灵活性上更优。  2

  3. Microsoft Learn, threadpoolapiset.h header. 关于包含四种对象创建函数 CreateThreadpoolWork、CreateThreadpoolTimer、CreateThreadpoolWait、CreateThreadpoolIo 的函数列表;清理组(CreateThreadpoolCleanupGroup);以及与回调完成绑定的清理(LeaveCriticalSectionWhenCallbackReturns、FreeLibraryWhenCallbackReturns 等)。  2 3 4 5 6

  4. Microsoft Learn, Using the Thread Pool Functions. 关于用 CreateThreadpoolWork 创建、用 SubmitThreadpoolWork 提交、用 WaitForThreadpoolWorkCallbacks 等待完成、用 CloseThreadpoolWork 关闭的基本步骤;以及结合自定义池、回调环境与清理组的配置示例。  2 3

  5. Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). 关于通知池当前回调可能运行很长时间,以便池据此决定是否为其他回调确保一条线程;以及在可能的情况下为长时间运行的回调考虑专用线程。  2

  6. Microsoft Learn, Thread Pooling. 关于提交到线程池的工作项及其调用的函数必须对线程池安全;不要假定执行线程是专用持久线程;以及避免使用 TLS 和需要持久线程的异步调用。  2

  7. Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). 关于可以在不等待前一次回调完成的情况下多次提交同一 work 对象,从而使回调并行运行;以及池可以为效率调整(节流)线程数。 

  8. Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). 关于可以为用 CreateThreadpool 创建的池设置工作线程数上限(下限是 SetThreadpoolThreadMinimum)。 

  9. Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). 关于从回调函数和上下文指针创建 work 对象;以及第三个参数 TP_CALLBACK_ENVIRON 可以指定回调的执行环境(所属池等),NULL 表示在默认环境中运行。 

  10. Microsoft Learn, <future>. 关于通过 std::async 和 future 按任务提供异步执行作为标准库,从而可以在不直接管理线程的情况下编写并发。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

和用 CreateThread 自建线程相比,线程池好在哪里?
大量短命工作时的效率,以及减少线程管理代码。创建和销毁线程的成本不可忽视,因此反复「每个工作 CreateThread、做完就销毁」的应用,或只为等待事件而睡眠的大量线程,迁到池上可以减少线程数和上下文切换。官方文档也把大量并行发出小型工作项的应用、创建大量短命线程的应用、以及专门等待内核对象的线程列为池的适用对象。反过来,需要「线程自身具备个性」的工作——改变优先级、COM STA、长时间运行的专用处理——仍应像以前一样放在专用线程上。
和 QueueUserWorkItem 等旧线程池函数有什么不同?
线程池在 Windows Vista 被全面重新设计。当前的 threadpoolapiset 系列 API(CreateThreadpoolWork 等)是新 API;QueueUserWorkItem、RegisterWaitForSingleObject 等是旧(遗留)API。新 API 统一了工作线程种类,可在一个进程内创建多个独立的池,并提供经清理组批量释放、以及与回调完成绑定的锁释放或 DLL 卸载等机制。官方文档也写明新 API 更简单,在可靠性、性能和灵活性上更优。旧 API 还有「一旦入队就无法取消工作」等结构性约束,因此新代码请用新 API。
回调里有没有不能做的事?
大的有三条。第一,在默认设置下长时间阻塞或做长时间工作。池按回调迅速结束来调整线程数,因此会很久的工作要么用 CallbackMayRunLong 声明,要么用专用线程。第二,同步等待提交到同一池的其他工作完成。若每个工作线程都变成「等待另一个工作线程」,就会发生池饥饿死锁。第三,依赖线程的个性。工作线程在回调之间共享,因此带着改过的线程优先级或 COM 初始化状态返回、或把状态留在 TLS 里,会污染下一次回调。结束时的清理(释放锁或卸载 DLL)有 LeaveCriticalSectionWhenCallbackReturns 和 FreeLibraryWhenCallbackReturns 等专用机制。
从 DLL 使用线程池有哪些注意点?
最大危险是「回调还在运行时 DLL 被卸载」。卸载后回调再跑就会访问违例。DLL 一侧必须在关闭处理中,用 WaitForThreadpoolWorkCallbacks 等等待函数、或清理组的 CloseThreadpoolCleanupGroupMembers,可靠地等待自己发出的回调完成,然后再关闭对象。但在 DllMain 里做这次等待,会因与加载器锁交互而死锁,因此原则是在显式关闭函数中做,而不是在 DllMain 中。当回调自己想因「这是最后一项工作」而释放 DLL 时,提供了专用 API FreeLibraryWhenCallbackReturns。
既然已有 C++ 的 std::async 和 .NET 的 ThreadPool,还有直接用这套 API 的场合吗?
有。判断标准是「那一层的工具是否够用」。若 C++ 里需要的并发粒度能被 std::async 或 std::thread 覆盖,从可移植性出发标准库也是第一候选。另一方面,希望把定时器、内核对象等待、异步 I/O 完成统一到一套回调机制;希望按工作种类拆分池并控制线程数;不希望在 DLL 或 COM 组件里自持线程——这些需求正是 Win32 线程池的覆盖范围。与 .NET ThreadPool 和 IOCP 的关系见相关文章;只要在写原生代码,了解下面这一层的机制就不会浪费。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表