CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • Windows 10系统编程 引言
  • 第1章:基础
  • 第2章:对象和句柄
  • 第3章:进程
  • 第4章:作业(Jobs)
    • 作业简介
    • 创建作业
    • 嵌套作业
    • 查询作业信息
      • 作业记账信息
      • 查询作业进程列表
    • 设置作业限制
      • CPU速率限制
      • 用户界面限制
    • 作业通知
    • 隔离仓(Silos)
    • 练习
    • 总结
  • 第5章:线程基础
  • 第6章:线程调度
  • 第7章:进程内线程同步
  • 第8章:进程间线程同步
  • 第9章:线程池
  • 第10章:高级线程
  • 第11章:文件和设备输入输出
  • 第12章:内存管理基础
  • 第13章:内存操作
  • 第14章:内存映射文件
  • 第15章:动态链接库
  • 第16章:安全性
  • 第17章:注册表
目录

第4章:作业(Jobs)

# 第4章:作业(Jobs)

作业对象(Job objects)自Windows 2000起便已存在,它能够管理一个或多个进程。其大部分功能主要围绕以某些方式限制被管理的进程。自Windows 8起,作业对象的实用性显著提升。在Windows 7及更早版本中,一个进程只能属于单个作业,而在Windows 8及更高版本中,一个进程可以与多个作业相关联。

本章内容包括:

  • 作业简介
  • 创建作业
  • 嵌套作业
  • 查询作业信息
  • 设置作业限制
  • 作业通知
  • 隔离仓(Silos)

# 作业简介

如果某个进程处于某个作业之下,在进程资源管理器(Process Explorer)中可以间接看到作业对象。在这种情况下,该进程的属性中会出现一个“作业”选项卡(如果该进程不属于任何作业,则不会出现此选项卡)。另一种查看作业是否存在的方法是在“选项”/“配置颜色...”中启用“作业”颜色(默认颜色为棕色)。图4-1展示了仅显示作业颜色(去除了所有其他颜色)的进程资源管理器。

img

图4-1:棕色的进程属于作业

如果某个进程是作业的一部分,其属性中的“作业”选项卡会列出作业的详细信息,包括作业名称(如果有)、属于该作业的进程以及对该作业设置的限制(如果有)。图4-2展示了一个属于名为“WmiProvider Sub SystemHostJob”作业的WMI工作进程(wmiprvse.exe)。请注意该作业的限制。

img

图4-2:进程资源管理器中的作业属性

一旦某个进程与作业关联,它就无法脱离该作业。这是合理的,因为如果进程可以从作业中移除,那么在许多情况下,作业的作用就会大打折扣。

# 创建作业

创建或打开作业的操作与其他内核对象类型的创建/打开函数类似。以下是创建作业对象的函数CreateJobObject:

HANDLE CreateJobObject(
    _In_opt_ LPSECURITY_ATTRIBUTES pJobAttributes,
    _In_opt_ LPCTSTR pName
);
1
2
3
4

第一个参数是常见的安全属性(SECURITY_ATTRIBUTES)指针,通常设置为NULL。第二个参数是为新作业对象设置的可选名称。与其他创建函数一样,如果提供了名称,并且具有该名称的作业已存在,那么(在没有安全限制的情况下)会返回指向现有作业的另一个句柄。与往常一样,调用GetLastError函数可以通过返回ERROR_ALREADY_EXISTS来判断作业是否已存在。

可以使用OpenJobObject函数通过名称打开现有作业对象,其定义如下:

HANDLE OpenJobObject(
    _In_ DWORD dwDesiredAccess, 
    _In_ BOOL bInheritHandle, 
    _In_ PCTSTR pName
);
1
2
3
4
5

现在,大多数参数的含义应该很容易理解。第一个参数指定对命名作业对象所需的访问掩码。该访问掩码会与作业对象的安全描述符进行比对,只有当安全描述符包含允许所请求权限的条目时,才会返回成功。表4-1列出了有效的作业访问掩码及其简要说明。

表4-1:作业对象访问掩码

访问掩码 说明
JOB_OBJECT_QUERY (4) 对作业进行查询操作,例如QueryInformationJobObject
JOB_OBJECT_ASSIGN_PROCESS (1) 允许将进程添加到作业中
JOB_OBJECT_SET_ATTRIBUTES (0x10) 调用SetInformationJobObject函数时需要此权限
JOB_OBJECT_TERMINATE (8) 调用TerminateJobObject函数时需要此权限
JOB_OBJECT_ALL_ACCESS 对作业的所有可能访问权限

获得作业句柄后,可以通过调用AssignProcessToJobObject函数将进程与作业关联起来:

BOOL AssignProcessToJobObject(
    _In_ HANDLE hJob,
    _In_ HANDLE hProcess
);
1
2
3
4

作业句柄必须具有JOB_OBJECT_ASSIGN_PROCESS访问掩码,在创建新作业时,调用者对作业拥有完全控制权,因此总是具备该权限。要分配到作业中的进程句柄必须具有PROCESS_SET_QUOTA和PROCESS_TERMINATE访问掩码位。这意味着有些进程永远无法成为作业的一部分,比如受保护进程,因为无法获取这些进程的上述访问掩码。

以下示例根据进程ID打开一个进程,并将其添加到指定的作业中:

bool AddProcessToJob(HANDLE hJob, DWORD pid) {
    HANDLE hProcess = ::OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE, pid);
    if (!hProcess)
        return false;
    
    BOOL success = ::AssignProcessToJobObject(hJob, hProcess);
    ::CloseHandle(hProcess);
    
    return success ? true : false;
}
1
2
3
4
5
6
7
8
9
10

一旦某个进程与作业关联,它就无法脱离。如果该进程创建了子进程,默认情况下,子进程会作为父进程所在作业的一部分被创建。有两种情况下,子进程可能在作业外部创建:

  • CreateProcess调用包含CREATE_BREAKAWAY_FROM_JOB标志,并且作业允许脱离(通过设置限制标志JOB_OBJECT_LIMIT_BREAKAWAY_OK,详见本章后面的“设置作业限制”部分)。
  • 作业具有限制标志JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK。在这种情况下,任何子进程都可以在作业外部创建,无需任何特殊标志。

# 嵌套作业

Windows 8引入了将一个进程与多个作业相关联的功能。这使得作业比以前更加实用,因为在过去,如果想要用某个作业控制的进程已经属于另一个作业,就无法再将其与其他作业关联。当一个进程被分配到第二个作业时(如果可能的话),会创建一个作业层次结构。第二个作业成为第一个作业的子作业。基本规则如下:

  • 父作业设置的限制会影响该作业及其所有子作业(进而影响这些作业中的所有进程)。
  • 父作业设置的任何限制,子作业都无法取消,但可以设置得更严格。例如,如果父作业设置了200MB的作业范围内存限制,子作业可以为其进程设置150MB的限制,但不能设置为250MB。

