Windows 工作项(WorkItem)
😄
1 工作项(WorkItem)
1.1 工作项和线程池
用户模式的工作项(WorkItem)参见 Windows 10 System Programming Chapter9 Thread Pools,本文关注内核模式的工作项(WorkItem)。
有时需要在与执行线程不同的线程上运行一段代码。 一种方法是显式创建一个线程并为其分配运行代码的任务。 内核提供允许驱动程序创建单独执行线程的函数:PsCreateSystemThread
和 IoCreateSystemThread
(在 Windows 8+ 中可用)。 如果驱动程序需要长时间在后台运行代码,这些功能是合适的。 但是,对于限时操作,最好使用内核提供的线程池,它会在某个系统工作线程上执行您的代码。创建线程开销比较大,如果我们有执行某些代码片段的需求,就可以借助线程池中的线程来执行。
IoCreateSystemThread
优于PsCreateSystemThread
,因为它允许将设备或驱动程序对象与线程相关联。 这使得 I/O 系统添加对该对象的引用,从而确保驱动程序不会在线程仍在执行时过早卸载。驱动程序创建的线程最终必须通过调用PsTerminateSystemThread
自行终止。 如果成功,此函数永远不会返回。
工作项是用于描述排队到系统线程池的函数的术语。 驱动程序可以分配和初始化工作项,指向驱动程序希望执行的函数,然后工作项可以排队到池中。 这看起来与 DPC 非常相似,主要区别在于工作项始终在 IRQL PASSIVE_LEVEL(0)
执行。 因此,IRQL 2 代码(例如 DPC)可以使用工作项来执行 IRQL 2 通常不允许的操作(例如 I/O 操作)。
从 Windows 2000 之后,每个进程可以创建一个或多个线程池。系统线程池实际上就是System进程的线程池,内核中驱动程序都是由 System
进程的线程来执行的。
Creating and initializing a work item can be done in one of two ways:
Allocate and initialize the work item with
IoAllocateWorkItem
. The function returns a pointer to the opaqueIO_WORKITEM
. When finished with the work item it must be freed withIoFreeWorkItem
.Allocate an
IO_WORKITEM
structure dynamically with size provided byIoSizeofWorkItem
. Then callIoInitializeWorkItem
. When finished with the work item, callIoUninitializeWorkItem
.这些函数接受设备对象,因此请确保在有工作项排队或执行时不要卸载驱动程序。
Io
和 Ex
开头函数的区别:
还有另外一组工作项的API,都是 Ex
开头的,比如 ExQueueWorkItem
。 这些函数不会将工作项与驱动程序中的任何内容相关联,因此有可能在工作项仍在执行时卸载驱动程序。 这些 API 被标记为已弃用 - 总是更喜欢使用 Io
函数。
线程池是工作线程的集合,可用来高效执行异步回调。可以通过 工作项(WorkItem)来使用线程池中的线程(工作线程)。
工作项可以简单理解为 PASSIVE_LEVEL 级别的异步过程调用。
DPC、APC、WorkItem 区别:
- DPC 对象、函数需要插入到指定的 CPU 编号。工作在 **DPC_LEVEL(2)**。
- APC 对象、函数需要插入到指定的线程。工作在 APC_LEVEL(1)
- WorkItem 使用回调机制,不需要自己创建线程,只需要提供好回调函数即可。只要成功向系统插入工作项函数,系统会调度线程池的么某个线程去调用 工作项回调例程 。工作在 **PASSIVE_LEVEL(0)**。
使用工作项的步骤如下:
分配并初始化新的工作项。
系统使用 IO_WORKITEM 结构来保存工作项。 若要分配新的 IO_WORKITEM 结构并将其初始化为工作项,驱动程序可以调用 IoAllocateWorkItem。或者手动分配 IO_WORKITEM 结构,并调用 IoInitializeWorkItem 将结构初始化为一个工作项。 (驱动程序应调用 IoSizeofWorkItem 来确定保存工作项所需的字节数。 )
将回调例程与工作项关联,并将该工作项插入队列,以便系统工作线程可以对其进行处理。
若要将工作项例程与工作项关联,并将工作项排队,驱动程序应调用 IoQueueWorkItem。 若要将 WorkItemEx 例程与工作项关联,并将该工作项排队,则驱动程序应调用 IoQueueWorkItemEx。
IoQueueworkItemEx
函数使用一个不同的回调,该回调具有一个附加参数,即工作项本身。 这很有用,工作项函数需要在退出前释放工作项。不再需要该工作项后,请将其释放。
IoAllocateWorkItem
分配的工作项应由 IoFreeWorkItem 释放。IoInitializeWorkItem
初始化的工作项必须先由 IoUninitializeWorkItem 取消初始化,然后才能释放。仅当工作项当前未排队时,才能取消初始化或释放工作项。 在调用工作项的回调例程之前,系统会取消排队工作项,因此可以从回调内调用
IoFreeWorkItem
和IoUninitializeWorkItem
。
1.2 WorkItem数据结构
内核 System
进程的线程池提供的工作线程,通过 ExpWorkerThread
函数来将工作项从工作队列中摘除,并调用其中的工作例程来服务这些工作队列。
WorkItem 结构为 IO_WORKITEM
(一个 IO_WORKITEM
结构表示一个工作项。):
1 | kd> dt nt!_IO_WORKITEM |
IO_WORKITEM
第一个成员 WorkItem
是一个 _WORK_QUEUE_ITEM
结构,上面串着与 IoObject
成员关联的所有工作项。
1 | kd> dt _WORK_QUEUE_ITEM -r1 |
Type
成员表示工作项的优先级,是一个 _WORK_QUEUE_TYPE
枚举类型,具体成员含义见 MSDN—WORK_QUEUE_TYPE枚举:
1 | typedef enum _WORK_QUEUE_TYPE { |
1.3 WorkItem API 使用
上面讲过,有两种方法使用 WorkItem 有以下三个步骤:
- 分配并初始化新的工作项。
- IoAllocateWorkItem、IoFreeWorkItem。
- IoInitializeWorkItem、IoUninitializeWorkItem、IoSizeofWorkItem。
- 将回调例程与工作项关联,并将该工作项插入队列,以便系统工作线程可以对其进行处理,IoQueueWorkItem。
- 不再需要该工作项后,请将其释放。
IoAllocateWorkItem 方式
创建工作项。
使用
IoAllocateWorkItem
方式由系统创建的WorkItem
。1
2
3PIO_WORKITEM IoAllocateWorkItem(
IN PDEVICE_OBJECT DeviceObject // 关联的驱动设备对象
);通过这种方式工作项创建好后,就已经和设备进行关联了。
将工作项插入队列。使用
IoQueueWorkItem
函数或IoQueueWorkItemEx
函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15VOID IoQueueWorkItem(
IN PIO_WORKITEM IoWorkItem, // 工作项结构指针
IN PIO_WORKITEM_ROUTINE WorkerRoutine, // 工作项例程指针,WorkItem
IN WORK_QUEUE_TYPE QueueType, // 指定一个 WORK_QUEUE_TYPE 值,该值规定处理工作项的系统工作线程的类型。
// 驱动程序必须指定 DelayedWorkQueue。
IN PVOID Context // 工作项的参数
);
VOID IoQueueWorkItemEx(
IN PIO_WORKITEM IoWorkItem, // 工作项结构指针
IN PIO_WORKITEM_ROUTINE_EX WorkerRoutine, // 工作项例程指针,WorkItemEx
IN WORK_QUEUE_TYPE QueueType, // 指定一个 WORK_QUEUE_TYPE 值,该值规定处理工作项的系统工作线程的类型。
// 驱动程序必须指定 DelayedWorkQueue。
IN PVOID Context // 工作项的参数
);可以看到,这两个函数都可以将工作项插入到队列中,只不过仅工作项回调例程指针类型不同。
注意:
QueueType
成员,驱动程序必须指定 DelayedWorkQueue。工作项的回调例程由系统调用
ExpWorkerThread
函数来执行,回调例程执行之前,该函数会先将工作项从队列中摘除(类似于APC摘出)。准备工作项回调例程:
- 如果是
IoQueueWorkItem
函数插入队列,工作项回调例程为WorkItem
例程。 - 如果是
IoQueueWorkItemEx
函数插入队列,工作项回调例程为WorkItemEx
例程。
1
2
3
4
5
6
7
8
9
10typedef VOID (*PIO_WORKITEM_ROUTINE) (
IN PDEVICE_OBJECT DeviceObject, // 工作项关联的设备对象
IN PVOID Context // 工作项指针参数,和 IoQueueWorkItem 参数 4 一致
);
typedef VOID (*IO_WORKITEM_ROUTINE_EX) (
IN PDEVICE_OBJECT DeviceObject, // 工作项关联的设备对象
IN PVOID Context // 工作项指针参数,和 IoQueueWorkItemEx 参数 4 一致
IN PIO_WORKITEM IoWorkItem // 指向当前工作项结构的指针
);注意:WorkItemEx 例程必须运行有限的时间。否则,系统可能会死锁。所以一般情况下可不使用该结构例程。
- 如果是
使用由
IoAllocateWorkItem
分配的工作项。1
2
3VOID IoFreeWorkItem(
IN PIO_WORKITEM IoWorkItem // 指向工作项结构的指针
);
使用范例如下:
1 | VOID IoWorkitemRoutine ( |
IoInitializeWorkItem 方式
使用 IoInitializeWorkItem
方式来使用工作项时,工作项结构 IO_WORKITEM
的内存空间需要自己手动申请。
1 | void IoInitializeWorkItem( |
注意:存放工作项的内存必须是非分页内存。内存大小由 IoSizeofWorkItem
函数指定。
在释放 IO_WORKITEM
结构之前,应该调用 IoUninitializeWorkItem
函数对工作项进行卸载。
1 | void IoUninitializeWorkItem( |
使用范例:
1 | VOID IoWorkitemRoutine ( |
参考:
- Windows Internals 7 Chapter4 Worker factories
- Windows Kernel Programming, 2nd Edition (Pavel Yosifovich) Chapter6 Work Item
- Windows 10 System Programming Chapter9 Thread Pools
工作项 WorkItem
https://www.cnblogs.com/jadeshu/p/10663605.html
https://www.matteomalvica.com/blog/2021/03/10/practical-re-win-solutions-ch3-work-items/
https://learn.microsoft.com/zh-cn/windows-hardware/drivers/kernel/system-worker-threads
https://learn.microsoft.com/zh-cn/windows-hardware/drivers/ddi/wdm/ne-wdm-_work_queue_type
https://blog.csdn.net/pureman_mega/article/details/112556264
内核队列(KQUEUE)管理线程池。
https://www.yhvod.net/play/97330-0-1.html
旧实现的一个问题是一个进程中只能创建一个线程池,这使得一些场景难以实现。 例如,尝试通过构建两个线程池来确定工作项的优先级是不可能的,这两个线程池将服务于不同的请求集。 另一个问题是实现本身,它处于用户模式(在 Ntdll.dll 中)。 由于内核可以直接控制线程调度、创建和终止,而无需从用户模式执行这些操作的典型成本,因此支持 Windows 中的用户模式线程池实现所需的大部分功能现在都位于内核中 . 这也简化了开发人员需要编写的代码。 例如,在远程进程中创建工作池可以通过单个 API 调用完成,而不是通常需要的一系列复杂的虚拟内存调用。 在此模型下,Ntdll.dll 仅提供与工作工厂内核代码交互所需的接口和高级 API。
Windows 中的此内核线程池功能由称为 TpWorkerFactory 的对象管理器类型以及用于管理工厂及其工人的四个本机系统调用(NtCreateWorkerFactory、NtWorkerFactoryWorkerReady、NtReleaseWorkerFactoryWorker 和 NtShutdownWorkerFactory)管理; 两个查询/设置本机调用(NtQueryInformationWorkerFactory 和 NtSetInformationWorkerFactory); 和等待调用 (NtWaitForWorkViaWorkerFactory)。 就像其他本机系统调用一样,这些调用为用户模式提供了 TpWorkerFactory 对象的句柄,其中包含名称和对象属性、所需的访问掩码和安全描述符等信息。 然而,与 Windows API 包装的其他系统调用不同,线程池管理由 Ntdll.dll 的本机代码处理。 这意味着开发人员使用不透明的描述符:线程池的 TP_POOL 指针和从池中创建的对象的其他不透明指针,包括 TP_WORK(工作回调)、TP_TIMER(计时器回调)、TP_WAIT(等待回调)等。这些结构持有 各种信息,例如 TpWorkerFactory 对象的句柄。
顾名思义,工作工厂实现负责分配工作线程(并调用给定的用户模式工作线程入口点)并维护最小和最大线程数(允许永久工作池或完全动态池) 作为其他会计信息。 这使得关闭线程池等操作只需调用一次内核即可执行,因为内核一直是负责线程创建和终止的唯一组件。
因为内核根据需要动态创建新线程(基于提供的最小和最大数量),这增加了使用新线程池实现的应用程序的可伸缩性。 只要满足以下所有条件,工作工厂就会创建一个新线程:
■ 启用动态线程创建。
■ 可用工人数低于为工厂配置的最大工人数(默认为500)。
■ 工作工厂有绑定对象(例如,这个工作线程所在的 ALPC 端口waiting on) 或者一个线程已经被激活到池中。
■ 有关联的挂起的 I/O 请求数据包(IRP;有关详细信息,请参阅第 6 章)
与工作线程。
此外,只要线程变为空闲状态(即它们未处理任何工作项)超过 10 秒(默认情况下),它就会终止线程。 此外,尽管开发人员始终能够通过旧实现利用尽可能多的线程(基于系统上的处理器数量),但现在使用线程池的应用程序可以自动利用添加的新处理器 运行。 这是通过其对 Windows Server 中的动态处理器的支持(如本章前面所述)实现的。
Worker factory creation
工人工厂支持只是一个包装器,用于管理平凡的任务,否则这些任务必须在用户模式下执行(性能损失)。 新线程池代码的大部分逻辑保留在该体系结构的 Ntdll.dll 端。 (理论上,通过使用未记录的函数,可以围绕工作工厂构建不同的线程池实现。)此外,提供可伸缩性、内部等待和工作处理效率的不是工作工厂代码。 相反,它是 Windows 的一个更古老的组件:I/O 完成端口,或者更准确地说,内核队列 (KQUEUE)。 其实在创建worker factory的时候,用户态肯定已经创建了一个I/O完成端口,需要传入句柄。
正是通过这个 I/O 完成端口,用户模式实现将排队并等待工作——但是通过调用工作工厂系统调用而不是 I/O 完成端口 API。 然而,在内部,“release”worker factory 调用(排队工作)是 IoSetIoCompletionEx 的包装器,它增加了待处理的工作,而“wait”调用是 IoRemoveIoCompletion 的包装器。 这两个例程都调用内核队列实现。 因此,worker factory 代码的工作是管理一个持久的、静态的或动态的线程池; 将 I/O 完成端口模型包装到接口中,试图通过自动创建动态线程来防止停滞的工作队列; 并在工厂关闭请求期间简化全局清理和终止操作(以及在这种情况下轻松阻止对工厂的新请求)。
创建工作工厂的执行函数 NtCreateWorkerFactory 接受多个允许自定义线程池的参数,例如要创建的最大线程数以及初始提交和保留的堆栈大小。 但是,CreateThreadpool Windows API 使用嵌入在可执行映像中的默认堆栈大小(就像默认的 CreateThread 一样)。 但是,Windows API 不提供覆盖这些默认值的方法。 这有点不妥当,因为在许多情况下线程池线程不需要很深的调用堆栈,分配较小的堆栈将是有益的。
工人工厂实现使用的数据结构不在公共符号中,但仍然可以查看一些工人池,正如您将在下一个实验中看到的那样。 此外,NtQueryInformationworkerFactory APl 几乎转储了工作工厂结构中的每个字段。
EXPERIMENT: Looking at thread pools
由于线程池机制的优势,许多核心系统组件和应用程序都使用它,尤其是在处理 ALPC 端口等资源时(以适当和可扩展的级别动态处理传入请求)。 识别哪些进程正在使用工作工厂的方法之一是查看 Process Explorer 中的句柄列表。 按照以下步骤查看它们背后的一些细节: