Windows 工作项(WorkItem)

😄

1 工作项(WorkItem)

1.1 工作项和线程池

用户模式的工作项(WorkItem)参见 Windows 10 System Programming Chapter9 Thread Pools,本文关注内核模式的工作项(WorkItem)。

有时需要在与执行线程不同的线程上运行一段代码。 一种方法是显式创建一个线程并为其分配运行代码的任务。 内核提供允许驱动程序创建单独执行线程的函数:PsCreateSystemThreadIoCreateSystemThread(在 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.

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

IoEx 开头函数的区别:

还有另外一组工作项的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 取消初始化,然后才能释放。

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

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