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)
  • 第5章:线程基础
  • 第6章:线程调度
    • 优先级
    • 调度基础
      • 单CPU调度
      • 时间片
    • 处理器组(Processor Groups)
    • 多处理器调度(Multiprocessor Scheduling)
      • 关联(Affinity)
      • 理想处理器(Ideal Processor)
      • 硬关联(Hard Affinity)
      • CPU集(CPU Sets)
      • CPU集与硬关联(Hard Affinity)
      • 系统CPU集
      • 修订后的调度算法
    • 观察调度
      • 常规调度
      • 硬亲和性(Hard Affinity)
      • CPU集(CPU Sets)
    • 后台模式
    • 优先级提升
      • 完成I/O操作
      • 前台进程
      • GUI线程唤醒
      • 避免饥饿
    • 调度的其他方面
      • 挂起和恢复
      • 挂起和恢复一个进程
      • 睡眠和让步
    • 总结
  • 第7章:进程内线程同步
  • 第8章:进程间线程同步
  • 第9章:线程池
  • 第10章:高级线程
  • 第11章:文件和设备输入输出
  • 第12章:内存管理基础
  • 第13章:内存操作
  • 第14章:内存映射文件
  • 第15章:动态链接库
  • 第16章:安全性
  • 第17章:注册表
目录

第6章:线程调度

# 第6章:线程调度

创建线程是为了执行代码,至少这应该是创建线程的目的。这意味着在某个时刻,逻辑处理器需要运行线程的函数。一般来说,在一个典型的系统中会有很多线程,但实际上在同一时刻只有一部分线程想要执行代码。大多数线程都在等待某些事件,因此在当时不具备被调度到处理器上运行的条件。如果想要运行(处于就绪状态)的线程数量小于或等于系统中的逻辑处理器数量(并且不存在本章稍后会讨论的关联限制),那么所有就绪线程都可以直接执行。

然而,可能会出现一些问题。线程能获得多长时间的CPU时间?如果有新线程唤醒会发生什么?当准备运行的线程数量多于可用处理器数量时又会怎样呢?在本章中,我们将尝试回答所有这些(以及其他一些)问题。

本章内容包括:

  • 优先级
  • 调度基础
  • 多处理器调度
  • 后台模式
  • 处理器组
  • 挂起和恢复

# 优先级

每个线程都有一个关联的优先级,当想要执行的线程数量多于可用处理器数量时,这个优先级就会发挥作用。在本节中,我们将了解可用的优先级以及如何对其进行调整,在下一节中,我们将探讨这些优先级在调度中是如何应用的。

线程优先级的范围是0到31,其中31表示最高优先级。从技术上讲,线程0被保留给一个特殊的线程,称为零页线程(zero page thread),它是内核中内存管理器的一部分。它是唯一被允许具有0优先级的线程。所以从技术层面来说,可用的优先级范围是1到31。在用户模式下(本书中编写代码的模式),线程优先级不能被设置为任意值。相反,线程的优先级是由进程的优先级类(在任务管理器中称为基本优先级)和围绕该基本优先级的一个偏移量共同决定的。图6-1展示了突出显示基本优先级列的任务管理器。

img 图6-1:任务管理器中的基本优先级

每个优先级类都与一个优先级值相关联,如表6-1所示。 表6-1:进程优先级类

优先级类 优先级值 API常量
空闲(低) 4 IDLE_PRIORITY_CLASS
低于正常 6 BELOW_NORMAL_PRIORITY_CLASS
正常 8 NORMAL_PRIORITY_CLASS
高于正常 10 ABOVE_NORMAL_PRIORITY_CLASS
高 13 HIGH_PRIORITY_CLASS
实时 24 REALTIME_PRIORITY_CLASS
表6-1中的“实时”(Real-time)并不意味着Windows是一个实时操作系统;它不是。Windows无法提供实时操作系统所具备的延迟和定时保证。这是因为Windows要与各种各样的硬件协同工作,所以在硬件范围不受限制的情况下,根本不可能做出这样的保证。表6-1中的“实时”仅仅意味着“比其他所有优先级都高”。

在使用CreateProcess创建进程时,可以设置进程的优先级类。第六个参数(标志)可以与表6-1中的某个常量相结合。如果未指定明确的优先级类,优先级类将默认为“正常”,除非创建者的优先级类是“空闲”或“低于正常”,在这种情况下,将使用创建者的优先级类。

如果进程已经存在,可以使用SetPriorityClass函数来更改其优先级类:

BOOL SetPriorityClass(
    _In_ HANDLE hProcess,
    _In_ DWORD  dwPriorityClass);
1
2
3
任务管理器以及进程资源管理器(Process Explorer)都提供了上下文菜单来更改进程的优先级类。

上述调用中的进程句柄必须具有PROCESS_SET_INFROMATION访问掩码,调用才会成功。此外,如果目标优先级类是“实时”,调用者必须具有SeIncreaseBasePriority特权。如果没有该特权,函数不会失败,但最终设置的优先级类将是“高”,而不是“实时”。

自然地,也存在相反的函数来检索进程的优先级类:

DWORD GetPriorityClass(_In_ HANDLE hProcess);
1

这个句柄的访问掩码只需要PROCESS_QUERY_LIMITED_INFORMATION,几乎所有进程都可以获取该权限。

进程优先级类对进程本身并没有直接影响,因为运行的不是进程,而是线程。在一个进程中创建的所有线程,其默认优先级都被设置为该进程的优先级类级别。例如,在一个“正常”优先级类的进程中,所有线程的默认优先级都是8。要更改线程的优先级,可以使用SetThreadPriority函数:

BOOL SetThreadPriority(
    _In_ HANDLE hThread,
    _In_ int        nPriority);
1
2
3

nPriority参数不是一个绝对的优先级值。相反,它是七个可能值之一(“实时”优先级类除外,详见下一个侧边栏说明),如表6-2所示。 表6-2:线程相对优先级

优先级值 效果
THREAD_PRIORITY_IDLE (-15) 除“实时”优先级类外,优先级降至1;在“实时”优先级类中,线程优先级降至16
THREAD_PRIORITY_LOWSET (-2) 相对于优先级类,优先级降低2
THREAD_PRIORITY_BELOW_NORMAL (-1) 相对于优先级类,优先级降低1
THREAD_PRIORITY_NORMAL (0) 优先级设置为进程优先级类的值
THREAD_PRIORITY_ABOVE_NORMAL (1) 相对于优先级类,优先级提高1
THREAD_PRIORITY_HIGHEST (2) 相对于优先级类,优先级提高2
THREAD_PRIORITY_TIME_CRITICAL (15) 除“实时”优先级类外,优先级提高到15;在“实时”优先级类中,线程优先级提高到31

“实时”优先级类与表6-2的关系较为特殊。该优先级类中的线程可以被分配16到31之间的任意值。SetThreadPriority函数接受-7到-3以及3到7之间的值,分别对应17到21以及27到30的优先级。

“高”优先级类只有六个级别。这是因为除“实时”优先级类的进程之外,其他进程中的线程优先级不能高于15。

空闲(idle)和时间关键(time critical)值被称为饱和值(Saturation values)。

表6-1和表6-2的综合效果可以总结在表6-3和图6-2中。在图6-2中,每个矩形表示基于SetPriorityClass/SetThreadPriority可能得到的线程优先级值。表6-3是表6-1和表6-2合并后的汇总表格表示形式。

img 图6-2:线程优先级 表6-3:按优先级类划分的线程优先级

优先级类(列)\相对优先级(行) 实时 高 高于正常 正常 低于正常 空闲
时间关键(+15) 31 15 15 15 15 15
最高(+2) 26 15 12 10 8 6
高于正常(+1) 25 14 11 9 7 5
正常(0) 24 13 10 8 6 4
低于正常(-1) 23 12 9 7 5 3
最低(-2) 22 11 8 6 4 2
空闲(-15) 16 1 1 1 1 1

进程优先级类和线程相对优先级的最终组合结果就是线程的实际优先级。从内核调度程序的角度来看,只有这个最终的数值是重要的。它并不关心这个数值是如何得来的。例如,优先级8可以通过以下三种方式之一得到:“正常”优先级类搭配“正常”(0)相对线程优先级;“低于正常”优先级类搭配“最高”(+2)相对线程优先级;“高于正常”优先级类搭配“最低”(-2)相对线程优先级。从调度程序的角度来看,它们都是一样的;一般来说,调度程序并不关心进程,只关心线程。

“实时”优先级范围(16 - 31)被许多为整个系统执行关键工作的内核线程所使用,因此,对于线程运行在该范围内的进程而言,不占用过多的CPU时间是很重要的。当然,一个进程首先必须有充分的理由才能将线程设置在这个范围内。

如果在诸如进程资源管理器这样的工具中查看线程优先级,我们会发现优先级有两个值:基本优先级(Base Priority)和动态优先级(Dynamic priority)(图6-3)。

img 图6-3:进程资源管理器中的线程属性

基本优先级是由开发人员设置的值(或者是默认值),而动态优先级是该线程当前实际的优先级。在某些情况下,优先级会被临时提高(提升)。在本章后面的内容中,我们将探讨这种提升的一些原因。

从调度程序的角度来看,动态优先级才是起决定作用的优先级值。

线程处于实时范围内时,其优先级永远不会被提升。

# 调度基础

一般来说,调度是相当复杂的,需要考虑多个因素,其中一些因素相互冲突:多个处理器、电源管理(一方面希望节省电量,另一方面又希望充分利用所有处理器)、非统一内存访问(NUMA,Non - Uniform Memory Architecture)、超线程技术、缓存等等。确切的调度算法没有公开文档是有原因的:微软可以在后续的Windows版本和更新中对算法进行修改和调整,而开发人员无需依赖这些确切算法。话虽如此,通过实验仍有可能体验到许多调度算法。

我们将从最简单的调度情况开始——系统中只有一个处理器时的调度,因为这是调度工作原理的基础。之后,我们会探讨这些算法在多处理系统中的一些变化方式。

# 单CPU调度

调度器维护一个就绪队列(Ready Queue),想要执行(处于就绪状态)的线程在这个队列中进行管理。此时所有其他不想执行(处于等待状态)的线程不会被调度器考虑,因为它们不想执行。图6-4展示了一个示例系统,其中有七个线程处于就绪状态。它们根据优先级被安排在多个队列中。

img 图6-4:就绪状态的线程

系统中可能存在数千个线程,但大多数都处于等待状态,因此调度器不会考虑它们。

单CPU的调度算法如下:

  1. 优先级最高的线程首先运行。在图6-4中,线程1和线程2具有最高(且相同)的优先级(31),所以优先级为31的队列中的第一个线程开始运行;假设是线程1(图6-5)。

img 图6-5:最高优先级线程运行

线程1运行一段被称为时间片(Quantum)的时间。下一节将讨论时间片的长度。假设线程1有很多任务要执行,当它的时间片到期时,调度器会抢占线程1,将其状态保存在内核堆栈中,然后它回到就绪状态(因为它还有任务未完成)。此时线程2成为运行线程,因为它具有相同的优先级(图6-6)。

img 图6-6:线程2正在运行,线程1回到就绪状态

所以,优先级是决定因素。只要线程1和线程2需要执行,它们就会在CPU上进行循环调度(round - robin),每个线程运行一个时间片。幸运的是,线程通常不会永远运行下去。相反,它们会在某个时刻进入等待状态。以下是一些导致线程进入等待状态的示例:

  • 执行同步I/O操作。
  • 等待当前未发出信号的内核对象。
  • 当没有UI消息时等待UI消息。
  • 主动进入睡眠状态。

一旦线程进入等待状态,它就会从调度器的就绪队列中移除。假设线程1和线程2进入了等待状态。现在最高优先级的线程是线程3,它成为运行线程(图6-7)。

img 图6-7:线程3正在运行

线程3运行一个时间片。如果它还有工作要做,由于它是其优先级级别中唯一的线程,它会再获得一个时间片。然而,如果线程1收到了它正在等待的内容,它会进入就绪状态并抢占线程3(因为线程1的优先级更高),成为运行线程。线程3回到就绪状态(图6-8)。这种切换不是在线程3的时间片结束时,而是在状态发生变化(线程1等待结束)时。如果线程3的优先级高于15(在这个例子中是这样),它回到就绪状态时,其时间片会被补充。

