在普通 Windows 上尽量做到软实时的实践指南

· 更新日期: · · Windows开发, 软实时, 设计, 测量

更新记录(1 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.21615425)
首次发布
引用本文(DOI: 10.5281/zenodo.21615424)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《在普通 Windows 上尽量做到软实时的实践指南》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615424 https://comcomponent.com/zh-CN/blog/2026/03/09/000-windows-soft-realtime-practical-guide-natural/

DOI(最新版本)
10.5281/zenodo.21615424
DOI(此版本)
10.5281/zenodo.22281922

在 Windows 上开发周期处理、音频处理、视频处理、测量、设备控制这类“一旦延迟就麻烦”的处理时,很容易给人一种“Windows 恐怕吃不消”的印象。这种印象一半对,一半不对:Windows 确实不是 hard real-time 操作系统,但只要把 设计、实现、测量、运维 这几个环节都做扎实,就能把它作为 soft real-time 用到相当实用的程度。

本文讨论的是 不依赖特殊 RTOS 扩展、自研内核驱动或专用控制器的普通 Windows 10 / 11。内容偏向实务:在日常使用的桌面 / 笔记本电脑上的 user-mode 应用中,延迟与抖动究竟能压到什么程度。音频、视频、周期控制、数据采集这些场景在细节上各有不同,但容易出问题的地方相当相似,因此本文把这些共同点整理成了一份 检查清单。

目标读者与代码示例所用的语言

本文面向 在 Windows 上开发“一旦延迟就麻烦”的处理(周期控制、音频与视频、测量、设备控制)的开发者。设定的场景是 user-mode 应用开发,内核模式驱动的实现不在讨论范围内。

代码示例所用的语言分工如下。

