Android CPU采集8个坑:从/proc到占用率计算,数据可信的避坑指南 做Android性能监控这几年我有个特别深的体会CPU采集的代码看上去就是读几个文件、做两个减法但等数据一上线上各种幺蛾子全冒出来——进程CPU占用率突然蹦到500%、负占用率频繁出现、同一份代码在不同系统版本上结果完全不一样、线程统计和进程统计加在一起直接爆表。最后排查下来你会发现大部分问题根源不在算法逻辑而在于我们对/proc文件系统、对Android系统自身的一些隐含假设。这篇文章会把我在Android CPU采集上踩过的8个坑逐一拆开每个都讲清楚现象、根因和正确解法最后给出一套可以落地的采集方案。适合正在做APM、性能监控、耗电分析、卡顿优化的同学参考。1. 先说清楚CPU采集到底在采什么1.1 三类指标先分开很多人把CPU相关的指标统称为“CPU占用率”这是后续所有混乱的起点。严格来说Android开发者最常接触的有三类指标物理意义完全不同。第一类是进程CPU占用率也就是某个进程消耗的CPU时间占墙钟时间的比例。它来源于/proc/[pid]/stat里的utime和stime两个字段分别代表进程在用户态和内核态消耗的CPU时间。因为多核可以并行累计这类指标完全可能超过100%一个四核设备上某个多线程进程跑到300%并不稀奇。第二类是系统CPU使用率也就是整机CPU忙闲程度来源于/proc/stat第一行的total时间统计。它是一台设备上所有核累加之后算出来的整体占比理论上只能在0%到100%之间。第三类是系统负载来源于/proc/loadavg表示就绪队列中等待运行的任务数滑动平均。很多人把它和CPU使用率混着用但高负载完全可能由IO等待引起这时CPU使用率反而很低。在做采集方案之前先要把这三类指标的命名在代码和文档里定死不然后端同学写报表时一定会搞混这是我在多个项目里反复踩过的坑。1.2 数据来自哪几个文件CPU采集的数据源其实非常固定汇总下来就是这几个路径数据源内容常见用途/proc/stat系统整体和各核的CPU时间统计计算系统CPU使用率/proc/[pid]/stat单个进程的CPU时间、状态、线程数等计算进程CPU占用率/proc/[pid]/task/[tid]/stat进程中单个线程的CPU时间线程级CPU分析/sys/devices/system/cpu/cpuX/cpufreq/scaling_cur_freq各CPU核心当前频率频率/功耗分析/proc/loadavg系统负载滑动平均衡量系统繁忙程度/proc/uptime系统启动时间时间对齐与校准这些文件看起来都是纯文本读出来split一下就能用但每个都有各自的隐藏陷阱。后面8个坑基本都是围绕这几个数据源展开的。1.3 8个坑分布在哪两层我把8个坑按发生位置分成两组读取层和计算层。读取层的问题发生在“数据还没被算出来”的时候包括解析字段错位、数据非原子读取、线程口径重复、系统版本权限限制。这个层面的错误往往是最隐蔽的因为代码不报错只是算出来的数值莫名其妙。计算层的问题发生在“数据已经读出来但处理方式不对”的时候包括大小核频率差异、固定采样周期失真、采集器自身开销污染、指标口径混淆。这个层面的问题需要用业务视角去判断单看代码可能发现不了。这两层相互叠加就是标题里说的“数据不能直接信”的根本原因。2. 读取层的四个坑数据还没算就已经错了2.1 坑一进程名里的空格和括号让split解析全线错位这是最常见的低级错误但几乎每个新手都会踩。/proc/[pid]/stat的格式是单行文本字段之间用空格分隔但第二个字段comm进程名被一对括号包裹而且进程名本身可以包含空格。比如有个进程名是“com.example.my app”它在文件里长这样1234 (com.example.my app) S 1 1234 1234 0 -1 4194560 100 0 0 0 100 0 0 20 0 1 0 12345 1000000 100 1000000 ...如果直接content.split( )后面的字段索引全部错位一格甚至好几格。我见过有人解析出来的utime一会儿是flags一会儿是minfltCPU占用率数值看起来完全随机。正确做法是不要用空格切整个字符串而是先找到进程名的结束位置从右括号后面再开始按空格切fun parseProcStat(content: String): ProcStat? { val openIdx content.indexOf(() val closeIdx content.lastIndexOf()) if (openIdx 0 || closeIdx openIdx) return null // comm content.substring(openIdx 1, closeIdx) // 从右括号后面开始才是正常字段 val afterComm content.substring(closeIdx 1).trim().split( ) if (afterComm.size 15) return null // afterComm[0] 对应原始第3个字段 state // 原始字段第14个是 utime所以 afterComm[11] 是 utime // 原始字段第15个是 stime所以 afterComm[12] 是 stime val utime afterComm[11].toLongOrNull() ?: 0L val stime afterComm[12].toLongOrNull() ?: 0L return ProcStat(utime, stime) }这个坑之所以要单独拿出来讲是因为它不会让你的App崩溃只会静默地污染所有上游数据。等到报表引擎发现趋势不对再回头排查已经浪费了好几天。2.2 坑二非原子的两次读取出现负占用率和超500%的假数据CPU占用率算法本质上是差值除以时间窗口先在t1时刻读一份快照再到t2时刻读一份快照两个快照的差除以时间间隔。问题在于/proc/stat和/proc/[pid]/stat是两个独立文件内核在生成它们的时候并不是原子一致的。具体场景是这样的采样线程先读了/proc/stat还没来得及读/proc/[pid]/stat目标进程正好被调度到一个新核上或者一个子线程刚好被创建、销毁。于是t2时刻读到的进程时间增量可能比同一时刻系统总CPU时间增量还要大算出来的占用率超过100%甚至500%。反过来如果进程刚好在这个间隙被杀掉、PID被复用还会出现负的占用率。解决这个问题的第一个要点是固定读取顺序我建议统一“先读进程stat再读系统stat”。固定顺序不一定能消除偏差但至少让偏差方向一致方便做统计分析。第二个要点是打上墙钟时间戳用自己记录的时间差作为分母。第三个要点是差值之后做钳位处理val processDelta max(0L, current.processTicks - last.processTicks) val systemDelta max(0L, current.systemTicks - last.systemTicks) if (systemDelta 0L) return 0.0 val usage processDelta.toDouble() / systemDelta.toDouble() * cores注意这里cores是设备核心数因为系统总tick是所有核的累计而进程tick也是所有核累计如果不乘核数算出来的是“进程占系统总CPU份额”而不是我们通常理解的“进程CPU占用率”。乘完核数之后单核满载进程在4核机上显示为100%4个线程全满载显示为400%这才是合理口径。2.3 坑三进程和线程重复叠加同一份CPU时间被算好几遍/proc/[pid]/stat的utime和stime统计的是该进程所有线程的CPU时间总和。也就是说进程维度已经包含线程维度了。如果你在做线程级监控又同时把所有线程的/proc/[pid]/task/[tid]/stat加起来再把结果跟进程值相加同一个线程的CPU时间就被算了两次。我在一次线上排查中看到某个进程上报的CPU占用率高达800%后来发现就是一个采集模块把“进程总值”和“所有线程求和值”直接相加再上报。这个低级错误隐蔽在主线程的统计值本身都是对的线程统计值也都是对的就是口径定义错了。正确的数据流应该是进程维度使用/proc/[pid]/stat线程维度使用/proc/[pid]/task/[tid]/stat两者作为两个独立的指标分开上报。如果报表需要展示“主线程CPU占比”不要用“进程CPU占用率 - 主线程CPU占用率”之类的减法因为进程还包含其他线程减法会得出荒谬的结果。另外遍历/proc/[pid]/task/目录本身是一件代价不小的事。线程多的时候一次遍历可能要创建几十个文件描述符还会和线程创建销毁产生锁竞争。所以我建议线程级采集只在调试场景开启线上默认只采集进程级数据。2.4 坑四Android版本升级后老代码读不到其他进程了早期Android版本上普通App进程可以遍历读取系统中很多其他进程的/proc/[pid]/stat用来做全局CPU监控很方便。但高版本Android收紧了这个权限尤其是Android 10API 29以后非系统App能看到的/proc条目被过滤只能访问自己进程、自己子进程以及少量系统进程的数据。这就导致一个很现实的问题同一套采集代码在Android 9上跑得好好的到了Android 12上读其他App的/proc/[pid]/stat直接Permission denied或者拿到空文件。而且targetSdkVersion不同行为还会不一样。我在适配Android 12时用同一台设备、同一份代码targetSdk 29能读到targetSdk 31就报错。面对这个限制方案要分层设计采集自己进程内部的数据不受影响正常工作采集子进程的数据要看进程是否属于自己App的uid采集第三方App或者系统进程的数据在非root、非系统App场景下基本不可行。如果你想做全局CPU监控不得不换思路。比如通过top命令的输出来间接获取或者使用系统级的dumpsys cpuinfo但这些方式采样频率低、格式不稳定只适合低频诊断不适合线上高频采集。如果在企业自有设备上做管控可以走系统签名或者root方案但这属于特殊场景不在常规App开发的讨论范围内。3. 计算层的四个坑数据读对了算出来还是不能信3.1 坑五大小核频率差异巨大用平均频率算负载必失真现在的Android设备几乎都是大小核异构架构大核和小核的标称频率差很多。有人希望从CPU频率推算处理器的实际负载于是去读每个核的scaling_cur_freq然后算平均频率用时间乘以频率来估算“工作容量”。这个做法在硬件层面就不成立。/proc/stat只报告每个核的dispatch时间不携带当时的频率信息。两个核都是50%忙碌小核跑在1.2GHz和大核跑在2.8GHz实际指令吞吐量差了一倍还多。如果你只看到平均频率会得出“处理器能力不足”的错误结论。更麻烦的是CPU频率是动态变化的采样时刻读到的频率只能代表那一瞬间。就算你把频率和时间做积分也会因为采样点太稀疏而严重失真。而且Android系统为了省电空闲时会把频率降到最低档此时读到的“低频”反而会误导负载判断设备确实是空闲的但数据显示“所有核都在最低频率运行”不懂的人会以为系统卡了。我的建议是CPU占用率一律用时间统计不要和频率混在一起。如果你真的需要估算功耗或性能容量那就单独上报各核频率让后端去做重采样和积分分析不要在前端用平均频率这种粗暴方式算。3.2 坑六固定周期采样瞬时尖峰永远错过锯齿倒是很稳定固定周期采样是最自然的方案但它的缺陷在性能分析场景下会致命。假设你每2秒采一次CPU快照某个1秒内的CPU尖峰事件正好落在两次采样之间这次尖峰就永远不会出现在数据里。反过来如果某次采样前系统经历了一次调度抖动下一次采样点延迟了200ms算出来的占用率就会产生一个假的凸起。我在做卡顿分析时被这个问题坑过。主线程卡顿通常是几十到几百毫秒级别的事件固定周期采样的时间分辨率根本抓不到看到的曲线反而像锯齿一样抖动。后来我调整了方案不再依赖固定周期而是采用“事件驱动 定时兜底”的混合采样。具体来说当卡顿检测模块检测到一个可疑卡顿时立刻触发一次额外的CPU采样平时再按低频兜底采样保证基线数据完整。如果你不想引入事件驱动机制那就用滑动窗口累计的方式。不要只保存两个快照点而是保存最近N个窗口的原始样本计算时取窗口内所有样本的平均值。这样做虽然会损失一点实时性但能显著降低单次采样抖动带来的噪声。3.3 坑七采集器自己的开销搅进数据测谁都会测出假象这是一个观测者效应问题测量行为本身改变了被测对象。读取/proc文件并不是零成本每次读取都是一次系统调用文件内容要拷贝到用户态解析要分配字符串对象。如果采集器跑在目标进程内部这些开销会计入目标进程的CPU时间。我做过一次实验一个进程本身CPU占用率只有1%给这个进程加一个每100ms采集一次CPU数据的线程采集逻辑里再做点字符串解析总占用率直接变成1.8%——数据翻倍。更严重的是如果采集代码里写了日志打印或者JSON序列化占用量会更高。解决思路有三个方向把采集工作放到独立进程里这样采集开销不会污染业务进程但这在有些架构下实现成本比较高在采集器内部自我计时计算每轮采样耗时然后从需要上报的值中做扣除修正不过精确扣除很难因为采样器和业务线程之间有调度交错降低采样频率比如从500ms改为5s代价是丢失瞬时尖峰。我自己的经验是线上做CPU监控采样频率不要高于1Hz每秒一次并且采集代码要极简只读取和累计原始tick不做复杂的序列化。详细的数据加工放到后端去做。3.4 坑八占用率、使用率、load average三个指标混成一个浆糊这个坑从需求阶段就开始埋雷。产品同事说“我要看CPU占用率”运维同学说“我要看CPU负载”你作为开发如果不追问清楚最后做出来的报表一定有人误读。我之前遇到过后端从/proc/loadavg拿到的值是8、10解读成“CPU占用率800%、1000%”还被写成故障告警推送。实际上load average是就绪队列长度它高不代表CPU忙也可能是一堆线程在等锁、等IO。即便要解读也得结合CPU使用率一起看负载高CPU低大概率是IO阻塞负载高CPU高才是计算密集。再看进程CPU占用率超过100%这件事。如果用墙钟时间做分母一个进程在多核上并行执行数值超过100%是正常且正确的。有些人看到超过100%就认为数据异常于是写了个钳位函数把值强制压到100%以下——结果把这个进程“用了多个核”的关键信息给抹掉了。我的建议是所有指标在命名上就带清楚后缀process_cpu_usage可大于100%system_cpu_util0~100%loadavg_1min可以大于核数4. 一套能落地的避坑采集方案4.1 采集链路和关键参数怎么定结合前面8个坑我整理了一套比较稳的采集方案。核心思路是进程级数据低频常驻采样线程级数据按需触发采样所有数据只上报原始tick不在客户端做复杂归因。参数方面我推荐这样配置场景采样周期窗口长度说明线上常规CPU监控5秒30个窗口开销极低适合长期运行卡顿/冻屏分析1秒60个窗口能捕捉短时尖峰耗电分析30秒60个窗口强调趋势忽略抖动调试阶段200ms120个窗口适合本地跑基准测试这里有一个容易被忽略的细节数据要带时间戳上报不要让后端直接假设“相邻两条数据间隔就是采样周期”。因为Android系统调度、进程优先级、GC等因素都会造成采样间隔抖动固定假设会让所有下游计算全错。4.2 核心代码片段下面是我压缩之后的核心采集逻辑去掉了项目相关的业务代码保留最关键的部分object CpuSampler { data class CpuSnapshot( val timestampMs: Long, val processTicks: Long, val systemBusyTicks: Long, val systemTotalTicks: Long, val cores: Int ) fun snapshot(pid: Int): CpuSnapshot? { // 先读进程stat val processStat readFile(/proc/$pid/stat) ?: return null val process parseProcessTicks(processStat) ?: return null // 再读系统stat val systemStat readFile(/proc/stat) ?: return null val systemTicks parseSystemTicks(systemStat) ?: return null // 读取核心数 val possible readFile(/sys/devices/system/cpu/possible) ?: 0-3 val cores parseCpuPossible(possible) return CpuSnapshot( timestampMs SystemClock.elapsedRealtime(), processTicks process.first process.second, systemBusyTicks systemTicks.first, systemTotalTicks systemTicks.second, cores cores ) } /** * 解析 /proc/[pid]/stat 的 utime stime * 返回Pair(utime, stime)单位是tick */ private fun parseProcessTicks(content: String): PairLong, Long? { val closeIdx content.lastIndexOf()) if (closeIdx 0) return null val after content.substring(closeIdx 1).trim().split( ) if (after.size 15) return null val utime after[11].toLongOrNull() ?: return null val stime after[12].toLongOrNull() ?: return null return utime to stime } /** * 解析 /proc/stat 第一行 * 返回Pair(busyTicks, totalTicks) */ private fun parseSystemTicks(content: String): PairLong, Long? { val line content.lineSequence().firstOrNull { it.startsWith(cpu ) } ?: return null val parts line.substring(4).trim().split( ) if (parts.size 8) return null val numbers parts.mapNotNull { it.toLongOrNull() } if (numbers.size 8) return null val idle numbers[3] val iowait if (numbers.size 4) numbers[4] else 0L val total numbers.sum() val busy total - idle - iowait return busy to total } private fun parseCpuPossible(content: String): Int { // 形如 0-7 或 0-3解析最大编号 val num content.substringAfter(-).trim().toIntOrNull() ?: 4 return num 1 } }这段代码默认了Android平台USER_HZ100也就是每个tick等于10ms。大多数Android设备的用户态时间单位确实是100但为了严谨建议在初始化时读一次系统配置或者直接用/proc/uptime的变化量和/proc/stat的总tick变化量做比例估算校准。4.3 数据校验和兜底策略采集代码写完并不代表结束我强烈建议给CPU采集模块加一套校准流程。第一个校准是基准测试。写一个单线程死循环固定跑10秒钟。理论上单核满载时进程CPU占用率应该在100%左右。如果测出来是80%或者120%说明你的tick单位换算有误或者采集间隔计算有问题。第二个校准是负值和越界检查。采集逻辑里一旦出现负差值或者超过cores * 100的数值直接丢弃这轮样本并打一条日志。你要的不是把这个异常值修平而是要看到异常频率异常频率突然升高往往说明系统出了别的问题。第三个校准是adb对照。在开发机上用adb shell top观察同一进程的CPU数值和你的上报值对比。直接看完全一致不现实但长期趋势应该一致。如果一个是缓慢上升、一个是缓慢下降那一定有一方口径不对。5. 排查速查表与我的经验清单5.1 现象到原因的对照表我把前面8个坑整理成一张对照表排查问题时直接对号入座现象可能原因解决方案解析出的utime忽大忽小进程名含空格导致split错位用lastIndexOf找到右括号再切占用率出现负值读取顺序或PID复用导致快照错位固定顺序、差值钳位、加时间戳线程求和后数据爆炸进程stat和线程stat重复计算进程、线程分开上报高版本读不到其他进程Android对/proc做了权限隔离只采自身进程或走系统方案平均频率算出的负载失真大小核频率差异和时间统计混淆频率单独上报不做时间换算瞬时尖峰捕捉不到固定周期采样粒度不够事件驱动 低频兜底采样低负载进程占用率翻倍采集器自身开销计入进程独立进程采集/降低频率load average被当成CPU占用指标口径混用规范命名分开解读5.2 日常排查的3个手段第一个手段是直接抓原始文件看数据。很多CPU采集的问题通过代码分析半天都定位不到但只要adb shell cat /proc/[pid]/stat看一遍再对比自己代码解析出来的数值问题马上现形。第二个手段是画时间序列图。把CPU占用率不要只看数值而是按时间轴画成曲线。锯齿状、毛刺状、平台状各有含义锯齿通常是采样周期和调度周期共振毛刺通常是瞬时任务或采集器自身抖动平台状通常是业务线程长时间占住了某个核。第三个手段是做“开关对比”。在线上定期开启/关闭采集功能对比业务进程自身的性能指标比如帧率、ANR率。如果开启采集后指标明显变差说明采集器开销太大需要降频或优化解析逻辑。5.3 关于“数据能不能信”的最终判断标准踩完这些坑之后我给自己定了一个判断数据是否可信的三条标准。第一条解析层必须通过单测覆盖包括含空格进程名、极端字段数量、缺失字段这三类输入。第二步把解析结果和adb输出的系统值做对照误差超过5%就算不过关。第二条采集器自身上报一条“采样耗时”指标。如果采样耗时占采样周期的比例超过5%说明采集频率太高或者解析逻辑太重数据本身就已经被污染了。第三条连续运行时CPU占用率曲线的短期波动和长期趋势都必须符合业务直觉。比如一个视频播放页面CPU占用率应该是播放开始时上升、暂停时下降。如果数据和人为判断完全不符宁可先不展示也不要推一个错误数据出去。最后分享两个我的土办法第一个土办法每次接入新设备尤其是新的芯片平台我都会先用脚本手工抓一分钟的/proc/stat数据自己用Python算一遍系统CPU使用率再做个小程序跑同样的计算对比两者。做这一遍看似浪费时间实际能同时校验tick单位换算、字段索引、核数解析这些基础问题。第二个土办法不要在客户端塞太多“智能逻辑”。CPU采集最稳的做法就是老老实实采原始tick所有百分比口径、窗口滑动、异常平滑全部放后端算。客户端一加逻辑就要面临真机调度、内存回收、进程优先级各种变量坑会指数级增加。我个人的体会是CPU采集这件事技术上不难难的是知道自己采到的数据在哪些前提下成立、在哪些情况下会失效。把上面这8个坑一个一个解决掉之后你才敢对着业务方说一句“这个数据可以直接信。”