随着 Windows Vista
® 的发布和 Windows Server
® 2008 的即将问世,Microsoft 为托管应用程序的开发人员提供了改进的 Windows
® 平台,它包含多种新技术,使得令原本 Windows 开发人员从中受益的改进能被广为利用。例如,自从 Windows 2000 发布以来,已是该平台组成部分的线程池组件经历了一次完整的体系结构重建。新的实现包含一个新的线程池 API,该 API 可以让开发人员更加轻松地编写正确的代码。传统的 API 仍受支持,所以传统的应用程序可以继续运行,但是正如您将看到的那样,移至新的 API 会带来很多好处。
线程池概述
在探究新的 API 之前,让我们一起回顾一下线程池组件所提供的功能的类型。简言之,在发送内核对象信号时,以及在异步 I/O 请求完成时,线程池允许按时间间隔异步调用各个函数。每当必须调用其中一个回调函数时,都会有一个回调函数的调用请求被添加到线程池维护的先进先出 (FIFO) 队列的末端。由线程池内部管理的工作线程可以从该队列中删除项目,并调用回调函数。由于线程池可以管理工作线程的生存期,因此应用程序开发人员无需显式管理线程的生存期。这同时也意味着该应用程序永远不能使用 TerminateThread 函数或从正在工作线程上执行的函数调用 ExitThread 函数,来终止线程池的工作线程。
应用程序通常会创建专用的线程,这些线程会花费大量时间等待事件或定期提醒要轮询某一状态。线程池可提高此操作的效率。对于正在尝试处理大量客户端请求的服务器类应用程序来说,线程池会启用 Windows 平台的首选服务器并发模型,系统中有多少个处理器,该模型就会同时处理多少个请求。我曾见到开发人员尝试过的另一个模型是针对每个要服务的客户端专门提供一个线程,但是该模型不能很好地扩展到应付大量的客户端。大量的线程不仅会消耗大量资源,而且在它们之间进行上下文切换所带来的成本也会显著增加,并且会影响到每个客户端接受到的服务的整体质量。
新的线程池组件克服了传统线程池的诸多局限性。例如,新的线程池允许您为每个进程创建多个线程池,而以前的模型只允许创建一个。这可让您根据某些选定的条件将应用程序执行的任务隔离开来。例如,假设您在编写一个分布式服务器应用程序,该应用程序将使用线程池,通过网络连接处理异步 I/O 完成。这些网络连接属于两种类型,一种是来自客户端应用程序,另一种是来自运行在另一台计算机上的服务器应用程序的其他实例。我们进一步假设针对每台服务器您必须支持大量同步客户端连接,并且客户端请求的数量要远远超出服务器到服务器的连接数,不过服务器到服务器的连接具有更高的优先级。
如果客户端和服务器的网络连接都是在单个的线程池上进行处理,就会存在一个所有的完成通知都在其中排队的单个队列。这意味着来自服务器连接的 I/O 完成一直要等到队列中排在它前面的所有 I/O 完成都已处理后(包括来自任何客户端的所有 I/O 完成),才会处理它。当系统面临较重的客户端负载时,这会导致处理服务器连接时发生严重延迟。拥有专用的客户端连接线程池和服务器连接线程池就能实现隔离,因为每个线程池都有它自己的队列和工作线程集。操作系统中的调度程序将确保这些处理器能在两个池的线程之间合理共享。但是,由于每个进程创建过多线程池会损害性能,并减少吞吐量,所以明智地使用多个线程池非常重要。
传统的线程池使用两种类型的工作线程:I/O 和非 I/O,它们有时会引起混淆。如果开发人员不了解它们之间的区别,就可能导致性能受限或不正确的行为。而且,有两个不同的线程分组意味着线程池实现本身的效率就不会太高,因为它不能在不同类型的回调函数之间共享线程。在新的线程池中,不再有这个区别,并且所有的工作线程都相同。
在传统的 API 中,QueueUserWorkItem 用于将要在线程池工作线程上异步调用的函数排入队列。传统的 API 无法让该应用程序确定工作线程结束执行回调函数的时间。如果回调函数位于 DLL 中,要确保能安全卸载该 DLL 实际上是不可能的。这表示有时候卸载 DLL 会导致宿主进程崩溃。同样,也无法取消位于线程池的队列中等待执行的回调,因此除了等待所有的请求清空以外别无选择,而这会带来严重的延迟。
最后,传统的 API 没有将资源分配与资源使用分开。由于这些资源分配可能会失败,而且也确实失败了,所以传统的 API 使开发具有可靠性保障的系统变得非常困难。新的线程池 API 将资源分配和使用进行了明确的区分,因此一旦资源分配成功,当这些资源付诸使用时,编写严谨的代码实际上都不大可能会失败。
新的线程池 API 是基于对象的,其中每个类型的对象都有一组用于创建、清理以及修改属性的函数。
图 1 汇总了 API 公开的对象类型。有一点必须要了解,那就是该线程池创建的所有对象以及由它管理的所有工作线程都成为使用它们的应用程序进程的一部分。
Figure 1 线程池对象的类型
对象类型 |
说明 |
TP_POOL |
用于执行回调的线程池。 |
TP_TIMER |
在到期时间调用回调函数。 |
TP_WAIT |
在发出内核对象信号或等待超时的时候调用回调函数。 |
TP_WORK |
异步调用回调函数。 |
TP_IO |
异步 I/O 完成后调用回调函数。 |
TP_CLEANUP_GROUP |
跟踪一个或多个线程池回调对象。 |
TP_CALLBACK_ENVIRON |
将线程池绑定到其回调对象,并可选择是否绑定到清理组。 |
线程池对象
准备要使用的线程池的第一步是使用 CreateThreadpool 函数创建一个线程池,如
图 2 所示。该函数取必须为空值的保留参数。如果该函数成功,会返回一个 PTP_POOL,表示新分配的线程池。如果该函数失败,会返回空值,可使用 GetLastError 获取详细的错误信息。
Figure 2 线程池对象 API
PTP_POOL WINAPI CreateThreadpool(PVOID reserved);
BOOL WINAPI SetThreadpoolThreadMinimum(PTP_POOL ptpp,
DWORD cthrdMin);
VOID WINAPI SetThreadpoolThreadMaximum(PTP_POOL ptpp,
DWORD cthrdMost);
VOID WINAPI CloseThreadpool(PTP_POOL ptpp);
一旦创建线程池,就可以控制该池将使用两个新 API 进行管理的线程的最小数量和最大数量。默认情况下,该线程池的最小数量为 0,最大数量为 500。选择这些数值是为了能与传统线程池进行向后兼容,因为有些使用传统线程池的应用程序需要大量的线程。这些默认值在 Windows 未来的版本中可能会有所改变,因为由于过多的上下文切换,使用如此多线程的应用程序可能会导致性能低下。
这些 API 有一些值得注意的有趣功能。请注意,SetThreadpoolThreadMinimum 会返回一个布尔值,而 SetThreadpoolThreadMaximum 则不会返回任何内容。这是因为如果设置线程的最小数量要求 SetThreadpoolThreadMinimum 增加该池的工作线程的数量以满足新的最小数量,并且分配这些线程时发生失败,那么这个失败的消息就会被报告给调用方。设置该池的线程最大数量不会导致任何资源分配,因为所有的函数都是根据该线程池可以创建的线程数来设置上限的。该线程池会根据线程池的实际工作负荷,在最小值和最大值之间进行选择来确定线程数量。遗憾的是,在 Windows Vista 中,当 SetThreadpoolThreadMinimum 需要增加该池的工作线程数量时,它会出现一个 Bug:函数会在没有即刻创建其他线程的情况下返回。这个 Bug 将在 Windows Vista Service Pack 1 中修复,并且在 Windows Server 2008 中不再是问题。但是对于现在的 Windows Vista(除了工作线程创建失败的问题以外),一旦排列了足够的工作而导致线程池创建指定的最小线程数量,线程池便会采用这个最小值,并且至少会维护该池中那些数量的线程。
将线程的最小数量和最大数量设置成相同的值会创建一个持久线程池,该线程池直到该池关闭后才会终止。这对于使用具有函数(例如 RegNotifyChangeKeyValue)的线程池来说非常方便,该函数必须从持久线程中调用。有关 RegNotifyChangeKeyValue 的详细信息,请参阅
msdn2.microsoft.com/ms724892。
注意:调用最小值大于目前最大值的 SetThreadpoolThreadMinimum 不仅会导致设置新的最小值,还会导致将这个最大值设置成那个新的最小值。调用最大值小于最小值的 SetThreadpoolThreadMaximum 会导致将该最小值设置成新的最大值。线程计数参数在这两个函数中被指定为 DWORD,但是 DWORD 的整个范围不可用于该线程计数。在内部,该值会被当作 LONG,并且要验证它是大于等于 0。
现在应当解释一下线程池 API 的错误报告原理。如果某个操作是一种可以被合理预期为失败的操作,那么就可以通过被调用函数的返回代码来报告结果。但是,异常的错误则通过结构化的异常来进行报告。例如,对于函数 SetThreadpoolThreadMinimum 和 SetThreadpoolThreadMaximum,传递无效的工作线程数量值或空线程池指针会导致出现结构化异常。但是,在调用 SetThreadpoolThreadMinimum 期间无法创建新工作线程,这一失败结果会作为 FALSE 向调用方报告,因为资源分配失败是一个应该预期到的错误。将结构化异常处理程序封装到对线程池 API 的调用周围可能比较有吸引力,这样应用程序便不会因为未处理的异常而终止,但其实这并不是一个好方法,因为捕获这些异常并继续执行程序只会隐藏该应用程序本身的问题。这只会使得应用程序最终失败时更加难以诊断问题的原因。
为了正确使用这些 API,了解如何实现该线程池的并发模型将会对您大有裨益。该模型基于系统中可用处理器的数量。例如,如果系统有两个物理处理器,而每个处理器又都是双核的,那么在大多数时间只有四个可运行的线程是最好的状态。这会减少上下文切换带来的系统开销。然而,如果应用程序在大多数情况只有一个未完成的回调,将线程池的大小调整为只有一个线程是非常明智的选择。让我们回到双处理器双内核的示例中来,假设应用程序有很多未完成的回调,池中至少应该有四个工作线程,允许每个内核处理一个项目。但是,为了维护最佳并发,可能需要让线程池包含四个以上的工作线程。如果想知道原因,可考虑这样一种情形,四个内核中的每个内核都已忙于执行回调函数,而线程池的队列中也有挂起的回调。如果执行回调块的其中一个工作线程阻塞会出现什么情况呢?会有三种可能性:
- 如果线程池在池中有一个可用的线程,它会调度另一个线程将下一个项目从队列中删除,并调用挂起的回调函数。
- 如果线程池没有其他可用的工作线程,且已创建的工作线程的数量小于最大线程数,那么经过短期的延迟之后,它会创建一个新的工作线程,该线程将被调度来执行挂起的回调函数。
- 如果线程池没有其他可用的工作线程,且已创建的工作线程的数量已达到最大线程数,那么将不会创建其他线程,并且挂起的回调项目会保留在队列中,直到前一个调度的工作线程结束运行其回调函数并返回到线程池,确定是否有其他要执行的项目为止。
因此,如果回调函数在执行的时候可能会阻塞,那么线程池的最大大小必须要大于系统中可用处理器的数量,以便实现该硬件支持的最大并发。理想情况下,回调函数应该永远不会阻塞。阻塞的回调函数不仅会减少并发,而且还会降低工作线程的重用级别。因此,想办法减少或最小化回调函数阻塞的时间是绝对值得的。如果您了解回调阻止的对象,就可以缩短这个时间。例如,如果回调在执行同步 I/O,可考虑将它更改成可以在线程池上完成的异步 I/O。另外请注意最小化任何必须在回调函数之间发生的同步,因为锁争用可能引起阻塞。
如果大多数的回调函数都不阻塞,则可以将线程池的大小调整成包含更少的线程,但要超过可用处理器的数量。如果大多数回调函数准备终止阻塞,则应该调整线程池的大小,使其包含的工作线程数大大超过可用处理器的数量。在这种情况下,大多数线程都会在处于等待状态的情况下结束。当回调函数完成后,为了最大化整体吞吐量,该线程池会阻止工作线程,将可运行线程的数量减少到一个最适合处理器配置的数量。关键是您需要调整线程池的大小,以便在有任务要运行,并且有足够的处理器带宽可用时,该池可以创建更多的线程来执行工作。但是,如果您发现您的应用程序实际上使用了非常多的线程,比如默认设置中的 500,或者如果存在过多的上下文切换成本,那么说明该应用程序的设计不合理,您需要使用性能监视器和代码探查器来检查您的代码,以确定哪里需要改进。
您处理完该线程池以后,就应该使用 CloseThreadpool 函数将其关闭。如果没有绑定到该线程池的未完成的回调对象,该线程池就会立即关闭。如果有,就会在释放那些未完成的对象时异步释放该线程池。
回调环境对象
既然您掌握了如何创建线程池,下一步就要了解回调环境对象是什么,以及应如何使用它。回调环境对象用于将线程池实例绑定到线程池回调对象的实例,您的应用程序创建该回调对象是为了在该线程池上执行实际工作。回调环境对象还允许您附加清理组对象,这使线程池回调对象的清理变得更加简单。本文稍后将对清理组进行详细介绍。回调环境 API 如
图 3 所示。
Figure 3 回调环境 API
VOID InitializeThreadpoolEnvironment(PTP_CALLBACK_ENVIRON pcbe);
VOID DestroyThreadpoolEnvironment(PTP_CALLBACK_ENVIRON pcbe);
VOID SetThreadpoolCallbackPool(PTP_CALLBACK_ENVIRON pcbe,
PTP_POOL ptpp);
VOID SetThreadpoolCallbackLibrary(PTP_CALLBACK_ENVIRON pcbe,
PVOID mod);
VOID SetThreadpoolCallbackRunsLong(PTP_CALLBACK_ENVIRON pcbe);
VOID SetThreadpoolCallbackCleanupGroup(PTP_CALLBACK_ENVIRON pcbe,
PTP_CLEANUP_GROUP ptpcg,
PTP_CLEANUP_GROUP_CANCEL_CALLBACK pfng);
创建回调环境的第一步是在堆栈上的静态存储区中声明 TP_CALL_BACK_ENVIRON 结构,或从堆中分配一个。第二步是使用 InitializeThreadpoolEnvironment 函数将它初始化,这会使指针指向 TP_CALLBACK_ENVIRON。最后一步是使用 SetThreadpoolCallbackPool 将线程池与回调环境关联起来。如果您不将线程池与回调环境关联起来,或者如果在调用 SetThreadpoolCallbackPool 时指定的是空值,则会使用该进程的默认线程池。正如您将看到的那样,该回调环境最终用于创建各种线程池回调对象实例。一旦您的应用程序修改完回调环境并创建完所需回调对象的全部实例,就应使用 DestroyThreadpoolEnvironment 函数来销毁该回调环境。
SetThreadpoolCallbackRunsLong API 用于提示线程池与该环境相关联的回调函数可能不会迅速返回。您稍后会明白这对从调用 API function CallbackMayRunLong 返回的结果有什么样的影响。
我之前提到过,如果应用程序需要确定何时可以安全卸载 DLL(它包含将在线程池上执行的回调函数),传统的线程池缺少这方面的支持。新线程池 API 提供了 SetThreadpoolCallbackLibrary 函数,可保证只要 DLL 内的回调函数仍在执行,库就保持加载状态。实际出现的情况是:在调用回调函数之前,会获得操作系统的加载程序锁(当 DLL 位于待加载或待卸载的进程中时,该锁始终锁定),并且 DLL 的引用计数会递增;然后加载程序锁也会解除锁定。回调执行完成后,会再次获得加载程序锁以减少引用数。这使得在执行回调函数时无法卸载 DLL。注意,当回调在线程池的队列中挂起时,DLL 上的引用计数保持不变。这表示可以在回调挂起的情况下卸载 DLL。但是它是 DllMain 函数的工作,您编写该函数就是为了确保能处理 DLL_PROCESS_DETACH 事件并取消所有挂起的回调。我稍后将谈到这一点。
工作对象
工作对象用于促使线程池在工作线程上异步调用回调函数。CreateThreadpoolWork 函数(如
图 4 所示)以及与 API 相关的所有其他工作对象都可用于创建工作对象。
Figure 4 工作对象 API
PTP_WORK WINAPI CreateThreadpoolWork(PTP_WORK_CALLBACK pfnwk,
PVOID Context,
PTP_CALLBACK_ENVIRON pcbe);
VOID WINAPI SubmitThreadpoolWork(PTP_WORK pwk);
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_WORK Work);
VOID WINAPI WaitForThreadpoolWorkCallbacks(PTP_WORK pwk,
BOOL fCancelPendingCallbacks);
VOID WINAPI CloseThreadpoolWork(PTP_WORK pwk);
BOOL WINAPI TrySubmitThreadpoolCallback(PTP_SIMPLE_CALLBACK pfns,
PVOID pv,
PTP_CALLBACK_ENVIRON pcbe);
VOID CALLBACK SimpleCallback(PTP_CALLBACK_INSTANCE Instance,
PVOID Context);
第一个参数 pfnwk 是指向工作线程要执行的回调函数的指针。第二个参数 Context 的类型为 PVOID,可用于提供回调函数需要的任何应用程序特定的数据。最后一个参数是指向 TP_CALLBACK_ENVIRON 的指针。工作对象将会绑定到线程池和已与回调环境关联起来的清理组(如果有)。
工作对象创建完成之后,它可以通过 SubmitThreadpoolWork 函数列入线程池队列。最后,工作线程会从该队列中删除该工作对象,并调用其相关的回调函数。每次调用 SubmitThreadpoolWork 都会产生一个对工作项目的回调函数的调用。
工作对象的 WorkCallback 函数采用三个参数。第一个是 Instance,它用于确定回调函数的执行的特定实例,并仅对该回调函数的执行保持有效。Context 参数是作为第二个参数提供给 CreateThreadpoolWork 的指针。最后一个参数是 Work,它是要被调用的回调函数的工作对象的实例。
回调函数可以被线程池中的任何工作线程调用,了解这一点很重要。要遵守的经验法则是:回调函数不应对它执行所处的工作线程进行任何假设,应让工作线程的状态与它在回调函数调用之前所处的状态保持一致。例如,如果回调函数准备使用 COM,那它必须在每次被调用时调用 CoInitializeEx。回调函数还应该在返回之前调用 CoUninitialize,因为工作线程可以重用,从而为许多不同的任务调度回调函数,或者当它被返回到线程池时甚至可能会被终止。这可以防止资源泄漏和放任状态信息。资源泄漏和状态信息的放任都可能会负面影响下一个可能拥有不同的线程执行环境要求的回调函数的执行。Application Verifier 是一个针对未托管代码的运行时验证工具,可以帮助查找一些一般应用程序测试可能难以识别的细小编程错误。在帮助查找与线程池相关的编程错误方面有了提高。它可以检测到的错误包括:不平衡的 CoInitializeEx 和 CoUninitialize 调用、在将工作线程返回到线程池之前尚未恢复的线程优先级和关联变化、尚未恢复的模拟,以及孤立的关键部分。如需完整列表,请查阅与 Application Verifier 一起安装的文档,它可从 microsoft.com/downloads/details.aspx?familyid=bd02c19c-1250-433c-8c1b-2619bd93b3a2 下载。
WaitForThreadpoolWorkCallbacks 函数会阻止调用线程,直到工作对象所有未完成的回调函数都完成执行为止。第二个参数控制是应该允许挂起的回调执行还是应该取消该挂起的回调;挂起的回调是指被列入线程池队列但尚未分配给工作线程进行执行的回调。请谨慎使用该 API——在工作回调函数内部使用它会导致死锁。
除了与工作对象相关的 API 集以外,还有 CloseThreadpoolWork 函数。如果没有未完成的回调,该工作对象会被立即释放;否则一旦回调完成,工作对象就会被异步释放。这也意味着将会取消线程池队列中任何在等待执行的挂起回调。
图 4 中剩下的函数 TrySubmitThreadpoolCallback 可以提供以下等效功能:创建工作对象、将其提交到线程池、一旦 pfns 参数指定的回调完成执行后确保该工作对象会关闭。该回调函数的签名必须与
图 4 中 SimpleCallback 的签名一致。它和工作对象所关联的回调稍有不同。由于该线程池负责在内部分配和释放工作对象,所以只将回调函数实例指针和应用程序定义的上下文(在 TrySubmitThreadpoolCallback 的 Context 参数中指定)传递给该回调函数。由于 TrySubmitThreadpoolCallback 分配资源来执行它的工作,因此有可能会失败,这就是它会返回布尔型返回代码的原因。
等待对象
等待对象用于在发送内核对象信号后或当指定的等待期间超时的时候调用回调函数。CreateThreadpoolWait 函数可用来创建等待对象。它的参数会遵循与 CreateThreadpoolWork 相同的模式,例外情况是,指向第一个参数中提供的回调函数的指针必须与 WaitCallback 函数的签名相匹配,如
图 5 所示。
Figure 5 等待对象 API
PTP_WAIT WINAPI CreateThreadpoolWait(PTP_WAIT_CALLBACK pfnwa,
PVOID pv,
PTP_CALLBACK_ENVIRON pcbe);
VOID WINAPI SetThreadpoolWait(PTP_WAIT pwa,
HANDLE h,
PFILETIME pftTimeout);
VOID CALLBACK WaitCallback(PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_WAIT Wait,
TP_WAIT_RESULT WaitResult);
VOID WINAPI WaitForThreadpoolWaitCallbacks(PTP_WAIT pwa,
BOOL fCancelPendingCallbacks);
VOID WINAPI CloseThreadpoolWait(PTP_WAIT pwa);
创建等待对象后,下一步是促使该线程池等待发送内核对象信号。SetThreadpoolWait 函数可用于实现这一点。第一个参数 pwa 是指向要设置的等待对象实例的指针。第二个参数是要等待的内核对象的 HANDLE。最后一个参数 pftTimeout 是指向 FILETIME 结构的指针,它指出了线程池必须等待发送内核对象信号的时间。等待时间可以用绝对值或相对期间表示,以 100 纳秒为单位。传递一个正值表示超时是一个自 1/1/1600 开始的绝对时间。传递一个负值表示是一个相对于调用该函数的当前时间的时间。传递 0 值表示该等待会立即超时。最后,传递空值表示一个永不超时的无限等待。
一旦发送了内核对象信号或等待超时,该线程池就会调用该等待对象的 WaitCallback 函数。最开始的两个参数与之前介绍的 WorkCallback 函数中的参数一模一样。Wait 参数指示要为哪个等待对象调用该回调,而 WaitResult 则用来指示调用的原因。WaitResult 将是 WAIT_ABANDONED_0、WAIT_OBJECT_0 或 WAIT_TIMEOUT。如果将 WaitResult 设置成 WAIT_OBJECT_0,表示已发送内核对象信号,并且可满足该等待。
如果 WaitResult 设置为 WAIT_TIMEOUT,这表示该等待不满足,并且已经过了对 SetThreadpoolWait 的调用中指定的超时间隔。WAIT_ABANDONED_0 的结果表示指定的对象是互斥量,在它终止之前拥有该互斥量对象的线程没有将它释放。但是,应该避免对等待对象使用互斥量,因为调用 WaitCallback 函数的工作线程并不是在互斥量上执行等待的线程。该线程池会使用另一个类型的线程(称为 waiter 线程)来实际执行内核对象上的等待。实际上是 waiter 线程拥有该互斥量。Waiter 线程未提供给应用程序,因此一旦 waiter 线程获得它的所有权,则无法释放该互斥量。
为等待对象调用 WaitCallback 函数后,必须再次调用 SetThreadpoolWait,以便重用该等待对象并使该线程池再次等待内核对象收到信号。注意当再次调用 SetThreadpoolWait 以重用等待对象时,您可以再次指定要等待的任何内核对象句柄。如果您想等待另一个内核对象,就不必使用在第一次调用 SetThreadpoolWait 时指定的句柄。最后,剩下的等待对象 API:WaitForThreadpoolWaitCallbacks 和 CloseThreadpoolWait,它们的行为方式和它们工作对象的对应部分一模一样。
计时器对象
当计时器对象到达过期时间时,计时器对象便可调用回调函数。它们是使用 CreateThreadpoolTimer 函数创建的,如
图 6 所示,同时还有其他与计时器对象相关的 API。再次重申,这些参数遵循与 CreateThreadpoolWork 函数相同的模式(参见
图 4),例外是指向第一个参数中所提供回调函数的指针必须与 TimerCallback 函数的签名相匹配,如
图 6 所示。
Figure 6 计时器对象 API
PTP_TIMER WINAPI CreateThreadpoolTimer(PTP_TIMER_CALLBACK pfnti,
PVOID pv,
PTP_CALLBACK_ENVIRON pcbe);
VOID WINAPI SetThreadpoolTimer(PTP_TIMER pti,
PFILETIME pftDueTime,
DWORD msPeriod,
DWORD msWindowLength);
VOID CALLBACK TimerCallback(PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_TIMER Timer);
VOID WINAPI WaitForThreadpoolTimerCallbacks(PTP_TIMER pti,
BOOL fCancelPendingCallbacks);
BOOL WINAPI IsThreadpoolTimerSet(PTP_TIMER pti);
VOID WINAPI CloseThreadpoolTimer(PTP_TIMER pti);
创建计时器对象以后,下一步是使用 SetThreadpoolTimer 函数来对它进行设置。pftDueTime 参数用于设置计时器首次到期的时间。这个时间的表示方式和上面介绍的 SetThreadpoolWait 函数中的超时一样。顾名思义,msPeriod 参数可以设置一个定期触发的计时器,以毫秒表示。因此,一旦由 pftDueTime 表示的第一个时间间隔过期,并引起回调被列入线程池的队列,则每个由 msPeriod 定义的后续时间间隔过期时间都会引起另一个回调被列入队列。
msWindowLength 参数可指定一个以毫秒表示的时间窗口,在这期间线程池可能会延迟计时器的过期时间。当您使用大量的计时器并且过期时间不必非常精确时,此参数可以提高效率。该时间窗口是一种附加因素,它允许系统将属于该范围的所有计时器过期时间进行合并,以便对它们进行批处理。这比唤醒线程、使计时器过期、休眠、唤醒线程、使另一个计时器过期、休眠等等更有效率。仅当该窗口中没有任何肯定会过期的计时器时,才会发生延迟。为了更好地了解 msWindowLength 的使用方法,请考虑这样一种服务器应用程序:它具有大量传入的客户端连接,而为了减少资源使用,必须关闭五分钟之内一直不活动的连接。在这种情况下,为不活动的计时器指定一个非零的窗口长度可能是可以接受的,因为这可导致在稍微超过过期时间的时候仍能保持连接。
SetThreadpoolTimer 也可以用来为以前设置的计时器设置新的过期时间、期间和窗口长度。如果 pftDueTime 参数为空值,它将停止将回调列入 TimerCallback 函数的队列,而已排队的回调仍然会被执行。这样您就可以在不关闭计时器对象的情况下取消计时器,以便可以重用。
一旦该计时器对象到期,调用该回调的请求就会被列入该线程池队列中。工作线程将选取该请求,并调用在调用 SetThreadpoolTimer 时提供的 TimerCallback 函数。该回调函数的参数与之前介绍的工作对象调用函数的那些参数相同,除了第三个参数是 PTP_TIMER,而不是 PTP_WORK 以外。
在
图 5 剩余的计时器 API 函数当中,IsThreadpoolTimerSet 函数(顾名思义)在计时器已设置时返回 TRUE,否则就返回 FALSE。其他两个函数 WaitForThreadpoolTimerCallbacks 和 CloseThreadpoolTimer 的行为方式与它们对应的工作对象的行为方式一模一样。
I/O 完成对象
线程池支持的最后一个类型的回调对象是 I/O 完成对象。
图 7 列出了所有与 I/O 完成对象相关的 API。I/O 完成对象可用来将文件句柄绑定到线程池,以便将异步 I/O 完成通知列入该线程池的队列,由工作线程进行处理。CreateThreadpoolIo 函数的各个参数都遵循与其他创建函数相似的模式,只是它多了一个 HANDLE 参数,必须对重叠的 I/O 开放。
Figure 7 I/O 完成对象 API
PTP_IO WINAPI CreateThreadpoolIo(HANDLE fl,
PTP_WIN32_IO_CALLBACK pfnio,
PVOID pv,
PTP_CALLBACK_ENVIRON pcbe);
VOID WINAPI StartThreadpoolIo(PTP_IO pio);
VOID CALLBACK IoCompletionCallback(PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PVOID Overlapped,
ULONG IoResult,
ULONG_PTR NumberOfBytesTransferred,
PTP_IO Io);
VOID WINAPI WaitForThreadpoolIoCallbacks(PTP_IO pio,
BOOL fCancelPendingCallbacks);
VOID WINAPI CancelThreadpoolIo(PTP_IO pio);
VOID WINAPI WaitForThreadpoolIoCallbacks(PTP_IO pio,
BOOL fCancelPendingCallbacks);
VOID WINAPI CloseThreadpoolIo(PTP_IO pio);
如
图 7 所示,CreateThreadpoolIo 的第一个参数是可以接收完成通知的 HANDLE,而第二、三、四个参数分别表示指向要被调用的回调函数的指针、可选的应用程序特定上下文,以及指向回调环境的指针。如果成功,CreateThreadpoolIo 会返回一个非空指针,否则就返回一个空指针。
为了促使由线程池处理 I/O 完成通知,必须在对句柄发出每个 I/O 异步请求操作之前调用 StartThreadpoolIo 函数。忘记做这一步会带来严重的后果,因为在这种情况下该线程池会忽略该 I/O 完成,同时会引起内存损坏。当启动异步 I/O 操作的调用返回一个失败,而不是 ERROR_IO_PENDING 时,您必须调用一个相关的函数 CancelThreadpoolIo。不小心忘记调用 CancelThreadpoolIo 会导致该线程池泄漏内存。
一旦发生 I/O 完成,工作线程就会调用与 I/O 完成对象相关的 IoCompletionCallback。该函数的签名遵循的约定和所有其他回调一样:该函数最开始的两个参数分别是指向回调实例的指针和用来创建函数的 Context 指针。第三个参数 Overlapped 是指向启动异步 I/O 操作时所提供的重叠结构的指针。第四个参数是 IoResult,它包含该操作的结果。如果该操作成功完成,IoResult 就会包含 NO_ERROR;否则会包含一个系统错误代码(请参见 msdn2.microsoft.com/ms681381)。第五个参数 NumberOfBytesTransferred 包含 I/O 操作期间传输的字节数,而第六个参数则是指向 I/O 完成对象本身的指针。
同样,
图 7 中剩余的函数 WaitForThreadpoolIoCallbacks 和 CloseThreadpoolIo 的行为方式和它们工作对象的对应部分类似。
使用清理组简化清理
现在让我们了解一下清理组可以如何帮助简化清理应用程序创建的线程池回调对象这一过程。通过将清理组和线程池回调环境关联起来,使用回调环境创建的所有线程池回调对象都可以被清理组跟踪。一旦您的应用程序使用完回调对象,接下来它要确保每个对象都已关闭,因此它需要为每个回调对象执行一个函数调用,而不是一个关闭调用。事实上,如果回调对象与清理组相关联,就不应该调用它的关闭 API。
使用清理组的第一步是使用 CreateThreadpoolCleanupGroup 函数创建一个清理组对象,如
图 8 所示。
Figure 8 清理组 API
PTP_CLEANUP_GROUP WINAPI CreateThreadpoolCleanupGroup(void);
VOID WINAPI CloseThreadpoolCleanupGroupMembers(PTP_CLEANUP_GROUP ptpcg,
BOOL fCancelPendingCallbacks,
PVOID pvCleanupContext);
VOID CALLBACK CleanupGroupCancelCallback(PVOID ObjectContext,
PVOID CleanupContext);
VOID WINAPI CloseThreadpoolCleanupGroup(PTP_CLEANUP_GROUP ptpcg);
第二步是将该清理组和要与之一起使用的回调环境关联起来。
图 3 中的 SetThreadpoolCallbackCleanupGroup 函数会设置这个关联。
该函数的第一个参数是指向该回调环境的指针。第二个参数是 PTP_CLEANUP_GROUP,第三个参数是一个指针,指向调用 CloseThreadpoolCleanupGroupMembers 函数时将被调用的回调函数。
一旦应用程序使用完被清理组跟踪的线程池回调对象,它唯一需要做的就是调用 CloseThreadpoolGroupMembers(它将一直阻塞,直到当前所有回调函数都完成执行之后)。如果 fCancelPendingCallbacks 为 TRUE,已列入线程池队列但尚未开始执行的回调也会被取消。如果 fCancelPendingCallbacks 为 FALSE,那么在所有挂起的回调函数都被调度给工作线程并完成执行之后 CloseThreadpoolCleanupGroupMembers 才会返回。pvCleanupContext 参数用于将应用程序数据传递给调用 SetThreadpoolCallbackCleanupGroup 时指定的可选取消清理回调函数。取消回调函数会针对每个被清理的线程池回调对象调用一次。取消回调的签名必须与
图 3 中显示的 CleanupGroupCancelCallback 函数匹配。第一个参数 ObjectContext 是为正被清理的线程池回调对象的创建函数提供的可选数据。第二个参数 CleanupContext 是调用方为 CloseThreadpoolGroupMembers 函数提供的可选数据。调用 CloseThreadpoolCleanupGroupMembers 后,CloseThreadpoolCleanupGroup 函数可用来关闭该清理组并释放与其相关的所有资源。当清理组还有成员的时候,不要调用 CloseThreadpoolCleanupGroup,这一点非常重要;如果调用,可能会导致资源泄漏。
回调实例 API
最后一组要探讨的 API 如
图 9 所示。它们的设计初衷是从正执行回调对象的回调函数的工作线程使用,因为它们都要求将 PTP_CALLBACK_INSTANCE 传递到回调函数中。第一个函数 CallbackMayRunLong 用于通知线程池回调函数想延长一段运行时间。该线程池会跟踪执行长期运行回调的工作线程的数量。回想一下之前的内容,我们使用了 SetThreadpoolCallbackRunsLong 函数通知线程池:与提供的回调环境相关联的回调函数都是长期运行回调。
Figure 9 回调实例 API
BOOL WINAPI CallbackMayRunLong(
PTP_CALLBACK_INSTANCE Instance);
VOID WINAPI DisassociateCurrentThreadFromCallback(
PTP_CALLBACK_INSTANCE Instance);
VOID WINAPI SetEventWhenCallbackReturns(
PTP_CALLBACK_INSTANCE Instance,
HANDLE evt);
VOID WINAPI ReleaseSemaphoreWhenCallbackReturns(
PTP_CALLBACK_INSTANCE Instance,
HANDLE sem,
DWORD crel);
VOID WINAPI LeaveCriticalSectionWhenCallbackReturns(
PTP_CALLBACK_INSTANCE Instance,
PCRITICAL_SECTION pcs);
VOID WINAPI ReleaseMutexWhenCallbackReturns(
PTP_CALLBACK_INSTANCE Instance,
HANDLE mut);
VOID WINAPI FreeLibraryWhenCallbackReturns(
PTP_CALLBACK_INSTANCE Instance,
HMODULE mod);
来自 CallbackMayRunLong 的 TRUE 结果表示线程池有可用于处理长期运行回调的工作线程。在考虑工作线程的可用性时,线程池仅考虑在调用该函数时存在的当前这一组工作线程。FALSE 返回代码表示所有可用的工作线程都正忙于执行长期运行回调。在这种情况下,回调函数应尽快返回,并且如果想让工作线程仍可用于执行短期运行回调,则将长期运行工作延迟到稍后某个时间。
图 9 中的下一个函数是 DisassociateCurrentThreadFromCallback,它可中断当前执行的回调函数和启动该回调的对象之间的关联。当前的线程将不再计为代表对象执行回调。例如,如果某工作对象的回调函数调用 DisassociateCurrentThreadFromCallback,它就可以使用到该工作对象的指针(已传递到回调函数中)调用 WaitForThreadpoolWorkCallbacks,而不会有死锁风险。但是,DisassociateCurrentThreadFromCallback 确实保留当前执行的回调与该回调环境的清理组的关联,所以如果另一个线程已调用 CloseThreadpoolCleanupGroupMembers,该函数就会等待执行回调函数的线程返回线程池。这可确保当 DLL 中仍有线程执行代码时,不会卸载 DLL。有一点您需要注意,如果您调用 DisassociateCurrentThreadFromCallback,并且计划在回调函数内重用对象(比方说为工作对象调用 SubmitThreadpoolWork 之类的函数),这必须与任何对 CloseThreadPoolCleanupGroupMembers 的调用同步,因为一旦 CloseThreadPoolCleanupGroupMembers 开始执行后,试图重用该对象会导致回调函数内部发生异常。
下一组函数会使用同步对象协调回调函数执行的完成。SetEventWhenCallbackReturns 函数用于在当前回调完成后,将事件对象设置成已发送信号的状态。与之类似,函数 ReleaseSemaphoreWhenCallbackReturns、ReleaseMutexWhenCallbackReturns 和 LeaveCriticalSectionWhenCallbackReturns 都旨在于当前回调函数完成后,释放不同类型的锁对象。这些函数可以确保不管回调函数返回什么内容,指定的锁都会被释放,从而帮助减少编程错误。但愿在 Windows 的未来版本中会增加一些新函数,以支持 Slim 读取器锁/写入器锁。
FreeLibraryWhenCallbackReturns 函数用于让线程池在指定的回调实例完成执行后,对传递的模块句柄调用 FreeLibrary。这要靠该应用程序来确保所有的未完成回调都已完成执行,并且在该回调函数返回之前线程池队列中的所有挂起回调都已取消。
以前,SetThreadpoolCallbackLibrary 函数被描述成一种当回调函数仍在执行 DLL 内的代码时防止 DLL 被过早卸载的方法。这种保险措施的成本相当高,因为这涉及到在调用回调函数之前和之后获得和释放加载程序锁的系统开销。还需注意,在该进程中有一个单个的加载程序锁,对它可能会有较严重的争用,这意味着依靠该线程池的机制来确保 DLL 不会因过早卸载而对该应用程序的性能产生负面影响。根据应用程序方案的不同,通过结合使用在此部分介绍的函数和 Window 的同步化基元,来构建可提供 DLL 安全卸载的应用程序特定机制可能会更有效。
使用示例应用程序尝试一下
正如您所看到的那样,新的线程池组件有了很多改进,可以帮助您轻松地编写具有高可靠性和可伸缩性的应用程序。这些改进中最关键部分的是可以为每个进程承载多个线程池,其中每个线程池都有自己独立的一组特征,所以您可以根据执行的工作类型对进程进行分区。
我希望新线程池的这个概述已激发起您的兴趣,让您想去探究如何使用它来优化您的应用程序。为了帮助您入门,我在本文中附上了两个示例应用程序。第一个是 ThreadPoolDemo,让您可以体验一下工作、等待和计时器这些对象,以便您可以探究它们是如何工作的。ThreadPoolDemo 既可以运行在进程的默认线程池上,也可以运行在自定义的线程池上,允许通过命令行参数来指定最小和最大线程数。
通过指定 –Work 命令行选项执行的工作对象演示可用于创建工作对象。通过使用命令行参数 –I 提供的计数,它会按照指定的次数将工作项提交到线程池。您可以使用 -B 选项来定义回调函数应阻塞多长时间,使用 -E 选项来指定回调函数应执行多长时间。默认情况下,该程序会一直运行,直到所有提交的工作项都执行完毕。在命令行指定 –CC 或 –CW 会导致在提交工作项的次数达到指定的次数后,该应用程序会立即调用 CloseThreadpoolCleanupGroupMembers。两个选项之间的差别在于:–CC 可以取消尚未启用的所有回调,而 –CW 会在从 CloseThreadpoolCleanupGroupMembers 调用返回之前等待它们执行。
您还可以试验一下计时器对象。您可以配置多个计时器对象,其中每个对象都可以有自己的过期时间和可选期间以及窗口。使用具有不同窗口大小的多个计时器,可让您看到将计时器过期结合在一起的系统的效果。
最后,等待对象演示可让您为要等待的线程池定义一个或多个事件规范。每个事件规范都可包括一个可选过期时间,用来指示发送该事件信号的时间;还可以包括一个可选超时间隔,用来定义等待应过期的时间。这些选项可让您试验发送信号的事件、等待超时的事件或永不发送信号和永不超时的事件的组合。
如需每个回调对象类型的命令行参数,请在命令提示符下键入下面其中一个命令:“ThreadPoolDemo –Work ?”、“ThreadPoolDemo –Timer ?”或“ThreadPoolDemo –Wait ?”。
本文包含的第二个示例应用程序叫 CopyFile。实际上,它是 Windows SDK 文件复制示例的更新版本,用于演示完成端口是如何工作的。CopyFile 会通过使用线程池将源文件复制到目标文件,并演示 I/O 完成对象是如何工作的。在命令行中键入“CopyFile –Usage”可以在控制台窗口中显示程序参数的完整说明。
我很感谢 Rob Earhart、Eric Li 和 Sandeep Ranade 回答我的问题,并给出了有见解的回馈;同时我也很感谢 Rob Shewan 审阅本文的内容,并向我提出宝贵的意见。
Robert Saccone是 Forefront Server Security 团队的一名首席架构师。他感兴趣的领域是大型软件设计、分布式系统和操作系统实现。您可以通过
[email protected] 与他联系。