内容 语言 出现位置
周期循环、MMCSS、电源 QoS 等直接调用 Win32 API 的部分 C++(Win32) 4.1、4.3、4.5
从 C# 调用同一组 Win32 API 的写法 C#(P/Invoke) 4.5
时间测量、GC、内存分配方面的注意事项 .NET(C#) 4.4 的“.NET 侧的检查”、5.2

第 3 章之前关于原因的讨论,以及第 4 章检查清单的主体,都与语言无关。只使用 C# 的读者,把 C++ 代码当作“按什么顺序调用哪些 API”的说明来读就足够了。

目录

  1. 先说结论(一句话)
    • 1.1. 按周期范围划分的速查表(该从哪里读起)
  2. 普通 Windows 上的“软实时”是什么
    • 2.1. 本文所说的“普通 Windows”
    • 2.2. 能做到什么,从哪里开始变难
    • 2.3. 先说明一下术语
  3. 延迟与抖动的主要原因
    • 3.1. 调度器与优先级
    • 3.2. DPC / ISR 与驱动程序
    • 3.3. 页面错误与内存
    • 3.4. 定时器分辨率与电源管理
    • 3.5. 核心迁移与发热
  4. 在普通 Windows 上减少延迟的实践检查清单
    • 4.1. 周期循环与等待方式
    • 4.2. fast path / slow path 与固定长度队列
    • 4.3. 优先级 / MMCSS / background mode
    • 4.4. 内存 / GC / 首次开销
    • 4.5. 电源设置 / EcoQoS / timer resolution
    • 4.6. CPU 分配 / 核心迁移 / 发热
    • 4.7. 驱动 / DPC / ISR / 外部干扰的排查
  5. 测量与评估
    • 5.1. 应该记录什么
    • 5.2. p99 / p99.9 / max 的解读方式
    • 5.3. 用什么工具查看
    • 5.4. 测试的方法
  6. 大致的选型建议
  7. 总结
  8. 参考资料

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 20 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

1. 先说结论(一句话)

  • 在普通 Windows 上追求的目标不是 hard real-time 的保证,而是作为 soft real-time 做到“不容易延迟、即使延迟也不容易崩”的结构。
  • 效果最大的做法是 把热路径(hot path)做短、做成固定长度、并且非阻塞。
  • 把 fast path(采集 / 控制)与 slow path(保存 / 通信 / UI)分开,中间用 固定长度队列 连接。
  • 周期循环不依赖 Sleep(1),而是按 绝对期限 运行。
  • 对于音频、视频这类连续流,首先考虑使用 MMCSS。
  • 时间测量使用 QueryPerformanceCounter(QPC),在 .NET 中则使用 Stopwatch。
  • 等待优先使用 设备事件 或 高精度 waitable timer(可等待计时器)。
  • timeBeginPeriod 只在需要的时间段内使用,不要以“始终生效”为前提进行设计。
  • 在实际运行中,AC 供电 / 电源模式 / EcoQoS 的处理 / 后台负载的整理 都很有效。
  • 评估时不能只看平均值,还要看 p99(测量 100 次时,最慢的那 1 次开始显现的分界线) / p99.9 / max / miss 次数 / DPC / ISR / page fault / 队列深度。

总而言之,在普通 Windows 上,比起提高优先级,通过设计来减少延迟发生的原因 更为有效。 优先级和电源设置固然重要,但仅靠它们并不能构建出稳定性。

构建稳定性靠的是设计而不是优先级说明把热路径做短、做成固定长度、非阻塞,从设计上减少延迟发生的原因最为有效,而优先级和电源设置虽然重要,但仅靠它们无法构建出稳定性这一结论。把热路径做短、做成固定长度从设计上减少延迟发生的原因不容易延迟、即使延迟也不会崩的结构调整优先级和电源设置重要,但仅靠它们还不够

图1:效果最大的不是调整优先级,而是从设计上减少延迟发生的原因本身。

1.1. 按周期范围划分的速查表(该从哪里读起)

本文篇幅较长,因此先放一张速查表,让读者只读与自己情况相符的那一行。这部分内容原本放在文末(第 6 章),现在提前到了这里。

周期与要求 首先搭建的结构 需要重点阅读的小节
10~20ms 级别,能够吸收偶尔的波动 fast path / slow path 分离、固定长度队列、普通到略高的优先级、事件驱动。这样往往就够了 4.1、4.2
1~5ms 级别,希望持续按时完成 在上面的基础上,再加上热路径无分配化、专用线程、MMCSS 或谨慎的优先级调整、高精度 waitable timer、AC 供电与电源设置的重新检查 4.1 ~ 4.5
逼近 1ms 以下,且长时间、高负载下也不想错过期限 仅靠普通 Windows 的 user-mode 会相当吃力。应先考虑把关键部分移到别处(设备端固件、专用控制器、FPGA、RTOS)的设计方案 2.2、6
想让 GUI / 日志 / 通信 / 数据库全部共存 不要用“全部塞进 1 个进程 1 个循环”的方式硬扛,而要划分职责。后段的情况很容易破坏前段的期限 4.2、4.3、6

原因的排查方法与测量的做法,在任何周期范围下都是共通的(第 3 章和第 5 章)。

2. 普通 Windows 上的“软实时”是什么

2.1. 本文所说的“普通 Windows”

这里所说的 普通 Windows,大致以下面这些为前提。

  • Windows 10 / 11 上常见的桌面 / 笔记本电脑
  • 不使用自研的 RTOS 扩展
  • 不开发自研的内核模式驱动
  • 普通的 user-mode 应用
  • 通过常规的 Windows API 与设置来调优

也就是说,本文谈的不是“把一整台专用机打造成实时控制设备”,而是“在普通 Windows PC 上究竟能把延迟压到什么程度才现实”。

普通的 Windows 10 / 11 PCuser-mode 应用以 soft real-time 为目标降低延迟减小抖动观测 deadline miss,防止系统崩溃想保证零期限违反RTOS / 专用控制器 / FPGA / 设备端处理

图2:普通 Windows 的 user-mode 应用所追求的是 soft real-time,保证零期限违反属于 RTOS 等领域。

2.2. 能做到什么,从哪里开始变难

即便是普通 Windows,如果是下面这类处理,也能相当现实地做到“不容易延迟”的状态。

  • 几毫秒到几十毫秒级别的周期处理
  • 音频 / 视频的缓冲驱动
  • 传感器采集与控制回路
  • 类似软 PLC 的固定周期处理
  • 在与 UI 分离的独立线程中运行的低延迟管线

不过,这里所说的“能做到”并不意味着 能把偶发的延迟尖峰完全归零,实际追求的始终是下面这种状态。

  • 让平时的延迟保持较低
  • 让抖动保持较小
  • 偶尔错过期限也不会导致系统崩溃
  • 能够观测到错过期限这一事实

反过来,如果出现下面这类需求,仅靠普通 Windows 的 user-mode 就很难满足了。

  • 想保证零期限违反
  • 想长时间稳定地守住数百微秒以下的延迟
  • 想与繁重的 GUI、网络、存储共存
  • 想在电池供电或省电优先模式下依然维持
  • 不允许出现由驱动或设备引起的尖峰

遇到这类情况,比较安全的做法是 只把真正对时间要求严苛的部分交给设备端固件、专用控制器、FPGA 或 RTOS 处理。

soft real-time 所追求的状态与外移的判断说明在普通 Windows 上追求的是让平时的延迟保持较低、抖动保持较小、偶尔错过期限也不会崩溃并且能够观测到错过这一事实的状态,而零期限违反这类严苛要求则应把对时间要求严苛的部分外移。soft real-time 所追求的状态让平时的延迟保持较低让抖动保持较小即使错过也不崩溃并可观测零期限违反的要求交给设备端或 RTOS

图3:“能做到”的含义不是尖峰归零,而是不容易延迟、不容易崩、并且可观测的状态。

2.3. 先说明一下术语

先把本文中出现的术语含义梳理一下。

术语 一句话概括 实务角度
soft real-time 允许偶尔延迟,但要把延迟控制得小,且延迟后不会导致崩溃的理念 普通 Windows 首先要追求的就是这个
hard real-time 要保证零期限违反的世界 不是普通 Windows 的 user-mode 单独能够追求的目标
抖动(jitter) 周期或响应时间的波动 即使平均值不错,抖动大的话实际运行中也会不稳定
deadline miss 处理未能在预定时刻前完成 不要隐瞒,要计数并记录到日志中
p99 / p99.9 用来观察慢的那一端尾部的指标 p99 是“测量 100 次中,最慢的那 1 次开始显现的分界线”
DPC / ISR 驱动或中断相关的内核侧处理 如果耗时较长,user-mode 线程就会被阻塞等待
MMCSS Windows 用于给音频 / 视频等对时间敏感的处理分配 CPU 的机制 对不希望缓冲区中断的处理很有效
QPC 指 QueryPerformanceCounter 测量经过时间的基础手段,是高精度计数器,而不是挂钟时间
waitable timer(可等待计时器) 在指定时刻变为 signaled 状态的内核对象。给 CreateWaitableTimerExW 加上 CREATE_WAITABLE_TIMER_HIGH_RESOLUTION 就是高精度版本 比 Sleep 更适合作为周期等待的基础(4.1)
EcoQoS 被归类为“可以优先考虑省电”的状态。系统会降低 CPU 频率,或把线程安排到能效核心上 对时间敏感的处理要避免。不显式指定时,操作系统会自动推测(4.5)
IGNORE_TIMER_RESOLUTION 表示可以忽略进程的定时器分辨率请求的设定(PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION) 一旦生效,timeBeginPeriod 的效果就会消失。在 Windows 11 上,进程从屏幕上隐藏后有时会自动应用(4.5)
CPU Sets 用来 柔性地 指定“希望线程或进程在这一组核心上运行”的机制 应该比固定到特定核心更先尝试(4.6)
ETW / WPR / WPA Windows 自带的跟踪基础设施(ETW),以及它的记录工具(WPR)和分析 GUI(WPA) 深入分析 context switch、DPC / ISR、page fault 时使用(5.3)
LatencyMon 用于查看驱动引起的延迟的第三方工具 按驱动查看 DPC / ISR 的执行时间,用来大致定位问题(5.3)

3. 延迟与抖动的主要原因

普通 Windows 上周期处理出现延迟的原因,基本都可以归结到下图中的某一项。

周期处理出现延迟调度器 / 优先级DPC / ISR / 驱动页面错误 / 内存定时器分辨率 / 电源管理核心迁移 / 发热

图4:周期处理出现延迟的原因,可归结为调度器、DPC/ISR、内存、定时器与电源、核心迁移与发热这 5 类。

3.1. 调度器与优先级

Windows 中线程的执行顺序由优先级决定。 优先级相同时按轮转(round-robin)方式运行,一旦优先级更高的线程变为可执行状态,优先级较低的线程就会被抢占。

也就是说,即使认真编写周期线程,

  • 其他线程
  • 其他进程
  • 操作系统内部处理
  • 安全软件
  • 设备辅助处理
  • 后台同步

先于它运行的情况也很常见。

优先级调度带来的抢占说明优先级相同的线程按轮转方式依次运行,一旦优先级更高的线程变为可执行状态,优先级较低的线程就会被抢占,因此其他进程或操作系统内部处理先于周期线程运行是很常见的情况。优先级相同的线程之间按轮转方式依次运行优先级更高的线程变为可执行优先级较低的线程被抢占其他进程或操作系统内部处理等

图5:即使认真编写周期线程,优先级更高的工作先运行也是很正常的事。

3.2. DPC / ISR 与驱动程序

这一点相当重要。 即使把应用侧的优先级调整好,只要 DPC(Deferred Procedure Call,延迟过程调用)或 ISR(Interrupt Service Routine,中断服务例程) 执行时间较长,在这期间 user-mode 线程就无法运行。

容易成为原因的,大多是下面这些设备或驱动。

  • USB
  • Wi-Fi / Bluetooth
  • 存储
  • 音频
  • GPU
  • ACPI / 电源相关

即使应用代码本身没有问题,也可能因为驱动或硬件方面的原因被阻塞。 如果想着“把应用的优先级再调高一点应该就能赢”,通常都会吃亏。

DPC 与 ISR 阻塞 user-mode 的结构说明 USB、Wi-Fi、GPU 等设备与驱动的中断处理 ISR 或 DPC 执行时间较长时,在这期间 user-mode 线程无法运行,即使提高应用的优先级也赢不了。USB、Wi-Fi、GPU 等设备ISR 或 DPC 在内核侧运行在这期间 user-mode 无法运行提高优先级也赢不了

图6:即使应用代码本身没有问题,user-mode 也会因为驱动或硬件的原因被阻塞。

3.3. 页面错误与内存

如果在热路径中发生 page fault(所需页面不在内存中,需要去取回),延迟会一下子变大。

下面列出特别要避免的模式。

  • 首次访问时的页面提交
  • 延迟加载
  • 内存映射文件的换入(page-in)
  • 超出必要的动态分配
  • 大对象或碎片化的堆

在周期处理的主体部分,做到 提前分配好所需内存,并在启动时先访问一次 就比较合适了。

热路径中的 page fault 造成的延迟说明热路径中访问到的页面如果不在内存中就要通过 page fault 去取回,延迟会一下子变大,因此应提前分配好所需内存并在启动时先访问一次。在不在在热路径中访问内存页面是否在内存中直接继续执行通过 page fault 取回延迟一下子变大提前分配并在启动时先访问一次

图7:延迟会不会跳变,取决于是否发生 page fault。对策是提前分配与启动时预热。

3.4. 定时器分辨率与电源管理

“因为想每 1ms 运行一次,所以用 Sleep(1)”这种想法,大多数情况下都行不通。 Windows 的等待精度会受到定时器分辨率、调度和电源状态的影响。

此外,提高定时器分辨率的设置 在改善等待精度的同时,也会对功耗以及系统整体行为产生副作用,这一点也不能忽视。

3.5. 核心迁移与发热

线程在核心之间迁移时,会发生缓存重新预热的情况。 这件事本身 OS 大多能妥善处理,但在负载较高的环境中会成为波动的原因。

此外,长时间运行时发热也不能忽视。 一旦触发温度限频(thermal throttling),原本稳定的周期就可能被打乱。

核心迁移与发热打乱周期的路径说明线程在核心之间迁移会引起缓存重新预热,在高负载环境中成为波动的原因,而长时间运行积累的热量会触发温度限频,使原本稳定的周期被打乱。线程在核心之间迁移缓存重新预热在高负载环境中成为波动的原因长时间运行积累热量温度限频原本稳定的周期被打乱

图8:核心迁移以抖动的形式、发热以限频的形式,共同削弱长时间运行的稳定性。

4. 在普通 Windows 上减少延迟的实践检查清单

从这里开始是实务部分。 针对上一节中提到的各种原因,本节以检查清单的形式,整理在普通 Windows 上 应该确认什么、应该避免什么、应该提前决定什么。

4.1. 周期循环与等待方式

首先,典型的反面模式是这样的。

while (running)
{
    Sleep(1);
    Step();
}

这并不是“1ms 周期”,而是一个 先等待 1ms 以上,再在此基础上加上 Step() 的执行时间 的循环。 而且等待的超量部分会原样不断累积。

基于绝对期限基于相对时间WaitUntil(next - margin)next += period必要时进行短暂 spinFastStep()Step()Sleep(1)等待误差与执行时间逐渐累积不容易积累漂移(drift)

图9:依赖 Sleep(1) 的相对时间循环会不断累积误差,而按绝对期限运行的循环不容易积累漂移。

检查清单

  • 没有把 Sleep(1) 当作周期循环的基础
  • 周期按照 next += period 的 绝对期限 运行
  • 等待优先使用 设备事件 或 waitable timer(可等待计时器)
  • 只在最后的微调阶段使用极短的 busy-spin(空转等待)
  • timeBeginPeriod 只在需要的时间段内使用,用完后还原
  • 已确认在最小化 / 隐藏 / 不可见状态下的行为

周期循环按照 绝对期限 而不是相对时间来运行,会更稳定。

int64_t next = QpcNow() + periodTicks;

while (running)
{
    WaitUntil(next - wakeMarginTicks);

    while (QpcNow() < next)
    {
        CpuRelax(); // 最后只做短暂 spin
    }

    int64_t started = QpcNow();
    FastStep();
    int64_t finished = QpcNow();

    RecordTiming(next, started, finished);

    next += periodTicks;

    while (finished > next)
    {
        ++missedDeadlines;
        next += periodTicks;
    }
}

4.2. fast path / slow path 与固定长度队列

构建的基本原则是:fast path 中只放“对期限敏感的工作”,其他一切都交给 slow path。

设备 / 采集事件fast path:采集、控制、最小限度的复制固定长度队列slow path:保存、发送、UI、汇总记录 lateness / miss / 队列深度

图10:fast path 中只放对期限敏感的工作,中间隔一个固定长度队列,把其余工作交给 slow path 的基本结构。

fast path 中要做的事,收窄到这个程度就够了。

  • 数据采集
  • 控制值计算
  • 必要最小限度的复制
  • 时间戳记录
  • 写入队列
  • 记录 miss / overrun

其他一切都放到 slow path 中处理。

检查清单

  • 热路径中没有进行文件写入、网络发送、数据库写入
  • 热路径中没有输出大量日志、调用 Flush、执行同步 RPC
  • 已按线程或职责明确划分 fast path / slow path
  • 队列使用 固定长度
  • 已提前决定队列溢出时的应对方针
  • 已监测 miss 次数、drop 数量、队列深度
  • UI 更新与日志汇总已分离到较低的周期中处理

当队列变满时,提前明确应对方针会更安全,不要让它含糊不清。

最新值更重要全部数据都重要日志用途队列已满要保住什么?丢弃旧元素,保留最新值告警 / 停止 / 上游限流丢弃旧元素,只记录 drop 数量

图11:队列变满时要保住什么,应按用途提前决定。

4.3. 优先级 / MMCSS / background mode

优先级的基本原则是 不要把所有线程都提高。 在普通 Windows 上,“只提高重要线程的优先级,同时切实降低后台工作的优先级”这种做法效果更好。 background mode 是一种不仅针对 CPU,也把 I/O 等资源一并调低优先级处理的机制。

划分工作对期限敏感的线程保存 / 发送 / 压缩 / 汇总UI必要时使用较高优先级或 MMCSSbackground mode / 较低优先级普通优先级一开始不要直接设为 REALTIME_PRIORITY_CLASS

图12:只提高对期限敏感的线程的优先级,并切实降低后台工作,这就是优先级的分配方式。

检查清单

  • 没有把所有线程都设为高优先级
  • 只提高了真正对时间要求严苛的线程的优先级
  • 保存、发送、压缩、同步等后台工作已降到 background mode
  • 对音频、视频、采集、播放等连续缓冲处理已考虑使用 MMCSS
  • 优先按 线程单位 而不是整个进程来考虑
  • 在明确有必要之前,不使用 REALTIME_PRIORITY_CLASS

MMCSS(Multimedia Class Scheduler Service)对于 音频 / 视频这类“希望在一定时间内填满缓冲区”的处理 特别有效。 比起单纯让高优先级线程一直运行,这种方式更符合 Windows 的设计思路。

代码大致是这样的感觉。

DWORD taskIndex = 0;
HANDLE avrt = AvSetMmThreadCharacteristicsW(L"Pro Audio", &taskIndex);
if (!avrt)
{
    throw std::runtime_error("AvSetMmThreadCharacteristicsW failed");
}

// 运行对时间敏感的循环

if (!AvRevertMmThreadCharacteristics(avrt))
{
    throw std::runtime_error("AvRevertMmThreadCharacteristics failed");
}
使用 MMCSS 的线程的注册与注销说明在运行对时间敏感的循环之前用 AvSetMmThreadCharacteristicsW 注册到 MMCSS,循环结束后用 AvRevertMmThreadCharacteristics 还原的流程,以及 MMCSS 会把 CPU 优先分配给对时间敏感的处理。AvSetMmThreadCharacteristicsW运行对时间敏感的循环AvRevertMmThreadCharacteristicsMMCSS 优先分配 CPU

图13:MMCSS 要成对地注册与注销。对于连续缓冲处理,这比一直用高优先级运行更符合 Windows 的设计。

4.4. 内存 / GC / 首次开销

如果在热路径中每次都调用 new / malloc / List<T>.Add / 字符串拼接 / LINQ,迟早会暴露出回收或重新分配相关的问题。GC(垃圾回收)本身并不是坏事,但 分配次数多的代码,其影响会以抖动的形式表现出来。

启动分配所需缓冲区先访问一次,预热页面完成 JIT / DLL 加载 / 首次 I/O之后再进行正式测量 / 正式运行

图14:在启动时完成缓冲区分配、页面预热和首次开销,之后再进入正式测量与正式运行。

检查清单

  • 热路径中没有每次都进行内存分配 / 释放
  • 已在启动时预先分配好所需缓冲区
  • 已在启动时先访问一次以预热页面
  • 首次 JIT、首次 DLL 加载、首次 I/O 没有混入正式测量中
  • 循环过程中没有让巨大结构体或可变长日志不断增长
  • 即使使用了 VirtualLock,也仅限于极小的关键区域

.NET 侧的检查

  • 时间测量使用 Stopwatch / Stopwatch.GetTimestamp()
  • 热路径中没有使用 LINQ、字符串拼接、ToString()、生成大量日志
  • 没有把 async/await 带入热路径
  • 已将预热前与预热后分开评估

4.5. 电源设置 / EcoQoS / timer resolution

这一部分看起来不起眼,但确实有效。 即使把代码打磨得很好,如果上层的电源控制影响较强,结果依然不会稳定。

普通 Windows 的电源相关设置使用 AC 供电运行电源模式:偏向最佳性能必要时使用面向正式环境的专用电源计划对时间敏感的进程要避免 EcoQoS确认定时器分辨率请求的处理方式

图15:电源方面需要确认的点:供电方式、电源模式、EcoQoS、定时器分辨率请求的处理方式。

检查清单

  • 正式评估首先在 AC 供电 下进行
  • 已将 [设置] > [系统] > [电源和电池] > [电源模式] 调整为偏向 最佳性能
  • 运行期间没有使用省电模式 / 省电优先模式
  • 已检查厂商自带工具中的静音 / eco / 电池优先模式
  • 没有不慎把对时间敏感的进程设为 EcoQoS(偏省电的 QoS)
  • 没有在对时间敏感的进程侧启用 IGNORE_TIMER_RESOLUTION
  • 已确认最小化 / 隐藏时定时器分辨率请求的效果是否会发生变化
  • 已把日常使用与正式环境 / 测量 / 演示用的电源设置区分开

timeBeginPeriod 如果使用得当会很有帮助,但 并不是万能药。

  • 在真正需要之前才调用
  • 使用结束后要通过 timeEndPeriod 还原
  • Windows 10 version 2004 以后,其行为已不再是过去那种完全全局的效果
  • 在 Windows 11 上,如果拥有窗口的进程完全隐藏 / 被最小化 / 不可见 / 无声音,高分辨率可能无法得到保证
  • 即使提高了分辨率,也不代表 QPC 的精度会提升

如果怀疑受到电源或 QoS 的影响,可以用 SetProcessInformation 确认 power throttling 的状态。

PROCESS_POWER_THROTTLING_STATE state{};
state.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION;
state.ControlMask =
    PROCESS_POWER_THROTTLING_EXECUTION_SPEED |
    PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION;
state.StateMask = 0; // HighQoS(偏向性能优先) + 尊重定时器分辨率请求

if (!SetProcessInformation(
        GetCurrentProcess(),
        ProcessPowerThrottling,
        &state,
        sizeof(state)))
{
    throw std::runtime_error("SetProcessInformation failed");
}

ControlMask 表示“由自己控制哪些机制”,StateMask 表示“把这些机制打开还是关闭”。上面的示例选择了两个机制作为控制对象,并把两者都关闭。也就是说,这是在声明 不降级到 EcoQoS(偏向 HighQoS) 以及 不让定时器分辨率请求被忽略。反过来,如果把 ControlMask 设为 0,两者都会交回操作系统处理(恢复默认行为)。

power throttling 设置中两个掩码的作用说明用 ControlMask 选择由自己控制的机制,用 StateMask 决定把这些机制打开还是关闭,从而声明不降级到 EcoQoS 以及不让定时器分辨率请求被忽略。用 ControlMask 选择控制对象用 StateMask 决定开与关声明不降级到 EcoQoS不让定时器分辨率请求被忽略ControlMask 为 0 则交回操作系统处理

图16:SetProcessInformation 中两个掩码的作用。先选定控制对象,再声明开与关。

从 C# 调用的写法

如果要从 C# 做同样的事,P/Invoke 声明如下。

// C# / .NET 8
using System.Runtime.InteropServices;

internal static class PowerQos
{
    [StructLayout(LayoutKind.Sequential)]
    private struct PROCESS_POWER_THROTTLING_STATE
    {
        public uint Version;
        public uint ControlMask;
        public uint StateMask;
    }

    private const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
    private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
    private const uint PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION = 0x4;

    // PROCESS_INFORMATION_CLASS 中的第 5 个(从 0 起算为 4)是 ProcessPowerThrottling
    private const int ProcessPowerThrottling = 4;

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool SetProcessInformation(
        IntPtr hProcess,
        int processInformationClass,
        ref PROCESS_POWER_THROTTLING_STATE processInformation,
        uint processInformationSize);

    [DllImport("kernel32.dll")]
    private static extern IntPtr GetCurrentProcess();

    /// <summary>同时解除偏省电的处理方式,以及对定时器分辨率请求的忽略。</summary>
    public static void OptOutOfPowerThrottling()
    {
        var state = new PROCESS_POWER_THROTTLING_STATE
        {
            Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION,
            ControlMask =
                PROCESS_POWER_THROTTLING_EXECUTION_SPEED |
                PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION,
            StateMask = 0,
        };

        if (!SetProcessInformation(
                GetCurrentProcess(),
                ProcessPowerThrottling,
                ref state,
                (uint)Marshal.SizeOf<PROCESS_POWER_THROTTLING_STATE>()))
        {
            throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
        }
    }
}

调用方式是在启动对时间敏感的循环之前执行一次 PowerQos.OptOutOfPowerThrottling();。进程句柄需要具备 PROCESS_SET_INFORMATION 访问权限,不过 GetCurrentProcess() 返回的是本进程的伪句柄,因此不存在问题。

4.6. CPU 分配 / 核心迁移 / 发热

CPU 分配方面,比起一开始就 固定到特定核心(hard affinity / CPU pinning),从 “尽量在这一组核心上运行”这种接近 soft affinity 的设定 开始,往往效果更好。

是否先测量SetThreadIdealProcessor / CPU Sets改善是否足够?就此止步最后再考虑 SetThreadAffinityMask同时确认温度 / 主频 / 长时间运行的情况

图17:CPU 分配要从测量开始,柔性设定够用就到此为止,固定到特定核心是最后的手段。

检查清单

  • 先测量,之后才调整 CPU 分配
  • 没有一开始就固定到特定核心
  • 首先尝试了 SetThreadIdealProcessor 或 CPU Sets
  • 把 SetThreadAffinityMask 当作最后的手段
  • 已在长时间运行中确认温度、主频、温度限频情况
  • 已确认笔记本电脑的静音模式或低噪音模式

按下面的顺序来做比较安全。

  1. 先进行测量
  2. 必要时使用 ideal processor / CPU Sets
  3. 如果仍需要进一步改善,再考虑固定到特定核心

固定到特定核心看起来会有效果,但因为这会 减少操作系统的调度余地,如果轻易使用,反而可能变得不够灵活。

4.7. 驱动 / DPC / ISR / 外部干扰的排查

遇到“偶尔 max 值暴涨”“平均值不错,但 p99.9 很差”这类情况时, 最好也怀疑一下应用代码之外的外部干扰因素。

是否是否是否是否出现了 late / miss / max spike自己的处理时间也很长吗?缩短热路径 / 减少分配 / 去除 I/O存在 DPC / ISR 尖峰吗?检查 USB / Wi-Fi / Bluetooth / GPU / Audio / Storage / ACPI / 驱动更新存在 page fault / GC / 首次开销吗?预先分配 / 预热 / 减少堆负载存在电池 / 省电 / 发热的影响吗?AC 供电 / 电源设置 / 散热 / 长时间测试用 ETW / WPA / LatencyMon 深入排查

图18:出现尖峰时的排查顺序。依次怀疑自己的处理、DPC/ISR、内存、电源与发热。

检查清单

  • 已检查 Wi-Fi / Bluetooth / USB / 存储 / GPU / 音频相关的驱动
  • 已停止不必要的云同步、建立索引、自动更新,并进行对比
  • 已尝试最小化后是否会变差、关闭屏幕后是否会变差
  • 已用 LatencyMon 或 ETW 查看 DPC / ISR 的趋势
  • 已把“自己的处理太重”与“被外部因素阻塞”区分开来看

5. 测量与评估

5.1. 应该记录什么

至少应该采集下面这些信息。

  • 周期预定时刻
  • 实际开始时刻
  • 实际结束时刻
  • lateness(相对预定开始时刻延迟了多久才开始)
  • 执行时间
  • missed deadline 次数
  • 连续 missed deadline 次数
  • 队列深度(queue depth)
  • drop 数量
  • CPU 使用率
  • 各核心的负载偏差
  • DPC / ISR 尖峰
  • page fault
  • 温度 / 主频变化

只看平均值很难把握本质。 在实际运行中真正让人头疼的,是偶尔出现的大幅延迟尖峰。

5.2. p99 / p99.9 / max 的解读方式

p99 之类的指标,是 用来观察慢的那一端的尾部 的。 只看平均值,偶尔出现的大幅延迟就会被掩盖。

指标 含义 测量 10,000 次时的直观感受
平均 整体的平滑值 尖峰容易被掩盖
p50 中间值 接近日常的实际体感
p95 最慢 5% 开始显现的分界线 排除最慢 500 次后的边界
p99 最慢 1% 开始显现的分界线 排除最慢 100 次后的边界
p99.9 最慢 0.1% 开始显现的分界线 排除最慢 10 次后的边界
max 最差值 最慢的那 1 次

比如,假设测得下面这样一组数值(这是为了说明指标的读法而举的例子,不是在特定设备上的实测值)。

  • 平均:0.8ms
  • p99:1.2ms
  • p99.9:3.5ms
  • max:28ms

这种情况就说明 平时速度很快,但偶尔会出现较大的尖峰。 在普通 Windows 上,真正的问题大多就出现在这段 从 p99 到 max 的尾部。

平均值与尾部指标的区别说明只看平均值会掩盖偶尔出现的大幅延迟,而看 p99、p99.9、max 就能看到慢的那一端的尾部,在普通 Windows 上真正的问题就出现在那里。只看平均值偶尔出现的大幅延迟被掩盖看 p99、p99.9、max能看到慢的那一端的尾部真正的问题出现在从 p99 到 max 之间

图19:平均值不错但 max 跳变,说明处于“平时很快但偶尔跳变”的状态。要用尾部指标来捕捉。

另外,本文没有用数值来说明推荐结构的效果。延迟与抖动会因为 CPU、驱动、常驻软件、电源设置和施加负载的方式而轻易改变,因此 别人环境中的数值不能直接成为你自己环境的依据。作为替代,下面给出在自己的环境中做出同样形式的表格的步骤。

在自己的环境中得出 p99 的最小步骤

  1. 热路径中 只做记录。用 Stopwatch.GetTimestamp()(C++ 则用 QueryPerformanceCounter)取得 lateness 和执行时间,写入预先分配好的数组。这里不能计算平均值或做排序
  2. 停止测量之后再做汇总。排序后取出百分位位置上的值
  3. 改变条件 重复同样的步骤。预热前后、AC / 电池、UI 前台 / 最小化、有 / 无其他进程负载(5.4)
  4. 每引入一项改动,都在相同条件下重新采集并对比
// C# / .NET 8。汇总要在停止测量之后进行
using System.Diagnostics;

// 热路径中只往数组里写(零分配)
long[] latenessTicks = new long[100_000];
int count = 0;

// 例:在周期循环中
// latenessTicks[count++] = Stopwatch.GetTimestamp() - scheduledTimestamp;

static double PercentileMs(long[] ticks, int count, double percentile)
{
    long[] sorted = ticks.AsSpan(0, count).ToArray();
    Array.Sort(sorted);

    int index = (int)Math.Ceiling(percentile / 100.0 * count) - 1;
    index = Math.Clamp(index, 0, count - 1);

    return sorted[index] * 1000.0 / Stopwatch.Frequency;
}

// 用法
// Console.WriteLine($"p50={PercentileMs(latenessTicks, count, 50):F3}ms");
// Console.WriteLine($"p99={PercentileMs(latenessTicks, count, 99):F3}ms");
// Console.WriteLine($"p99.9={PercentileMs(latenessTicks, count, 99.9):F3}ms");
// Console.WriteLine($"max={PercentileMs(latenessTicks, count, 100):F3}ms");

Stopwatch.Frequency 是每秒的计数值,所以先除以它再乘以 1000 就得到毫秒。样本数太少时 p99.9 没有意义。如果要谈 p99.9,请至少收集 10,000 个样本(最好是 10 万个)。

5.3. 用什么工具查看

常用的工具大致是固定的。

  • 应用内测量 首先自行采集 period / lateness / execution time / queue depth / drop
  • ETW / WPR / WPA 深入分析 CPU、context switch、DPC / ISR、page fault
  • LatencyMon 排查由驱动引起的波动
  • 温度 / 主频监控 查看发热带来的影响
应用内测量p50 / p95 / p99 / p99.9 / maxmiss / drop / 队列深度ETW / WPR / WPAcontext switch / DPC / ISR / page fault温度 / 主频监控确定改善的优先顺序

图20:把应用内测量、ETW 和温度监控的结果相互对照,确定改善的优先顺序。

用到 WPA 这一步会稍微费些功夫, 但对于区分 是 DPC / ISR 引起的,还是单纯自己的处理太重,相当有效。

获取方式与最简用法

工具 获取方式 最简步骤
WPR / WPA(Windows Performance Toolkit) 安装 Windows ADK(Windows Assessment and Deployment Kit)时勾选“Windows Performance Toolkit”即可装上。默认位置是 C:\Program Files (x86)\Windows Kits\10\Windows Performance Toolkit 以管理员身份打开命令提示符,(1) 用 wpr -start CPU 开始记录,(2) 让有问题的处理运行几十秒,(3) 用 wpr -stop trace.etl "周期延迟的调查" 保存。之后用 WPA 打开 trace.etl。可用的配置文件名可以通过 wpr -profiles 查看
LatencyMon 从 Resplendence Software 的网站下载。面向个人的 Home Edition 是免费版 启动后开始测量,让目标处理保持运行并放置数分钟。工具会汇总内核定时器的最大延迟,以及 按驱动统计的 ISR / DPC 执行时间和 hard pagefault,把执行时间格外突出的驱动记下来

在 WPA 中首先要看的,是 CPU 相关图表中的 DPC / ISR 执行时间 和 context switch。它会列出“在自己的线程无法运行的那段时间里,是哪个驱动占着 CPU”,把它与 5.1 中记录的延迟发生时刻对照起来看。

另外,本文没有放置界面截图。因为界面外观容易随版本变化,请按照上面的步骤实际操作,在真实界面上确认。

5.4. 测试的方法

光靠安静的测试环境是不够的。至少应该把下面这些条件区分开来分别测试。

  • 启动后、预热之前
  • 预热之后
  • 长时间连续运行
  • UI 处于前台
  • UI 最小化 / 接近隐藏的状态
  • AC 供电
  • 电池供电
  • 网络或磁盘处于高负载的状态

只靠测试环境进行评估,容易漏掉实际运行中才会出现的问题。 普通 Windows 的行为很容易被“使用方式”牵着走,因此把测试条件贴近实际使用场景来确认非常重要。

区分条件进行测试的思路说明只在安静的测试环境中评估会漏掉实际运行中的问题,因此要把预热前后、长时间运行、UI 前台与最小化、AC 供电与电池供电、有无负载等条件区分开来确认。只在安静的测试环境中评估漏掉实际运行中的问题区分条件分别评估预热与长时间运行UI 前台与最小化供电方式与负载条件

图21:普通 Windows 的行为容易被使用方式牵着走。要贴近实际使用条件来评估。

6. 大致的选型建议

按周期范围划分的速查表已经 提前到了 1.1,方便读者边读边回头查阅。这里补充两个仅凭那张表还决定不了的判断。

在哪里判断“普通 Windows 做不到”

一旦需要在长时间、高负载下守住 1ms 以下的期限,与其继续调优,不如判断 只把对时间要求严苛的部分移到外面,这样往往更快。可以移交的对象有设备端固件、专用控制器、FPGA、RTOS。判断依据不是主观感受,而是第 5 章的数字。

  • 热路径已经无法再缩短,但 p99.9 和 max 持续超出要求
  • 原因来自外部干扰(DPC / ISR、驱动、其他进程),应用侧的对策够不着(用 4.7 的排查方法确认)
  • 要求已经从“偶尔错过也不会崩”变成了“一次都不能错过”

当想把所有东西都放进 1 个进程时

如果把 GUI、日志、通信、数据库都塞进同一个进程的同一个循环,后段的情况就会破坏前段的期限。因为等待文件刷盘、数据库重连、UI 重绘,任何一项都可能拉长到几十毫秒。把 fast path / slow path 的分离(4.2)从线程层面扩展到进程层面,也是一个选项。分成不同进程后,即使后段卡住,前段的周期依然会继续运转。

共存结构与进程分离的对比说明把 GUI、日志、通信、数据库塞进同一个进程的同一个循环时后段的情况会破坏前段的期限,而把 fast path 与 slow path 的分离扩展到进程层面后,即使后段卡住前段的周期依然会继续运转。把所有东西塞进 1 个进程 1 个循环后段的情况破坏前段的期限刷盘等待、重连、重绘把前段与后段分成不同进程后段卡住时前段仍继续运转

图22:如果实在需要共存,可以选择把 fast path / slow path 的分离扩展到进程层面。

7. 总结

有两个前提需要牢记。

  • 在普通 Windows 上追求的目标不是 hard real-time 的保证,而是作为 soft real-time 把延迟与抖动降到最小,并让系统即使出现期限违反也不会崩溃
  • 效果最大的做法,是整理热路径,而不是调整优先级

在实现层面,下面这些做法比较有效。

  • 分离 fast path / slow path
  • 使用固定长度队列,并提前决定溢出时的应对方针
  • 用 QPC 测量,用 event / waitable timer(可等待计时器)等待
  • 热路径中避免内存分配、阻塞 I/O、重量级锁

在运维层面,下面这些做法比较有效。

  • 使用 AC 供电运行
  • 单独设置面向正式环境的电源方案
  • 减少不必要的后台负载
  • 用 p99 / p99.9 / max 与 miss 次数来评估

普通 Windows 上的软实时,并不是仅靠优先级设置就能决定的。只要把设计、实现、电源设置、测量和运维分别做扎实,就能构建出相当稳定的系统。

8. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

在 Windows 上能实现实时处理吗?
无法保证 hard real-time(零期限违反),但只要把设计、实现、测量、运维这几个环节都做到位,就可以把 soft real-time 做到相当实用的程度。几毫秒到几十毫秒级别的周期处理、音频/视频的缓冲驱动、传感器采集与控制回路等,在普通的 Windows 10/11 上都是现实可行的。反过来,如果需要保证零期限违反,或者需要长时间稳定守住数百微秒以下的延迟,就应该考虑把这部分交给 RTOS、专用控制器、FPGA 或设备端处理。
为什么周期处理不应该使用 Sleep(1)?
Sleep(1) 并不是按 1ms 周期运行,而是至少等待 1ms 以上,再加上处理本身耗费的时间,等待的超量部分会原样不断累积。周期循环应该按照 next += period 这种绝对期限来运行,等待优先使用设备事件或高精度的 waitable timer,只在最后做极短的忙等(busy-spin)微调才比较稳定。timeBeginPeriod 也只应在需要的时间段内使用,用完后要还原。
要减少延迟和抖动,最有效的方法是什么?
比起提高优先级,更有效的是把热路径(hot path)做得更短、长度固定、并且非阻塞。把 fast path(采集、控制)与 slow path(保存、通信、UI)分开,中间用固定长度队列连接,热路径中要避免写文件、发送网络请求、输出大量日志以及每次都重新分配内存。在运维层面,使用 AC 供电、重新检查电源模式、确认 EcoQoS 设置、清理后台负载等做法也很有效。
应该用什么指标来评估周期处理的稳定性?
不能只看平均值,还要看 p99、p99.9、max 以及错过期限(missed deadline)的次数。比如平均只有 0.8ms,但 max 却达到 28ms,就说明大多数时候很快,但偶尔会出现较大的尖峰;在普通 Windows 上,真正的问题往往就出现在从 p99 到 max 这段尾部。同时也应该记录 DPC/ISR 尖峰、page fault、队列深度和温度/主频变化,并把预热前后、长时间运行、最小化状态、电池供电等条件分开评估。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表