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:

  1. Allocate and initialize the work item with IoAllocateWorkItem. The function returns a pointer to the opaque IO_WORKITEM. When finished with the work item it must be freed with IoFreeWorkItem.

  2. Allocate an IO_WORKITEM structure dynamically with size provided by IoSizeofWorkItem. Then call IoInitializeWorkItem. When finished with the work item, call IoUninitializeWorkItem.

    这些函数接受设备对象,因此请确保在有工作项排队或执行时不要卸载驱动程序。

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)**。

使用工作项的步骤如下:

  1. 分配并初始化新的工作项。

    系统使用 IO_WORKITEM 结构来保存工作项。 若要分配新的 IO_WORKITEM 结构并将其初始化为工作项,驱动程序可以调用 IoAllocateWorkItem。或者手动分配 IO_WORKITEM 结构,并调用 IoInitializeWorkItem 将结构初始化为一个工作项。 (驱动程序应调用 IoSizeofWorkItem 来确定保存工作项所需的字节数。 )

  2. 将回调例程与工作项关联,并将该工作项插入队列,以便系统工作线程可以对其进行处理。

    若要将工作项例程与工作项关联,并将工作项排队,驱动程序应调用 IoQueueWorkItem。 若要将 WorkItemEx 例程与工作项关联,并将该工作项排队,则驱动程序应调用 IoQueueWorkItemEx。

    IoQueueworkItemEx 函数使用一个不同的回调,该回调具有一个附加参数,即工作项本身。 这很有用,工作项函数需要在退出前释放工作项。

  3. 不再需要该工作项后,请将其释放。

    IoAllocateWorkItem 分配的工作项应由 IoFreeWorkItem 释放。 IoInitializeWorkItem 初始化的工作项必须先由 IoUninitializeWorkItem 取消初始化,然后才能释放。

    仅当工作项当前未排队时,才能取消初始化或释放工作项。 在调用工作项的回调例程之前,系统会取消排队工作项,因此可以从回调内调用 IoFreeWorkItem 和 IoUninitializeWorkItem。

1.2 WorkItem数据结构

内核 System 进程的线程池提供的工作线程,通过 ExpWorkerThread 函数来将工作项从工作队列中摘除,并调用其中的工作例程来服务这些工作队列。

WorkItem 结构为 IO_WORKITEM(一个 IO_WORKITEM 结构表示一个工作项。):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kd> dt nt!_IO_WORKITEM
+0x000 WorkItem : _WORK_QUEUE_ITEM // 是一个链表,串着 IoObject 上的工作项
+0x020 Routine : Ptr64 void // 当前工作项关联的例程
+0x028 IoObject : Ptr64 Void // 与之关联的 IO 设备对象
+0x030 Context : Ptr64 Void // 工作项例程的参数
+0x038 WorkOnBehalfThread : Ptr64 _ETHREAD
+0x040 Type : Uint4B // 优先级,_WORK_QUEUE_TYPE 枚举的类型
+0x044 ActivityId : _GUID

//0x58 bytes (sizeof)
struct _IO_WORKITEM
{
struct _WORK_QUEUE_ITEM WorkItem; //0x0
VOID (*Routine)(VOID* arg1, VOID* arg2, struct _IO_WORKITEM* arg3); //0x20
VOID* IoObject; //0x28
VOID* Context; //0x30
struct _ETHREAD* WorkOnBehalfThread; //0x38
ULONG Type; //0x40
struct _GUID ActivityId; //0x44
};

IO_WORKITEM 第一个成员 WorkItem 是一个 _WORK_QUEUE_ITEM 结构,上面串着与 IoObject 成员关联的所有工作项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kd> dt _WORK_QUEUE_ITEM -r1
nt!_WORK_QUEUE_ITEM
+0x000 List : _LIST_ENTRY
+0x000 Flink : Ptr64 _LIST_ENTRY
+0x008 Blink : Ptr64 _LIST_ENTRY
+0x010 WorkerRoutine : Ptr64 void
+0x018 Parameter : Ptr64 Void

//0x20 bytes (sizeof)
struct _WORK_QUEUE_ITEM
{
struct _LIST_ENTRY List; //0x0
VOID (*WorkerRoutine)(VOID* arg1); //0x10
VOID* Parameter; //0x18
};

Type 成员表示工作项的优先级,是一个 _WORK_QUEUE_TYPE 枚举类型,具体成员含义见 MSDN—WORK_QUEUE_TYPE枚举:

1
2
3
4
5
6
7
8
9
10
11
typedef enum _WORK_QUEUE_TYPE {
CriticalWorkQueue,
DelayedWorkQueue,
HyperCriticalWorkQueue,
NormalWorkQueue,
BackgroundWorkQueue,
RealTimeWorkQueue,
SuperCriticalWorkQueue,
MaximumWorkQueue,
CustomPriorityWorkQueue
} WORK_QUEUE_TYPE;

