回炉重造之重读Windows核心编程-011-线程池和其他异步方式
Posted leotsou
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了回炉重造之重读Windows核心编程-011-线程池和其他异步方式相关的知识,希望对你有一定的参考价值。
线程池的使用
多线程应用程序很难设计,有两大难点,一是要管理线程的创建和撤销,再就是要对线程访问资源时实施同步。同步的工具有几个了。为了应对线程频繁地创建和撤销,线程池这个方案被放上了台面。Windows2000提供了一些新的线程池函数,使得线程的创建、撤销和基本管理更加容易。线程池的实现不拘一格,只要遵循以下的要点:
- 异步调用函数。
- 按照规定的时间间隔调用函数。
- 当单个内核对象变为已通知状态时调用函数。
- 当异步IO请求完成时调用函数。
为了完成这些操作,线程池由4个独立的部分组成。下面shixianc
|
线程初始值 |
创建一个线程时 |
线程被撤销时 |
线程如何等待 |
是什么唤醒了线程 |
定时器 |
总是1 |
当调用一个函数时 |
进程终止运行 |
待命状态 |
定时器通知排队的用户APC |
等待 |
0 |
每63个注册对象有一个线程 |
注册的等待对象数量是0 |
WaitForMultiple- Objects |
内核对象变为已通知状态 |
I/O |
0 |
系统使用试探算法,也有影响线程创建的因素:
NCTION标志
|
线程没有未处理的I/O请求并且已经等待了一个阈值周期时 |
线程没有未处理的I/O请求并且已经等待了一个阈值周期时 |
排队的用户API和已完成的I/O请求 |
非I/O |
0 |
当线程空闲了一个周期时 |
GetQueuedCom- plectionStatus |
展示已完成的状态和I/O请示(完成端口最多允许数量为2*CPU同时运行的数量) |
可见一旦新线程池函数之一被调用,就为进程创建某些组件,其中一些还将被保留到进程终止运行为之。因此线程池运行的开销并不小,需要谨慎地使用线程池。
11.1线程池的方案1
新的线程池也可以应对传统的客户端/服务器架构,通常调用这个函数:
BOOL QueueUserWorkItem(
PTHREAD_START_ROUTINE pfnCallback,
PVOID pvContext,
ULONG dwFlags);
这个函数将一个“工作项目”放进线程池中的一个线程并立即返回。工作项目是指一个函数,它被调用并传递单个参数pvContext。最后线程池中的某个线程将处理该工作项目,这个函数的原型是:
DWORD WINAPI WorkItemFunc(PVOID pvContext);
线程池中的线程运行结束之后并不会立即撤销,而是返回线程池,准备好处理其他的工作项目,这样可以省略对线程频繁的创建和撤销,以节省更多的开销,提高运行的效率;由于和完成端口相关联,可以同时运行的线程数量是CPU数量的两倍,还节省了线程上下文转移的开销。
这个函数的内部情况是:
- QueueUserWorkItem检查非I/O组件中的线程数量,然后根据负荷量(已经排队的工作项目的数量)将另一个线程添加给组件。
- QueueUserWorkItem执行对PostQueuedCompletionStatus的等价调用,把工作项目的信息传递给I/O端口。
- 在完成端口上等待的线程调用GetQueuedCompletionStatus取出信息,并调用函数。
- 当函数返回时,该线程继续调用GetQueuedCompletionStatus等待另一个工作项目。
异步IO是创建高性能可伸缩的应用程序的秘诀,但是Windows对异步IO请求规定了一个限制:如果线程发出一个异步IO请求,而线程池因为缩小而撤销,那么这个IO请求也会撤销。因此发出异步请求的时候,一定要分清楚IO和非IO的,当用QueueUserWorkItem函数的时候,给dwFlags参数传递WT_EXECUTEINIOTHREAD。如果只传递WT_EXECUTEDEFAULT,工作项目就会防护非IO组件的线程中。
Microsoft还提供了函数(如RegNotifykeyValue)能够异步执行与非IO相关的任务。这些线程也要求带哦用线程不能终止运行。使用WT_EXECUTEINPERSISTENTTHREAD标志,可以调用永久线程池中的一个,它使得定时器组件的线程能够执行已排队的工作项目函数。由于定时器组件的线程不会停止运行,因此可以确保最终发生异步操作,保证回调函数不会中断而是迅速执行。
现在的矛盾是,线程池需要是设计成可以随时处理工作项目的,但是又不能确定工作项目将要花费多长的时间。当然可以为QueueUserWorkItem传递WT_EXECUTELONGFUNCTION,这个标志可以帮忙觉得是否要将新线程添加给线程池:
线程池不能对线程池的线程数量规定一个上限,否则就会发生死锁现象,所以要寻找潜在的死锁条件,即使是使用了关键代码段、信标和互斥对象上中断运行。始终应该了解是那个组件(IO、非IO、等待定时器等)的线程正常运行你的代码。
还有DLL。如果工作项目的函数位于已经被动态卸载的DLL中,那么就有违规访问的风险。这种情况必须要在调用QueueUserWorkItem时递增引用计数的值,当引用计数变为0的时候,才是卸载DLL的时机。
11.2方案2:按规定的时间间隔调用函数
如果为了一个基于时间的任务创建一个等待定时器对象,是不必要的,浪费系统资源。而创建一个等待定时器,为它设定下一个预定运行的时间,然后为下一个时间重置定时器。再用线程池函数管理就方便多了。
首先是创建一个定时器队列 :
HANDLE CreateTimerQueue();
这样就有了一组安排定时器的定时器队列。一旦拥有它,就可以在改队列中创建定时器。
BOOL CreateTimerQueueTimer (
PHANDLE phNewTimer,
HANDLE hTimerQueue,
WAITOFTIMERCALLBACK pfnCallback,
PVOID pvContext
DWORD dwDueTime,
DWORD dwPeriod,
ULONG dwFlags);
第二个参数是定时器队列的句柄,如果定时器数量少,传递NULL即可,还不用调用CreateTimerQueue,使用默认的定时器队列,简化代码;pfnCallback和pvContext分别是函数和返回值;dwDueTime表示第一次调用函数的时间(如果是0,那就尽可能地调用函数,那么CreateTimerQueueTimer就像QueueUserWorkItem了);dwPeriod是再次执行函数的间隔时间,如果是0,排一次队就结束。pfnCallback的原型如下:
VOID WINAPI WaitOrTimerCallback(
PVOID pvContext,
BOOL fTimerOrWaitFired);
函数被调用的时候fTimerOrWaitFired总是TRUE,表示定时器已经触发。
dwFlags参数告诉函数如何给工作项目排队:
- 如果是非IO组件的线程来处理工作项目,则使用WT_EXECUTEDEFALTE。
- 如果是让一个不会终止的工作项目来处理,则使用WT_EXECUTEPERSISTENTTHREAD。
- 如果是执行长时间的函数,使用WT_EXECUTELONGFUNCTION。
- 如果是WT_EXECUTEINTIMERTHREAD,可以使得定时器组件的线程能够更高效地运行,但也很危险,有可能无法执行其他的任务。即使等待定时器会将APC项目放入该线程,在这个函数返回前,其他函数是不能得到处理的。
WT_EXECUTEINTOTHREAD、WT_ EXECUTEPERSISTENTTHREAD和EXECUTEINTIMERTHREAD等标志是互斥的,如果不传递它们之中的任何一个,工作项目就放入IO组件的线程中。
删除定时器的方式:
BOOL DeleteTimerQueueTimer(
HANDLE hTimerQueue,
HANDLE hTimer,
HANDLE hCompletionEvent);
hCompletionEvent通知你,什么时候不在存在没有处理的已经排队的工作项目,如果是INVALID_HANDLE_VALUE,那么在所有已经排队的工作项目完成运行前,函数DeleteTimerQueueTimer不会返回。这是有风险的,如果有死锁的话。
删除一个正在执行的定时器也会产生死锁。试图删除定时器的话,会将一个APC通知放出定时器组件的线程队列中,而这个线程正在等待一个定时器被删除,却删除不掉,就会产生死锁。不传递INVALID_HANLDE_VALUE,而传递NULL,这是告诉函数你想尽快删除定时器。这样函数就立即返回,但是你不知道该定时器的项目何时被完成处理。
最稳妥的做法是传递一个时间内核对象的句柄,这样DeleteTimerQueueTimer直接返回,也可以让定时器的所有工作项目完成之后,定时器组件设置这个事件。
创建一个定时器后,是可以改变它的到期时间和周期的:
BOOL ChangeTimerQueueTimer(
HANDLE hTimerQueue,
HANDLE hTimer,
ULONG dwDueTime,
ULONG dwDueTime);
可以修改dwDueTime和dwDueTime,只是修改已经触发的单步定时器不起作用。另外调用这个函数没有死锁的风险。
删除定时器队列:
BOOL DeleteTimerQueueEx (
HANDLE hTimerQueue,
HANDLE hCompletionEvent);
它取出定时器队列的句柄,并删除其中所有的定时器,不必调用DeleteTimerQueueTimer。hCompletionEvent语义相同,同样有死锁的风险。
11.3方案3:当单个内核对象变为已通知状态时调用函数
其实多数的线程只是为了等待内核对象变为已通知状态,之后执行代码,再把内核对象变为未通知状态。实现的时候,有些方式是若干个线程各自等待一个内核对象,这对系统资源是相当大浪费。
想在内核对象得到通知时注册一个要执行的工作项目,可以使用另一个新的线程池函数:
BOOL RegisterWaitForSingleObject(
PHANDLE phNewWaitObject,
HANDLE hObject,
WAITEORTIMERCALLBACK pfnCallback,
PVOID pvContext,
ULONG dwMillisecond,
ULONG dwFlags);
当注册成功,phNewWaitObject返回一个句柄以表示这个等待组件;参数hObject告诉函数想要在那个内核对象得到通知的时候,对工作项目进行排队;函数将参数pvContext传递给pfnCallback;dwMillisecond可以是0或者INFINITE,这个函数的运行情况和WaitForSingleObject相似。
在内部,等待组件使用WaitForMultipleObjects来等待已经注册的对象,并受到该函数已经存在的任何限制,其中之一就是不能等待多个句柄。要多次注册单个对象,使用函数DuplicateHandle,对原始句柄和复制句柄分开注册。由于调用了WaitForMultipleObjects,等待的内核对象不能超过64个。每隔63个对象,就要将另一个线程添加给这个组件,因为这些线程也必须等待负责控制超时的等待定时器对象。
工作项目准备执行时,模式是放入非IO组件的线程中的。线程之一醒来后就调用你的函数,函数的原型必须是下面的形式:
VOID WINAPI WaitOrTimerCallbaFunc(
PVOID pvContext,
BOOLEAN fTimerOrWaitFired);
如果等待超时fTimerOrWaitFired就是TRUE,否则就是FALSE。上面说过如果给函数RegisterWaitForSingleObject传递WT_EXECUTEWAITTHREAD,它让线程之一的运行工作项目本身,这样效率更高,因为不必放入IO组件。由于有其他工作项目的等待函数无法得到通知,所以也是有一定的危险性。
如果工作项目将要发出异步的IO请求,或者使用从不终止的线程来执行某些操作的话,那么也可以传递WT_EXECUTEINTOTHREAD或者WT_PERSISTENTTHREAD。也可以使用WT_EXECUTELONGCUNCTION标志告诉线程池,你的函数可能需要花费较长的时间来运行,而且应该考虑讲一个新的线程添加给线程池。只有工作项目被移动到IO或者非IO组件的线程中,才能使用该标志。
最后一个标志WT_EXECUTEONLYONCE。该标志告诉等待组件在工作项目执行了一次后就停止等待该对象。如果等待一个自动重置的事件内核对象,一旦该对象变为已通知状态,它就停留在这个状态中,这会导致等待组件不断地给工作项目排队。这时使用这个标志,就可以防止这种情况的发生。
除了使用EXECUTEONLYONCE标志,调用下面的函数也可以取消等待组件的注册状态。
BOOL UnregistererWaitEx(
HANDLE hWaitHandle,
HANDLE hCompletionEvent);
第一个参数指明一个已经注册的等待组件的句柄(由函数RegisterWaitForSingleObject返回),第二个参数表示你想如何被通知。和上面一样,hCompletionEvent可以传递NULL(如果不需要通知),也可以传递INVALID_HANDLE_VALUE(中断对函数的调用,直到所有排队的项目都已经执行),也可以传递一个事件对象的句柄(当排队的工作项目已经执行就会得到通知)。如果没有中断函数的调用,函数返回TRUE,否则返回FALSE,GetLastError返回STATUS_PENDING。
同样,传递INVALID_HANDLE_VALUE有死锁的风险。在曲线等待组件的注册状态之前,不要关闭内核对象的句柄。这会令句柄无效。同时,等待组件的线程会在内部使用WaitForMu
11.4方案4:当异步IO请求完成时调用函数
这个方法是通过IO完成端口,并创建一个等待该端口的线程池。还需打开多个IO设备,将他们的句柄与完成端口关联起来,当IO请求完成时,设备驱动程序就将“工作项目”排队列入该完成端口。
这是一种很出色的设计,使得少数的线程将能够有效地处理若干个工作项目,同时又是很特殊的结构,因为线程池内置了这个结构,使你节省了大量的设计和精力。要利用这个结构,只需要打开设备,将它与线程池的非IO组件关联起来。记住,IO组建的线程全部在一个IO端口上等待。若要将一个设备与该组件关联起来,将调用 下列函数:
BOOL BindIoCompletionCallback(
DWORD dwErrorCode,
DWORD dwNumberOfBytesTransfererred,
DWORD dwFlags,
POVERLAPPED pOverLapped);
这里多了一个OVERLAPPED结构传递给函数,这种结构通常被传递给ReadFile和WriteFile之类的函数,系统在内部始终保持对这个带有待处理IO请求的重叠结构进行跟踪。当请求完成时,系统将该结构的地址放入完成端口,从而使它能够被传递给你的OverLapped
CompletionRoutine函数,应该使用上下文信息放入OVERLAPPED结构的结尾处的传统方法。
如果关闭设备,会导致它的所有待处理的IO请求立即完成,并产生一个错误代码。要做好准备面对这种情况,如果想关闭设备后确保没有运行任何回调函数,可以使用引用计数的特性。
目前没有特别的标志传递给BindIoCompletionCallback的dwFlags参数,只能传递0,或者是WT_EXECUTEINTOTHREAD。如果一个IO请求经完成,它将被排队放入一个非IO组件线程。在OverLappedCompletionRoutine函数中,可以发出一个异步IO请求,只是要小心如果发出IO请求的线程终止运行,IO请求也会被撤销。
非IO组件中的线程是根据工作量来创建或撤销的。如果工作量非常小,该组件中的线程将会终止运行,IO请求却是出于未处理的状态的。如果BindIoCompletionCallback支持WT_EXECUTEINTOTHREAD标志,那么在完成端口上等待的线程将会醒来,并将结果移植到一个IO组件中。由于在IO请求出于未处理的状态下,这些线程不会停止运行,所以不用担心它们被撤销。
虽然EXECUTEINTOTHREAD标志的作用不错,但是很容易模仿相似的特性。在OverLappedCompletionRoutine中,只需要调用QueueUserWorkItem,传递EXECUTEINTOTHREAD标志和想要的任何数据(至少是重叠结构)。
这就是线程池可以为你执行的全部功能。
以上是关于回炉重造之重读Windows核心编程-011-线程池和其他异步方式的主要内容,如果未能解决你的问题,请参考以下文章