做Android和Linux性能分析的人,Perfetto应该都不陌生。CPU Scheduling events是每次打开trace文件时信息量最大、也最容易看懵的一块:满屏的彩色条、忽长忽短的色块、各种缩写状态,乍一看像心电图,细看又不知道到底能说明什么问题。但一旦学会从调度事件里读出"谁在哪个CPU上跑、什么时候被换下去、换下去之后是继续排队还是去睡觉、又是被谁唤醒的",很多卡顿和性能问题就能直接定位到线程级,不需要靠猜。
这篇内容就是围绕Perfetto官方文档里CPU Scheduling events部分展开的,我会先讲清楚调度事件本身记录了什么,再给一套从下载、录制配置、UI实操到问题定位的完整流程。适合刚接触Perfetto、想系统看调度数据的同学,也适合已经在用但只停留在"看一眼彩色条"阶段的工程师。
1. CPU Scheduling events:先搞清楚内核到底记录了什么
1.1 调度事件不是采样,是每一次切换的完整账本
提到性能分析,很多人先想到的是perf采样、火焰图。CPU调度事件和采样有本质区别:采样是每隔一段时间抽一次样,两次采样之间发生了什么只能靠猜;调度事件是内核在每个线程切换发生的瞬间主动记录下来的,一次切换记录一条,准确率接近百分之百。
Perfetto里的CPU Scheduling events,核心数据来自内核ftrace的调度tracepoint。这里有三类事件是基础,几乎每次排查都会用到,整理成表格更直观:
| 事件名 | 触发时机 | 回答的问题 |
|---|---|---|
| sched_switch | 线程从CPU上被换下来,另一个线程被换上去 | 谁被换下了?换下去时是什么状态?哪个线程接盘了CPU? |
| sched_wakeup | 一个睡眠中的线程被唤醒,准备进入可运行队列 | 谁醒了?被谁唤醒的?目标CPU是哪个? |
| sched_wakeup_new | 一个新创建的线程第一次被唤醒 | 新线程从哪里来?第一次登录CPU的路径是什么? |
这三类事件合起来,覆盖了线程生命周期里最重要的一段:从睡眠到被唤醒、从排队到拿到CPU、从运行到让出CPU。Perfetto的UI之所以能把调度过程画成一条条带颜色的横条,是因为它把连续的sched_switch事件在时间轴上拼了起来,每一段连续占用CPU的时间就是一个调度片(slice)。理解这点很重要,后面所有的操作都是围绕"slice"展开的。
1.2 sched_switch事件里藏着的细节
sched_switch事件本身携带的信息量很大。一次完整的sched_switch通常包含:prev_comm(上一个线程名)、prev_pid、prev_prio(上一个线程的优先级)、prev_state(上一个线程被切换出去时的状态),以及next_comm、next_pid、next_prio(下一个线程的信息)。
这里最值得关注的是prev_state。这个字段决定了线程被换下去之后去哪:
- 值为0,表示线程仍然处于可运行状态,只是暂时让出了CPU,后续还会回来排队;
- 值为S,表示线程是自愿睡眠,一般是等锁、等IO或者主动sleep;
- 值为D,表示不可中断睡眠,典型场景是等待内核IO完成,磁盘卡住时经常会看到大片的D状态。
Perfetto官方文档里在讲调度事件时,特别强调了要区分"让出CPU后仍在排队"和"让出CPU后去睡觉"这两种情况。前者说明CPU资源紧张,后者说明线程在等待某个东西。后面的排查套路基本都从这里起跳。
另外再说一个容易忽略的点:sched_wakeup事件里通常会带target_cpu字段。这个字段能告诉你线程被唤醒时内核想让它去哪个CPU上跑,但实际最终跑到哪个CPU,要等sched_switch真正发生才知道。从wakeup到switch之间如果隔了很长时间,说明线程醒是被醒了,但排队等CPU等了很久,那问题大概率出在调度延迟上。
2. 准备阶段:Perfetto下载、录制配置与数据打开
2.1 其实大部分时候你只需要一个网页
先说结论:如果只是打开别人发来的trace文件分析,根本不需要下载任何工具。Perfetto官方提供了在线UI,直接用浏览器打开ui.perfetto.dev,把trace文件拖进页面就能开始分析,解析和渲染都在本地完成,trace数据不会上传。
那什么时候需要下载Perfetto工具链呢?两种情况:一是需要自己录制trace,二是需要对trace做命令行级的分析。这时候才需要下载perfetto的预编译包,通常从GitHub的Perfetto官方Release页面拿对应平台的压缩包,解压后目录里有几个常用可执行文件:perfetto负责录制,trace_processor负责解析和SQL查询,tracebox是Android上的录制工具。下载和解压没有特别讲究,解压后直接命令行调用即可。
如果你同时有adb环境,也可以直接用设备自带的perfetto。Android 10之后的系统镜像里普遍内置了perfetto,用adb shell which perfetto检查一下就能确认。
2.2 录制配置:把调度事件完整抓下来的最小方案
自己录制时,配置是关键。Perfetto支持通过一个文本格式的pbtx配置文件指定要采集的数据源。下面是抓取CPU调度事件的最低配置,我已经在一台Pixel和一台Linux服务器上验证过,直接可以用:
buffers { size_kb: 131072 } data_sources { config { name: "linux.ftrace" ftrace_config { ftrace_events: "sched_switch" ftrace_events: "sched_wakeup" ftrace_events: "sched_wakeup_new" ftrace_events: "sched_process_exit" ftrace_events: "power_cpu_frequency" } } } duration_ms: 15000简单解释一下各个配置的作用:
- buffers.size_kb设成了128MB,调度事件在高频场景下产生速度很快,buffer太小会导致事件丢失,录完后发现有大量的空白断层,多半是buffer不够;
- ftrace_events里只列了5类事件,sched_switch和sched_wakeup系列是核心,sched_process_exit负责标记线程退出,power_cpu_frequency是为了同时拿到CPU频率曲线,排查降频问题时必须有它;
- duration_ms设15秒,一般够复现一次卡顿操作了,太长的录制会让buffer持续滚动,把早期的关键数据挤掉。
录制时,Android设备上可以这样操作:
adb push config.pbtx /data/local/tmp/ adb shell perfetto --txt -c /data/local/tmp/config.pbtx -o /data/local/tmp/trace.perfetto-trace adb pull /data/local/tmp/trace.perfetto-trace .Linux服务器上则需要在root权限下执行:
sudo perfetto -c config.pbtx -o trace.perfetto-trace录制完成后,把生成的.perfetto-trace文件拖进ui.perfetto.dev就能看到完整的时间轴。注意Android设备上如果没有root权限,部分事件可能受限,建议优先在可root的设备或者userdebug版系统上录制。
2.3 打开trace后,先认识界面布局
Perfetto UI打开一个trace后,默认会显示两条大类的轨道区域:上面是CPU轨道,记录每个物理CPU核心上的活动;下面是进程轨道,按进程分组,进程里再展开各个线程。每个线程轨道上那些横向的彩色条,就是调度slice。
上方CPU轨道通常还分两块:一块是CPU slice轨道,显示每个核心上被哪个线程占用;一块是频率轨道,显示该核心的实时频率。这两个轨道配合起来,能直接看出"CPU是在满频跑还是在降频摸鱼"。
界面底部有一个统计区域,当你在时间轴上拖拽选中一段范围后,它会显示选中范围内所有slice的统计信息,包括总时长、slice总数、各状态占比。这是后面做量化分析的主要入口。
3. UI实操:把调度事件读成一段因果故事
3.1 在轨道里快速定位目标线程
一个复杂trace打开后,线程轨道动辄上百条,直接找目标线程不现实。Perfetto UI右上角有一个搜索框,输入线程名或进程名的关键词,就能快速过滤和定位。更好用的方式是右键某个进程名,在弹出来的菜单里选择"Pin",把这个进程固定到视图顶部固定区域,这样上下滚动其他轨道时它始终可见,对照分析时非常省事。
我自己的习惯是:先pin住目标进程的main线程,再pin住CPU0轨道,然后从时间轴上定位到疑似卡顿区间。这样需要关注的轨道最多也就两三条,比全量打开清晰得多。
3.2 点击一个slice,右侧面板会给出所有关键字段
在某个线程轨道上点击任意一条slice,右侧详情面板会显示该slice的完整信息。这里要重点看的字段有:
| 字段 | 含义 | 使用场景 |
|---|---|---|
| ts | slice起始时间戳 | 确认这条slice在时间轴上的精确位置 |
| dur | slice持续时间 | 判断这次CPU占用是长是短 |
| cpu | 运行在哪个CPU核心 | 结合CPU轨道判断绑核/迁移情况 |
| end_state | 线程切出时的最终状态 | 区分让出CPU后是继续排队还是去睡觉 |
| priority | 线程优先级 | 排查高优先级线程抢占、RT线程等问题 |
| wakeup ts | 本次被唤醒的时间点(如果存在) | 计算唤醒到真正上CPU之间的延迟 |
实际分析时,ts和dur是最常被盯着的。比如你要判断一次触摸卡顿发生在哪个阶段,就把时间轴缩放到卡顿点附近,找到主线程对应的slice,看它的dur是否异常长,或者ts和期望的时刻是否对不上。
3.3 Runnable和Running:最容易看错的两个状态
刚接触Perfetto的人最容易犯的错,是把Runnable误当成Running。虽然两个状态下线程都在"准备干活",但本质区别很大:
- Running:线程真正占用CPU,正在执行指令,在UI里显示为比较实的深绿色条;
- Runnable:线程处于可运行状态,但还没获得CPU,正在排队等待,在UI里通常显示为浅绿色窄条,经常紧贴在Running slice的左侧。
想象一下排队打饭:Running是那个正在窗口打饭的人,Runnable是排在他后面的下一位。下一位已经准备好刷卡了,但窗口还没空出来,他只能等着。系统里如果很多线程长时间处于Runnable,说明CPU资源供不应求,或者有高优先级线程在频繁抢占。
在UI里,这两种状态可以这样区分:选中一段时间范围,看底部统计区域里Runnable和Running的占比。如果Runnable占比显著超过Running,优先怀疑CPU资源争抢。另外,仔细看主线程轨道时,经常能看到由Running切换到另一个线程时,中间夹着一块很短的浅绿色,那就是主线程在排队。排队越频繁、越宽,调度延迟越明显。
3.4 用wakeup链追踪"到底是谁在叫我"
一条完整的调度链通常是这样的:线程在Sleeping状态下等待某个事件,某时刻另一个线程触发了唤醒操作,内核发出sched_wakeup事件,目标线程进入可运行队列,最终在某个CPU上切换执行。这里最关键的连接点是wakeup ts和slice的起始ts之间的间隔。
实操方法是:点击目标线程的某条Running slice,在右侧详情面板找到wakeup ts字段。如果wakeup ts和slice起始ts相差很小,说明线程被唤醒后几乎立刻拿到了CPU,调度延迟很低,问题不在调度;如果wakeup ts远早于slice起始ts,比如差了十几毫秒甚至还多,那说明线程虽然醒了,但一直在队列里排队,这时要去看当时的CPU负载情况,看看是哪个线程占着核心不走。
反过来的情况也值得注意:如果一条slice结束后,线程进入Sleeping,并且之后很长一段时间都没有新的wakeup信息,说明唤醒动作本身就没有及时发生。这时候要去查是谁应该负责唤醒却迟迟没做,通常是等锁、等某个回调,或者定时器没到点。沿着这个思路不断往上追,最终一定能追到一个具体线程的具体动作。
4. 从调度事件到性能结论:几类高发问题的识别套路
4.1 主线程长时间Runnable:CPU资源被抢占
先说一个非常典型的卡顿形态。你打开trace,找到出问题的进程主线程,在卡顿时间段内,主线程几乎没有长条的Running slice,取而代之的是大量紧挨着的浅绿色Runnable块,偶尔有短暂的Running闪过,但很快又被切走。
这种形态说明主线程在"抢CPU"过程中处于下风。原因通常有两个方向:
- 系统总CPU负载过高,所有核心都被塞满,主线程排不上;
- 有更高优先级的线程在持续占用CPU,尤其是实时优先级(RT)线程,它们的抢占能力远高于普通线程。
排查时,先把时间轴缩放到Runnable密集区,找到此时占着CPU的线程是谁。如果是RT线程,看它的priority字段和运行时长,再确认它是否有持续高频运行的必要。很多音频处理框架里的RT线程,在异常情况下会疯狂占用CPU,把主线程挤到排队。这个问题光看业务代码不好发现,但调度图上一目了然。
4.2 大面积Uninterruptible Sleep:IO才是元凶
另一种常见的异常形态,是线程轨道上出现成片的红色长条。在Perfetto里,Uninterruptible Sleep(不可中断睡眠)通常用红色标识,可以直接理解为"线程在等一个必须等完的内核IO操作"。
这种状态最常见的来源是磁盘和网络IO。进程发起了read或write请求,内核把请求提交给块设备后,线程进入D状态等待IO完成。如果设备响应慢,线程就一直挂着,表现为红色长条。
定位思路也比较直接:找到大段红色块所在时间段,向上看该线程的调用栈,或者看同时段其他线程的IO相关事件,再结合trace里是否采集了block事件的ftrace数据来判断具体是哪个文件、哪个设备。这里有个实操提醒:基础配置里没有含block类事件,如果怀疑IO问题,记得回来把block_block_rq_issue和block_block_rq_complete加进ftrace_events重新录制,不然只有D状态没有IO事件,只能猜是IO问题但看不到完整证据链。
4.3 唤醒延迟异常:定时器与锁的另一面
有些卡顿既不是CPU满载,也不是IO阻塞,而是线程被唤醒得太晚。这种问题从主线程窗口看,卡顿段内没有明显的Runnable堆积,反而是一段较长的Sleeping,直到临界点才突然出现一条Running slice。
这时候要去对比wakeup ts和slice起始ts。如果wakeup本身发生得很晚,说明唤醒源有问题。最常见的唤醒源有三类:定时器、锁、跨线程消息。Perfetto trace里如果同时采集了sched_process_hang或者其他同步事件,可以直接看是哪个线程持有锁时间过长;如果没有这些事件,就回到主线程的wakeup pid字段,挨个检查唤醒者线程当时在干什么。
我踩过不少次这个坑,一开始总以为是主线程慢,后来发现是某个后台线程持有锁太长时间,主线程一直在锁上睡眠。只看主线程轨道永远只能看到一行Sleeping,必须沿着wakeup链找到那个"磨磨蹭蹭"的持锁线程,问题才能破。
4.4 用选区统计把"感觉"变成数字
有时候视觉上觉得某段时间不对劲,但说不清到底异常在哪。这时可以拖拽选中一个可疑时间范围,看底部统计面板:选中区域内slice总数、总时长、每个状态的时间占比都会列出来。
我的做法是:先选一个正常时间段做基准,再选一个卡顿时间段做对比,看两组数据的差异。比如正常段Runnable占比只有2%,卡顿段暴增到30%,那是调度延迟问题;再比如正常段主线程Running slice平均长度是5ms,卡顿段变成了0.5ms,那是频繁切换问题。有了数字做支撑,和开发同学对齐时能少很多无谓的争论。
5. 进阶:用Trace Processor SQL把调度数据变成可查的表格
5.1 SQL面板在哪,能干什么
Perfetto内置的Trace Processor系统化地把trace里的所有事件解析成了SQL表,UI左下角或者右下角有一个Query(SQL)面板,在里面可以直接写SQL查询。这是把调度事件从"看图"升级到"统计分析"的关键入口。
常用的两张表是sched_slice(UI中通常简称为sched)和thread_state。sched表里每一行是一条调度slice,字段有ts、dur、cpu、utid、end_state等;thread_state表则更细,把每个线程每一瞬间的状态变化都记录了下来,state字段直接标注了是Running、Runnable还是Sleeping。
5.2 三个实用查询模板
第一个查询,统计某进程所有线程在各状态上花了多长时间:
SELECT thread.name AS thread_name, thread_state.state, SUM(thread_state.dur) / 1000000 AS total_ms FROM thread_state JOIN thread USING (utid) WHERE thread.upid = ( SELECT upid FROM process WHERE name = 'com.example.app' ) GROUP BY thread.name, thread_state.state ORDER BY total_ms DESC;这个查询能把一个进程的各个线程状态分布拉成一张表,一眼看出哪个线程大部分时间在Running、哪个线程一直在Sleeping、哪个线程Runnable占比异常高。
第二个查询,找出指定时间段内Runnable时间最长的线程:
SELECT thread.name AS thread_name, cpu, COUNT(*) AS runnable_pieces, SUM(dur) / 1000000 AS runnable_ms FROM thread_state JOIN thread USING (utid) WHERE state GLOB 'R*' AND ts BETWEEN 500000000 AND 600000000 GROUP BY thread_name, cpu ORDER BY runnable_ms DESC LIMIT 20;state GLOB 'R*'的意思是匹配R和R+两种Runnable状态。执行完这个查询,当前时间范围内所有"排队等待CPU"的线程就都浮出水面了。
第三个查询,查找某线程连续运行超过10毫秒的slice:
SELECT ts, dur, cpu, end_state FROM sched WHERE utid = ( SELECT utid FROM thread WHERE tid = 12345 ) AND dur > 10000000 ORDER BY dur DESC;这个查询常用于找长耗时slice。如果主线程在卡顿点附近出现了超长slice,说明是单次执行时间过长;如果没有超长slice,但状态统计显示总运行时间很高,那问题就是碎片化运行导致的整体延迟。
5.3 用SQL算调度延迟:wakeup到switch的间隔
前面提到wakeup ts和slice起始ts的时间差,也可以直接用SQL算。把sched_wakeup事件和sched_slice按utid和时间点关联,就能批量计算这些延迟:
SELECT w.utid, thread.name AS thread_name, (MIN(s.ts - w.ts)) / 1000000 AS min_wakeup_to_switch_ms, (AVG(s.ts - w.ts)) / 1000000 AS avg_wakeup_to_switch_ms FROM instant w JOIN thread ON w.utid = thread.utid JOIN sched_slice s ON w.utid = s.utid WHERE w.name GLOB 'sched_wakeup*' AND s.ts > w.ts GROUP BY w.utid, thread.name ORDER BY avg_wakeup_to_switch_ms DESC LIMIT 20;这个查询跑完,哪些线程平均要等很久才能从唤醒状态切换到Running,直接被排序暴露出来。不过注意,不同Perfetto版本的instant表结构可能会有些差异,如果跑不对,优先查看该版本文档里Trace Processor的Schema定义,调整表名和字段名即可。
6. 常见问题与避坑速查
6.1 trace里看不到任何slice,全是空白
优先检查录制配置。ftrace_events漏了sched_switch,或者buffer设得太小导致事件被丢弃,都会造成空白。另外,如果录制时系统正处于深度睡眠状态(比如手机灭屏),大部分核都idle,从调度视角看自然一片安静。先做一个唤醒屏幕再操作的动作,看看slice是否出现。
6.2 时间轴上出现明显的时间断层
相邻两个slice之间的时间戳跳变过大,通常是trace buffer溢出丢事件了。解决办法是加大buffers.size_kb,比如从64MB调到256MB,或者缩短duration_ms。排查高负载场景时,宁可录短一点,也要保证录制完整性。如果一直丢失,还要确认是不是录制工具本身在高负载下CPU抢占严重,考虑先限制录制系统上的其他负载。
6.3 state字段的含义在不同版本里略有差异
Perfetto版本更新迭代很快,thread_state表里的state取值在不同版本之间可能有细微差别,比如R和R+的分界、U和D的区分方式。遇到不确定的取值,直接去Trace Processor文档里查当前版本Schema的枚举值定义,不要凭经验硬套。
6.4 主线程明明在跑,但UI上看不到长条
有时候主线程的slice很多,但都是碎片化的极短条,看起来像虚线。这种情况说明主线程在频繁让出CPU,通常是遇到了锁竞争或者被高优先级线程打断。视觉上很容易误判成"主线程没干活",这时候结合SUM(dur)按时间范围统计一下主线程总运行时间,和期望值对比,差距就是被抢占或者等锁的损失。
再补充两个实用小技巧。第一个,Perfetto UI中选中一个slice后按F快捷键可以直接聚焦放大到该slice,双击也能达到类似效果,比手动缩放精准得多。第二个,右键轨道选择"Filter"可以快速隐藏无关轨道,分析时只保留目标进程和CPU轨道,能大幅降低信息噪音。
我个人在实际操作中最深的体会是:看CPU Scheduling events一定要带着问题去看,不要漫无目的地扫。每次打开trace先问自己三个问题——目标线程在哪段时间不在Running?那段空档里它是处于Runnable还是Sleeping?如果Sleeping,最后是谁、在什么时间把它唤醒的?把这三个问题的答案串起来,基本上所有调度相关的性能问题都能落到一个具体的线程和一个具体的时刻上。Perfetto官方文档其实已经把底层机制讲得很清楚了,剩下的功夫就是多上手看真实trace,看得多了,那些彩色条和状态字段自然而然就有直觉了。