正如我之前提到的,xperf(Windows 性能工具包,也称为 ETW)的文档非常匮乏。在这篇文章中,我将尝试解释 CPU Usage (Precise) 表中那些极其微妙而不明显的列名含义,该表展示了在跟踪中记录的所有上下文切换。理解这些列对于分析空闲时间非常重要,而这正是 xperf 的最有价值的功能之一。
注:本文于 2015 年 9 月更新,包含了关于 UIforETW、WPA,以及新列的信息。

CPU Usage (Precise) 表因为那些令人困惑的列名而备受诟病。快看——Switch-In Time 和 Last Switch-Out Time 有什么区别?New Prev Out Pri 这样一个列名是真的吗?是复合词吗?还有 ProcessOutOfMemory 竟然真的是上下文切换表中的一列?别走开,我会逐一解答。WPA 10 在列名中添加了一些空格和连字符,也有一些工具提示,但我们依然需要文档加以说明。
以下是本系列的其他文章:

本文假设你正在使用 UIforETW 录制跟踪,并已安装 UIforETW 所附带的 WPA(Windows Performance Analyzer)启动配置文件。你可以在“Settings”对话框中点击“Copy startup profiles”进行安装。本文还假设你使用的是 WPA 10 来查看跟踪文件——这是在 UIforETW 的“Traces”列表中双击某个跟踪文件时的默认行为。
CPU Usage (Precise)
这个表被命名为 CPU Usage (Precise),是为了将其与 CPU Usage (Sampled) 数据区分开来。采样(Sampled)数据来自频繁中断 CPU 进行采样,查看 CPU 此时正在执行什么;而 精准(Precise)数据则来自对内核中上下文切换代码的检测。每次线程开始或停止运行时,都会记录下这个动作的精准时间(可选地还会包含调用栈信息)。这意味着 CPU Usage (Precise) 能精确统计每个线程消耗的 CPU 时间,以及当线程失去/获得 CPU 时的调用栈。虽然 CPU Usage (Precise) 无法告诉你线程在两次上下文切换之间到底在做什么——那是 CPU Usage (Sampled) 的工作。理解它们之间的区别至关重要。
UIforETW 的默认启动配置文件以图表形式展示 CPU Usage (Precise),因为这是最准确的图表,可以显示每个进程使用了多少 CPU 时间。如果你想查看上下文切换的细节,可以点击“Display graph and table”按钮,表格就会显示在图表下方。

CPU Usage (Precise) 的默认列
可用的 View Presets(视图预设)对于获取不同类型的图表很有帮助(例如 “Timeline by Process, Thread” 或 “Timeline by CPU” 会很有意思)……

……但是,我发现内置的任何预设都不太适合深入研究程序为什么空闲。所以,UIforETW 的默认 View Preset 是 Randomascii Wait Analysis,它专门为研究线程何时、在哪里以及为什么会空闲而优化。该预设所使用的列如下:
- New Process
- New Thread Id
- New Thread Stack
- Readying Process
- Readying Thread Id
- Ready Thread Stack
- Orange bar(橙色栏,用来分割分组列和度量列)
- Count
- Sum: Time Since Last (μs)
- Max: Time Since Last (μs)
- Count: Waits
- Switch-In Time (s)
启用这些列之后,你可以轻松地深入查看线程是在哪里进入空闲状态、空闲多少次、每次多长时间、线程在空闲时的调用栈、又是由哪个进程里的哪个线程(及其调用栈)唤醒了它,等等。这功能非常强大。
CPU 调度展示上下文切换
CPU Scheduling(CPU 调度)汇总表中的所有列都与特定的上下文切换有关(或者是多个上下文切换汇总后的一行)。分组方式(在橙色栏左侧的列)会决定你在每一行看到多少上下文切换。如果你使用默认推荐视图,初始状态下每个进程只会显示一行。当你展开(drill in)时,就会显示更多行。通过调用栈进行分组尤其重要,因为这样能更轻松地把握整体情况。
如果在可见时间段内,某些线程或进程没有发生上下文切换,那么它们就不会在表中出现。
在深入介绍这些列(总共有 60 多个!)之前,我们需要先了解一些概念和命名前缀。
上下文切换的概念
CPU Scheduling(CPU 调度)汇总表总结的是上下文切换的数据,每当某个逻辑或物理 CPU 从执行一个线程切换到另一个线程时,系统就会记录下这样一次切换。要理解上下文切换,必须先了解线程可能处于的不同状态。如果你对 Windows 线程调度及其命名方式不熟悉,就很难完全理解 CPU Scheduling 汇总表。Windows 线程可以处于多个状态:
- Waiting:线程在等待某些事件(例如调用 WaitForMultipleObjects、EnterCriticalSection、ReadFile 等),因此无法运行。
- Ready:线程已经满足了等待条件或超时,此时已做好运行准备,但还未被调度到 CPU。若线程在 Ready 状态下停留了非短暂的时间,说明有一定程度的 CPU 饥饿。
- Running:线程正在某个 CPU 上运行,执行指令并完成工作。线程可由 Ready 状态切换到 Running 状态,当它失去 CPU 时可回到 Ready 状态,也可因调用等待 API 回到 Waiting 状态。
- 系统还使用一些其他中间状态(如 DeferredReady、Standby 等)来提升调度效率,以及针对新生和死亡线程的 Initialized、Terminated 状态。但从性能角度看,这些一般都不太相关。
一次上下文切换最多会涉及三个进程:
- New Process/Thread:切换后即将运行的线程/进程,可能是空闲线程(当 CPU 空闲时,被视为“执行”空闲线程)。New Thread 从 Ready 切换到 Running。
- Old Process/Thread:切换前正在执行的线程/进程,失去或放弃了 CPU 的那一个,也可能是空闲线程。Old Thread 从 Running 切换到 Waiting 或 Ready。
- Readying Process/Thread:把 New Thread 从 Waiting 转为 Ready(满足其等待条件)的线程/进程,这可能是一个释放了临界区或设置事件的线程,也可能是为键盘输入、文件 I/O 完成或网络包到达服务的线程。有时并没有明确的 Readying Thread(例如线程只是被抢占从 Running 回到 Ready 再回来,而没有任何真正的唤醒)。有时 Readying Thread 是个 DPC(推迟的过程调用),那就意味着它“劫持”了某个线程来执行中断服务。有时候,这个 Readying Thread 不存在。
总之,在 ETW 里,一次上下文切换是指把 New Thread 从 Ready 切换到 Running,并且把 Old Thread 从 Running 切换到其他状态(Waiting 或 Ready),在某个特定的 CPU 上完成。
有些文档会说 Windows 始终会让最高优先级的线程执行,但那顶多是半真半假,通常也没太大意义。说它是“半真”是因为当前版本的 Windows 只保证每个 CPU会去运行其各自 CPU 队列中最高优先级的线程。很可能会出现:某个高优先级线程在另一个 CPU上处于 Ready 状态等待了好几个量程(quantum),而低优先级线程还在此 CPU 上继续运行。此外,优先级保证通常也不太重要,因为 Windows 不断地在动态调整线程优先级,所以你也很难确定某个线程在具体时间点上的实际优先级(不过你可以在 ETW 跟踪中看到优先级)。
复合词
许多列名都是类似复合词,如果先理解组成它们的术语含义,会比死记硬背列名更有价值。下面是一些常见的、重要的词:
- New:切换后会运行的线程/进程,也就是这次上下文切换后处于 Running 状态的那个
- Old:被切换下来的线程/进程,也就是失去 CPU 的那个
- Ready/Readying:指向唤醒了(readied)新线程的线程/进程
- New Prev:通常指新线程上一次(previous)运行结束(switch out)时的相关信息
- In:指线程刚开始运行时(切换进,switch in)的状态
- Out:指线程停止运行时(切换出,switch out)的状态
- Max:对折叠在一起的多行数据取最大值
- Sum:对折叠在一起的多行数据取总和。许多列虽然没有“Sum”或“Max”字样,但同样是加总或最大值统计,如 Count、时间持续列等
- Time:在本汇总表中,“Time (s)”通常表示某个时刻(绝对时间,单位秒),而 “Time (us)” 通常表示一个时长(持续时间,单位微秒)。区分它们十分重要。
因此,我们会遇到类似 New Prev Out Pri 这样看似怪异但其实是New 线程之前(Prev)切出(Out)时的优先级(Pri)。很拗口吧?
重要列的说明
- New/Readying/Old Process Name:进程名(如 devenv.exe),不包含进程 ID。可在按此列分组时,把同一可执行文件的所有进程聚合到一起。
- New/Readying/Old Process:进程名加上进程 ID。可在按此列分组时,为每个进程单独分组。
- New/Readying/Old ThreadId:线程 ID,如果没有线程则为 -1。
- New Thread Stack:新线程的调用栈,既是它获得 CPU 后开始执行的地方,也代表它在上一次切换中被挂起时的栈位置。此调用栈表明线程在等待时处于哪儿。
- Ready Thread in DPC:当该列的值为 True 时,说明唤醒新线程的并不是某个进程,而是一个 DPC (DPC,priority tasks for later execution.)(推迟的过程调用,中断服务的延迟执行)。当出现这种情况,_Readying Process_ 和 Readying Thread Id 就没有意义,因为它们只是被 “劫持” 的线程。而 Ready Thread Stack 大多数情况下也没啥意义,这里通常会看到
ntoskrnl.exe!KiProcessExpiredTimerList
,因为出现 “Ready Thread in DPC” 往往意味着线程因计时器到期被唤醒,比如 Sleep(1) 结束或 WaitForSingleObject 超时。 - Ready Thread Stack:如果有的话,这是唤醒(readied)新线程的那个线程的调用栈。
- Old Thread Stack:并没有记录旧线程(被切换下去的线程)的调用栈的单独列。要想知道旧线程切出的调用栈,需要查找下次它切入( switch in)时的 New Thread Stack。
- Ready Time (s):线程被唤醒(变为 Ready)时的时间戳。
- Count:当前行汇总了多少次上下文切换。如果将第一列设置为 NewProcess 并折叠所有数据,那么这就代表在选定时间段内每个进程发生的上下文切换次数。若一直展开到最底层,就会看到每条上下文切换各自一行,此时 Count=1。
- Sum/Max: Time Since Last (μs):线程上次运行后直到这次重新运行之间的间隔,即线程处于 Waiting 和 Ready 状态的时间。
- Sum/Max: Waits (μs):线程处于 Waiting 状态的时间。线程进入 Ready 状态后,这段时间就停止累加。
- Sum/Max: Ready (μs):线程处于 Ready 状态的时间。这通常只有几十微秒,但在 CPU 资源紧张的系统上可能很长。Sum:Waits 和 Sum:Ready 之和应等于 Sum:Time Since Last。用 Excel 测试了一下,的确成立。
- Count: Waits:记录有多少次上下文切换的等待时间不为零,也就是有多少次上下文切换是在新线程真正等待过之后才切入。如果线程只是被抢占(从 Running 回到 Ready),那么它会再次从 Ready 回到 Running,而不经历等待。如果下钻到某条具体的上下文切换,会发现 Count:Waits 要么是 0 要么是 1。若值是 0,则 Sum/Max:Waits 也为 0,ReadyingProcess、ReadyingThreadId 和 ReadyThreadStack 也会为空。
- New/Old InSwitchTime (μs):代表新线程/旧线程将/已运行的持续时间,也就是本次上下文切换和下一次/上一次上下文切换之间的时间段(WPA 10 中已经没有这些列,最接近的替代品是 CPU Usage (ms))。
- Switch-In Time (s):这次上下文切换发生的时间戳。
- Annotation:在 WPA 的大多数表格里都能看到的列,可用于在分析时添加任意标注。详见下文。
下图是一个简单的 Visio 示意图,展示了上下文切换中某些时间点和持续时间,用括号表示时间间隔(us),用箭头指向某个时刻(s)。紫色背景文字描述的就是该次上下文切换所对应的数据。

若你更喜欢用公式来理解:
Switch-In Time (s) + CPU Usage (ms) * 1e-3 = Next Switch-Out Time (s)
Ready Time (s) + Ready (μs) * 1e-6 = Switch-In Time (s)
Waiting (μs) + Ready (μs) = Time Since Last (μs)
Annotation
Annotation(注释)列在分析时很有用,可以给数据添加一些标签。若需要使用此功能,建议把此列放在最左侧。完成对一组数据的理解后,可以选中它们,右键选择“Annotate Selection…”,并输入相应的描述。接下来,你可以把注意力放在 <Not Annotated>
(未注释)节点里还未理解的数据上,不会被已标记数据分心。如下图所示,Annotation 列被用来标记上下文切换为 “Self-readied” 或 “Readied by other process”,以便更好地可视化火焰图数据:

不太重要的列
若微软给出详细文档,也许以下一些列会更有用,不过多数只对内核开发者有意义(有些列名在 WPA 10 中稍有重命名,如增加了空格):
- Last Switch-Out Time (s):旧线程上一次停止运行的时间,但有时对空闲线程不准确(会始终等于 SwitchInTime (s))。
- Next Switch-Out Time (s):下一次上下文切换发生的时间,也可以理解为这次新线程将会被切换出去的时间。
- New Prev Out Pri:我理解为,新线程上一次运行结束时的优先级。
- New In Pri:这次切换将新线程切入 Running 时的优先级。Windows 调度器通常会对刚被唤醒的线程进行优先级提升,所以会比之前高一两级。
- New Out Pri:我理解为当前运行这段时间结束时(下一次上下文切换)线程所具有的优先级,通常会恢复到与 New Prev Out Pri 相同的水平。
- Old Out Pri:旧线程被切出时的优先级。空闲线程优先级是 0。
- Cpu:发生上下文切换的 CPU。线程可以在多个 CPU 之间切换(尽管 Windows 会尽量避免),但某个特定的上下文切换一定发生在某个具体 CPU 上。
- New/Readying/Old ThreadStartModule:线程最初启动时所在的模块。
- New/Readying/Old ThreadStartFunction:线程最初启动时所执行的函数。
- Ideal Cpu:每个线程都有一个理想 CPU,Windows 会尽力在其理想 CPU 上运行该线程。本列显示的应该就是 New Thread 的理想 CPU。按 Cpu 再按 Ideal Cpu 分组能看到某些 CPU 上有更多的上下文切换,并且大多数上下文切换都是在理想 CPU 上进行。但我没发现它会带来什么具体的性能影响或问题。
- New/Old Qnt:始终是 -1,即使在 Windows 10 上也是如此。这似乎是个老问题,一直没修。
- New Prev Wait Mode, New/Old Wait Mode:可用的等待模式包括 Unknown, NonSwap 和 Swapable。对非内核开发者而言,这不太有用。
- New Pri Decr:此数值在 -112 至 +112 之间浮动,最常见的是 0 或 2。我不知道它的具体含义。
- New Prev State, New/Old State:指线程可能处于 Waiting、Ready、Running 等状态,但具体数据显示较为混乱,不太好分析。
- New Prev Wait Reason, New/Old Wait Reason:理论上表示线程为什么在等待或不运行,但原因种类繁多,加上调用栈各式各样,很难提取出直观结论。
- Prev CState:CState 表示 CPU 的活跃程度,0 表示完全活跃,数字越大表示越深的节能状态。我在跟踪中看到过的最大值是 2。
- AdjustIncrement:未知。
- AdjustReason:未知。
- DirectSwitchAttempt:未知。
- KernelStackNotResident:未知。
- NewThreadPrevRemainingQuantum:未知。
- OldThreadRank:未知。
- OldThreadRemainingQuantum:大概记录旧线程量程中还剩余多少时间。
- ProcessOutOfMemory:未知,看起来在上下文切换数据里有点格格不入。
通过探索来学习
我并没有什么秘密渠道去了解这些列的含义,也没找到什么系统性的官方文档。网上搜索也基本无果。大部分结论是我自己通过调整汇总表来观察模式得出来的。比如,为了理解(WPA 10 里已经删除的)OldInSwitchTime (μs) 和 NewInSwitchTime (μs),我会把列顺序设置成:
- Cpu
- Orange bar(列在橙色栏左侧用于分组)
- New Process
- NewThreadId
- SwitchInTime (s)
- OldInSwitchTime (μs)
- NewInSwitchTime (μs)
然后按照 Switch In Time (s) 排序,规律就显现了:

InSwitchTime (μs) 表示线程实际运行的时间,从线程切入运行那一刻开始,一直到下次有新线程切入运行这段间隔。你可以验证:在某行的 SwitchInTime (s) 加上 NewInSwitchTime (μs)(换算成秒),就得到下一行的 SwitchInTime (s)。在 Excel 中测试几千行数据也能确认这一点。
OldInSwitchTime (μs) 指这次切出线程已经运行了多长时间,NewInSwitchTime (μs) 指这次切入线程将要运行多长时间。NewInSwitchTime (μs) 在上下文切换发生时并不知道(当时无法预见未来),但 xperf 会在分析时帮你算出来。你会发现,在同一 CPU、按时间排序的上下文切换中,某条记录的 OldInSwitchTime (μs) 就是上一条记录的 NewInSwitchTime (μs)。
(题外话:我也不知道上图里 Outlook 在干啥,看起来像是注意力缺失多动症,总是频繁上下文切换,时间都不到 0.02 ms,一共切换了七次(图中没显示全)。这绝不是什么好现象。)
分组的多种方式
查看某个 CPU 上的所有上下文切换也是挺有意思(或者会让你觉得沮丧,如果你看到 CPU 每秒要进行数千次切换)。可以把列调整为:
- Cpu
- Switch-In Time (s)
- Orange bar
- New Process
- New Thread Id
- Sum: Time Since Last (μs)
- Sum: Ready (μs)
- Sum: Waits (μs)
- 你想要的其他列……
这样就能看到每个 CPU 的上下文切换,或者跟它相关的各种统计。
计时器到期
在追踪那些唤醒线程的链式调用(线程 A 等线程 B,后者又等线程 C……)时,明确知道唤醒者(readying thread)是谁尤为关键,但有时它并不重要。如果线程没有真正等待(比如只是被抢占),那么就不会有显式的 “唤醒者”。如果它在等一个计时器(比如调用了 Sleep 或 WaitForMultipleObjects 超时),那么唤醒它的就是执行计时器到期处理的 DPC,这时候唤醒者显示的可能是某个被劫持的线程,调用栈里通常会出现 KiProcessExpiredTimerList
。这是因为在 DPC 级别,系统会用某个线程的上下文来运行中断服务,WPA 并不会在高层次上把它单独区分出来。
大胆去探索汇总表吧
如果你理解汇总表的工作方式,也理解它展示给你的数据,那么你就能在 CPU Scheduling(CPU 调度)这里进行非常丰富的探索。如果你找到什么有趣的技巧,请告诉我。如果你发现了某些错误,或者对列的意义有更新或更好的解释,也请分享。
P.S.
直到写这篇文章、发文前半小时,我才搞明白 “Time (s)” 和 “Time (μs)” 的区别(前者表示时间点,后者表示持续时间)。这个发现大大提升了使用 CPU Scheduling 表的效率,也算是我写这篇文章的最大收获吧。
P.P.S.
微软的文档已经进步了很多。请参阅 这篇官方文档 获取另一种视角。
关于 brucedawson
我是 Google 的一名程序员,主要工作是做优化和可靠性开发。让代码跑得快 10 倍比什么都开心,除非能同时消灭大量 bug。我也玩单轮车、打冰球(ice hockey)和轮椅冰球(sled hockey),还会玩杂耍(juggle)。有时我会纠结这个博客究竟该不该叫 randomutf-8。了解更多可看我在 Twitter 上的2010 年代回顾。
以上翻译仅供参考,原文信息请以英文原版为准。
原文链接:https://randomascii.wordpress.com/2012/05/11/the-lost-xperf-documentationcpu-scheduling/
2