如果被抢占线程的优先级为16或更高,它回到就绪状态时,其时间片会被恢复。

img 图6-8:线程1正在运行,线程3回到就绪状态

基于这个算法,如果就绪状态中没有更高优先级的线程,线程4、5和6将各自运行一个时间片。

这就是调度的基础。实际上,在真正的单CPU场景中,使用的正是这个算法。然而,即使在这种情况下,Windows也会在一定程度上力求 “公平”。例如,图6-4到图6-8中的线程7(优先级为4)如果在就绪状态中有更高优先级的线程,它可能就无法运行,从而遭受CPU饥饿问题。那么在这样的系统中,这个线程就没希望了吗?并非如此;系统大约每4秒会将该线程的优先级提升到15,让它有更好的机会继续执行。这个优先级提升会持续到该线程实际执行一个时间片,然后优先级会降回初始值。这只是临时优先级提升的一个例子。在本章后面的 “优先级提升” 部分,你会看到其他例子。

# 时间片

上一节多次提到了时间片,但是时间片有多长呢?调度器以两种相互独立的方式工作:第一种是通过一个定时器,默认情况下每15.625毫秒触发一次,可以通过调用GetSystemTimeAdjustment函数并查看第二个参数来获取该值。另一种方式是使用SysInternals的clockres工具:

C:\Users\pavel>clockres
Clockres v2.1 - Clock resolution display utility
Copyright (C) 2016 Mark Russinovich
Sysinternals
Maximum timer interval: 15.625 ms
Minimum timer interval: 0.500 ms
Current timer interval: 1.000 ms
1
2
3
4
5
6
7

与时间片相关的要查看的值是最大时间间隔值。

clockres工具显示的当前时间间隔是当前定时器的触发间隔。由于可能请求了多媒体定时器,这个值通常低于最大间隔。这使得定时器通知的分辨率可以达到1毫秒。无论如何,时间片本身不受当前定时器间隔的影响。

客户端机器(家庭版、专业版、企业版、XBOX等)的默认时间片是2个时钟周期,服务器机器的默认时间片是12个时钟周期。换句话说,客户端的时间片是31.25毫秒,服务器的时间片是187.5毫秒。

服务器版本的时间片更长,是为了增加客户端请求在单个时间片内被完全处理的机会。在客户端机器上,这不是一个大问题,因为它可能有许多进程,每个进程做的工作相对较少,有些进程还有需要响应迅速的用户界面,所以较短的时间片更合适。可以通过以下对话框(我称之为 “Windows中最让人难以理解的对话框”,图6-9)来在客户端和服务器的时间片设置之间进行切换。

img 图6-9:性能选项对话框

以下是打开这个对话框的方法:进入系统属性(可以从控制面板进入,或者在资源管理器中右键单击 “此电脑” 并选择 “属性”)。然后点击 “高级系统设置”。会打开一个对话框,点击 “性能设置” 按钮。“性能选项” 对话框就会出现。点击 “高级” 选项卡,就可以看到相关设置了。

这个对话框分为两个完全不相关的部分。下半部分控制页面文件(如果有的话)的大小,本书后面会讨论。上半部分就是那个 “让人难以理解” 的部分。“程序” 选项意味着短时间片,而 “后台服务” 选项意味着长时间片。如果你选中另一个单选按钮,更改会立即生效。

这两个选项还有另一个区别。“程序” 选项还意味着前台进程(承载前台窗口的进程)中的所有线程默认会获得三倍的时间片。“后台服务” 选项没有这种时间片延长效果,因为真正的服务器不太可能有交互式用户坐在控制台前操作。此外,本章后面我们会看到的一些优先级提升在选择 “后台服务” 时也不适用。

还有其他方法可以更改时间片(quantum)。若要进行精细控制,注册表值HKLM\SYSTEM\CurrentControlSet\Control\PriorityControl\Win32PrioritySeparation不仅控制时间片的长度,还控制前台进程的时间片拉伸。详细内容可参阅《Windows内核原理》(Windows Internals)一书的第4章。最好将此值保持为默认设置,这样前面描述的规则才会生效。

另一种控制时间片的方法是使用作业对象(job object,第4章有详细介绍)以及JOBOBJECT_BASIC_LIMIT_INFORMATION中的SchedulingClass(调度类)字段。此方法仅适用于长固定时间片系统(服务器系统的默认设置)。调度类值(介于0到9之间)按以下方式为属于相关作业的进程中的线程设置时间片:

时间片 = 2 ×(定时器间隔)×(调度类 + 1)
1

默认调度类为5,实际上会产生12×定时器间隔的时间片,正如我们前面所见,这是服务器系统的默认设置。最高值(9)会使线程变为非抢占式,这意味着它们具有无限的时间片(理论上可以无限期地继续运行,直到它们自愿进入等待状态)。大于5的值要求调用方具有SeIncreaseBasePriority特权,默认情况下,管理员组中的用户拥有此特权,但标准用户没有。

调度类值仅适用于优先级高于空闲(Idle)的进程。

# 处理器组(Processor Groups)

最初的Windows NT设计最多支持32个处理器,使用一个机器字(32位)来指示系统上的实际处理器,每个位代表一个处理器。64位Windows出现后,处理器的最大数量自然扩展到了64个。

从Windows 7(仅64位系统)开始,微软希望支持超过64个处理器,于是引入了一个额外参数:处理器组。例如,Windows 7和Server 2008 R2最多支持256个处理器,这意味着在拥有256个处理器的系统中有4个处理器组。

Windows 8和Server 2012支持640个处理器(10个组),而Windows 10支持的处理器数量更多。基本规则仍然是——每个组最多64个处理器,组数根据需要增加。

一个线程可以是一个处理器组的成员,这意味着一个线程最多可以在其当前组中的64个处理器之一上进行调度。创建进程时,它会以循环(round - robin)方式分配到一个处理器组,以便在各个组之间实现进程的“负载均衡”。进程中的线程会被分配到该进程组。父进程可以通过以下方式之一影响子进程的初始处理器组:

  1. 父进程可以在CreateProcess函数的标志中使用INHERIT_PARENT_AFFINITY标志,以指示子进程应继承其父进程的处理器组,而不是按照系统管理的循环方式获取。如果父进程的线程使用多个关联组,会任意选择其中一个组作为子进程组。
  2. 父进程可以使用PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY进程属性来指定所需的默认处理器组。

可以使用GetProcessGroupAffinity函数获取进程组关联:

BOOL GetProcessGroupAffinity( 
    _In_    HANDLE  hProcess,
    _Inout_ PUSHORT GroupCount,  
    _Out_   PUSHORT GroupArray
);
1
2
3
4
5

可以使用SetThreadGroupAffinity函数控制特定线程的处理器组。本章后面的“硬关联(Hard Affinity)”部分将对此进行讨论。

# 多处理器调度(Multiprocessor Scheduling)

多处理器调度增加了调度算法的复杂性。Windows仅保证想要执行的最高优先级线程(如果有多个,则至少有一个)当前正在运行。在本节中,我们将研究一些影响调度的参数。

# 关联(Affinity)

通常情况下,一个线程可以在任何处理器上进行调度。然而,线程的关联(即它被允许运行的处理器)可以通过以下几种方式进行控制。

# 理想处理器(Ideal Processor)

理想处理器是线程的一个属性,有时也称为“软关联(Soft Affinity)”。理想处理器是对调度器的一个提示——在其他条件相同的情况下,它是该线程执行代码的首选处理器。默认的理想处理器是以循环方式选择的,从进程创建时随机生成的处理器开始。在超线程系统中,下一个理想处理器是从下一个核心中选择,而不是从下一个逻辑处理器中选择。

可以使用Process Explorer工具查看理想处理器,它是“线程”选项卡中显示的属性之一(图6-10)。

可以使用SetThreadIdealProcessor函数更改线程的理想处理器:

DWORD WINAPI SetThreadIdealProcessor(
    _In_ HANDLE hThread,
    _In_ DWORD  dwIdealProcessor
);
1
2
3
4

此函数更改的理想处理器编号介于0和处理器最大数量减1之间,最大值为63,因为这是任何组中的最高处理器编号。如果系统支持多个组,则使用当前线程所在的组。该函数返回先前的理想处理器编号,如果出错则返回 - 1(0xffffffff)。为理想处理器传递特殊值MAXIMUM_PROCESSORS(在32位系统中等于32,在64位系统中等于64)仅返回当前的理想处理器。

SetThreadIdealProcessor函数更改线程所属当前处理器组的理想处理器。要为不同的组进行更改,可以使用扩展函数SetThreadIdealProcessorEx:

typedef  struct  _PROCESSOR_NUMBER {
    WORD Group;
    BYTE Number;
    BYTE Reserved;
} PROCESSOR_NUMBER, *PPROCESSOR_NUMBER;

BOOL SetThreadIdealProcessorEx(
    _In_      HANDLE            hThread,
    _In_      PPROCESSOR_NUMBER lpIdealProcessor,
    _Out_opt_ PPROCESSOR_NUMBER lpPreviousIdealProcessor
);
1
2
3
4
5
6
7
8
9
10
11

PROCESSOR_NUMBER结构的Group成员是要设置理想处理器的组,Number成员是CPU索引(0到63)。与非Ex版本的函数一样,可以使用最后一个可选参数检索先前的理想处理器。

# 硬关联(Hard Affinity)

理想处理器是对线程应在哪个处理器上执行的一种提示和建议,而硬关联(有时也简称为关联)则允许为特定线程或进程指定允许执行的处理器。硬关联在两个层面上起作用:进程层面和线程层面,基本规则是线程不能“超出”其所在进程设置的关联范围。

一般来说,设置硬关联约束通常不是个好主意。它限制了调度器分配处理器的自由度,可能导致线程获得的CPU时间比没有硬关联约束时更少。不过,在某些罕见情况下,这可能会有用,因为在同一组处理器上运行的线程更有可能更好地利用CPU缓存。这对于运行特定已知进程的系统可能有用,而对于可能运行任何程序的普通机器则不一定适用。硬关联的另一个用途是在压力测试中,比如在某些执行过程中使用较少的处理器,来观察在运行相同进程时,处理器数量受限的系统会有怎样的表现。

可以使用SetProcessAffinityMask函数来设置进程级别的硬关联:

BOOL WINAPI SetProcessAffinityMask(
    _In_ HANDLE    hProcess,
    _In_ DWORD_PTR dwProcessAffinityMask
);
1
2
3
4

进程句柄必须具有PROCESS_SET_INFORMATION访问掩码。关联掩码本身是一个位掩码,其中设置为1的位表示允许使用的处理器,设置为0的位表示禁止使用的处理器。例如,关联掩码0x1a(二进制为11010)表示仅允许处理器1、3和4使用。该函数会更改当前进程处理器组的关联掩码。

任务管理器(Task Manager)和Process Explorer都允许更改进程的关联掩码。在任务管理器中,在“详细信息”选项卡中右键单击一个进程,然后选择“设置相关性”,会显示一个对话框,其中列出了当前进程处理器组的可用处理器(系统关联掩码)(图6-11)。单击“确定”会调用SetProcessAffinityMask函数来设置新的关联掩码。Process Explorer也有类似的功能。

图6-11:在任务管理器中设置硬关联性(硬亲和性)

自然,也有反向函数,它还能提供系统关联掩码:

BOOL WINAPI GetProcessAffinityMask(
    _In_  HANDLE     hProcess,
    _Out_ PDWORD_PTR lpProcessAffinityMask, 
    _Out_ PDWORD_PTR lpSystemAffinityMask
);
1
2
3
4
5

下面是一个获取当前进程关联掩码的示例,可能只是为了获取系统关联掩码:

DWORD_PTR processAffinity, systemAffinity;
::GetProcessAffinityMask(::GetCurrentProcess(), &processAffinity, &systemAffinity);
1
2

例如,在一个有16个逻辑处理器的系统上,返回的系统关联掩码是0xffff。

设置进程关联掩码会限制进程中的所有线程使用该掩码。单个线程可以通过调用SetThreadAffinityMask函数进一步限制其关联掩码:

DWORD_PTR WINAPI SetThreadAffinityMask(
    _In_ HANDLE    hThread,
    _In_ DWORD_PTR dwThreadAffinityMask
);
1
2
3
4

该函数会尽可能设置线程的关联掩码。记住基本规则:线程的关联掩码不能包含其进程关联掩码中未指定的处理器。返回值是线程之前的关联掩码,如果出错则返回零。

在有超过64个处理器的系统上,线程可以在使用SetThreadGroupAffinity函数指定关联掩码的同时更改其处理器组:

typedef  struct  _GROUP_AFFINITY {
    KAFFINITY Mask;      // 关联位掩码
    WORD      Group;     // 组编号
    WORD      Reserved[3];
} GROUP_AFFINITY, *PGROUP_AFFINITY;

BOOL SetThreadGroupAffinity(
    HANDLE               hThread,
    const  GROUP_AFFINITY *GroupAffinity,
    PGROUP_AFFINITY      PreviousGroupAffinity
);
1
2
3
4
5
6
7
8
9
10
11

该函数可以做两件事:更改指定线程的处理器组和 / 或更改该组中的硬关联掩码。如果更改了组,它将成为该线程所属进程的默认处理器组。这会让情况变得复杂,所以通常最好确保进程中的所有线程都属于同一组。不过,如果该进程中可能同时运行超过64个线程(并且系统上有超过64个处理器),那么更改某些线程的处理器组可能会有好处,因为它们可以利用其他组的处理器。

KAFFINITY被定义为ULONG_PTR。

正如你可能预期的,也有反向函数:

BOOL GetThreadGroupAffinity(
    HANDLE          hThread,
    PGROUP_AFFINITY GroupAffinity
);
1
2
3
4

# CPU集(CPU Sets)

正如我们在上一节中看到的,线程的关联不能“超出”其进程的关联范围。然而,在某些情况下,让一个或多个线程使用进程中其他线程禁止使用的处理器是有益的。Windows 10和Server 2016增加了这项功能,称为CPU集。

“CPU集”表示对处理器的一种抽象视图,每个CPU集都可能映射到一个或多个逻辑处理器。不过目前,每个CPU集恰好映射到一个逻辑处理器。系统有自己的CPU集,默认情况下包括系统上的所有处理器。可以使用GetSystemCpuSetInformation函数获取此信息:

BOOL WINAPI GetSystemCpuSetInformation(
    _Out_opt_ PSYSTEM_CPU_SET_INFORMATION Information,
    _In_      ULONG                       BufferLength,
    _Out_     PULONG                      ReturnedLength,
    _In_opt_  HANDLE                      Process,
    _Reserved  ULONG                      Flags
);
1
2
3
4
5
6
7

该函数返回一个SYSTEM_CPU_SET_INFORMATION类型的结构体数组,其定义如下:

typedef  struct  _SYSTEM_CPU_SET_INFORMATION {
    DWORD                    Size;
    CPU_SET_INFORMATION_TYPE Type;    // 当前的
    union  {
        struct  { 
            DWORD  Id;
            WORD   Group;
            BYTE   LogicalProcessorIndex;
            BYTE   CoreIndex;
            BYTE   LastLevelCacheIndex;
            BYTE   NumaNodeIndex;
            BYTE   EfficiencyClass;
            union  {
                BYTE AllFlags;
                struct  {
                    BYTE Parked : 1;
                    BYTE Allocated : 1;
                    BYTE AllocatedToTargetProcess : 1;
                    BYTE RealTime : 1;
                    BYTE ReservedFlags : 4;
                };
            };
            union  {
                DWORD;
                BYTE;
            };
            DWORD64;
        } CpuSet;
        Reserved;
        SchedulingClass;
        AllocationTag;
    };
} SYSTEM_CPU_SET_INFORMATION, *PSYSTEM_CPU_SET_INFORMATION;
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

要了解其中一些值的含义,可以运行CPUStress并选择“系统 / CPU集...”菜单项,系统CPU集应该会显示出来。图6-12展示了一个有1个插槽、8个核心和16个逻辑处理器的系统的输出。

图6-12:CPUStress(软件名)中的系统CPU设置集

目前,任务管理器和Process Explorer都不提供有关CPU集的信息。

图6-12中的ID对应于SYSTEM_CPU_SET_INFORMATION中的CpuSet.Id成员。这是CPU集本身ID的一个抽象值。当前第一个CPU集从256(0x100)开始,每增加一个CPU集,该值就递增1。256这个值是任意选择的,其本身没有特殊含义。不过,在后续更改进程和线程的CPU集时,需要用到这些ID,下面会进行介绍。

图6-12中的“组(Group)”列对应一个进程组(上述结构中的CpuSet.Group)。“核心(Core)”列表示该CPU集(CPU set)对应的核心(CpuSet.CoreIndex)。通常情况下,这是实际的核心编号(该成员的确切定义更为精细且理论性较强——如果感兴趣,可以查看文档)。“逻辑处理器(LP)”列显示该CPU集的逻辑处理器编号(CpuSet.LogicalProcessorIndex)。“节点(Node)”列显示该CPU集的NUMA节点(CpuSet.NumaNodeIndex)。

有关SYSTEM_CPU_SET_INFORMATION其他成员的更多详细信息,请查阅文档。

进程可以使用SetProcessDefaultCpuSets为其线程设置默认的CPU集:

BOOL WINAPI SetProcessDefaultCpuSets(
    _In_     HANDLE       Process,
    _In_opt_ const  ULONG* CpuSetIds,
    _In_     ULONG        CpuSetIdCound
);
1
2
3
4
5

CpuSetIds数组应包含CPU集ID,这些ID可在SYSTEM_CPU_SET_INFORMATION的CpuSet.Id成员中获取。如果该值为NULL,则会移除当前的CPU集分配,这意味着对于进程中未选择特定CPU集的线程,其CPU集约束将被移除。线程可以选择特定的CPU集,这可能与其进程分配的CPU集不同,可通过SetThreadSelectedCpuSets来实现:

BOOL WINAPI SetThreadSelectedCpuSets(
    _In_     HANDLE       Thread,
    _In_     ULONG        CpuSetIdCount
);
1
2
3
4

以下是使用这些函数的示例:

ULONG sets[] = { 0x100, 0x101, 0x102, 0x103 };
::SetProcessDefaultCpuSets(::GetCurrentProcess(), sets, _countof(sets));

ULONG tset[] = { 0x104 };
::SetThreadSelectedCpuSets(::GetCurrentThread(), tset, _countof(tset));
1
2
3
4
5

上述示例使进程中的所有线程默认使用CPU集0x100到0x103,但当前线程除外,它使用CPU集0x104,实际上“脱离”了其父进程的CPU集。在某个线程应拥有自己的CPU,而进程中的其他线程无法使用该CPU的情况下,这可能会很有用。

# CPU集与硬关联(Hard Affinity)

CPU集和硬关联可能会相互冲突。在这种情况下,硬关联始终优先。如果CPU集与硬关联相矛盾,则CPU集将被忽略。

# 系统CPU集

系统有自己的CPU集,可以通过GetSystemCpuSetInformation来确定,该函数通常会返回系统上可用的CPU集。Windows API没有提供文档记录的方法来更改它,但可以使用原生的NtSetSystemInformation调用进行更改。这可以告知“系统”避开某些处理器,以免干扰用户进程。此功能在Windows 10版本1703及更高版本中可用的游戏模式(Game Mode)中有所应用。

关于游戏模式的详细讨论超出了本书的范围。

# 修订后的调度算法

多处理器(MP)调度很复杂:硬关联、理想处理器、CPU集、功耗考量、游戏模式以及其他方面,至少可以说,这些都使得多处理器的调度决策变得复杂。“调度基础”一节中描述的就绪队列(实际上是一个包含32个队列的数组,每个优先级对应一个队列)在多处理器系统中得到了扩展:每个处理器都有自己的就绪队列。此外,在Windows 8及更高版本中,存在针对处理器组的共享就绪队列(目前每组最多4个处理器)。这使得调度器在为附加到共享就绪队列的就绪线程寻找处理器时拥有更多选择(具有硬关联约束的线程仍使用每个CPU的就绪队列)。

上述细节可能会在未来的Windows版本中由微软进行更改。此处的内容旨在让读者对调度的复杂性有所了解。

图6-13展示了一个修订后的简化多处理器调度算法。该算法假设不存在关联或CPU集约束,也不考虑功耗或其他特殊因素。

图6-13:多处理器(Multi-Processor)的简化调度

从图6-13可以看出,理想处理器是首选使用的处理器,其次是线程上次运行所在的处理器(该处理器的缓存中可能仍包含该线程使用的数据)。如果所有处理器都处于忙碌状态,调度器不会抢占第一个运行低优先级线程的处理器;这样做效率低下,因为可能需要搜索多个处理器。相反,线程会被放入其理想处理器的(共享)就绪队列中。

下一个Windows版本2004(2020年4月发布)可能会更改理想处理器和前一个处理器的检查顺序。无论如何,从开发者的角度来看,这影响很小。

# 观察调度

调度变化相当频繁,但可以使用工具进行观察。在以下部分,我将介绍一些实验,你可以通过这些实验来研究和体验调度。本节为选读内容,可以放心跳过。

# 常规调度

要大致了解调度情况,可以使用性能监视器(Performance Monitor)。运行CPUStress并终止两个线程,以便仅保留两个线程。激活这两个线程(图6-14)。

图6-14:运行着两个活动线程的CPUStress(程序)

现在打开性能监视器(在“运行”提示符中输入perfmon,或者直接搜索它)。性能控制台将会出现。点击“性能监视器”项(图6-15)。

图6-15:性能监视器

性能监视器是一个内置工具,可以显示性能计数器,这些计数器实际上就是由各种系统组件公开的数字。从技术上讲,任何应用程序都可以注册并公开性能计数器。在以下示例中,我们将使用一些与调度相关的内置计数器。

删除默认计数器,然后点击“添加”(绿色加号按钮)以添加新的计数器。搜索“线程”类别并打开它(图6-16)。

图6-16:线程性能计数器类别

现在选择以下计数器:“当前优先级(Priority Current)”和“线程状态(Thread State)”(使用Ctrl键进行多选)(图6-17)。

图6-17:从“线程”类别中选择的计数器

在下方的搜索框中输入“CPustress”并按回车键。应该会显示一个线程列表。选择线程1和线程2(这些应该是“CPUStress”左侧显示的编号)。点击“添加”(图6-18),然后点击“确定”。

img

图6-18:在“线程”类别中选择了两个线程

现在会显示4个图表,展示来自“CPUStress”的两个工作线程的当前优先级和状态。右键单击图表的空白区域,选择“属性”。切换到“图表”选项卡,将垂直刻度更改为0到16之间(图6-19)。点击“确定”。

img

图6-19:更改图表刻度

现在,优先级和状态应该更容易识别了(图6-20)。

img

图6-20:正在工作的计数器

注意,线程的优先级是8(图6-20中的红线和绿线,绿色被红色遮住了)。线程状态在2和5之间交替。主要的状态编号如下:运行(Running)=2,就绪(Ready)=1,等待(Waiting)=5。现在切换到“CPUStress”应用程序。注意,线程优先级会跳到10。如果你切换到另一个应用程序,优先级又会降回来。你可以将其中一个线程的活动级别更改为“高”,并观察其效果(图6-21)。你也可以调整优先级进行测试。

img

图6-21:线程状态变化

# 硬亲和性(Hard Affinity)

你可以在上一个实验的基础上测试硬亲和性。“CPUStress”允许限制亲和性(你也可以使用任务管理器)。从菜单中选择“进程/亲和性”,并选择单个CPU作为硬亲和性(选择哪个CPU都可以)(图6-22)。

img

图6-22:“CPUStress”中的进程亲和性

你应该会看到线程时不时进入就绪状态,因为两个线程在争夺单个CPU。将它们的活动级别提高到“最大”,观察线程在状态2(运行)和状态1(就绪)之间交替(图6-23)。

img

图6-23:单个处理器亲和性下的最大活动状态

性能监视器(Performance Monitor)每1秒才更新一次,这意味着在这段时间内会经过几个时间片。这意味着你所看到的并不完全准确,但它能给出大致的概念。