图4-3展示了通过按顺序执行以下操作创建的作业层次结构:

  1. 将进程P1分配到J1。
  2. 将进程P1分配到J2,形成层次结构。
  3. 将进程P2分配到J2,进程P2现在受作业J1和J2的影响。
  4. 将进程P3分配到J1。

最终的进程/作业关系如图4-3所示。

img

图4-3:作业层次结构

查看作业层次结构并不容易。例如,进程资源管理器在显示作业详细信息时,会包含所显示作业及其所有子作业(如果有)的信息。例如,查看图4-3中作业J1的信息时,会列出三个进程:P1、P2和P3。此外,由于作业访问是间接的(只有当进程处于某个作业之下时才会出现“作业”选项卡),所显示的作业是该进程所属的直接作业,不会显示任何父作业。

以下代码创建了图4-3所示的层次结构:

#include <windows.h>
#include <stdio.h>
#include <assert.h>
#include <string>

HANDLE CreateSimpleProcess(PCWSTR name) {
    std::wstring sname(name);
    PROCESS_INFORMATION pi;
    STARTUPINFO si = { sizeof(si) };
    if (!::CreateProcess(nullptr, const_cast<PWSTR>(sname.data()), nullptr, nullptr,
        FALSE, CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_CONSOLE,
        nullptr, nullptr, &si, &pi))
        return nullptr;

    ::CloseHandle(pi.hThread);
    return pi.hProcess;
}

HANDLE CreateJobHierarchy() {
    auto hJob1 = ::CreateJobObject(nullptr, L"Job1");
    assert(hJob1);

    auto hProcess1 = CreateSimpleProcess(L"mspaint");
    auto success = ::AssignProcessToJobObject(hJob1, hProcess1);
    assert(success);

    auto hJob2 = ::CreateJobObject(nullptr, L"Job2");
    assert(hJob2);

    success = ::AssignProcessToJobObject(hJob2, hProcess1);
    assert(success);

    auto hProcess2 = CreateSimpleProcess(L"mstsc");
    success = ::AssignProcessToJobObject(hJob2, hProcess2);
    assert(success);

    auto hProcess3 = CreateSimpleProcess(L"cmd");
    success = ::AssignProcessToJobObject(hJob1, hProcess3);
    assert(success);

    // 不关闭进程和作业2的句柄
    return hJob1;
}

int main() {
    auto hJob = CreateJobHierarchy();
    printf("Press any key to terminate parent job...\n");
    ::getchar();
    ::TerminateJobObject(hJob, 0);
    
    return 0;
}
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

代码位于本章源代码的JobTree项目中。

进程映像名称故意设置得不同,以便于区分(P1=mspaint,P2=mstsc,P3=cmd)。作业也进行了命名,同样是为了便于识别。

每个进程最初是在任何作业之外创建的,方法是在CreateProcess调用中指定CREATE_BREAKAWAY_FROM_JOB。否则,从已经属于某个作业的进程(如Visual Studio)中运行此应用程序会使作业层次结构变得复杂。

图4-4展示了画图工具(mspaint)运行所在的作业。注意,它在Job2中,尽管画图工具也在Job1之下。图4-5展示了命令提示符(cmd)运行所在的作业,显示了三个进程。这是因为命令提示符是Job1的一部分,而Job1会显示所有进程,包括子作业中的进程。

图4-4:进程资源管理器(Process Explorer)中的Job2属性

图4-5:进程资源管理器中的Job1属性

查看作业层次结构并不容易,因为没有公开文档说明(实际上也没有未公开文档说明)的应用程序编程接口(API)来枚举作业,更不用说作业层次结构了。我创建了一个名为“作业资源管理器(Job Explorer)”的工具来试图填补这一空白。你可以在我的Github仓库(https://github.com/zodiacon/jobexplorer)中找到它。

在“作业树(JobTree)”应用程序等待按键时运行“作业资源管理器”,当选择“所有作业”树节点并按名称对作业进行排序时,会显示图4-6中的屏幕截图。

图4-6:作业资源管理器的“所有作业”节点

双击Job1会在左侧展开作业层次结构,并在右侧显示作业详细信息,如图4-7所示。

图4-7:作业资源管理器的作业层次结构视图

在树形视图中可以清楚地看到作业层次结构。注意,当启动cmd.exe时总是会创建的conhost.exe进程也属于同一个作业。

你可能想知道“作业资源管理器”是如何工作的。我打算写一篇关于它的博客文章。

# 查询作业信息

即使没有任何特殊设置,作业对象(job object)也会跟踪一些基本的作业统计信息。用于查询作业对象信息的主要应用程序编程接口(API)恰当地命名为QueryInformationJobObject:

BOOL QueryInformationJobObject(
    _In_opt_ HANDLE hJob,
    _In_ JOBOBJECTINFOCLASS JobObjectInfoClass,
    _Out_ LPVOID pJobObjectInfo,
    _In_ DWORD cbJobObjectInfoLength,
    _Out_opt_ LPDWORD pReturnLength
);
1
2
3
4
5
6
7

hJob参数是指向作业的句柄,该句柄必须具有JOB_QUERY访问掩码;不过,正如SAL注释所暗示的,NULL值是一个有效值,它指向调用进程所属的作业(如果有的话)。通过这种方式,进程可以查询可能与其执行相关的信息,例如作业对其施加的任何内存限制。如果作业是嵌套的,则查询的是直接所属的作业。JOBOBJECTINFOCLASS是一个枚举,包含了可以查询的各种信息。对于每种请求的信息类型,必须在pJobObjectInfo参数中提供一个大小合适的缓冲区,以便函数填充。最后一个参数是一个可选值,包含提供的缓冲区中返回的数据大小,这对于某些返回可变大小数据的查询类型很有用。最后,与大多数应用程序编程接口一样,该函数在成功时返回非FALSE值。

表4-2总结了在查询操作中可用于作业的(有文档记录的)信息类。

表4-2:有文档记录的作业查询操作的JOBINFOCLASS

信息类 信息结构类型 描述
基本记账信息(BasicAccountingInformation,值为1) JOBOBJECT_BASIC_ACCOUNTING_INFORMATION 基本记账
基本限制信息(BasicLimitInformation,值为2) JOBOBJECT_BASIC_LIMIT_INFORMATION 基本限制
基本进程ID列表(BasicProcessIdList,值为3) JOBOBJECT_BASIC_PROCESS_ID_LIST 作业中的进程ID列表
基本用户界面限制(BasicUIRestrictions,值为4) JOBOBJECT_BASIC_UI_RESTRICTIONS 用户界面限制
作业结束时间信息(EndOfJobTimeInformation,值为6) JOBOBJECT_END_OF_JOB_TIME_INFORMATION 达到作业时间限制结束时的操作
基本和I/O记账信息(BasicAndIoAccountingInformation,值为8) JOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION 基本和I/O记账
扩展限制信息(ExtendedLimitInformation,值为9) JOBOBJECT_EXTENDED_LIMIT_INFORMATION 扩展限制
组信息(GroupInformation,值为11) USHORT数组 (Windows 7及更高版本)作业的处理器组(见第6章)
通知限制信息(NotificationLimitInformation,值为12) JOBOBJECT_NOTIFICATION_LIMIT_INFORMATION (Windows 8及更高版本)通知限制
限制违规信息(LimitViolationInformation,值为13) JOBOBJECT_LIMIT_VIOLATION_INFORMATION (Windows 8及更高版本)限制违规信息
组信息扩展(GroupInformationEx,值为14) GROUP_AFFINITY数组 (Windows 8及更高版本)处理器组关联性
CPU速率控制信息(CpuRateControlInformation,值为15) JOBOBJECT_CPU_RATE_CONTROL_INFORMATION (Windows 8及更高版本)CPU速率限制信息
网络速率控制信息(NetRateControlInformation,值为32) JOBOBJECT_NET_RATE_CONTROL_INFORMATION (Windows 10及更高版本)网络速率限制信息
通知限制信息2(NotificationLimitInformation,值为33) JOBOBJECT_NOTIFICATION_LIMIT_INFORMATION_2 (Windows 10及更高版本)扩展限制信息
限制违规信息2(LimitViolationInformation2,值为34) JOBOBJECT_LIMIT_VIOLATION_INFORMATION_2 (Windows 10及更高版本)扩展限制违规信息

# 作业记账信息

如前所述,无论对作业施加何种限制,作业都会跟踪一些信息。通过JobObjectBasicAccountingInformation枚举和定义如下的JOBOBJECT_BASIC_ACCOUNTING_INFORMATION结构,可以获取基本的记账信息:

typedef struct _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION {
    LARGE_INTEGER TotalUserTime;           		// 总用户模式CPU时间
    LARGE_INTEGER TotalKernelTime;         		// 总内核模式CPU时间
    LARGE_INTEGER ThisPeriodTotalUserTime; 		// 与上述相同的计数器
    LARGE_INTEGER ThisPeriodTotalKernelTime; 	// 针对“某个周期”
    DWORD TotalPageFaultCount; 					// 页面错误计数
    DWORD TotalProcesses; 						// 作业中曾经存在的总进程数
    DWORD ActiveProcesses; 						// 作业中的活动进程数
    DWORD TotalTerminatedProcesses; 			// 因限制违规而终止的进程数
} JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, *PJOBOBJECT_BASIC_ACCOUNTING_INFORMATION;
1
2
3
4
5
6
7
8
9
10

各种时间以LARGE_INTEGER结构提供,每个结构以100纳秒为单位保存一个64位值。“this period(本周期)”前缀表示自最近设置的每个作业的用户/内核时间限制(如果有)以来的时间。在创建作业和设置新的每个作业的时间限制时,这些值会被清零。

以下代码片段展示了如何对作业对象进行基本记账信息的查询调用:

// assume hJob is a job handle
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION info;
BOOL success = QueryInformationJobObject(hJob, JobObjectBasicAccountingInformation, &info, sizeof(info), nullptr);
1
2
3

类似地,使用JobObjectBasicAndIoAccountingInformation和JOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION可以为作业提供扩展的记账信息,其中包括I/O操作计数和大小。这个扩展结构包含两个结构,其中一个是JOBOBJECT_BASIC_ACCOUNTING_INFORMATION:

typedef struct _IO_COUNTERS {
    ULONGLONG ReadOperationCount;
    ULONGLONG WriteOperationCount;
    ULONGLONG OtherOperationCount;
    ULONGLONG ReadTransferCount;
    ULONGLONG WriteTransferCount;
    ULONGLONG OtherTransferCount;
} IO_COUNTERS, *PIO_COUNTERS;

typedef struct JOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION {
    JOBOBJECT_BASIC_ACCOUNTING_INFORMATION BasicInfo;
    IO_COUNTERS IoInfo;
} JOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION, *PJOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION;
1
2
3
4
5
6
7
8
9
10
11
12
13

读取和写入操作是指ReadFile和WriteFile(以及类似的)应用程序编程接口,我们将在本书后面介绍这些接口。“其他”操作是指DeviceIoControl应用程序编程接口的使用,它用于非读写操作,通常针对设备而不是文件系统文件。

本章源代码中的JobMon项目包含了我们在本章中讨论的许多作业特性。运行该项目会显示图4-8中的窗口。

图4-8:作业监视器初始窗口

点击“创建作业(Create Job)”按钮创建一个空作业。在创建作业前,你可以为作业设置名称。新创建的作业没有任何进程,并且会显示基本信息和输入/输出(I/O)信息(图4-9)。

图4-9:新建作业后的作业监视器

若要查看作业统计信息的实际情况,请从https://github.com/zodiacon/AllTools下载CPUStres.exe工具。点击三点按钮浏览查找CpuStres.exe,然后多次点击“创建并添加进程(Create and Add Process)”按钮,将CPUStres实例添加到作业中(图4-10)。注意,此时统计信息不再为零。

图4-10:包含多个CPUStres进程的作业监视器

CPUStres是一个占用CPU资源的实用工具,下一章会更多地使用它。显示信息大约每1.5秒更新一次。你可以向作业中添加更多进程(可以是CPUStres或其他程序),也可以关闭进程。图4-11展示了添加更多CPUStres进程并关闭部分进程后的作业监视器。

图4-11:包含更多进程的作业监视器

你可以点击“终止作业(Terminate Job)”一次性终止作业中的所有进程。这是通过调用TerminateJobObject实现的:

BOOL TerminateJobObject(
    _In_ HANDLE hJob,
    _In_ UINT   uExitCode
);
1
2
3
4

TerminateJobObject的行为就好像作业中每个活动进程都通过TerminateProcess终止,其中uExitCode是作业中所有进程的退出代码。

此时,可以向作业中添加新进程,统计信息也会正常更新。

# 查询作业进程列表

可以通过使用JobObjectBasicProcessIdList信息类调用QueryInformationJobObject来检索作业中的活动(正在运行)进程列表,该函数会在JOBOBJECT_BASIC_PROCESS_ID_LIST结构中返回一个进程ID数组:

typedef struct _JOBOBJECT_BASIC_PROCESS_ID_LIST {
    DWORD NumberOfAssignedProcesses;
    DWORD NumberOfProcessIdsInList;
    ULONG_PTR ProcessIdList[1];
} JOBOBJECT_BASIC_PROCESS_ID_LIST, *PJOBOBJECT_BASIC_PROCESS_ID_LIST;
1
2
3
4
5

由于进程ID数组的存在,该结构的大小是可变的。这意味着固定大小的数组需要足够大,才能包含所有进程ID,并且只能寄希望于它能满足需求。或者,也可以使用动态分配的缓冲区,如果缓冲区大小不够,可以调整其大小。

以下示例展示了如何在根据需要分配足够大的缓冲区的同时检索活动进程列表。

#include <vector>
#include <memory>

std::vector<DWORD> GetJobProcessList(HANDLE hJob) {
    auto size = 256;
    std::vector<DWORD> pids;
    while (true) {
        auto buffer = std::make_unique<BYTE[]>(size);
        auto ok = ::QueryInformationJobObject(hJob, JobObjectBasicProcessIdList,
                                              buffer.get(), size, nullptr);
        if (!ok && ::GetLastError() == ERROR_MORE_DATA) {
            // buffer 太小 - 调整大小并再次尝试
            size *= 2;
            continue;
        }
        
        if (!ok)
            break;
        
        auto info = reinterpret_cast<JOBOBJECT_BASIC_PROCESS_ID_LIST*>(buffer.get());
        pids.reserve(info->NumberOfAssignedProcesses);
        for (DWORD i = 0; i < info->NumberOfAssignedProcesses; i++)
            pids.push_back((DWORD)info->ProcessIdList[i]);
        
        break;
    }
    return pids;
}
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

这段代码使用了一些C++ 结构来简化内存管理。该函数本身返回一个std::vector<DWORD>,其中包含作业中的进程ID。在大多数情况下,进程数量事先是未知的,因此该函数使用std::make_unique<BYTE>[]分配一个字节数组,它会分配一个具有指定元素数量(size)的字节数组。当unique_ptr超出作用域时,其析构函数会释放该缓冲区。

接下来,使用分配的字节缓冲区调用QueryInformationJobObject。如果它返回FALSE且GetLastError返回ERROR_MORE_DATA,则意味着分配的缓冲区太小,因此该函数将size加倍并再次尝试。

一旦缓冲区足够大,将指针转换为JOBOBJECT_BASIC_PROCESS_ID_LIST*,就可以检索进程ID并将其放入std::vector中。奇怪的是,在此结构中返回的进程ID类型为ULONG_PTR,这意味着在64位进程中每个进程ID都是64位的。这很不寻常,因为进程ID通常是32位值(DWORD)。这就是为什么我们不能一次性将整个数组复制到向量中(除非将向量改为保存ULONG_PTR类型的值)。

你可能很好奇为什么进程ID的类型是ULONG_PTR。这是Windows API中极少数出现这种情况的例子之一。在内核中,进程(和线程)ID是通过一个专用的句柄表生成的。由于64位系统上的句柄是64位值,所以直接使用它们可能“自然而然”地很方便。不过,由于句柄表最多只能容纳约1600万个句柄,目前并不需要64位值(在内核之外,进程ID也不使用64位值)。

# 设置作业限制

作业的主要目的是对其包含的进程设置限制。要使用的函数与QueryInformationJobObject相反,是SetInformationJobObject,其定义如下:

BOOL SetInformationJobObject(
    _In_ HANDLE             hJob,
    _In_ JOBOBJECTINFOCLASS JobObjectInfoClass,
    _In_ PVOID              pJobObjectInfo,
    _In_ DWORD              cbJobObjectInfoLength
);
1
2
3
4
5
6

此时,这些参数应该很容易理解。作业句柄必须具有JOB_OBJECT_SET_ATTRIBUTES访问掩码,并且不能为NULL。表4 - 3总结了可以与SetInformationJobObject一起使用的(已记录的)信息类。

表4 - 3:已记录的作业设置操作的JOBINFOCLASS

信息类 信息结构 描述
BasicLimitInformation (2) JOBOBJECT_BASIC_LIMIT_INFORMATION 基本限制
BasicUIRestrictions (4) JOBOBJECT_BASIC_UI_RESTRICTIONS 用户界面限制
EndOfJobTimeInformation (6) JOBOBJECT_END_OF_JOB_TIME_INFORMATION 达到作业时间限制时的操作
AssociateCompletionPortInfo (7) JOBOBJECT_ASSOCIATE_COMPLETION_PORT 将完成端口与作业关联
ExtendedLimitInformation (9) JOBOBJECT_EXTENDED_LIMIT_INFORMATION 扩展限制
GroupInformation (11) USHORT数组 (Windows 7及更高版本)作业的处理器组(见第6章)
GroupInformationEx (14) GROUP_AFFINITY数组 (Windows 8及更高版本)作业的处理器组和关联性(见第6章)
NotificationLimitInformation (12) JOBOBJECT_NOTIFICATION_LIMIT_INFORMATION (Windows 8及更高版本)通知限制
LimitViolationInformation (13) JOBOBJECT_LIMIT_VIOLATION_INFORMATION (Windows 8及更高版本)限制违规信息
GroupInformationEx (14) GROUP_AFFINITY数组 (Windows 8及更高版本)处理器组关联性
CpuRateControlInformation (15) JOBOBJECT_CPU_RATE_CONTROL_INFORMATION (Windows 8及更高版本)CPU速率限制信息
NetRateControlInformation (32) JOBOBJECT_NET_RATE_CONTROL_INFORMATION (Windows 10及更高版本)网络速率限制信息
NotificationLimitInformation (33) JOBOBJECT_NOTIFICATION_LIMIT_INFORMATION_2 (Windows 10及更高版本)扩展限制信息
LimitViolationInformation2 (34) JOBOBJECT_LIMIT_VIOLATION_INFORMATION_2 (Windows 10及更高版本)扩展限制违规信息

最“基本”的限制是通过JobObjectBasicLimitInformation和JOBOBJECT_BASIC_LIMIT_INFORMATION指定的,而扩展限制是通过JobObjectExtendedLimitInformation和JOBOBJECT_EXTENDED_LIMIT_INFORMATION设置的。这些结构的定义如下:

typedef struct _JOBOBJECT_BASIC_LIMIT_INFORMATION {
    LARGE_INTEGER PerProcessUserTimeLimit;
    LARGE_INTEGER PerJobUserTimeLimit;
    DWORD LimitFlags;
    SIZE_T MinimumWorkingSetSize;
    SIZE_T MaximumWorkingSetSize;
    DWORD ActiveProcessLimit;
    ULONG_PTR Affinity;
    DWORD PriorityClass;
    DWORD SchedulingClass;
} JOBOBJECT_BASIC_LIMIT_INFORMATION, *PJOBOBJECT_BASIC_LIMIT_INFORMATION;

typedef struct _JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
    JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
    IO_COUNTERS						  IoInfo;
    SIZE_T							  ProcessMemoryLimit;
    SIZE_T							  JobMemoryLimit;
    SIZE_T							  PeakProcessMemoryUsed;
    SIZE_T							  PeakJobMemoryUsed;
} JOBOBJECT_EXTENDED_LIMIT_INFORMATION, *PJOBOBJECT_EXTENDED_LIMIT_INFORMATION;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

可以设置的各种限制取决于JOBOBJECT_BASIC_LIMIT_INFORMATION(无论是单独使用还是作为JOBOBJECT_EXTENDED_LIMIT_INFORMATION的一部分)中的LimitFlags成员。有些标志没有关联成员,因为这些标志本身就足够了。其他标志则会使SetInformationJobObject使用相应成员的值。表4-4总结了没有相应成员的标志。表4-5总结了有关联成员的标志。

表4-4:没有关联成员的LimitFlags

限制标志(JOB_OBJECT_LIMIT_*) 描述
DIE_ON_UNHANDLED_EXCEPTION (0x400) 在发生未处理异常时,防止作业中的进程显示对话框
PRESERVE_JOB_TIME (0x40) 此标志保留之前设置的任何作业限制,以便调用方可以进一步进行更改。此标志不能与JOB_OBJECT_LIMIT_JOB_TIME一起使用(反之亦然)
BREAKAWAY_OK (0x800) 如果在调用CreateProcess时指定了CREATE_BREAKAWAY_FROM_JOB标志,则允许作业中的进程创建的进程在作业外部创建。如果该进程属于作业层次结构,新进程将脱离此作业以及所有设置了此标志的父作业
SILENT_BREAKAWAY_OK (0x1000) 允许作业中的进程创建的进程在作业外部创建,且无需在CreateProcess调用中使用任何特殊标志
KILL_ON_JOB_CLOSE (0x2000) 当最后一个作业句柄关闭时,终止作业中的所有进程