1.3 WorkItem API 使用

上面讲过,有两种方法使用 WorkItem 有以下三个步骤:

  1. 分配并初始化新的工作项。
    • IoAllocateWorkItem、IoFreeWorkItem。
    • IoInitializeWorkItem、IoUninitializeWorkItem、IoSizeofWorkItem。
  2. 将回调例程与工作项关联,并将该工作项插入队列,以便系统工作线程可以对其进行处理,IoQueueWorkItem。
  3. 不再需要该工作项后,请将其释放。

IoAllocateWorkItem 方式

  1. 创建工作项。

    使用 IoAllocateWorkItem 方式由系统创建的 WorkItem。

    1
    2
    3
    PIO_WORKITEM IoAllocateWorkItem(
    IN PDEVICE_OBJECT DeviceObject // 关联的驱动设备对象
    );

    通过这种方式工作项创建好后,就已经和设备进行关联了。

  2. 将工作项插入队列。使用 IoQueueWorkItem 函数或 IoQueueWorkItemEx 函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    VOID 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摘出)。

  3. 准备工作项回调例程:

    • 如果是 IoQueueWorkItem 函数插入队列,工作项回调例程为 WorkItem 例程。
    • 如果是 IoQueueWorkItemEx 函数插入队列,工作项回调例程为 WorkItemEx 例程。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    typedef 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 例程必须运行有限的时间。否则,系统可能会死锁。所以一般情况下可不使用该结构例程。

  4. 使用由 IoAllocateWorkItem 分配的工作项。

    1
    2
    3
    VOID IoFreeWorkItem(
    IN PIO_WORKITEM IoWorkItem // 指向工作项结构的指针
    );

使用范例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
VOID IoWorkitemRoutine (
IN PDEVICE_OBJECT DeviceObject,
IN PVOID Context
)
{
PIO_WORKITEM pIoWorkItem = NULL;

// *************
// 执行其他代码逻辑
// *************
pIoWorkItem = (PIO_WORKITEM)Context;
IoFreeWorkItem(pIoWorkItem);

return;
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING /*RegistryPath*/)
{
DriverObject->DriverUnload = UnloadDriver;

PIO_WORKITEM pIoWorkItem;
pIoWorkItem = IoAllocateWorkItem(DeviceObject);
IoQueueWorkItem(pIoWorkItem, IoWorkitemRoutine, DelayedWorkQueue, pIoWorkItem);

return STATUS_SUCCESS;
}

IoInitializeWorkItem 方式

使用 IoInitializeWorkItem 方式来使用工作项时,工作项结构 IO_WORKITEM 的内存空间需要自己手动申请。

1
2
3
4
void IoInitializeWorkItem(
[in] PVOID IoObject, // 设备对象指针
[in] PIO_WORKITEM IoWorkItem // 工作项结构指针
);

注意:存放工作项的内存必须是非分页内存。内存大小由 IoSizeofWorkItem 函数指定。

在释放 IO_WORKITEM 结构之前,应该调用 IoUninitializeWorkItem 函数对工作项进行卸载。

1
2
3
void IoUninitializeWorkItem(
[in] PIO_WORKITEM IoWorkItem // 工作项结构指针
);

使用范例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
VOID IoWorkitemRoutine (
IN PDEVICE_OBJECT DeviceObject,
IN PVOID Context
)
{
PIO_WORKITEM pIoWorkItem = NULL;

// *************
// 执行其他代码逻辑
// *************
pIoWorkItem = (PIO_WORKITEM)Context;
IoUninitializeWorkItem(pIoWorkItem);
IoFreeWorkItem(pIoWorkItem);

return;
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING /*RegistryPath*/)
{
DriverObject->DriverUnload = UnloadDriver;

PIO_WORKITEM pIoWorkItem;
pIoWorkItem = (PIO_WORKITEM)ExAllocatePool(NonPagedPool, IoSizeofWorkItem());
IoInitializeWorkItem(DeviceObject, pIoWorkItem);
IoQueueWorkItem(pIoWorkItem, IoWorkitemRoutine, DelayedWorkQueue, pIoWorkItem);

return STATUS_SUCCESS;
}

参考:

  • 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://blog.csdn.net/jyl_sh/article/details/118278118?spm=1001.2101.3001.6650.3&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EOPENSEARCH%7Edefault-3-118278118-blog-112556264.pc_relevant_landingrelevant&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EOPENSEARCH%7Edefault-3-118278118-blog-112556264.pc_relevant_landingrelevant&utm_relevant_index=4

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 中的句柄列表。 按照以下步骤查看它们背后的一些细节: