CPU 采集的 8 个坑——为什么你的数据不能直接信

3 阅读15分钟

24 小时压测结束,导出 CPU 数据一看:

  • 某进程 CPU% 是 -127%
  • 两个进程 CPU% 加起来比整机还高
  • 设备卡到 ANR,但整机 CPU% 只有 20%

采集工具跑了半年才发现,报出来的数据有一半不能信。以下是排查出的 8 个问题,按杀伤力排序。


坑 1:关核场景,两个 Bug 互相遮掩

现象:同一时间、同一台设备,A 工具报 CPU 23%,B 工具报 45%。

A 工具直接读 /proc/stat 做差值。B 工具用了一套"更精确"的自造公式:

分母 = 时间差(秒) × 总核数 × HZ

意图是算出"理论上产生了多少 tick"。听起来合理,但三个参数全在撒谎

Bug 1:HZ 写死了 100

CONFIG_HZ 是内核每秒的 tick 中断次数,决定 /proc/stat 里时间片的粒度。写死 100 的后果:

硬编码 HZ=100:分母 = 10s × 4核 × 100 = 4000
设备实际 HZ=250:分母应该 = 10s × 4核 × 250 = 10000

分母少 2.5 倍 → CPU% 偏高 2.5 倍。20% 的真实值被报成 50%。

MTK 平台常用 HZ=250,部分高通平台 HZ=300。可通过 adb shell getconf CLK_TCK 查看。

Bug 2:核数用的是"总核数"

4 核设备关了 2 核省电,公式分母还是 × 4。但 tick 只有 2 核在产生 → 分母偏大一倍 → CPU% 偏低 50%

叠加效果:真实值 20% × 2.5(HZ 错)× 0.5(核数错)= 25%,和真实值只差 5 个百分点。

两个方向相反的 bug 互相抵消,制造了"差不多准"的假象——这就是它能存活半年没被发现的原因。

Bug 3:时间用的是宿主机的 time.time()

ADB 命令从发出到设备执行有 50~200ms 不等的延迟。用宿主机时间做除法,引入第三层随机误差。

根因

/proc/stat 汇总行本身就是所有在线核 tick 之和。核心关闭后不产生 tick,Δtotal 自然变小;上线后自动变大。内核已经帮你做好了在线核的汇总,根本不需要你手动重建分母。

正确做法

整机 CPU% = (Δtotal − Δidle − Δiowait) / Δtotal × 100
进程 CPU% = Δprocess / Δtotal × 100

一套公式,满核关核通用。三个"不需要":

  • 无需 HZ——分子分母同单位,自动约掉
  • 无需核数——汇总行只含在线核
  • 无需宿主时间——纯设备内核数据
严重度杀伤力
致命CPU% 偏差 2~3 倍,但因 bug 互相抵消可能永远发现不了

坑 2:iowait 到底算不算 CPU "使用"

现象:大文件拷贝时,CPU 显示 20%。以为 CPU 很空闲,又加了一个计算任务,设备立刻卡顿。

/proc/stat 增量:

user=50  system=30  idle=800  iowait=120  其余=0
Δtotal = 1000

不排除 iowait:(1000 − 800) / 1000 = 20% 排除 iowait:(1000 − 800 − 120) / 1000 = 8%

12 个百分点。那 20% 里有 12% 是 CPU 在等磁盘,没做任何计算。你以为"CPU 忙碌度 20%、还剩 80%",但真正在做计算的只有 8%。表面 20% 的"忙碌"里有一大半是在干等 IO。

两种口径

口径公式适用场景
计算密集度(推荐)(total − idle − iowait) / total关注 CPU 算了多少东西
整体繁忙度(total − idle) / total关注 CPU 是否还有空

关键不在选哪种,在于团队必须统一口径。 同事 A 用"排除 iowait",同事 B 用"不排除",对着数据讨论永远对不上。

一个自查方法:跑一个纯磁盘读写(dd if=/dev/zero of=/sdcard/test bs=1M count=512),如果 CPU 显示 10%+,大概率是 iowait 被算进了"使用"。

严重度杀伤力
12% 绝对偏差,IO 密集场景更严重

坑 3:整机和进程数据不是同一份快照

现象:监控 3 个进程,CPU% 加起来 85%,但整机 CPU% 只有 70%。"部分 > 整体"。

翻代码,结构是这样的:

for process in process_list:
    dev_stat = adb_shell('cat /proc/stat')       # ← 每个进程各读一次
    pro_stat = adb_shell(f'cat /proc/{pid}/stat')

三个进程读了 3 次 /proc/stat,横跨 300~600ms。每次读到的 tick 不同,三个进程用的分母各不相同。更隐蔽的是,取核心数的函数里还额外读了一次(只为了数行数就丢掉)。一轮采集累计 7 次 ADB 调用,其中 4 次是 /proc/stat 的冗余读取。

正确做法

/proc/stat 只读一次,所有信息从同一份数据中提取:

raw = adb_shell('cat /proc/stat')
dev_use, dev_total = parse_cpu(raw)
alive_cores = count_cores(raw)

for process in process_list:
    pro_stat = adb_shell(f'cat /proc/{pid}/stat')
    # 所有进程共用同一份分母

ADB 调用从 2N+1 降到 N+1(N 为进程数)。数据一致性 + 性能,一箭双雕。

严重度杀伤力
多进程 CPU% 加和超过整机值,对比逻辑矛盾

坑 4:进程悄悄重启了,你还在用旧基线

现象:24 小时压测,某进程 CPU 曲线在第 8 小时突然出现一个 -1788% 的尖刺,然后恢复正常。

/proc/{pid}/stat 的 utime/stime 是从进程启动时刻开始累积的。如果进程在两次采集之间 crash 并重启:

第 1 次采样:process_time = 85000(累积了 8 小时)
          进程 crash → 自动拉起 → 新进程
第 2 次采样:process_time = 120(新进程刚跑了几秒)

Δprocess = 12085000 = −84880
CPU% = −84880 / 4746 × 100 = −1788%

更隐蔽的变体:PID 复用

Linux 的 PID 是循环分配的(默认范围 1~32768)。进程 A(PID 12345)退出后,系统可能把 12345 分配给一个完全无关的进程 B。

你的采集工具还在读 /proc/12345/stat,数据格式正常、类型正常、数值也在合理范围内——不报错,但测的已经不是你的目标进程了

在 24h+ 压测中,如果被测进程有偶发 crash + 自动拉起的逻辑,PID 复用几乎一定会出现。

正确做法——双重校验

delta_pro = cur_pro - old_pro

# 校验 1:负值 = 进程已重启
if delta_pro < 0:
    old_pro = cur_pro      # 重置基线
    return None             # 本轮不出数据,下轮重新开始

# 校验 2:进程身份核验(防 PID 复用)
cur_name = parse_comm(pro_stat)     # /proc/{pid}/stat 里的进程名
if cur_name != expected_name:
    new_pid = find_pid_by_name(expected_name)
    if new_pid:
        pid = new_pid
        old_pro = 0         # 新 PID,重置基线
    return None
严重度杀伤力
致命CPU% 出现负值尖刺,或静默监控错误进程而不自知

坑 5:Δtotal = 0,除零直接崩

现象:压测脚本跑到凌晨 3 点挂了,日志:ZeroDivisionError: division by zero

正常运行中 Δtotal 几乎不可能为 0,但两种真实场景会触发:

场景 1:跨越深度休眠

设备进入 suspend 后 CPU 完全停止,/proc/stat 不计时。唤醒后第一次采样和休眠前最后一次采样的 total 值可能完全相同。在自动化测试中,设备可能因为无操作超时自动休眠,被下一轮 ADB 命令唤醒。

场景 2:极短间隔 + 低负载

1 秒采集间隔,HZ=100,4 核关了 3 核,设备几乎全在 idle。理论 tick 增量 100/s,但如果两次采样恰好落在同一个 jiffies 更新周期 → Δtotal = 0。

正确做法

if delta_total <= 0:
    return None    # 丢弃本轮,下轮重算

dev_cpu = delta_use / delta_total * 100
app_cpu = delta_pro / delta_total * 100

# 上限校验(注意两个上限不同)
if dev_cpu < 0 or dev_cpu > 100:
    return None
if app_cpu < 0 or app_cpu > alive_cores * 100:
    return None

整机上限是 100%Δuse ≤ Δtotal)。进程上限是在线核数 × 100%(多线程进程可以同时跑满多个核,4 核上限 400%)。

严重度杀伤力
采集脚本崩溃,24h 压测数据中断

坑 6:采集工具是设备上最大的 CPU 消耗者

现象:测待机功耗,CPU 始终显示 3~5%。拔掉 USB、用纯电池计量,功耗低了 40%。

每次 adb shell cat /proc/stat,设备端发生:

  1. adbd 收到请求 → fork sh → fork cat
  2. cat 读 /proc/stat → 数据经 USB 回传
  3. sh 和 cat 进程退出,内核回收

2 次 fork + 2 次进程退出 ≈ 2~5ms CPU 时间。10 秒一轮、5 次 ADB 调用 → 采集自身占 10~25ms/10s,正常场景可忽略。

但两种场景下变成灾难:

场景 1:待机功耗测试

设备应该完全空闲。但 ADB 采集会唤醒 USB 控制器、唤醒 CPU 核心、阻止进入深度休眠。采集工具本身成了设备上唯一活跃的 CPU 使用者——你测到的是"设备被反复唤醒的功耗",不是"设备待机的功耗"。

场景 2:高频采集(1~2s 间隔)

每秒 35 次 ADB → fork 610 个进程,CPU 时间计入 system 字段 → 整机 CPU% 偏高 13 个百分点。低负载场景(整机 10%)下,**1030% 的相对误差**。

缓解方案

方案效果适用场景
合并 ADB(一次 shell 脚本读所有数据)调用次数减半通用
设备端 Agent(push 脚本持续运行,stdout 流式输出)消除 fork 开销高精度 / 低干扰需求
选对数据源单次开销从 100ms 降到 3ms通用
三种方式的自身开销对比:
cat /proc/stat           ≈ 2~3ms     最轻量
top -n 1                 ≈ 50~200ms  需遍历所有进程
dumpsys cpuinfo          ≈ 100~500ms 经 Binder + SystemServer
严重度杀伤力
(功耗场景)测到的是采集工具自己的性能

坑 7:/proc 文件解析的两个暗雷

两个问题单独看都不大,但合在一起说明一件事:/proc 文件是给人看的文本,不是结构化 API,格式比你想的脆弱得多

暗雷 A:空格数量不一致

cpu  10132153 290696 3084719 ...     ← "cpu"2 个空格
cpu0 2503274  72633  771347  ...     ← "cpu0"1 个空格

split(' ') 按单空格拆分,汇总行多出一个空字符串,索引全部偏移。fields[5] 在汇总行上恰好是 idle,在 cpu0 行上变成了 iowait——同一套代码在不同行上指向不同字段

最恶心的是:如果你只解析汇总行,这个 bug 永远不会暴露("恰好对了")。直到有一天你需要单核数据,才会莫名其妙地发现数值不对。

暗雷 B:进程名含空格

/proc/{pid}/stat 的格式:

12345 (com.example.app) S 1 12345 12345 0 ... utime stime ...

进程名被 () 包裹。按空格拆分后取 fields[13] 作为 utime。但如果进程名包含空格(某些系统进程如 (kworker/0:1 H)),所有字段索引错位。

不报错,只返回一个"看起来合理"的错误数字——因为错位后取到的可能是某个 ppid 或 pgrp 字段,也是整数。

统一修复

# /proc/stat:无参 split 合并所有连续空白
fields = line.split()
nums = [int(x) for x in fields[1:]]
idle = nums[3]    # 永远是第 4 个数值字段

# /proc/{pid}/stat:用 ')' 做锚点
tail = raw.split(')')[1].strip().split()
utime  = int(tail[11])    # ')' 之后固定偏移
stime  = int(tail[12])

Linux 内核保证进程名被 () 包裹且名称内部不含 )(有则截断),所以 ) 锚点是内核级的安全保证。

严重度杀伤力
静默返回错误值,可能长期隐藏

坑 8:cutime/cstime——子进程的 CPU 该不该算进来

现象:监控一个多进程 App,CPU 曲线每隔几分钟突然飙到 150~200%,然后立刻回落。看起来像性能毛刺,但复现不了。

第 5 篇给出的进程 CPU 公式是 process_time = utime + stime + cutime + cstime。这四个字段的含义:

字段含义增长方式
utime进程自己在用户态的 tick持续累积
stime进程自己在内核态的 tick持续累积
cutime已退出子进程的用户态 tick子进程 exit 时一次性灌入
cstime已退出子进程的内核态 tick子进程 exit 时一次性灌入

关键在"已退出"三个字。只有当子进程退出并被父进程 wait 回收后,子进程的 CPU 时间才会一次性累加到 cutime/cstime。

一个具体场景:

第 1 次采样:utime=5000 stime=1200 cutime=0    cstime=0   → total=6200
        ↓ 子进程干了 3 分钟的活后退出,cutime 一次性增加 2000
第 2 次采样:utime=5100 stime=1220 cutime=2000 cstime=300 → total=8620

Δprocess = 86206200 = 2420

但父进程自己只增长了 (5100-5000) + (1220-1200) = 120 tick,剩下 2300 tick 是子进程退出时灌入的。这一轮的 CPU% 会突然飙到正常值的 20 倍

包含 vs 不包含

包含 cutime/cstime只用 utime + stime
含义整个进程树的 CPU 消耗目标进程本体的 CPU 消耗
曲线特征子进程退出时有突发跳变平稳,无跳变
适合场景分析"这个 App 总共消耗了多少 CPU"实时监控曲线(推荐)

大部分实时监控场景建议只用 utime + stime。如果需要分析进程树总消耗,建议单独上报一条 cpu_with_children 指标,不要和实时 CPU% 混在一起。

top 命令默认只用 utime + stime,不包含子进程时间。如果你的公式包含了 cutime/cstime,就会和 top 的结果对不上——这也是"为什么我算的和 top 不一样"最常见的原因

严重度杀伤力
CPU 曲线间歇性飙高,误判为性能毛刺;和 top 结果不一致引发困惑

修复清单

8 个坑归纳成 7 条改动

#改动修了哪些坑
1统一 Δuse / Δtotal 公式,删满核/关核分支坑 1
2use = total − idle − iowait,团队明确口径坑 2
3/proc/stat 只读一次,放循环外坑 3
4负值重置基线 + 进程名校验防 PID 复用坑 4
5Δtotal ≤ 0 丢弃 + 结果上下限校验坑 5
6合并 ADB / 设备端 Agent / 选轻量数据源坑 6
7无参 split() + ) 锚点解析坑 7

坑 8 的修复取决于业务需求:实时监控用 utime + stime,进程树分析用全量。

重构前后对比

维度重构前重构后
ADB 调用(3 进程)7 次4 次(↓43%)
/proc/stat 读取4 次(数据不一致)1 次
关核处理2 个分支 + 3 个假设无分支,统一公式
进程身份校验负值检测 + 进程名校验
边界保护分母 / 符号 / 上限三道防线
子进程 CPU混在一起不可分两个指标分开上报

小结

严重度症状修复
1. 关核自造公式致命CPU% 偏差 2~3 倍但不易察觉统一 Δuse/Δtotal,删分支
2. iowait 口径IO 场景 CPU% 偏高 12%+明确 use = total−idle−iowait
3. 数据快照不一致进程 CPU% 加和 > 整机/proc/stat 只读一次
4. 进程重启 / PID 复用致命CPU% 负值或监控错进程负值重置 + 进程名校验
5. Δtotal = 0除零崩溃,数据中断分母/符号/上限三道防线
6. 观察者效应待机场景 CPU% 虚高合并 ADB / 设备端 Agent
7. /proc 解析静默返回错误数值无参 split + ) 锚点
8. cutime/cstime曲线间歇性飙高实时监控只用 utime+stime

这 8 个问题覆盖了 CPU 采集最常见的翻车场景。写采集逻辑时逐一对照,能避掉绝大部分坑。

修完这些坑,Raw CPU% 就够用了吗?

不够。 上面所有修复都是为了把 Raw CPU%(时间片占比)算准。但 Raw CPU% 有一个根本性的盲区——它不关心频率

设备降频到 408MHz 后,50% 的时间片只能完成满频状态四分之一的计算量。Raw CPU% 会告诉你"50% 在忙",但不会告诉你"实际只干了 12% 的活"。

这就是下篇的主题:引入频率维度,从 Raw CPU% 进化到 Normalized CPU%。


下篇预告

CPU 降频了,你的采集数据还准吗?

同样 50% 的 CPU 占用,跑在 408MHz 和 1800MHz 有什么区别?为什么 CPU% 稳定在 45%,用户却反馈"越用越卡"? 下篇揭开频率维度对 CPU 数据的影响,引入 Normalized CPU% 概念。


系列目录

  • 第 1 篇:内存泄漏自动检测(上)——采集层设计
  • 第 2 篇:内存泄漏自动检测(中)——检测层设计
  • 第 3 篇:内存泄漏自动检测(下)——响应层设计
  • 第 4 篇:Android 内存采集避坑指南
  • 第 5 篇:Android CPU 使用率采集入门
  • 第 6 篇(本篇):CPU 采集的 8 个坑
  • 第 7 篇(下一篇):CPU 降频了,你的采集数据还准吗?
  • 第 8 篇:CPU 单核分析:均值是最大的谎言
  • 第 9 篇:CPU 采集落地:从公式到平台的完整方案

我是测试工坊,专注 Android 系统级性能工程。 如果你也踩过 CPU 采集的坑,欢迎评论区交流 👇 关注我,后续更新不迷路。