# CPU集(CPU Sets)

观察CPU集需要使用不同的工具,该工具要能显示线程使用的CPU编号。我们将使用Windows SDK(Windows软件开发工具包,Windows Software Development Kit)的Windows性能工具包(Windows Performance Toolkit)中的Windows性能记录器(Windows Performance Recorder,WPR)。

搜索“Windows性能记录器(wprui.exe)”。如果你找不到它,很可能是没有安装。再次运行Windows 10 SDK安装程序,并添加Windows性能工具包。

在WPR的主界面中,仅选择“一级诊断(First Level Triage)”。这会捕获CPU、内存、I/O和其他事件,我们将从中查看与CPU相关的事件(图6-24)。

WPR使用Windows事件跟踪(Event Tracing for Windows,ETW)来捕获包括调度程序在内的各种系统组件发出的各种事件。

img

图6-24:WPR主用户界面

转到“CPUStress”,将亲和性重置为所有处理器。此外,使用“进程/CPU集”菜单项将进程的CPU集设置为前4个处理器(图6-25)。

img

图6-25:“CPUStress”中受限的CPU集

接下来,使用“线程/选定的CPU集(Thread/Selected CPU Sets …)”菜单项,将其中一个工作线程设置为使用其他CPU(图6-26),这里选择了CPU 10。

img

图6-26:CPUStress中某个线程的选定CPU集

确保CPUStress中的两个线程处于活动状态。现在回到Windows性能记录器(WPR),点击“开始”按钮。等待2到3秒,然后点击“停止”。等待处理完成,完成后,使用“在WPA中打开”按钮在Windows性能分析器(WPA)中打开跟踪文件。

Windows性能分析器(WPA)是用于分析事件跟踪会话(ETW,Event Tracing for Windows)捕获数据的工具。它相当复杂且功能多样,以下介绍只是略微涉及这个强大工具的皮毛。WPA超出了本书的讨论范围。

跟踪文件打开后,在左窗格中导航到“计算(Computation)”/“CPU使用率(精确)(CPU Usage (Precise))”/“按进程、线程、活动划分的CPU利用率(CPU Utilization by Process, Thread, Activity)”。你应该会看到类似图6-27的内容。

img

图6-27:WPA的典型用户界面

ETW跟踪总是针对整个系统的,所以我们首先需要筛选出我们感兴趣的进程——CPUStress。展开左上角未命名的“系列”树节点,找到CPUStress。右键点击它,选择“筛选到所选内容”。视图应该会清除,只留下CPUStress的信息。展开进程节点,会显示线程节点(图6-28)。

img

图6-28:筛选到CPUStress进程的WPA界面

如果你展开一个选定CPU集未更改的线程,你应该会看到CPU编号0到3(为进程CPU集设置的前四个处理器)(图6-29)。另一方面,展开有自己选定CPU集的线程,应该只会显示该CPU被使用(图6-30)。

img

图6-29:未分配CPU集的线程

img

图6-30:分配了CPU 10的线程

# 后台模式

有些进程天生比其他进程更重要。例如,如果用户使用Microsoft Word,她可能期望自己与Word的交互和使用体验非常好。另一方面,诸如备份应用程序、防病毒扫描程序、搜索索引程序等进程就没那么重要,不应干扰用户的主要应用程序。

这些后台应用程序限制自身影响的一种方法是降低它们的CPU优先级。这有一定作用,但CPU只是进程使用的一种资源。其他资源包括内存和输入/输出(I/O)。这意味着降低线程的CPU优先级或进程的优先级类可能不足以减少这类进程的影响。

Windows提供了后台模式(Background Mode)的概念,在这种模式下,线程的CPU优先级会降至4,内存优先级和I/O优先级也会降低。例如,在进程资源管理器(Process Explorer)的“线程”视图中查看Windows资源管理器(Windows Explorer),可以看到内存优先级、I/O优先级以及CPU优先级(图6-31)。I/O优先级的默认值为“正常(Normal)”,内存优先级的默认值为5(可能的值为0到7)。

img

图6-31:进程资源管理器中的内存和I/O优先级

对于本次讨论而言,内存优先级和I/O优先级的确切定义并不重要。我们将在后面的章节中讨论内存优先级。对于I/O优先级,直观来讲,在访问I/O时,较高的级别比较低的级别具有更高的优先级。

作为一个相反的例子,查看图6-32,它展示了SearchFilterHost.exe进程的线程情况。注意其内存和I/O优先级。 img

图6-32:进程资源管理器中显示的低内存和I/O优先级

这个SearchProtocolHost.exe进程通过使用特殊值PROCESS_MODE_BACKGROUND_BEGIN调用SetPriorityClass函数,一举降低了其I/O、内存优先级以及CPU优先级,代码如下:

::SetPriorityClass(::GetCurrentProcess(), PROCESS_MODE_BACKGROUND_BEGIN);
1

进程句柄必须指向当前进程,否则调用将失败。在使用PROCESS_MODE_BACKGROUND_END进行互补调用之前,该进程中的所有线程都将进入后台模式:

::SetPriorityClass(::GetCurrentProcess(), PROCESS_MODE_BACKGROUND_END);
1

类似地,可以使用标准的SetThreadPriority函数,并传入特殊值THREAD_MODE_BACKGROUND_BEGIN和THREAD_MODE_BACKGROUND_END,在单个线程的基础上进行同样的操作。同样,线程句柄必须引用当前线程,调用才能成功。

上述调用要求当前进程/线程的事实意味着,一个线程或进程不能被 “强制” 进入后台模式;相反,线程或进程本身应该是一个 “好公民”,自愿进入后台模式。

进程资源管理器确实允许强制将一个进程设置为后台模式。右键单击一个进程,选择 “设置优先级/后台模式”。

# 优先级提升

正如我们在 “调度基础” 一节中看到的,优先级是调度的决定因素。然而,Windows采用了几种对优先级的调整方式,称为优先级提升(priority boosts)。这些临时的优先级提升旨在从某种意义上使调度更加 “公平”,或者为用户提供更好的体验。在本节中,我将讨论一些常见的优先级提升原因。无论如何,不要依赖这些提升,因为它们可能会在未来版本的Windows中被移除,也可能会出现新的提升机制。

请记住,实时范围内(优先级16到31)的线程永远不会有优先级提升。

# 完成I/O操作

当一个线程发起一个同步I/O操作时,它会进入等待状态,直到操作完成。一旦操作完成,负责该I/O操作的设备驱动程序就有机会提升请求线程的优先级,以增加它尽快运行的机会,因为操作终于完成了。优先级提升(如果应用)会根据驱动程序的判断提升线程的优先级,并且线程每运行一个时间片(quantum),优先级就会降低一级,直到优先级降回到其基本级别。图6-33展示了这个过程的概念视图。

img

图6-33:线程优先级提升和衰减

# 前台进程

系统中总有一个活动窗口 —— 通常是标题栏颜色不同的那个窗口。这个窗口是由一个线程创建的,而这个线程是某个进程的一部分。这个进程有时被称为前台进程(Foreground Process)。在配置了短时间片(客户端版本Windows的默认设置)的系统中,前台进程中的线程在完成对内核对象的等待时,其优先级会提升2级。这个优先级在一个时间片后会衰减回其基本级别。

# GUI线程唤醒

当一个拥有用户界面的线程收到一个Windows消息(通常是其对GetMessage的调用返回时),它的优先级会提升2级,以增加它尽快运行的机会。这个优先级在一个时间片后会衰减回其基本级别。

# 避免饥饿

处于就绪状态至少4秒的线程,在单个执行时间片内,其优先级会大幅提升到15级,之后优先级会降回到原来的级别。这使得低优先级的线程即使在相对繁忙的系统中也能取得一些进展。

# 调度的其他方面

本节将探讨尚未讨论的调度的其他方面。

# 挂起和恢复

可以通过在调用CreateThread时指定CREATE_SUSPENDED标志,将线程创建为挂起状态。这使得调用者可以在线程实际执行有用代码之前,为其执行做准备,比如操作线程的优先级或亲和性。使用这个标志创建的线程,挂起计数为1。更普遍的情况是,可以通过调用SuspendThread函数来挂起一个线程:

DWORD WINAPI SuspendThread(_In_ HANDLE hThread);
1

线程句柄必须具有THREAD_SUSPEND_RESUME访问掩码,调用才能成功。该函数会增加线程的挂起计数,并暂停其执行。函数返回线程之前的挂起计数,如果失败则返回(DWORD)-1。线程的挂起计数有一个最大值,定义为MAXIMUM_SUSPEND_COUNT(定义为127)。一个线程可以挂起自身,但不能恢复自身。

一旦线程被挂起,可以使用ResumeThread函数恢复它:

DWORD WINAPI ResumeThread(_In_ HANDLE hThread);
1

ResumeThread函数会减少线程的挂起计数,如果计数变为零,线程就有资格被执行。如果一个线程是用CREATE_SUSPENDED标志创建的,那么就需要调用这个函数。

一般来说,挂起一个线程不是一个好主意,因为无法确切知道挂起会发生在什么地方。例如,线程可能已经获取了一个锁,而其他线程正在等待这个锁。如果在线程释放锁之前将其挂起,就会导致死锁,因为其他竞争该锁的线程将无限期等待。

线程同步和锁将在下一章讨论。

# 挂起和恢复一个进程

进程本身并不参与调度,参与调度的是线程。然而,有时可能需要一次性挂起一个进程中的所有线程。例如,在UWP进程中就会用到这个功能,当应用程序进入后台时,其所有线程都会被挂起。Windows API并没有提供这样的函数。从技术上讲,可以遍历一个进程中的所有线程并调用SuspendThread函数,但这充其量只是一种冒险的做法。在遍历过程中可能会启动一个新线程,这样很可能会遗漏这个新线程。

UWP进程挂起是基于一个未公开的作业对象(job object)特性,称为 “深度冻结(Deep Freeze)”。

不过,原生API确实提供了(未公开的)NtSuspendProcess函数,定义如下:

NTSTATUS NtSuspendProcess(_In_ HANDLE hProcess);
1

虽然该函数未公开,但它已经存在很长时间了,所以使用起来相当安全。如果你使用它,不要忘记在函数定义中添加extern "C",以便让链接器知道这是一个C函数。另外,需要将ntdll导入库添加到项目的链接器输入库中,或者在源代码中像这样添加:

#pragma comment(lib, "ntdll")
1

也存在一个相反的函数,恰如其分地命名为NtResumeProcess:

NTSTATUS NtResumeProcess(_In_ HANDLE hProcess);
1
进程资源管理器提供了对进程的右键操作来挂起/恢复进程。它内部使用的就是上述函数。

# 睡眠和让步

线程可以通过进入睡眠状态,自愿放弃其剩余的时间片:

void Sleep(_In_ DWORD dwMilliseconds);
1

线程会进入等待状态,大约持续请求的毫秒数。值0是有效的,它会使调度器执行队列中具有相同优先级的下一个线程。如果没有相同优先级的线程,该线程将继续执行。另一个合法的值是INFINITE,它会使线程永远睡眠;这几乎没什么用。

睡眠间隔的准确性实际上取决于内部定时器分辨率是否被更改。通常情况下,它应该与用于调度的时钟滴答时间相同,但通常会比这个时间短很多,因为其他一些线程可能请求过更改定时器分辨率。Sysinternals工具中的clockres.exe工具的输出显示了当前的定时器间隔,这个间隔会影响(包括但不限于)睡眠时间的准确性。

作为调用Sleep(0)的替代方法,线程可以调用SwitchToThread:

BOOL SwitchToThread(void);
1

SwitchToThread函数告诉调度器调度下一个就绪线程,即使其优先级低于当前线程。如果调度器能够执行该操作,函数返回TRUE;否则,线程继续执行,函数返回FALSE。

# 总结

在本章中,我们探讨了调度的各个方面。从优先级以及如何设置优先级,到简单的单CPU调度,再到多处理器的相关考虑,包括亲和性(affinities)和CPU集(CPU sets)。在下一章中,我们将探讨线程同步,即线程必须协调它们的操作,以及实现协调的各种方式。

第5章:线程基础
第7章:进程内线程同步

← 第5章:线程基础 第7章:进程内线程同步→

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