第6章:线程调度
优先级(Priorities)
在 Windows 中,每个线程都有一个关联的优先级(Priority),数值范围为 0 到 31,31 为最高优先级。优先级 0 被保留给零页线程(Zero Page Thread),它是内核内存管理器的一部分,用于在系统空闲时将物理内存页清零。因此,用户模式下实际可用的优先级范围是 1 到 31。
在用户模式下,线程的最终优先级由两个因素共同决定:进程的优先级类(Priority Class)(即基本优先级)和线程的相对偏移量。
进程优先级类
Windows 定义了以下进程优先级类:
| 优先级类 | 优先级值 | API 常量 |
|---|---|---|
| 空闲(Idle) | 4 | IDLE_PRIORITY_CLASS |
| 低于正常(Below Normal) | 6 | BELOW_NORMAL_PRIORITY_CLASS |
| 正常(Normal) | 8 | NORMAL_PRIORITY_CLASS |
| 高于正常(Above Normal) | 10 | ABOVE_NORMAL_PRIORITY_CLASS |
| 高(High) | 13 | HIGH_PRIORITY_CLASS |
| 实时(Realtime) | 24 | REALTIME_PRIORITY_CLASS |
需要注意的是,"实时"并不意味着 Windows 是一个实时操作系统(RTOS)——它无法提供实时操作系统所具备的严格延迟和定时保证,因为 Windows 需要与种类繁多的硬件协同工作,且内核本身并非为硬实时场景设计。
使用 CreateProcess 创建进程时可以指定优先级类,如果未指定则默认为"正常"(NORMAL_PRIORITY_CLASS)。
SetPriorityClass 函数用于在进程运行期间更改其优先级类:
BOOL SetPriorityClass(
HANDLE hProcess,
DWORD dwPriorityClass
);进程句柄需要 PROCESS_SET_INFORMATION 权限。需要注意的是,如果试图将进程设置为"实时"优先级类,调用者必须拥有 SeIncreaseBasePriority 特权;否则最终会被设置为"高"而非"实时"。
GetPriorityClass 用于检索进程当前的优先级类:
DWORD GetPriorityClass(
HANDLE hProcess
);该函数仅需 PROCESS_QUERY_LIMITED_INFORMATION 权限。
线程相对优先级
SetThreadPriority 函数用于更改线程的相对优先级。其第二个参数 nPriority 不是绝对值,而是相对于所属进程优先级类的偏移量:
| 优先级值 | 含义 | 效果 |
|---|---|---|
THREAD_PRIORITY_IDLE | -15 | 非实时类降至 1,实时类降至 16 |
THREAD_PRIORITY_LOWEST | -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 |
"实时"优先级类与这些偏移量的关系较为特殊——在该优先级类中,线程可以被分配到 16 到 31 之间的任意值。"空闲"(Idle)和"时间关键"(Time Critical)这两个取值被称为"饱和值"(Saturation Values),因为它们会将线程的优先级推到该优先级类允许的极限。
进程优先级类与线程相对优先级组合后的最终结果,就是线程的实际优先级(Actual Priority)。从调度器的角度看,"它并不关心这个数值是如何得来的"——调度器只根据最终的数字做决策。
在调试工具中,你可能会看到两个概念:
- 基本优先级(Base Priority):线程在没有优先级提升时的优先级
- 动态优先级(Dynamic Priority):考虑临时提升后的当前优先级。动态优先级才是调度器实际用来做决策的值。
线程处于实时范围内(优先级 16-31)时,其优先级永远不会被操作系统自动提升。这意味着实时线程不受优先级提升机制的影响。
调度基础(Scheduling Basics)
Windows 调度器维护一个就绪队列(Ready Queue),其中包含所有处于就绪状态的线程。大多数线程在大部分时间里处于等待状态(例如等待 I/O 完成、等待内核对象、等待用户输入等),调度器不会考虑这些线程。
单 CPU 调度
在单处理器系统上,Windows 调度器的基本算法如下:
- 优先级最高的就绪线程获得 CPU 并运行
- 该线程运行一个时间片(Quantum)
- 时间片到期后,调度器抢占该线程,线程回到就绪状态
- 同一优先级的多个线程以循环调度(Round-Robin) 的方式轮流执行
线程进入等待状态的常见原因包括:
- 执行同步 I/O 操作
- 等待一个未发出信号的内核对象(如事件、信号量、互斥体)
- 没有 UI 消息需要处理时的等待
- 主动调用
Sleep进入睡眠
如果高优先级线程的等待结束,它会**抢占(Preempt)**当前正在运行的低优先级线程。如果被抢占的线程优先级为 16 或更高(即处于实时范围内),当它重新回到就绪状态时,其剩余时间片会被恢复,不会因为抢占而损失 CPU 时间。
关于**饥饿(Starvation)**问题:系统大约每 4 秒会将低优先级线程的优先级临时提升到 15,让它们有机会执行至少一个时间片——这是一种临时优先级提升机制,确保即使是优先级最低的线程也能在繁忙的系统中取得进展。
时间片(The Quantum)
调度器通过定时器来工作,默认每隔 15.625 毫秒触发一次。可以使用 Sysinternals 工具集中的 clockres 工具查看当前的时钟间隔。
客户端机器(如 Windows 家庭版、专业版等)的默认时间片为 2 个时钟周期(约 31.25 毫秒),而服务器机器的默认时间片为 12 个时钟周期(约 187.5 毫秒)。
服务器版本的时间片更长,是为了增加客户端请求在单个时间片内被完全处理的机会,从而减少上下文切换开销,提高吞吐量。
可以通过"性能选项"对话框中的两个选项在短时间片和长时间片之间切换:
- "程序"选项:短时间片(2 个时钟周期),前台进程中的线程获得三倍时间片
- "后台服务"选项:长时间片(12 个时钟周期),无前台时间片拉伸效果
控制时间片长度和前台进程时间片拉伸的是注册表值:
HKLM\SYSTEM\CurrentControlSet\Control\PriorityControl\Win32PrioritySeparation此外,**作业对象(Job Object)**也可以控制其内部线程的时间片长度(仅适用于使用长固定时间片的系统)。计算公式为:
时间片 = 2 ×(定时器间隔)×(调度类 + 1)默认的调度类值为 5。最高值(9)会使线程变为非抢占式(无限时间片)。设置大于 5 的值需要 SeIncreaseBasePriority 特权。
处理器组(Processor Groups)
最初的 Windows NT 设计最多支持 32 个处理器(因为处理器掩码使用一个 32 位机器字表示)。64 位 Windows 将这一最大值扩展到 64 个。从 Windows 7 / Server 2008 R2 开始,为了支持超过 64 个处理器的系统,微软引入了**处理器组(Processor Groups)**的概念。每个处理器组最多包含 64 个逻辑处理器。
不同版本的 Windows 支持的处理器总数上限:
- Windows 7 / Server 2008 R2:最多 256 个处理器(4 个组)
- Windows 8 / Server 2012:支持 640 个处理器(10 个组)
- Windows 10 / Server 2016 及更新版本:支持更多处理器
线程是某个处理器组的成员,它只能在当前组内的 64 个处理器之一上被调度执行。进程创建时,系统以循环(Round-Robin)方式将进程分配到处理器组。
父进程可以影响子进程初始处理器组的方式有两种:
- 使用
INHERIT_PARENT_AFFINITY标志,让子进程继承父进程的处理器关联设置 - 使用
PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY进程属性,在创建时显式指定处理器组
相关 API:
// 获取进程的处理器组关联
BOOL GetProcessGroupAffinity(
HANDLE hProcess,
PUSHORT GroupCount,
PUSHORT GroupArray
);// 设置线程的处理器组关联
BOOL SetThreadGroupAffinity(
HANDLE hThread,
const GROUP_AFFINITY *GroupAffinity,
PGROUP_AFFINITY PreviousGroupAffinity
);多处理器调度(Multiprocessor Scheduling)
在多处理器系统上,Windows 仅保证至少一个最高优先级的就绪线程正在运行。调度器的决策变得更加复杂。
亲和力(Affinity)
理想处理器(Ideal Processor)
理想处理器是一种"软关联"(Soft Affinity),它是对调度器的一个提示——在其他条件相同的情况下,调度器会优先将线程调度到其理想处理器上执行。默认情况下,系统以循环方式为线程选择理想处理器。
// 更改线程的理想处理器(编号 0-63)
DWORD SetThreadIdealProcessor(
HANDLE hThread,
DWORD dwIdealProcessor
);
// 返回值是之前的理想处理器编号,出错返回 -1// 获取当前线程的理想处理器(不改变设置)
DWORD GetThreadIdealProcessor(HANDLE hThread);
// 使用 MAXIMUM_PROCESSORS 常量表示仅查询对于跨处理器组的场景,使用扩展版本:
BOOL SetThreadIdealProcessorEx(
HANDLE hThread,
PPROCESSOR_NUMBER lpIdealProcessor,
PPROCESSOR_NUMBER lpPreviousIdealProcessor
);其中 PROCESSOR_NUMBER 结构体包含 Group(组号)和 Number(组内处理器编号)两个成员。
硬关联(Hard Affinity)
硬关联限制线程或进程能够在哪些处理器上执行。基本规则是:线程不能超出其进程设置的关联范围——即线程的硬关联是进程硬关联的子集。
设置硬关联约束通常不是个好主意。它限制了调度器的自由调度能力,可能减少线程获得的 CPU 时间。但某些特定场景下仍然有用,例如为了优化 CPU 缓存利用率,或者在进行压力测试时隔离 CPU。
SetProcessAffinityMask 设置进程级的硬关联:
BOOL SetProcessAffinityMask(
HANDLE hProcess,
DWORD_PTR dwProcessAffinityMask
);
// 掩码中位为 1 表示允许该处理器GetProcessAffinityMask 获取进程关联掩码,同时提供系统关联掩码:
BOOL GetProcessAffinityMask(
HANDLE hProcess,
PDWORD_PTR lpProcessAffinityMask,
PDWORD_PTR lpSystemAffinityMask
);SetThreadAffinityMask 进一步限制线程的关联范围(但不能超出进程关联):
DWORD_PTR SetThreadAffinityMask(
HANDLE hThread,
DWORD_PTR dwThreadAffinityMask
);在超过 64 个处理器的系统上,可使用 SetThreadGroupAffinity 同时更改处理器组和关联掩码。如果更改了组,该组将成为该线程所属进程的默认处理器组。
CPU 集与硬亲和力
**CPU 集(CPU Sets)**是 Windows 10 / Server 2016 新增的功能。CPU 集是对处理器的抽象视图,每个 CPU 集映射到一个逻辑处理器。
GetSystemCpuSetInformation 用于获取系统的 CPU 集信息:
BOOL GetSystemCpuSetInformation(
PSYSTEM_CPU_SET_INFORMATION Information,
ULONG BufferLength,
PULONG ReturnedLength,
HANDLE hProcess,
ULONG Flags
);该函数返回 SYSTEM_CPU_SET_INFORMATION 结构体数组。CPU 集的 ID 从 256(0x100)开始递增。
SetProcessDefaultCpuSets 为进程设置默认的 CPU 集:
BOOL SetProcessDefaultCpuSets(
HANDLE hProcess,
const ULONG *CpuSetIds,
ULONG CpuSetIdCount
);
// 若 CpuSetIds 为 NULL,则移除当前 CPU 集分配SetThreadSelectedCpuSets 为特定线程选择 CPU 集,这可能与其进程分配的 CPU 集不同:
BOOL SetThreadSelectedCpuSets(
HANDLE hThread,
const ULONG *CpuSetIds,
ULONG CpuSetIdCount
);这允许一种有趣的用法:让某个线程拥有自己的专用 CPU,而进程中其他线程无法使用该 CPU,从而实现资源隔离。
CPU 集与硬关联冲突
当 CPU 集与硬关联设置同时存在且发生冲突时,硬关联始终优先。如果 CPU 集与硬关联约束相矛盾,CPU 集设置将被忽略。
系统 CPU 集
操作系统本身也有自己的 CPU 集,可以通过 GetSystemCpuSetInformation 来确定系统当前使用哪些 CPU。可以通过原生 API NtSetSystemInformation 调用来更改系统 CPU 集。这项功能在 Windows 10 1703 版本及之后引入的**游戏模式(Game Mode)**中有所应用——游戏模式下,系统 CPU 集会排除一部分处理器,专门用于游戏进程。
修订后的调度算法
多处理器(MP)调度非常复杂——硬关联、理想处理器、CPU 集、功耗考量、游戏模式等各种因素都会影响调度决策。
在早期的多处理器系统中,就绪队列被扩展为每个处理器拥有自己的就绪队列(Per-Processor Ready Queue)。从 Windows 8 / Server 2012 开始,为减少锁竞争,引入了处理器组内的共享就绪队列(Shared Ready Queue),每组最多 4 个处理器共享一个就绪队列。
简化版的调度算法流程如下:
- 优先尝试使用线程的理想处理器
- 其次是线程上次运行所在的处理器(利用缓存热度)
- 如果所有候选处理器都处于忙碌状态,调度器不会抢占某个正在运行低优先级线程的处理器;相反,它会将线程放入其理想处理器的就绪队列中等待
观察调度(Observing Scheduling)
可以使用 性能监视器(Performance Monitor) 来观察线程调度行为。在性能监视器中,选择"线程"类别,添加以下计数器:
- Priority Current:线程当前的动态优先级
- Thread State:线程状态(2 = 运行,1 = 就绪,5 = 等待)
通过 Sysinternals 工具集中的 CPUStress 可以观察硬亲和性的效果:将进程的亲和性限制为单个 CPU 后,可以看到两个线程在该 CPU 上交替处于运行和就绪状态。
对于 CPU 集的观察,需要使用更高级的工具:
- Windows 性能记录器(WPR, Windows Performance Recorder):录制系统性能事件
- Windows 性能分析器(WPA, Windows Performance Analyzer):分析录制数据,筛选目标进程后查看线程在哪些 CPU 上运行
通用调度
在介绍了优先级、时间片、多处理器调度等核心概念后,下面总结 Windows 调度器的通用行为模式:
- 调度器始终优先选择优先级最高的就绪线程运行
- 同一优先级的线程以循环方式共享 CPU 时间
- 线程的完整调度周期为:就绪 -> 运行 -> 等待 -> 就绪
- 高优先级线程会抢占低优先级线程,除非处于实时范围(此时时间片被保留)
- 系统通过临时优先级提升防止低优先级线程饥饿
硬亲和力(Hard Affinity)
如前文所述,硬亲和力允许将线程或进程限制在特定的处理器子集上运行。关键规则:
- 进程级的硬关联定义了该进程中所有线程可以运行的处理器集合
- 线程级的硬关联可以进一步缩小这个范围,但不能扩大
- 在多核 NUMA 系统上,硬关联可能导致内存访问延迟增加,因为线程可能被迫在远离其内存的处理器上运行
使用场景包括:
- 在特定核心上运行遗留的单线程应用
- 测试和基准测试中的 CPU 隔离
- 实时场景下为关键线程预留专用核心
CPU 集(CPU Sets)
CPU 集是 Windows 10 / Server 2016 引入的更灵活的处理器抽象。与硬关联相比,CPU 集的主要优势在于:
- CPU 集是一个更高层次的抽象,不直接绑定到物理处理器编号
- 系统可以在运行时调整 CPU 集到物理处理器的映射
- CPU 集与硬关联可以共存(虽然冲突时硬关联优先)
CPU 集的典型使用场景:
- 为关键应用保留专用 CPU 资源
- 在虚拟化环境中灵活分配处理器资源
- 与 Windows 游戏模式配合,隔离游戏进程
后台模式(Background Mode)
后台模式(Background Mode)同时降低进程的三个维度的优先级:
- CPU 优先级:降至 4
- 内存优先级:默认值从 5 降低(范围 0-7)
- I/O 优先级:降至较低水平(默认为"正常")
启用和禁用后台模式:
// 启用后台模式 — 针对当前进程
SetPriorityClass(GetCurrentProcess(), PROCESS_MODE_BACKGROUND_BEGIN);
// 恢复 — 针对当前进程
SetPriorityClass(GetCurrentProcess(), PROCESS_MODE_BACKGROUND_END);类似地,可以针对单个线程设置后台模式:
// 启用后台模式 — 针对当前线程
SetThreadPriority(GetCurrentThread(), THREAD_MODE_BACKGROUND_BEGIN);
// 恢复 — 针对当前线程
SetThreadPriority(GetCurrentThread(), THREAD_MODE_BACKGROUND_END);关键约束:这些调用要求句柄指向当前进程或当前线程。这意味着一个线程或进程不能被外部"强制"进入后台模式——它必须自愿进入。这是设计上的考量,防止恶意行为者将其他进程降级。
优先级提升(Priority Boosts)
Windows 调度器会在特定场景下临时提升(Boost)线程的优先级,以改善响应性和公平性。以下是几种常见的优先级提升原因——但编写代码时不应依赖这些行为,因为它们可能在未来的 Windows 版本中被修改或移除。
完成 I/O 操作
当线程发起同步 I/O 操作并阻塞等待时,在操作完成后,设备驱动程序可以提升该线程的优先级。这种提升不是永久性的:线程每运行一个时间片,优先级降低一级,直到回到其基本优先级。
前台进程
活动窗口所属的进程称为前台进程(Foreground Process)。在短时间片系统("程序"优化模式)中,前台进程的线程在完成对内核对象的等待后,优先级提升 2 级。这一提升同样会随时间衰减——一个时间片后自动撤销。
GUI 线程唤醒
拥有用户界面(窗口)的线程在收到 Windows 消息时,优先级提升 2 级。这确保了 UI 线程能够快速处理用户输入,提供流畅的交互体验。该提升同样在一个时间片后衰减。
避免饥饿
处于就绪状态至少 4 秒的线程,系统会在单个执行时间片内将其优先级大幅跃升至 15 级,之后降回原级别。这确保了即使是优先级最低的线程,在繁忙系统中也能取得进展,不会被无限期饿死。这是 Windows 防止**饥饿(Starvation)**的关键机制。
调度的其他方面
暂停和恢复
可以使用 CREATE_SUSPENDED 标志创建线程,此时线程的**挂起计数(Suspend Count)**为 1,不会立即执行。
SuspendThread 增加线程的挂起计数并暂停其执行:
DWORD SuspendThread(HANDLE hThread);
// 返回值是先前的挂起计数,0xFFFFFFFF 表示失败挂起计数的最大值为 MAXIMUM_SUSPEND_COUNT(127)。线程可以挂起自身,但不能恢复自身(因为一旦被挂起就无法执行代码来恢复)。
ResumeThread 减少挂起计数:
DWORD ResumeThread(HANDLE hThread);
// 返回值是先前的挂起计数当挂起计数降为零时,线程变得可以执行。
一般来说,挂起一个线程不是一个好主意——因为你无法确切知道挂起发生在代码执行的哪一点。如果线程恰好持有一个锁或其他同步对象,可能导致死锁。线程有可能正在堆管理器或其他关键系统组件内部执行,挂起它可能产生不可预知的后果。
暂停和恢复进程
Windows API 没有提供直接挂起整个进程的函数。但原生的 NT API 提供了未公开的 NtSuspendProcess 和 NtResumeProcess:
extern "C" NTSTATUS NtSuspendProcess(HANDLE ProcessHandle);
extern "C" NTSTATUS NtResumeProcess(HANDLE ProcessHandle);使用这些函数需要在项目中链接 ntdll.lib 库。由于这些是未公开的 API,在正式产品代码中使用需谨慎。
睡眠和让步
Sleep 函数使线程进入等待状态约指定的毫秒数:
void Sleep(DWORD dwMilliseconds);
// 值为 0:将时间片剩余部分让给同优先级的其他线程
// 值为 INFINITE:线程永远睡眠(直到被其他机制唤醒)SwitchToThread 告诉调度器立即调度下一个就绪线程,即使其优先级更低:
BOOL SwitchToThread();
// 成功调度到另一个线程返回 TRUE,否则返回 FALSESleep(0) 和 SwitchToThread 的区别在于:
Sleep(0)仅切换到同等或更高优先级的就绪线程SwitchToThread即使只有更低优先级的线程就绪,也会进行切换
总结
本章涵盖了 Windows 线程调度系统的各个方面:
- 优先级系统:进程优先级类与线程相对优先级共同决定线程的最终调度优先级(0-31)
- 单 CPU 调度:基于优先级的抢占式调度,配合时间片和循环调度
- 多处理器考量:理想处理器(软关联)、硬关联、CPU 集,以及它们之间的优先级关系
- 处理器组:扩展支持超过 64 个处理器的系统
- 优先级提升机制:I/O 完成、前台进程、GUI 唤醒、饥饿避免等
- 后台模式:同时降低 CPU、内存和 I/O 优先级
- 线程控制:暂停/恢复、睡眠/让步等操作
下一章将探讨线程同步——线程如何协调其操作,以及实现协调的各种方式(互斥体、信号量、事件、临界区等)。