表4-5:有关联成员的LimitFlags

限制标志(JOB_OBJECT_LIMIT_*) 关联成员(B/E) 描述
WORKINGSET (1) MinimumWorkingSetSize,
MaximumWorkingSetSize (B)
限制每个进程的工作集(随机存取存储器,RAM)。如果进程需要使用更多的RAM,它将自行分页
PROCESS_TIME (2) PerProcessUserTimeLimit (B) 限制作业中每个进程的用户模式执行时间(以100纳秒为单位)
JOB_TIME (4) PerJobUserTimeLimit (B) 限制整个作业的用户模式CPU时间。如果进程使用的CPU时间超过限制,它将被终止
ACTIVE_PROCESS (8) ActiveProcessLimit (B) 限制作业中的活动(运行)进程数量。违反此限制的新创建进程将自动终止
AFFINITY (0x10) Affinity (B) 为作业中的所有进程设置CPU亲和力(有关亲和力的更多信息,请参阅第6章)
PRIORITY_CLASS (0x20) PriorityClass (B) 限制作业中的进程使用相同的优先级类别(请参阅第6章)
SCHEDULING_CLASS (0x80) SchedulingClass (B) 限制作业中所有进程的调度类别。值的范围是0到9,其中9最高。默认值是5(更多信息,请参阅第6章)
PROCESS_MEMORY (0x100) JobMemoryLimit (E) 限制每个进程的提交内存
JOB_MEMORY (0x200) JobMemoryLimit (E) 限制整个作业的提交内存
SUBSET_AFFINITY (0x4000) Affinity (B) (Windows 7及更高版本)允许进程使用指定亲和力的一个子集。同时还需要设置JOB_OBJECT_LIMIT_AFFINITY标志

表4-4中的所有标志都必须与扩展限制结构(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)一起使用,在该结构中,这些标志通过嵌套的JOBOBJECT_BASIC_LIMIT_INFORMATION结构的LimitFlags成员指定。在表4-5中,B/E表示此限制是通过基本(B)结构还是扩展(E)结构指定的。

以下代码为给定的作业设置低于正常(Below Normal)的优先级类别:

bool SetJobPriorityClass(HANDLE hJob) {
    JOBOBJECT_BASIC_LIMIT_INFORMATION info;
    info.LimitFlags = JOB_OBJECT_LIMIT_PRIORITY_CLASS;
    info.PriorityClass = BELOW_NORMAL_PRIORITY_CLASS;
    return ::SetInformationJobObject(hJob, JobObjectBasicLimitInformation, &info, sizeof(info));
}
1
2
3
4
5
6

我们可以使用作业监视器(Job Monitor)应用程序测试这类功能。打开作业监视器,创建一个新作业,并向作业中添加一个进程(图4-12中的记事本)。

图4-12:已添加记事本(程序)的作业监视器

如果你在任务管理器中查看此记事本进程的“基本优先级”列,你应该会看到“正常”这个值(图4-13)。

图4-13:任务管理器中记事本的基本优先级

基本优先级(优先级类别)的确切含义和效果将在第6章中讨论。

现在回到作业监视器,选择“优先级类别”限制,将其值设置为“低于正常”,然后点击“设置”(图4-14)。

图4-14:在作业监视器中设置优先级类别限制

现在切换到任务管理器。记事本的基本优先级现在应该显示为“低于正常”(图4-15)。

图4-15:记事本被限制的基础优先级

由于作业限制,尝试在任务管理器中通过右键单击记事本并选择任何级别(“低于正常”除外)的“设置优先级”将不会有任何效果。

回到作业监视器,点击“优先级类别”限制旁边的“移除”将重新允许修改基本优先级。

你可以在JobMon项目的MainDlg.cpp文件中找到设置/移除大多数可用作业限制的代码。

# CPU速率限制

Windows 8在可用的作业限制中添加了CPU速率限制,它不是通过基本或扩展限制来设置的,而是使用其自己的作业限制枚举:JobObjectCpuRateControlInformation。

相关的结构是JOBOBJECT_CPU_RATE_CONTROL_INFORMATION,定义如下:

typedef struct _JOBOBJECT_CPU_RATE_CONTROL_INFORMATION {
    DWORD ControlFlags;
    union {
        DWORD CpuRate;
        DWORD Weight;
        struct {
            WORD MinRate;
            WORD MaxRate;
        };
    };
} JOBOBJECT_CPU_RATE_CONTROL_INFORMATION, *PJOBOBJECT_CPU_RATE_CONTROL_INFORMATION;
1
2
3
4
5
6
7
8
9
10
11

有三种不同的方式来设置CPU速率限制,由ControlFlags字段控制。其可能的值总结在表4-5中。

表4-5:CPU速率控制标志

标志(JOB_OBJECT_CPU_RATE_*) 描述
ENABLE (1) 启用CPU速率控制
WEIGHT_BASED (2) CPU速率基于相对权重(Weight成员)
HARD_CAP (4) 为CPU消耗设置硬上限
NOTIFY (8) 如果存在与作业关联的I/O完成端口,则在速率违规时通知该端口
MIN_MAX_RATE (0x10) 将CPU速率设置在最小值和最大值(MinRate和MaxRate成员)之间

如果启用了CPU速率控制,并且没有指定JOB_OBJECT_CPU_RATE_CONTROL_WEIGHT_BASED和JOB_OBJECT_CPU_RATE_CONTROL_MIN_MAX_RATE,则CpuRate成员指定相对于10000的CPU限制百分比。例如,如果需要15%的CPU使用率,该值应设置为1500。这允许指定小数形式的CPU速率。

如果还指定了JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP标志,则该限制是硬性的——即使有可用的CPU,作业也不会获得更多的CPU资源。如果没有这个标志,如果有可用的处理器,作业可能会获得更多的CPU时间。

在幕后,内核通过以300毫秒的时间间隔测量作业的CPU消耗来应用这些限制,从而允许或阻止作业在下一个或多个时间间隔内执行。

CpuLimit项目演示了如何使用CpuRate成员和硬上限来进行CPU速率控制。主函数接受一个进程ID数组,用于放入一个作业中,以及一个用作硬CPU速率限制的百分比。

以下是主函数的开头部分:

int main(int argc, const char* argv[]) {
    if (!::IsWindows8OrGreater()) {
        printf("CPU Rate control is only available on Windows 8 and later\n");
        return 1;
    }

    if (argc < 3) {
        printf("Usage: CpuLimit <pid> [<pid> ...] <precentage>\n");
        return 0;
    }

    // 创建作业对象
    HANDLE hJob = ::CreateJobObject(nullptr, L"CpuRateJob");
    if (!hJob)
        return Error("Failed to create object");

    for (int i = 1; i < argc - 1; i++) {
        int pid = atoi(argv[i]);
        HANDLE hProcess = ::OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE, pid);
        if (!hProcess) {
            printf("Failed to open handle to process %d (error=%d)\n", pid, ::GetLastError());
            continue;
        }
        
        if (!::AssignProcessToJobObject(hJob, hProcess)) {
            printf("Failed to assign process %d to job (error=%d)\n", pid, ::GetLastError());
        }
        else {
            printf("Added process %d to job\n", pid);
        }
        
        ::CloseHandle(hProcess);
    }
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
29
30
31
32
33

main函数首先检查应用程序是否至少在Windows 8上运行,因为在之前的版本中没有CPU使用率控制功能。然后,程序通过检查是否至少有3个参数(程序本身、进程ID、使用率)来验证是否至少有一个进程ID和一个CPU百分比。

然后创建一个作业对象(job object),并为其命名,这样使用工具时更容易识别。每个进程都会被打开,并尽可能分配到该作业中。

现在程序准备好应用CPU使用率控制:

JOBOBJECT_CPU_RATE_CONTROL_INFORMATION info;
info.CpuRate = atoi(argv[argc - 1]) * 100;
info.ControlFlags = JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP;

if (!::SetInformationJobObject(hJob, JobObjectCpuRateControlInformation, &info, sizeof(info)))
	return Error("Failed to set job limits");

printf("CPU limit set successfully.\n");
printf("Press ENTER to quit.\n");

char dummy[10];
gets_s(dummy);

::CloseHandle(hJob);
return 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

CPU使用率限制是通过获取命令行参数并将其乘以100来计算的。最后,调用SetInformationJobObject来设置限制。

在程序退出之前,它会等待用户按下回车键。这使得作业对象的句柄保持打开状态,这样使用工具时更容易发现。否则,句柄会被关闭,作业也会被标记为删除。不过,只要作业中有活动进程,限制就仍然会生效。

我们通过启动两个CpuStress应用程序实例来测试这一点(图4-16)。这里使用的系统有16个逻辑处理器,所以我们将在两个实例中都激活4个最大活动线程。这应该会消耗系统大约50%的CPU时间(图4-17)。任务管理器显示确实是这样(图4-18)。

img 图4-16:启动时的两个CPU Stress实例

img 图4-17:两个有4个最大活动线程的CPU Stress实例

img

图4-18:任务管理器显示50%的CPU使用率

现在我们使用进程ID和所需的CPU使用率限制(本示例中为20%)来执行CpuLimit:

cpulimit 38984 28760 20
1

你应该会看到一系列成功消息,如下所示:

CpuLimit.exe 20132 17480 20
Added process 20132 to job
Added process 17480 to job
CPU limit set successfully.
Press ENTER to quit.
1
2
3
4
5

此时,你应该能够看到两个CPUStress实例中的CPU消耗下降(图4-19)。两个实例消耗的总CPU使用率应该在20%左右,可以在任务管理器中查看。

打开作业资源管理器(Job Explorer)并查看CpuRateJob作业(这是代码中为了便于识别而给出的名称),应该会显示CPU使用率限制(图4-20)。

遗憾的是,在撰写本文时,进程资源管理器(Process Explorer)不会显示作业的CPU使用率控制信息。

img

图4-19:受CPU使用率控制影响的CPU Stress实例

img

图4-20:作业资源管理器显示CPU使用率控制

如果CpuLimit在添加进程时失败并出现错误5(访问被拒绝),请从命令窗口而不是通过资源管理器启动CpuStress。如果你好奇的话,可以研究一下为什么会发生这种情况。

# 用户界面限制

通过JobObjectBasicUIRestrictions信息类可以设置另一组与用户界面相关的作业限制。这些限制由存储在一个简单结构中的单个32位值表示:

typedef  struct  _JOBOBJECT_BASIC_UI_RESTRICTIONS {
DWORD UIRestrictionsClass;
} JOBOBJECT_BASIC_UI_RESTRICTIONS, *PJOBOBJECT_BASIC_UI_RESTRICTIONS;
1
2
3

可用的限制是表4-6中列出的位标志。

使用用户界面限制的作业不能成为作业层次结构的一部分。

表4-6:用户界面作业限制

UI标志(JOB_OBJECT_UILIMIT_*) 描述
NONE (0) 无限制
HANDLES (1) 作业中的进程无法访问不属于该作业的进程所拥有的用户句柄(例如窗口)
READCLIPBOARD (2) 作业中的进程无法从剪贴板读取数据
WRITECLIPBOARD (4) 作业中的进程无法向剪贴板写入数据
SYSTEMPARAMETERS (8) 作业中的进程无法通过调用SystemParametersInfo更改系统参数
DISPLAYSETTINGS (0x10) 作业中的进程无法调用ChangeDisplaySettings
GLOBALATOMS (0x20) 作业中的进程无法访问全局原子。作业有自己的原子表(见下一个侧边栏)
DESKTOP (0x40) 作业中的进程无法创建或切换桌面(CreateDesktop、SwitchDesktop)
EXITWINDOWS (0x80) 作业中的进程无法调用ExitWindows或ExitWindowsEx

原子表(Atom Table)是一个由系统管理的表,它将字符串(或特定范围内的整数)映射到整数。表中的每个条目都是一个原子。这些原子与用户界面API一起使用,例如在注册窗口类(RegisterClass / RegisterClassEx)时,或者通过手动操作原子表(AddAtom、FindAtom、GlobalAddAtom等)时。全局原子表对所有应用程序都可用——这是在使用JOB_OBJECT_UILIMIT_GLOBALATOMS限制的作业中无法访问的表。

这里有一个简单的实验来展示用户界面限制的一个效果。打开作业监视器(Job Monitor),创建一个新作业,并将一个记事本(Notepad)实例添加到其中。然后设置“写入剪贴板”的用户界面限制(图4-21)。

img

图4-21:带有记事本和用户界面限制的作业监视器

现在打开作业外部的另一个记事本实例(或者使用任何其他文本编辑应用程序)。从该应用程序复制一些文本,然后尝试将其粘贴到作业中的记事本实例中。即使“编辑”菜单显示“粘贴”选项已启用,该操作也应该会失败。

JOB_OBJECT_UILIMIT_HANDLES标志会阻止作业中的进程访问作业外部的其他用户界面对象(例如窗口)。这意味着调用诸如PostMessage或SendMessage之类的函数向作业外部的窗口发送消息会失败。在某些情况下,作业内部需要与作业外部的特定窗口进行通信。作业外部的进程可以通过调用UserHandleGrantAccess来授予(或移除)对窗口(或其他用户对象,如菜单或钩子)的访问权限:

BOOL UserHandleGrantAccess(
    _In_ HANDLE hUserHandle, // 用户对象句柄
    _In_ HANDLE hJob,        // 作业句柄
    _In_ BOOL   bGrant);     // TRUE表示授予访问权限,FALSE表示移除访问权限
1
2
3
4

上一段中提到的“钩子”是可以使用SetWindowsHookEx安装的钩子之一(将在后面的章节中讨论)。有了这个限制,作业中的进程无法挂钩运行在作业外部进程中的线程。

# 作业通知

当作业限制被违反,或者发生某些特定事件时,作业可以通过一个I/O完成端口(I/O completion port)通知相关方,该端口可以与作业关联。I/O完成端口通常用于处理异步I/O操作的完成(我们将在后面的章节中讨论),但在这种特殊情况下,它被用作在某些作业事件发生时进行通知的机制。

作业是一个调度程序(可等待)对象,当发生CPU时间违规时,它会变为已通知状态。对于这个简单的情况,一个线程可以使用WaitForSingleObject(作为一个常见示例)进行等待,然后处理CPU时间违规。设置新的CPU时间限制会将作业重置为未通知状态。

获取通知的第一步是将一个I/O完成端口与作业关联。以下是JobMon(MainDlg.cpp中的OnBindIoCompletion函数)中的相关代码片段(为清晰起见,省略了错误处理):

wil::unique_handle hCompletionPort(::CreateIoCompletionPort(
    INVALID_HANDLE_VALUE, nullptr, 0, 0));

JOBOBJECT_ASSOCIATE_COMPLETION_PORT info;
info.CompletionKey = 0;    // 应用程序定义
info.CompletionPort = hCompletionPort.get();
::SetInformationJobObject(m_hJob.get(), JobObjectAssociateCompletionPortInformation,
    					  &info, sizeof(info));
// 转移所有权并存储在成员变量中
m_hCompletionPort = std::move(hCompletionPort);
1
2
3
4
5
6
7
8
9
10

通常,CreateIoCompletionPort函数的第一个参数是文件句柄,但在这种情况下,它是INVALID_HANDLE_VALUE,这表明没有文件与I/O完成端口(I/O completion port)相关联。

下一步是通过调用GetQueuedCompletionStatus函数来等待完成端口发出通知,该函数定义如下:

BOOL GetQueuedCompletionStatus(
    _In_  HANDLE CompletionPort,
    _Out_ PDWORD pNumberOfBytesTransferred,
    _Out_ PULONG_PTR lpCompletionKey,
    _Out_ LPOVERLAPPED* pOverlapped,
    _In_  DWORD dwMilliseconds
);
1
2
3
4
5
6
7

JobMon工具创建了一个线程并调用此函数,无限期等待通知的到来。在这种情况下,需要一个新线程,这样JobMon的用户界面(UI)线程就不会被阻塞,导致用户界面失去响应。下面是JobMon在创建完成端口后相关的代码:

// 创建一个线程来监控通知
wil::unique_handle hThread(::CreateThread(nullptr, 0, [](auto p) {
    return static_cast<CMainDlg*>(p)->DoMonitorJob();
}, this, 0, nullptr));
1
2
3
4
线程创建将在下一章详细解释。

线程被传入this指针,以便它可以方便地调用成员函数DoMonitorJob。DoMonitorJob函数调用GetQueuedCompletionStatus函数,并在等待结束时做出响应:

DWORD CMainDlg::DoMonitorJob() {
    for (;;) {
        DWORD message;
        ULONG_PTR key;
        LPOVERLAPPED data;
        if (::GetQueuedCompletionStatus(m_hCompletionPort.get(),
            &message, &key, &data, INFINITE)) {
            // 处理通知
1
2
3
4
5
6
7
8

当GetQueuedCompletionStatus函数用于作业通知(而不是文件通知)时,其参数的含义较为特殊。pNumberOfBytesTransferred参数表示通知类型,详见表4-7。CompletionKey参数与CreateIoCompletionPort函数中指定的相同,由应用程序定义。最后,pOverlapped参数是额外信息,其格式取决于通知类型(见表4-7)。

表4-7:作业通知

通知(JOB_OBJECT_MSG_*) 关联数据(pOverlapped) 描述
END_OF_JOB_TIME(1) NULL 作业时间限制已用尽。此时时间限制被取消,作业中的进程继续运行
END_OF_PROCESS_TIME(2) 进程的PID 某个进程的CPU使用时间超过了其进程级别的限制(该进程正在被终止)
ACTIVE_PROCESS_LIMIT(3) NULL 活动进程数量限制已超出
ACTIVE_PROCESS_ZERO(4) NULL 活动进程数量变为零(所有进程因某种原因退出)
NEW_PROCESS(6) 新进程的PID 有新进程添加到作业中(可能是直接添加,也可能是作业中的其他进程创建的)。当完成端口最初关联时,所有活动进程也会被报告
EXIT_PROCESS(7) 退出进程的PID 作业中的某个进程已退出
ABNORMAL_EXIT_PROCESS(8) 退出进程的PID 某个进程异常退出,这意味着它因未处理的异常而终止,异常来自给定的异常列表(查看文档获取完整列表)
PROCESS_MEMORY_LIMIT(9) 进程的PID 作业中的某个进程已超出其内存消耗限制
JOB_MEMORY_LIMIT(10) 进程的PID 作业中的某个进程导致作业超出了其全局内存限制
NOTIFICATION_LIMIT(11) 进程的PID (Windows 8及以上版本)作业中注册接收通知限制的某个进程已超出限制。

对于通知限制的情况,需要调用QueryInformationJobObject函数,并传入JobObjectNotificationLimitInformation和(或)JobObjectNotificationLimitInformation2(Windows 10),以查询被违反的限制。

下面是JobMon中的代码片段,展示了如何处理其中一些通知代码:

switch (message) {
    case JOB_OBJECT_MSG_ACTIVE_PROCESS_LIMIT:
        AddLog(L"Job Notification: Active process limit exceeded");
        break;

    case JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO:
        AddLog(L"Job Notification: Active processes is zero");
        break;

    case JOB_OBJECT_MSG_NEW_PROCESS:
        AddLog(L"Job Notification: New process created (PID: "
            + std::to_wstring(PtrToUlong(data)) + L")");
        break;

    case JOB_OBJECT_MSG_EXIT_PROCESS:
        AddLog(L"Job Notification: process exited (PID: "
            + std::to_wstring(PtrToUlong(data)) + L")");
        break;

    case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS:
        AddLog(L"Job Notification: Process " + std::to_wstring(
            PtrToUlong(data)) + L" exited abnormally");
        break;

    case JOB_OBJECT_MSG_JOB_MEMORY_LIMIT:
        AddLog(L"Job Notification: Job memory limit exceed attempt by process "
            + std::to_wstring(PtrToUlong(data)));
        break;

    case JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT:
        AddLog(L"Job Notification: Process " + std::to_wstring(
            PtrToUlong(data)) + L" exceeded its memory limit");
        break;

    case JOB_OBJECT_MSG_END_OF_JOB_TIME:
        AddLog(L"Job time limit exceeded"); 
            break;

    case JOB_OBJECT_MSG_END_OF_PROCESS_TIME:
        AddLog(L"Process " + std::to_wstring(PtrToUlong(data))
            + L" has exceeded its time limit");
        break;
}
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

AddLog是一个私有函数,用于将相应的消息添加到底部的列表视图中。

对于作业时间限制违规的情况,默认操作是终止作业中的所有进程。每个进程的退出代码设置为ERROR_NOT_ENOUGH_QUOTA(1816),并且不会发送通知。要更改此行为,必须事先调用SetInformationJobObject函数,使用JobObjectEndOfJobTimeInformation信息类设置不同的作业结束操作,并传入以下结构:

typedef struct _JOBOBJECT_END_OF_JOB_TIME_INFORMATION {
    DWORD EndOfJobTimeAction;
} JOBOBJECT_END_OF_JOB_TIME_INFORMATION, *PJOBOBJECT_END_OF_JOB_TIME_INFORMATION;

#define JOB_OBJECT_TERMINATE_AT_END_OF_JOB 0
#define JOB_OBJECT_POST_AT_END_OF_JOB 1
1
2
3
4
5
6

JOB_OBJECT_TERMINATE_AT_END_OF_JOB的值是默认设置,而JOB_OBJECT_POST_AT_END_OF_JOB会在作业结束时发送通知消息,但不会终止进程。如果完成端口未与作业关联,此值无效,仍会使用终止协议。

# 隔离仓(Silos)

Windows 10 1607版本和Windows Server 2016引入了一种增强版的作业,称为隔离仓(Silo)。隔离仓总是以作业的形式启动,但可以通过使用SetInformationJobObject函数,并传入一个未公开的信息类JobObjectCreateSilo(35)将其升级为隔离仓,这个信息类在Windows SDK头文件中可以找到,但没有相关文档说明。一些隔离仓的应用程序编程接口(API)在Windows驱动程序开发工具包(WDK)中有文档记录,供设备驱动程序编写者使用。由于隔离仓大多在内核模式下进行控制,其编程使用超出了本书的范围。

隔离仓有两种类型:应用程序隔离仓(Application Silos)和服务器隔离仓(Server Silos)。服务器隔离仓仅在从Server 2016开始的Windows服务器机器上受支持。如今,它们用于实现Windows容器(Windows Containers)功能,即对进程进行沙盒化,创建一个虚拟环境,使进程认为自己运行在独立的机器上。这需要将文件系统、注册表和对象命名空间重定向到特定的隔离仓,因此内核内部必须进行重大更改以支持隔离仓。

应用程序隔离仓用于通过桌面桥接技术(Desktop Bridge technology)转换为通用Windows平台(UWP)的应用程序中。它们不像服务器隔离仓那么强大(也不需要那么强大)。Job Explorer工具中有一个“隔离仓类型”列,通过列出类型来指示某个作业是否实际上是一个隔离仓。图4-22展示了机器上的三个应用程序隔离仓。

img

图4-22:Job Explorer显示隔离仓

注意,隔离仓有一个隔离仓ID,它是一个唯一的作业ID,用于在内部分辨隔离仓。

有关隔离仓更详细的信息,可以在《Windows Internals, 7th edition, Part 1》的第3章中找到。

# 练习

  1. 编写一个名为MemLimit的工具,该工具接受一个进程ID和一个表示进程最大提交内存的数字,并使用作业设置该限制。
  2. 扩展JobMon工具,以涵盖目前尚未实现的所有剩余限制,例如I/O和网络限制。

# 总结

作业提供了许多控制和限制进程的方式,这些功能全部由内核本身实现。Windows 8中引入的嵌套作业(nested jobs)使作业更加实用,限制也更少。

在下一章中,我们将开始探讨线程。进程和作业是管理对象,而线程才是实际分配到处理器上执行工作的实体,因此没有线程就没有操作系统的运行。

第3章:进程
第5章:线程基础

← 第3章:进程 第5章:线程基础→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式