《Windows核心编程系列》十一谈谈Windows线程池
Windows线程池
上一篇博文我们介绍了IO完成端口。得知IO完成端口可以非常智能的分派线程。但是IO完成端口仅对等待它的线程进行分派,创建和销毁线程的工作仍然需要我们自己来做。
我们自己也可以创建线程,但是涉及到线程的编码操作比较复杂,容易出现差错。为了简化程序员的工作,Windows提供了一个线程池机制来简化线程的创建、销毁以及日常管理。这个新线程池可能不适用于所有的情况,但大多数情况下它都能够满足我们的需要。
这个线程池能够帮助我们做一下事情:
一:以异步的方式调用一个函数。
二:每隔一段时间调用一个函数。
三:当内核对象触发时调用一个函数。
四:当异步IO请求完成时调用一个函数。
我们将在后面一一介绍上面各项。
一:以异步方式调用函数。
让线程池执行的函数需要遵循一下原型:
- VOID NTAPI ThreadFunc(
- PTP_CALLBACK_INSTANCE pInstance,
- PVOID pvContext);
定义了线程池线程入口函数,就需要提交请求让线程池执行该函数:
- BOOL TrySubmitThreadpoolCallback(
- PTP_SIMPLE_CALLBACK pfnCallback,
- PVOID pvContext,
- PTP_CALLBACK_ENVIRON pche);
该函数将一个工作项添加到线程池队列中。若调用成功,则返回true。否则返回false。
pfnCallback表示线程池线程入口函数。即我们上面定义的函数。
pvContext是传给线程入口函数的参数。
pche可以先传给它NULL。在后面我们还会有详细的介绍。
当我们提交一个请求后,线程池就会创建一个默认的线程池并让线程池的一个线程来调用回调函数。并不需要我们手动调用CreateThread。当线程从入口函数返回时,并不会销毁而是返回到线程池。线程池会不断重复使用各个线程,而不会频繁销毁和新建线程。这显著的提高了性能。
在某些情况下,如内存不足时TrySubmitThreadpoolCallback可能会失败。第一次调用TrySubmitThreadpoolCallback时,系统会在内部分配一个工作项。如果打算提交大量的工作项,出于性能和内存使用方面的考虑,应该手动创建工作项然后多次提交它。
下面的函数创建一个工作项:
- PTP_WORK CreateThreadpoolWork(
- PTP_WORK_CALLBACK pfnWorkHandler,
- PVOID pvContext,
- PTP_CALLBACK_ENVIRON pche);
pfnWorkHandler是一个函数指针,当线程池中的线程最终对工作项进行处理时会调用该函数。该函数必须遵循一下函数原型:
- VOID CALLBACK WorkCallback(
- PTP_CALLBACK_INSTANCE Instance,
- PVOID Context,
- PTP_WORK Work);
pvContext是传给pfnWorkHandler的参数。
我们可以调用SubmitThreadpoolWork向线程池提交一个请求:
- VOID SubmitThreadpoolWork(PTP_WORK pWork);
如果我们项取消已经提交的工作项或是等待工作项处理完毕。可以调用以下函数:
- VOID WaitForThreadpoolWorkCallbacks(
- PTP_WORK pWork,
- BOOL bCancelPendingCallbacks);
此函数将线程挂起,直到工作项处理完毕。
pWork指向工作项。此工作项可以是CreateThreadpoolWork和SubmitThreadpoolWork来创建和提交的。如果工作项尚未被提交,那么等待函数立即返回。
如果传入true给bCancelPendingCallbacks,那么WaitForThreadpoolWorkCallbacks会试图取消pWork标识的工作项。如果线程正在处理此工作项,则不会取消,而等待工作项处理完毕后返回。如果工作项还未被处理,函数会将此工作项标记为已取消,然后立即返回。
如果传入false给bCancelPendingCallbacks那么WaiForThreadpoolWorkCallbacks会将线程挂起,直到工作项处理完毕。
如果用一个PTP_WORK提交了多个工作项,传给bCancelPendingCallbacks为false,那么等待函数会等待所有的工作项都被处理完毕。
当不需要一个工作项时,可以调用CloseThreadpoolWork。
- VOID CloseThreadpoolWork(PTP_WORK pwk);
看例子:该例展示了如何使用线程池的工作项。
点击开始提交四次同一个工作项。
当回调函数返回时,程序将向程序发送自定义的TASK_COMPELETED消息。然后该消息处理函数将项下拉列表添加一项。
在MFC中手动添加消息需要一下步骤:
1:定义消息:如#defined TASK_COMPELETED WM_USER+1
2:定义消息处理函数原型:
- afx_msg LRESULT OnTaskCompeleted(WPARAM wparam,LPARAM lparam);
3:调用PostMessage发送消息。
- PostMessage(this,TASK_COMPELETED,0,(LPARAMm_CurrentTask);
4:在消息映射表中添加消息和消息处理函数的映射:
代码如下:
开始按钮消息处理函数:
- void CThreadpoolexap1Dlg::OnBnClickedBtnStart()
- {
- m_WorkItem=CreateThreadpoolWork(ThreadFunc,this,NULL);
- if(m_WorkItem==NULL)
- {
- MessageBox(TEXT("工作项创建失败"));
- return ;
- }
- // TODO: 在此添加控件通知处理程序代码
- SubmitThreadpoolWork(m_WorkItem);
- SubmitThreadpoolWork(m_WorkItem);
- SubmitThreadpoolWork(m_WorkItem);
- SubmitThreadpoolWork(m_WorkItem);
- }
线程入口函数:
- VOID NTAPI CThreadpoolexap1Dlg::ThreadFunc( PTP_CALLBACK_INSTANCE Instance,PVOID Context,PTP_WORK Work )
- {
- CThreadpoolexap1Dlg*pdlg=(CThreadpoolexap1Dlg*)Context;
- InterlockedIncrement(&pdlg->m_CurrentTask);
- DWORD num=pdlg->m_CurrentTask;
- CString s;
- s.Format(TEXT("【%d】任务%d开始运行!"),GetCurrentThreadId(),pdlg->m_CurrentTask);
- pdlg->m_list.InsertString(-1,s);
- pdlg->PostMessage(TASK_COMPELETED,0,(LPARAM)num);
- }
自定义消息处理函数:
- LRESULT CThreadpoolexap1Dlg::OnTaskCompeleted( WPARAM wparam,LPARAM lparam )
- {
- DWORD num=(DWORD)lparam;
- CString s;
- s.Format(TEXT("任务 [%d]执行完毕!"),num);
- m_list.InsertString(-1,s);
- return 0;
- }
执行结果:
多次点击开始键发现确实是还是那四个线程在执行。
修改代码:提交六个工作项:
多次点击开始参与执行的仍然是原来的线程。说明在线程池中线程是重用的。
情形二:每隔一段时间调用一个函数
有时候应用程序需要在某些时间执行某些任务。Windows提供了可等待计时器对象,它使我们我们可以非常方便的得到一个时间通知。我们可以为每个需要执行基于时间的任务创建一个可等待的计时器对象,但这是不必要的。线程池函数为我们解决了这些事情。
为了将一个工作项安排在某个时间执行,我们必须定义一个回调函数。该函数会在某个时刻被调用。回调函数原型为:
- VOID CALLBACK TimeoutCallback(
- PTP_CALLBACK_INSTANCE pInstance,
- PVOID pvContext,
- PTP_TIMER pTimer);
然后调用下面的函数来通知线程池应在何时调用我们的函数:
- PTP_TIMER CreateThreadpoolTimer(
- PTP_TIMER_CALLBACK pfnTimerCallback,
- PVOID pvContext,
- PTP_CALLBACK_ENVIRON pcbe);
这个函数与前面介绍的CreateThreadpoolWork相似。
pfnTimerCallback是一个函数指针。指向前面介绍的回调函数TimeroutCallback。每当线程池调用pfnTimerCallback指向的函数时会将pvContext传给它,并传给pTimer一个由CreateThreadpoolTimer返回的计时器对象指针。
pvContext为传给回调函数参数。
CreateThreadpoolTimer返回计时器对象。该计时器对象由CreateThreadpoolTimer函数创建并返回。
当我们想要向线程池注册计时器时,应该调用SetThreadpoolTimer:
- VOID SetThreadpoolTimer(
- PTP_TIMER pTimer,
- PFILETIME pftDueTime,
- DWORD msPeriod,
- DWORD msWindowLength);
pTimer用来标识CreateThreadpoolTimer返回的计时器对象。
pftDueTime表示第一次调用回调函数是什么时候。传入一个负值表示一个相对时间。该时间相对于调用SetThreadpoolTimer的时间。传入-1表示立即调用。传入的正值以100ns为单位,从1600年的1月1日开始计算。
msPeriod表示调用回调函数的时间间隔,传入0表示只调用1次。
msWindowLength用来给回调函数的执行增加一些随机性。这使得回调函数会在当前设定的时间到当前设定的触发时间加上msWindowLength设定的时间之间触发。这对于多个计时器来说非常有用。这可以避免多个计时器间的冲突。
在设置计时器之后还可以再次调用SetThreadpoolTimer来对计时器进行修改。同时也可以调用IsThreadpoolSet来确定某个计时器是否已经被设置(即pfnDueTime不为NULL)。
- BOOL IsThreadpoolTimerSet(PTP_TIMER pti);
最后我们可以通过WaitForThreadpoolTimerCallbacks来等待一个计时器完成。调用CloseThreadpoolTimer来释放计时器。它们与前面介绍的WaitForThreadpoolWork和CloseThreadpoolWork相似。
例子:该例展示了如何使用线程池计时器函数。程序启动时会将m_TimeToLeft设置为10。接着调用CreateThreadpoolTimer函数来创建线程池函数计时器。并将它传给SetThreadTimer。告诉线程池从第一秒开始,以后每隔一秒调用一次回调函数。注意:由于回调函数是由线程池的线程调用,因此在类中定义时必须是static的。另外之所有回调函数会被多次调用,是因为系统内部每个一段时间会将一个工作项添加到队列中。对于这些工作项线程池可能调用多个线程进行处理。因此要注意采取必要的线程同步机制。本例中采用InterlockedDecrement函数对m_TimeToLeft进行同步。要注意InterlockedDecrement的参数是ULONG类型。传入负值会有问题。
.h文件定义:
- <span style="font-size:18px;"> public:
- PTP_TIMER m_pTimer;
- CString m_display;
- ULONG m_TimeToLeft;
- public:
- static VOID CALLBACK TimerCallbackFunc(PTP_CALLBACK_INSTANCE pInstance,PVOID pvContext,PTP_TIMER pTimer);
- afx_msg void OnBnClickedBtnOk();</span>
初始化代码:
- <span style="font-size:18px;"> // TODO: 在此添加额外的初始化代码
- m_pTimer=CreateThreadpoolTimer(TimerCallbackFunc,this,NULL);
- if(m_pTimer==NULL)
- {
- MessageBox(TEXT("PTP_TIMER创建失败!!"));
- //return false;
- }
- FILETIME filetime;
- LARGE_INTEGER li;
- li.QuadPart=-(10000000);
- filetime.dwHighDateTime=li.HighPart;
- filetime.dwLowDateTime=li.LowPart;
- SetThreadpoolTimer(m_pTimer,&filetime,1000,0);
- CString s;
- s.Format(TEXT("你还有[%d]s时间拯救地球!!"),10);
- m_display=s;
- UpdateData(false);</span>
按钮消息响应函数:
- <span style="font-size:18px;"> void CThreadpoolExap2Dlg::OnBnClickedBtnOk()
- {
- // TODO: 在此添加控件通知处理程序代码
- static bool IsClicked=false;
- if(IsClicked==false)
- {
- CloseThreadpoolTimer(m_pTimer);
- IsClicked=true;
- }
- if(m_TimeToLeft>1)
- {
- MessageBox(TEXT("你已拯救地球!!"));
- }
- else
- {
- MessageBox(TEXT("地球已毁灭!!"));
- }</span>
线程池回调函数:
- <span style="font-size:18px;"> VOID CALLBACK CThreadpoolExap2Dlg::TimerCallbackFunc( PTP_CALLBACK_INSTANCE pInstance,PVOID pvContext,PTP_TIMER pTimer )
- {
- CThreadpoolExap2Dlg*pdlg=(CThreadpoolExap2Dlg*)pvContext;
- DWORD temp=pdlg->m_TimeToLeft;
- CString s;
- s.Format(TEXT("你还有[%d]s时间拯救地球!!"),temp-1);
- pdlg->SetDlgItemTextW(IDC_STATIC_DISPLAY,s);
- //pdlg->m_display=s;
- //pdlg->UpdateData(true);
- if(temp==1)
- {
- CloseThreadpoolTimer(pdlg->m_pTimer);
- pdlg->MessageBox(TEXT("地球已毁灭!!"));
- return ;
- }
- InterlockedDecrement(&pdlg->m_TimeToLeft);
- }</span>
执行结果:
情形三:在内核对象触发时调用一个函数:
在实际使用中我们会发现我们会经常的等待一个内核对象被触发,触发后等待线程又会进入下一轮循环继续等待。Windows线程池提供了一些机制可以简化我们的工作。
如果我们想让内核对象被触发时执行某函数。需要进行以下步骤:
首先编写一个回调函数,它是内核对象被触发时被调用的函数。需要满足一下原型:
VOID CALLBACK WaitCallback(
PTP_CALLBACK_INSTANCE pInstance,
PVOID Context,
PTP_WAIT Wait,
TP_WAIT_RESULT WaitResult);
然后创建CreateThreadpoolWait来将一个内核对象绑定到线程池:
VOID SetThreadpoolWait(
PTP_WAIT pWaitItem,
HANDLE hObject,
PFILETIME pftTimeout);
pWaitItem用来标识CreateTheadpoolWait返回的对象。
hObject用来标识内核对象。当此对象被触发时,回调函数会被调用。
pftTimeout用来表示线程池最长应该花多少时间来等待内核对象被触发。传入0表示不用等待。传入负值表示相对时间传NULL表示无限长的时间。
线程池内部会让一个线程调用WaitForMultipleOBjecs函数。传入SetThreadpoolWait函数注册的一组句柄,并传入false给bWaitAll参数。当任何一个内核对象被触发时,线程池就会被唤醒。
当内核对象被触发或是超出等待时间时,线程池的某个线程就会调用我们的回调函数。
WaitResult用来表示WaitCallback被调用的原因。它可以是以下值:
WAIT_OBJECT_0 超时之前有对象被触发。
WAIT_TIMEOUT 由于超时导致回调函数被触发。
WAIT_ABANDONED_0 如果传入的内核对象是互斥量且被遗弃。回调函数将收到这个值。
一旦线程池调用了我们的回调函数,对应的等待项将进入不活跃状态。所谓不活跃状态:如果想让回调函数在同一个内核对象被触发时再次被调用,我们需要调用SetThreadpoolWait来再次注册。
最后我们同样可以等待一个等待项完成。这可以调用WaitForThreadpoolWaitCallbacks。还可以调用CloseThreadpoolWait来释放一个等待项的内存。
注意:不要让回调函数调用WaitForThreadpoolWork并将自己的工作项作为参数传入,这会导致死锁。
情形四:在异步IO完成时调用一个函数。
我们在上一篇博文中介绍了如何使用IO完成端口来高效的执行异步IO操作,也介绍了如何创建一个线程池并让其中的线程等待IO完成端口。这里我们将介绍线程池如何管理线程的创建和销毁。
在打开一个关联起来文件或设备时,我们必须现将该设备与线程池的IO完成端口,然后告诉线程池在异步IO完成时应该调用哪个函数。
首先我们需要定义回调函数,它需要满足一下原型:
- VOID CALLBACK OverlappedCompletionRoutine(
- PTP_CALLBACK_INSTANCE pInstance,
- PVOID pvContext,
- PVOID pOverlapped,
- ULONG IoResult;
- ULONG_PTR NumberOfBytesTransferred,
- PTP_IO pIo);
当一个IO操作完成时此回调函数会被调用并得到一个指向OVERLAPPED结构的指针。此结构是我们在调用ReadFile后WriteFile时传入的。
IoResult表示IO异步操作的执行结果。如果IO请求成功,将传给回调函数NO_ERROR。
NumberOfBytesTransferred参数传入已传输的字节数。
pIo传入指向线程池IO项的指针。马上介绍。
pInstance后面会有介绍。
定义好回调函数后,我们就需要调用CreateThreadpoolIo来创建一个线程池IO对象。
- PTP_IO CreateThreadpoolIo(
- HANDLE hDevice,
- PTP_WIN32_IO_CALLBACK pfnIoCallback,
- PVOID pvContext,
- PTP_CALLBACK_ENVIRON pcbe);
hDevice是与IO对象相关联的设备句柄。
pfnIoCallback是前面我们介绍的回调函数指针。
pvContext当然是传给回调函数的参数。
当IO对象创建好之后,我们就可以通过下面的函数来将潜入在IO项的设备与IO完成端口关联起来。
- VOID StartThreadpoolIo(PTP_IO pio);
关联之后我们就可以调用ReadFile或WriteFile了。此后当异步IO请求完成后,回调函数将会被调用。
此外我们还可以调用以下函数来停止线程池调用回调函数,此后回调函数将不会被调用:
- VOID CancelThreadpoolIo(PTP_IO pio);
CloseThreadpoolIo将取消设备与线程池的关联:
- VOID CloseHandlepoolIo(PTP_IO pio);
WaitForThreadpoolIoCallbacks将等待一个待处理的IO请求我完成。
- VOID WaitForThreadpoolIoCallback(
- PTP_IO pio,
- BOOL bCancelPendingCallbacks);
如果传给bCancelPendingCallbacks的值为true,那么当请求完成时,回调函数不会被调用。
对线程池进行定制
在调用CreateThreadpoolWork、CreateThreadpoolTimer,CreateThreadpoolWait或CreateThreadpoolIo时,有一个PTP_CALLBACK_ENVIRON类型的参数。如果传给它NULL则表示我们会将工作项添加到默认的线程池中。一般情况下默认的线程池能够满足大多数情况下的要求。
如果我们想定制我们自己的线程池,可以调用CreateThreadpool来创建新线程池:
- PTP_POOL CreateThreadpool(PVOID reserved);
Reserved是保留的,传入NULL即可。
该函数返回一个PTP_POOL值,它表示新创建的线程。
此后我们就可以设置线程池中的最大线程和最小线程了。默认的线程池中线程最少为1个,最多为500。
- void SetThreadpoolThreadMinimum(PTP_POOL pThreadpool,DWORD cthrdMin);
- void SetThreadpoolThreadMaximum(PTP_POOL pThreadpool,DWORD cthrdMin);
这仅仅是告诉线程池最大和最少线程个数。但实际使用中,线程池会非常智能的决定创建或是销毁线程。只要这样做提高性能有益。
当我们不需要自己定制的线程池时可以调用CloseThreadpool销毁。
- VOID CloseThreadpool(PTP_POOL pThreadpool);
调用此函数后线程池所有的线程都被终止。线程池队列中所有尚未开始处理的项将被取消。
创建了线程池之后,并对线程池进行一定的配置后。我们就可以初始化线程池了,以便构造出一个回调环境。回调环境是一个PTP_CALLBACK_ENVIRON结构。该结构中包含了线程池、清理组等字段。我们可以调用InitializeThreadpoolEnvironment对其进行初始化:
- VOID InitializeThreadpoolEnvironment(
- PTP_CALLBACK_ENVIRON pcbe);
该函数会将回调环境的版本字段置为1,其余字段为0.
初始化回调环境后,还需要将其与线程池进行关联:
- VOID SetThreadpoolCallbackpoo(
- PTP_CALLBACK_ENVIRON pche,
- PTP_POOL pThreadPool);
该函数将回调环境的线程池字段置为pThreadpool指向的线程池。当不调用此函数时,PTP_CALLBACK_ENVIRON结构的线程池字段一直为NULL,当用这个回调环境来添加工作项时,工作项会被添加到默认的线程池中。
当我们不需要回调环境时可以调用DestroyThreadEnvironment来将其销毁:
- VOID DestroyThreadEnvironment(PTP_CALLBACK_ENVRION pcbe);
线程池同样提供了将自己销毁的机制。默认的线程池不会被销毁,它与进程的生命期一样长。进程结束后,Windows将负责所有的清理操作。
要想对我们自定义的线程池执行清理操作,首先需要创建一个清理组:
- PTP_CLEANUP_GROUP CreateThreadpoolCleanuGroup();
创建清理组后还需要将清理组和线程池相关联:
- VOID SetThreadpoolCallbackCleanupGroup(
- PTP_CALLBACK_ENVIRON pcbe,
- PTP_CLEANUP_GROUP ptpcg,
- PTP_CLEANUP_GROUP_CANCEL_CALLBACK pfng);
此函数会设置回调环境的清理组字段为ptpcg指定的清理组。
pfng指向一个回调函数。当清理组被取消时回调函数将会被调用。该回调函数必须满足一下原型:
- VOID CALLBACK CleanupGroupCancelback(
- PVOID pvObjectContext,
- PVOID pvCleanupContext);
当我们调用CreateThreadpoolWork,CreateThreadpoolTimer,CreateThreadpoolWait或CreateThreadpoolIo时,如果最后的指向PTP_CALLBACK_ENVIRON结构的指针不为NULL,那么所创建的项会被添加到对应的回调函数清理组中。它表示线程池中又添加了一项,需要清理。当我们调用CloseThreadpoolWork,CloseThreadpoolTimer,CloseThreadpoolWait或是CloseThreadIo时,等于是隐式的将对应项从清理组中清理。
当我们想清理线程池时可以调用:
- VOID CloseThreadpoolCleanupGroupMemebers(
- PTP_CLEANUPGROUP ptpcg,
- BOOL bCancelPendingCallbacks,
- PVOID pvCleanupContext);
如果传入false给bCancelPendignCallbacks,调用此函数的线程会一直等待直到线程池工作组中所有剩余的项当已经处理完毕为止。当传入true给bCancelPendingCallbacks时,所有已提交但未处理的工作项将直接被取消。对于每一个待处理的项,pfng指向的回调函数将会被调用。
当所有的工作项都被取消或被处理后,可以调用CloseThreadpoolCleanupGroup来释放清理组所占的资源:
- <span style="font-size:18px;">VOID WINAPI CloseThreadpoolCleanupGroup(
- PTP_CLEANUP_GROUP ptpcg);
- </span>