5 秒一格的 load,和尖峰那一刻的线程 右边是一排 5 秒一根的柱子,中间 6 根强调色的高柱是一次 30 秒的负载尖峰;一条平滑的 load1 曲线慢慢爬升又慢慢回落,明显落后于柱子。尖峰上方是一小堆标着 R 和 D 的线程方块,表示那一刻被抓下来的线程。左边写着 load5s、load2p -c 和 R 16 · D 24。 load5s load2p -c R 16 · D 24 RRDDRDDRDRDR

设想一台 4 核的机器,晚上 22:31 业务报了一次响应变慢,持续不到一分钟。几分钟后,22:34 我们登录上去,/proc/loadavg 的三个数是:

2.90 2.87 1.41

load 已经在往下走了,top 里也没有任何异常进程。可刚才那半分钟到底发生了什么?是 CPU 不够用,还是有一批线程卡在了磁盘或锁上?如果是后者,是哪个进程、卡在哪里?

这种“事后回看”的需求,Linux 上有一整个家族的工具在做:sysstat 的 sar1、atop2、Meta 的 below3、Red Hat 主推的 PCP4、collectl5、nmon6、淘宝的 tsar7,以及本文的主角,阿里贡献给龙蜥社区的 SSAR8。它们都在后台按固定间隔把系统状态写进磁盘,出事以后再拿出来翻。区别在于记什么、多久记一次、记下的东西能回答多少问题。

我们没有去比较功能列表,而是在同一台机器上把它们全部装好,按各自的默认配置跑起来,然后人为制造开头那样一次 30 秒的尖峰,看事后每个工具能说出什么。上面那三个数,和下文所有的输出,都来自这次实验。

一、实验:同一个尖峰,八个记录工具

这一家族的工具

先把参赛的工具介绍一下。它们都分成“采集”和“查询”两半:采集端常驻或由定时任务拉起,把数据写进本地文件;查询端事后读这些文件。

参加对比的八个工具(都按默认或最常见的配置运行)
工具出处 · 语言默认采样间隔进程级记录落盘格式
SSAR 1.0.5阿里,2021 年开源到龙蜥社区;C 采集,C++ 查询,Python 扩展脚本整机 60 秒,load 5 秒,进程 60 秒每分钟全部进程快照;load 超阈值时按线程抓现场和内核栈/proc 原文件,进程数据 gzip
sar(sysstat 12.6.1)Linux 上最老牌的一个,C600 秒(发行版定时任务)不记(pidstat 只能实时看)二进制 sa 文件
atop 2.10.0Atoptool,C600 秒区间内每个进程的累计资源,含已退出进程二进制,增量压缩
below 0.11.0Meta,2021 年开源;Rust5 秒每次完整快照:进程状态、cgroup、PSI每个样本完整存储,zstd 压缩
PCP 6.2.0源自 SGI,现由 Red Hat 主导;C,插件式 PMDA大部分 60 秒,进程内存 300 秒默认只有进程内存,可另配带元数据的归档
collectl 4.3.1源自 HP;Perl系统 10 秒(服务方式)、进程 60 秒进程快照gzip 的原始文本
nmon 16p源自 IBM;C常用 300 秒加 -T 时记下当时的 top 进程CSV 文本
tsar淘宝,C,模块化60 秒(cron)不记文本,每分钟一行

算上 sar 和 atop 各多跑的一份 60 秒配置,一共十路记录同时运行。collectl 在尖峰实验里没有加 -D,系统指标实际是 1 秒一次;开销测量时改回了服务方式的 10 秒。

图 1 · sar 家族的工具:都分采集和查询两半,差别在记什么、多久记一次

netdata、Prometheus 的 node_exporter 这一类也能看历史,但它们是监控系统:数据要发到服务端或单独的时序库里,粒度和保存时长取决于整套监控怎么搭,和“机器本地自带一个黑匣子”不是一回事,本文不放进来。龙蜥社区还有一个 sysak,它是一组诊断工具的合集,偏向出事时现场排查,也不在这里比较。

制造一个尖峰

实验机是一台 4 核、16 GB 的虚拟机,Ubuntu 24.04,内核 6.18。为了让结果更像生产环境,后台常驻了 400 个空闲进程,整机大约 550 个线程。

22:31:45,我们同时拉起两样东西,持续 30 秒:

  • stress-ng --cpu 16:16 个进程把 4 个 CPU 打满,它们是 R 状态(运行或在运行队列里排队)。
  • 一个叫 stuck 的小程序,fork 出 24 个进程,每个都调用 vfork(),子进程睡 30 秒,父进程就一直卡在内核里等它,处于 D 状态(不可中断睡眠)。生产环境里,等 NFS、等磁盘、等内核里的读写锁都会让线程进入 D 状态,这里用 vfork 只是因为它最容易精确控制。

所以在这 30 秒里,真实情况是 16 个 R 加 24 个 D,一共 40 个“活跃”线程,而机器只有 4 个核。22:32:15 两者同时结束。

这 30 秒,事后能在各个工具里找回多少?

二、事后回放:采样点有没有落进去

现象:默认配置的 sar 什么也没看到

先用最常见的 sar 看 22:20 以来的 load:

$ sar -q -f sa600
22:19:36 runq-sz plist-sz ldavg-1 ldavg-5 ldavg-15 blocked
22:29:36 1 556 0.16 0.30 0.34 0
22:39:36 0 556 0.07 0.97 0.99 0

发行版默认每 10 分钟采一次,两个点一个在尖峰前两分钟,一个在尖峰后七分钟。只有 ldavg-5 和 ldavg-15 微微抬头,暗示中间“有过点什么”。

第一次尝试:把间隔调到 60 秒

很多人会把 sar 的间隔改成 1 分钟。我们同时跑了一份 60 秒的:

$ sar -q -f sa60
22:29:36 runq-sz plist-sz ldavg-1 ldavg-5 ldavg-15 blocked
22:31:36 0 552 0.03 0.20 0.30 0
22:32:36 0 552 11.37 3.76 1.53 0

22:32:36 的 ldavg-1 跳到了 11.37,看得出出过事。可是 runq-sz 是 0,blocked 也是 0,因为这个采样点比尖峰结束晚了 21 秒,runq-sz 是采样那一瞬间的值。一个 30 秒的事件,用 60 秒的间隔去采,大约一半的机会会漏掉。

每个工具的采样点落在哪里

把八个工具(sar 和 atop 各有 600 秒、60 秒两份)的采样时刻画在同一条时间轴上,就能看清谁“碰上了”、谁没碰上:

同一个 30 秒尖峰落在各工具采样点上的位置 时间轴 22:31:20 到 22:32:50,虚线框是 22:31:45 到 22:32:15 的尖峰。ssar load5s 和 below 每 5 秒一个点,尖峰里有 6 个点;ssar load2p 在 22:31:49 触发一次;ssar procs 和 tsar 的 22:32:00、PCP 的 22:31:49 碰巧落在尖峰里;collectl 进程采样 22:31:37、22:32:37,atop 和 sar 的 22:31:36、22:32:36 都落在尖峰外,但 atop 记的是整个区间的累计值;默认 600 秒的 sar 和 300 秒的 nmon 在这段时间没有采样。右侧一列写着每个工具事后能读到的内容。 每个工具的采样点落在哪里 事后能读到 尖峰 30 秒 真实情况 16 R + 24 D,持续 30 秒 ssar load5s 5 秒 每 5 秒一个 40 ssar load2p 超阈值触发 40 个线程 + 内核栈 ssar procs 60 秒 进程状态快照 below 5 秒 16 运行 + 24 不可中断 PCP pmlogger 60 秒 只有计数 19 / 24 tsar 60 秒 runq 16,load1 8.88 collectl 1 秒 / 进程 60 秒 RunQ 16,BlkT 0 atop 60 / 600 秒 各进程累计 CPU sar 60 秒 load1 11.37,runq 0 sar · nmon 600 / 300 秒 没有记录 22:31:30 22:31:45 22:32:00 22:32:15 22:32:30 22:32:45 采样落在尖峰里 采样落在尖峰外
图 2 · 同一个 30 秒尖峰落在各工具的采样点上:只有 5 秒一采的 SSAR load5s 和 below 稳稳碰上,其余要么靠运气,要么只留下区间累计值

能稳稳抓住这 30 秒的只有 5 秒一采的两个:SSAR 的 load5s 和 below。PCP、tsar 和 SSAR 的进程快照是碰巧有一个点落在了里面;atop 的点虽然在外面,但它记的是整个区间里每个进程的累计值,已经退出的进程也会以 <stress-ng-cp> 这样带尖括号的名字列出来2,所以照样能看到 16 个 stress-ng 各用了约 7.5 秒 CPU。

即使碰上了,各家读到的东西也不一样。tsar 22:32:00 那一行是 runq 16,PCP 22:31:49 的 proc.runq.blocked 是 24,collectl 每秒都记,RunQ 一直是 16,BlkT 却一直是 0。同一时刻,为什么有的说 16,有的说 24,有的说 0?

原因:“blocked”并不是全部 D 状态

RunQ、runq-sz 来自 /proc/loadavg 第四列或 /proc/stat 的 procs_running,是 R 状态的线程数,16 没有问题。问题出在 blocked:sar、collectl、tsar 读的都是 /proc/stat 里的 procs_blocked,而内核在这里输出的是 nr_iowait(),只统计正在等块设备 IO 的任务9。我们的 24 个 stuck 卡在 vfork 上,不是在等磁盘,所以永远是 0。

PCP 的 proc.runq.blocked 不一样,它是 PCP 自己遍历 /proc/<pid>/stat 数出来的 D 状态进程数,所以是 24。但它只有一个数字,默认配置里进程级的指标只有内存,5 分钟一次。

到这里,大部分工具能告诉我们的是“22:32 前后 load 很高”。可这个“高”到底有多高?sar 说 11.37,tsar 说 8.88,PCP 在 22:31:49 甚至只读到 0.02,没有一个接近真实的 40。load average 为什么会差这么多?

三、load average 是怎么算出来的

内核每 5 秒更新一次指数平均

Linux 内核每隔 5 秒(准确说是 5*HZ+1 个时钟节拍)数一次全系统的活跃线程数 active,也就是 R 状态加 D 状态的线程数,然后用它去更新三个指数衰减平均10:

loadnew=loadold⋅18842048+active⋅1642048\text{load}_{new} = \text{load}_{old}\cdot\frac{1884}{2048} + \text{active}\cdot\frac{164}{2048}

这里 1884/2048 ≈ 0.92,约等于 e−5/60e^{-5/60}。每次更新,旧值保留 92%,新数进来 8%。把 D 状态也算进 load 是 Linux 特有的做法,本意是让“等磁盘”的压力也能体现出来11。

于是 load1 就像一辆车的车速,active 是油门。油门一下踩到 40,车速要好几分钟才能追上;油门松开,车速也是慢慢降下来。我们这次的 30 秒尖峰里,load1 只爬到 15.77;尖峰结束 20 秒后 sar 读到 11.37,此时真实的活跃线程已经回到 1 个左右:

同一个尖峰里 load1 和 load5s 的走势 横轴 22:31:30 到 22:35:00。虚线框标出 22:31:45 到 22:32:15 的尖峰,真实的 R 加 D 线程数是 40。强调色柱子是 ssar 每 5 秒算出的 load5s,尖峰期间 6 根都是 40,之前之后都是 0 或 1。折线是 load1,从 0.02 爬到 22:32:15 的 15.77,然后慢慢回落,22:32:35 还有 11.37,22:35:00 还有 1.21。 ssar load5s 记录的 5 秒一行(4 核机器) 尖峰 22:31:45–22:32:15 0 10 20 30 40 22:31:30 22:32 22:32:30 22:33 22:33:30 22:34 22:34:30 22:35 load5s = 40(16 R + 24 D) load1 最高 15.77 ← 22:32:35 尖峰已过,load1 还有 11.37 22:35:00 仍有 1.21 load5s:ssar 由相邻两次 load1 反推的 R+D 线程数 load1:/proc/loadavg 第一列
图 3 · load1 是指数平均:40 个活跃线程持续 30 秒,load1 只爬到 15.77,尖峰过后又拖了几分钟;load5s 每 5 秒给出一次真实值

所以 sar 的 11.37 既不是峰值,也不是采样时的状态,而是“过去几分钟加权后的余温”。PCP 那个 0.02 更极端:22:31:49 尖峰已经开始了 4 秒,内核还没轮到下一次更新。

能不能把那一次 active 找回来

既然内核每 5 秒用一个精确的 active 去更新 load1,公式又是公开的,那只要拿到相邻两次 load1,就能把 active 反解出来:

active=loadnew⋅2048−loadold⋅1884164\text{active} = \frac{\text{load}_{new}\cdot 2048 - \text{load}_{old}\cdot 1884}{164}

难点在于“相邻两次”。内核的 5 秒周期从开机时刻算起,和整分钟、整 5 秒都对不齐,而 /proc/loadavg 也不会通知谁它更新了。sar、tsar 们按自己的节奏读,读到的两个 load1 之间可能隔了好几次更新,就没法反解。SSAR 的 load5s,解决的就是这个对齐问题。

四、SSAR 的 load5s:把指数平均倒推回去

每 100 毫秒看一眼 /proc/loadavg

SSAR 的采集进程 sresar 里有一个专门的线程,每 100 毫秒读一次 /proc/loadavg,只要第一列和上一次不一样,就说明内核刚刚完成了一次更新,它立刻用上一次和这一次的 load1 反推 active,记成一行12。源码里就是这一行:

load5s = (int)round((2048 * (2048 * current_node->load1 - 10) - 1024
- 1884 * 2048 * current_node->prev->load1) / 164 / 2048);

公式里多出来的 -10 和 -1024,是在补偿内核定点数运算和 /proc/loadavg 只保留两位小数带来的取整偏差。拿尖峰开始后的第一次更新来算一遍:

从两次 load1 反推出 load5s 左卡片:内核在 22:31:49 前后做了一次 calc_load,active 等于 16 个 R 加 24 个 D 共 40,load1 从 0.02 变成 0.02 乘 1884/2048 加 40 乘 164/2048,约 3.23。中卡片:/proc/loadavg 在 22:31:44 读到 0.02,22:31:49 读到 3.23;sresar 每 100 毫秒读一次,只在第一列变化时记一行。右卡片:反推 3.23 乘 2048 减 0.02 乘 1884,除以 164,得 40.1,取整为 40;下一次 6.17 和 3.23 反推得 39.9。 内核 calc_load() 每 5 秒 active = R + D = 16 + 24 = 40 load1 = 0.02 × 1884/2048 + 40 × 164/2048 ≈ 3.23 /proc/loadavg 每 100 ms 读一次 22:31:44 0.02 0.20 0.30 22:31:49 3.23 0.86 0.51 第一列变了 → 内核刚更新过 没变就不记,接着等 sresar 反推 load5s (3.23×2048 − 0.02×1884) ÷ 164 = 40.1 → 40 下一次 22:31:54: (6.17×2048 − 3.23×1884) ÷ 164 = 39.9 → 40 1884/2048 ≈ 0.92:每次更新旧值保留 92%,新的 active 只进来 8%(164/2048)。 知道了相邻两次的 load1,就能把这 8% 里的 active 解出来。
图 4 · 内核每次更新只让新的 active 进来 8%,拿到相邻两次 load1 就能把它解出来

结果:5 秒一行,起止都对得上

查询端用 ssar load5s 读出来:

$ ssar load5s
collect_datetime threads load1 runq load5s stype sstate zstate act act_rto actr actr_rto actd
2026-10-21T22:31:44 554 0.02 1 0 5s N U - - - - -
2026-10-21T22:31:49 618 3.23 17 40 5s Y Z 40 100.0% 16 94.1% 24
2026-10-21T22:31:54 617 6.17 17 40 5s Y U - - - - -
2026-10-21T22:32:00 617 8.88 19 40 5s Y U - - - - -
2026-10-21T22:32:05 618 11.37 17 40 5s Y U - - - - -
2026-10-21T22:32:10 621 13.66 18 40 5s Y U - - - - -
2026-10-21T22:32:15 621 15.77 17 40 5s Y U - - - - -
2026-10-21T22:32:20 552 14.51 1 0 5s N U - - - - -

六行 40,一个不差;尖峰从 22:31:44 到 22:31:49 之间开始,从 22:32:15 到 22:32:20 之间结束,误差就是一个 5 秒周期。load1 那一列从 3.23 慢慢爬到 15.77,正好是图 2 里那条曲线。这就是 SSAR 文档里说的“load5s 是油门,load1 是车速”13。

它也有两个小毛病。一是精度受 /proc/loadavg 的两位小数限制,反推出来的数有 ±0.1 左右的误差,对“几十个线程”的问题无关紧要。二是它靠“第一列变了”来判断内核更新过,机器很闲、load1 连续两次都是 0.03 时就认不出来,那 5 秒就没有记录,我们的输出里 22:31:19、22:31:24 就缺了行。闲的时候缺几行,并不影响排查。

load5s 回答了“有多少、什么时候”。可是 40 个线程,是谁?

五、是谁:进程级的历史

各家的进程记录

要知道是谁,就得有进程级的历史。八个工具里,sar、tsar 根本不记进程,nmon 和 collectl 记,但 5 分钟、1 分钟一次,都没碰上。剩下几家:

  • atop 记的是区间累计:10 分钟(或 60 秒)里每个进程用了多少 CPU、内存、磁盘,已退出的进程也在。它清楚地告诉我们 16 个 stress-ng-cpu 各用了 7.5 秒 CPU。但 24 个 stuck 也以 <stuck> 出现,CPU 是 0%,状态是 E(已退出),看不出它们曾经卡在 D 状态。累计值擅长回答“谁花了资源”,不擅长回答“谁在等”。
  • below 每 5 秒存一份完整的进程快照,包括状态14。在尖峰的 6 个采样点上,它都记下了 16 个 RUNNING 的 stress-ng-cpu 和 24 个 UNINTERRUPTIBLE 的 stuck,单就这一次实验,它和 SSAR 打了个平手。
  • SSAR 的 ssar procs 每分钟存一份全部进程的快照,22:32:00 这一份正好落在尖峰里,状态里能看到 24 个 D 的 stuck。但这是运气,换个时刻发生,一分钟一次的快照就会错过。
$ below dump process -b "22:31:50" -e "22:31:55" -f datetime pid comm state
2026-10-21 22:31:53 19492 stuck UNINTERRUP
2026-10-21 22:31:53 19493 stuck UNINTERRUP
2026-10-21 22:31:53 19540 stress-ng-cpu RUNNING
...

既然 below 每 5 秒都存了进程状态,SSAR 还有什么不同?我们注意到 below 记的是进程,状态取的是主线程的状态。如果卡住的不是 24 个进程,而是一个进程里的 24 个线程呢?

六、是哪个线程,卡在哪里:load2p

现象:进程是 S,load 却是 24

我们又做了一次实验。22:45:45,起一个名叫 javaapp 的进程,它开 24 个工作线程(线程名 pool-worker),每个线程都去 vfork,同样卡 30 秒,而主线程只是在 pthread_join 里正常睡眠。这更接近 Java、Go 服务的真实情况:一个进程,几百个线程,出事的是其中一部分。

真实情况是:进程 24317 里有 24 个线程处于 D 状态,主线程处于 S 状态。各家事后的记录是这样的:

一个进程里 24 个线程卡在 D 状态(22:45:45–22:46:15)
工具事后读到的能定位到线程吗
SSAR load5s22:45:55 起每 5 秒都是 24,其中 actr 0、actd 24—
SSAR load2p24 个 D 线程,全在 pid 24317,线程名 pool-worker,逐个列出 tid 和所在 CPU;内核栈都是 __do_sys_vfork能,带内核栈
SSAR procsjavaapp 状态 S,线程数 25不能
below5 个采样点里 javaapp 都是 SLEEPING,线程数 25;没有任何进程是 UNINTERRUPTIBLE不能
atopjavaapp 和退出的子进程 <pool-worker>,CPU 都是 0%不能
PCP22:45:49 的 proc.runq.blocked 是 0不能
sar · tsarload1 6.77 / 5.32,runq 0,blocked 0不能

below 和 SSAR procs 记录的是进程,状态取自主线程;PCP 22:45:49 的采样已经在线程卡住 3 秒之后,仍然是 0,看来它也是按进程统计的(推断)。

图 6 · 卡住的是线程而不是进程时,只有按线程扫描的 load2p 认得出来

load5s 这一次还暴露了一个边界:尖峰前机器很闲,22:45:25 起 load1 已经降到 0,公式里的“上一次”是 0,sresar 就退回到直接拿 load1 当 load5s(输出里 stype 一列标成 1m),22:45:50 那一行只记成了 1,从 22:45:55 起才是 24。同一段输出里,22:44:10、22:44:40、22:45:25 之间也缺了好几行,就是上一节说的“load1 没变就认不出更新”。

SSAR 的做法:超过阈值才抓现场

load2p 的思路是“平时只记数,超标才抓人”。还是 sresar 里那个 100 毫秒看一眼 /proc/loadavg 的线程,它算出的 load5s 一旦超过 CPU 核数 × load5s_threshold(默认阈值 1,4 核机器就是 4),并且正在上涨(load5s 和 load1 都比上一次高 5% 以上)或者最近 10 次更新里还没抓过,就通过 eventfd 叫醒另一个线程,后者立刻遍历 /proc/*/task/*/stat,把所有 R 和 D 状态的线程连同所在 CPU、优先级记下来,再抽样最多 100 个 D 状态线程,读它们的 /proc/<tid>/stack12。

sresar 的几个线程和它写下的文件 左边是常驻进程 sresar 的线程:load1_thread 每 100 毫秒读 /proc/loadavg,算出 load5s 写进 2026102122_load5s,超过 4 核乘阈值 1 且在上涨时,通过 eventfd 叫醒 loadrd_thread;后者扫描 /proc/*/task/*/stat,把 R 和 D 线程写进 20261021223149_loadrd,抽样最多 100 个 D 线程读内核栈写进 _stack。另几个线程每分钟拷贝 stat、meminfo、vmstat 等原文件,并把全部进程写进 sresar25.gz 和 cmdline.gz。右边是数据目录的文件列表,下面是查询端 ssar,按 sys.conf 现场解析这些文件。 sresar(常驻采集进程) /var/log/sre_proc/data/2026102122/ load1_thread 每 100 ms 读 /proc/loadavg,第一列变了就反推 load5s 追加一行到 _load5s load5s > 4 核 × 1 且在上涨 → 叫醒 eventfd loadrd_thread 平时睡着 扫 /proc/*/task/*/stat,只留 R 和 D 每个线程的 tid、pid、CPU 号 → _loadrd 抽 ≤100 个 D 读 /proc/<tid>/stack → _stack snapsys / sresar / cmdinfo 线程 每分钟 原样拷贝 stat、meminfo、vmstat、snmp… 全部进程 → sresar25.gz、cmdline.gz 一小时一个目录 节选 2026102122_load5s 5 秒一行 20261021223149_loadrd 触发时 20261021223149_stack 触发时 20261021223200_stat 原文件 20261021223200_meminfo 原文件 20261021223200_vmstat … 原文件 20261021223200_sresar25.gz 进程 20261021223200_cmdline.gz 查询时才解析 ssar(查询端) 按 /etc/ssar/sys.conf ssar load5s · load2p -c · procs ssar -o 'cfile=stat|line_begin=…'
图 5 · sresar 平时只记数字和原文件,load5s 超过阈值时才叫醒另一个线程,按线程抓现场

查询时用 ssar load5s -z 找出抓过现场的时刻,再用 ssar load2p -c 打开。第一次实验在 22:31:49 抓到的现场是这样的:

$ ssar load2p -c 2026-10-21T22:31:49 --loadd
count s pid cmd cmdline
1 D 19492 stuck -
1 D 19493 stuck -
...(共 24 行)
$ ssar load2p -c 2026-10-21T22:31:49 --stackinfo
count s_unit wchan stackinfo
24 number kernel_clone kernel_clone,__do_sys_vfork,x64_sys_call,do_syscall_64,entry_SYSCALL_64_after_hwframe

--stackinfo 把 D 状态线程按内核栈聚合:24 个全卡在 __do_sys_vfork 里。生产环境里,这一行可能是 rwsem_down_read_slowpath、jbd2_log_wait_commit 或者 nfs_wait_bit_killable,一眼就能分辨是锁、日志提交还是网络存储。SSAR 的分享里有一个例子,D 状态线程 80% 卡在 call_rwsem_down_read_failed,顺着栈找到了缺页时抢 mmap 读写锁的那一组进程13。

--psr 还会按 CPU 号聚合 R 状态线程,用来发现 cgroup 绑核不均:如果 150 个可运行线程都挤在 34 号 CPU 上,平均负载看起来并不夸张,但那颗核上的服务已经排了很长的队。

我们也看到了它在新内核上的一处小瑕疵:输出里还有一列 nwchan,本意是取栈顶函数地址的后 6 位。老内核的 /proc/<pid>/stack 每行是 [<ffffffff813003d4>] call_rwsem_down_read_failed 这种格式,而 4.x 以后的内核出于安全考虑把地址统一显示成 [<0>],SSAR 按固定偏移截取,这一列就成了乱码。上面的输出里我们把它删掉了。它说明 SSAR 主要是在阿里内部的 3.10、4.9、4.19 这几代内核上长大的。

线程名、内核栈都有了。还剩一个问题:如果当时就是没记某个指标呢?

七、事后才想到的指标

现象:sar 不记的,事后就没有

回头看第二节,sar 的 blocked 一直是 0,我们是查了内核源码才知道它只算 iowait。假如这时还想看看那一分钟里 fork 了多少进程(/proc/stat 的 processes 行),sar 没有这一列,也就无从查起。sar、atop、tsar 的数据文件都是采集时就解析好的二进制或文本记录,记了哪些字段,在写代码时就定死了。

SSAR 的做法:存原文件,查询时再解析

SSAR 的整机数据,存的是 /proc 文件本身。上面图 5 右边的数据目录里,每分钟一组 20261021223200_stat、_meminfo、_vmstat、_snmp……都是原样拷贝的文件。哪一行哪一列是什么指标,写在查询端的配置 /etc/ssar/sys.conf 里,查询时现场解析8。

所以只要文件采了,任何一个字段都能事后查。比如 fork 速率和 procs_running、procs_blocked,不改配置,直接在命令行里写:

$ ssar -i 1 -o 'metric=d|cfile=stat|line_begin=processes|column=2|alias=forks;
metric=c|cfile=stat|line_begin=procs_running|column=2|alias=running;
metric=c|cfile=stat|line_begin=procs_blocked|column=2|alias=blocked'
collect_datetime forks/s running blocked
2026-10-21T22:31:00 13.07 2 0
2026-10-21T22:32:00 14.30 18 0
2026-10-21T22:33:00 10.98 3 0

metric=d 表示取相邻两次的差再除以秒数,c 表示取当时的值。这同时也印证了第二节的结论:procs_blocked 在 24 个 D 状态进程面前确实是 0。

想加一个原本不采的文件也只要一行配置。比如我们在实验开始前往 sys.conf 里加了 {src_path='/proc/pressure/io', turn=true, cfile='pressure_io'},之后 IO 的 PSI 就每分钟落盘一份,同样可以用 -o 查。

这条路的代价是:只对“文件”有效,而且得是事先采了的文件。PCP 走的是另一条路,每类数据源写一个 PMDA 插件,指标有名字、有元数据、能远程查,扩展能力最强,但新加一个指标就是写一个插件。below 的数据模型则是写在 Rust 代码里的。

记得越多越细,代价是什么?

八、记录的代价

我们在尖峰之后取了一段 40 分钟的平稳窗口(22:40 到 23:20,期间包含第二次实验),记录每个采集进程的 CPU 时间、常驻内存和数据文件的增长,换算成每小时 CPU 秒和每天的磁盘占用:

采集端的开销(约 550 个线程的 4 核机器)
采集端(间隔)CPU 秒 / 小时常驻内存磁盘 / 天
SSAR sresar(60 秒,load 5 秒)4.14.4 MB50 MB
below(5 秒,--compress)32.612.7 MB362 MB
below(5 秒,不压缩)41.915.5 MB4.0 GB
collectl(10 秒 / 进程 60 秒)7.631 MB112 MB
PCP(pmcd + PMDA + pmlogger)0.546 MB51 MB
atop(600 秒)0.218 MB8.0 MB
atop(60 秒)1.511 MB28 MB
tsar(cron 60 秒)1.1—1.2 MB
sar(600 秒 / 60 秒)<0.12.5 MB0.3 / 3.4 MB
nmon(300 秒)0.22.3 MB0.4 MB

CPU 是采集进程及其子进程的 utime + stime 之和,窗口 22:40–23:20(40 分钟);collectl 按服务方式的 10 秒间隔重启后测了 37 分钟;带 --compress 的 below(官方 systemd 单元的参数)另测了 15 分钟。tsar 每分钟由 cron 拉起一次,没有常驻进程。一小时 3600 CPU 秒是一个核跑满。

图 7 · 记录的代价:SSAR 每小时用 4 秒 CPU、每天 50 MB,比 sar 重,但比 5 秒一采的 below 轻一个数量级

几点值得说一下:

SSAR 的开销主要花在进程上。 每分钟把全部进程的状态和命令行压缩落盘,一天 50 MB,默认保留 7 天,也就 350 MB 左右;每小时 4 秒 CPU,大约是一个核的 0.1%。它的 README 说在万台规模上观察到的上限是单核 3%、内存 4 MB8,和我们测到的量级一致。load5s 那个每 100 毫秒醒一次的线程几乎不花什么,读一次 /proc/loadavg 只是几十个字节。

below 贵在“每 5 秒一份完整快照”。 它不存增量,每个样本都完整存储,这正是它能稳稳抓住尖峰、回放体验好的原因3。但不加 --compress 时一天 4 GB,加上之后也有 362 MB,是 SSAR 的七倍;CPU 大约一个核的 1%。直接 below record 默认是不压缩的,要按官方的 systemd 单元加上 --compress。

sar、atop、nmon 几乎不花钱,代价是我们在第二、五节看到的:10 分钟一个点,或者只有区间累计值。PCP 的 CPU 不高,但 pmcd 和一组 PMDA 进程加起来常驻 46 MB,是这里最重的部署。

这些数字和进程数直接相关:SSAR 和 below 每次都要遍历 /proc,机器上有几千个线程时,开销会成比例上涨。

九、对照与选择

把前面的结果放进一张表:

事后排查一次 load 尖峰,各工具能回答到哪一步
工具何时、多大是谁哪个线程、卡在哪事后查新指标cgroup
SSARload5s,5 秒精度每分钟进程快照load2p:按线程 + 内核栈存原文件,事后解析不记
below5 秒的运行 / 阻塞计数,没有 load每 5 秒进程状态只到进程固定的数据模型以 cgroup v2 为中心
atop区间末尾的 load 平均区间累计,含已退出进程不记固定字段进程所属 cgroup v2
PCP60 秒的 load 平均和 R / D 计数默认只有进程内存,可另配不记写 PMDA 插件,要事先配可配
collectl10 秒的 RunQ 和 load 平均每分钟进程快照不记固定字段不记
tsar每分钟 load 平均和 runq不记不记写 C 模块不记
sar10 分钟一次 load 平均不记不记固定字段不记
nmon5 分钟一次当时的 top 进程不记固定字段不记

“D 状态计数”只有 PCP 和 below 的进程扫描是真的全部 D,sar、collectl、tsar 读的 procs_blocked 只含等块设备 IO 的任务。

图 8 · 同一个尖峰排查到底:SSAR 在“多大、哪个线程、卡在哪”上最强,below 在“是谁”和 cgroup 上最强,atop 擅长回答谁花了资源

从这张表看,没有一个工具能覆盖所有格子,选择取决于机器上最常出的是哪类问题:

物理机、大规模同构集群,问题集中在 load 尖峰和 D 状态,SSAR 最合适。load5s 和 load2p 是这个家族里独一份的设计,而且它在阿里内部的万台规模上跑过8,开销实测也很低。但要接受它的几个短板:它对 cgroup 和容器一无所知,看不出是哪个容器把 load 打上去的;仓库最后一次提交是 2025 年 2 月,几乎靠一位维护者;Ubuntu 24.04 的软件源里搜不到它,我们是用仓库里的源码编译安装的(仓库也带了打 rpm / deb 的脚本),安装脚本只认 systemd;在新内核上还有 nwchan 这样的小毛病。

容器化、cgroup v2 的机器,below 更合适。它每 5 秒一份完整的进程和 cgroup 快照,事后回放的体验最好,代价是只支持 cgroup v2,以及比别人多一个数量级的 CPU 和磁盘。

只想知道“这 10 分钟谁花了资源”,atop 就够了,发行版直接装,已退出的进程也不会漏。

需要全公司统一的指标体系、远程查询,PCP 是这里唯一一个按“监控框架”设计的,代价是部署和学习成本。sar 适合做所有机器上的保底,tsar、nmon、collectl 则更多是存量环境里还在用。

它们也不互斥。比较实际的组合是:sar 或 atop 作保底,再加上 SSAR 专门盯 load 尖峰,容器机器换成 below。

十、回到 22:31

现在可以回答开头的问题了。登录后用 ssar load5s -z 一查,22:31:49 有一行 zstate Z:load5s 从 0 跳到 40,其中 R 16 个、D 24 个,持续了 6 个 5 秒周期。ssar load2p -c 2026-10-21T22:31:49 给出了这 40 个线程:16 个 stress-ng-cpu 在抢 4 个 CPU,24 个 stuck 卡在 __do_sys_vfork 里。22:34 读到的那个 2.90,只是 30 秒尖峰过去两分钟后的余温。

小结

  1. 默认配置的 sar 能看清一次 30 秒的尖峰吗? 不能。10 分钟一次的采样点落在尖峰两侧,改成 60 秒也有一半的机会错过,而且 blocked 只算等块设备 IO 的任务。
  2. 为什么各家的 load 数字差这么多? load1 是内核每 5 秒更新一次的指数平均,旧值保留 92%,追不上也退不下来;各工具在不同时刻读到的只是这条曲线上的不同点。
  3. SSAR 的 load5s 是什么? 每 100 毫秒盯一次 /proc/loadavg,在内核每次更新后,用相邻两次 load1 反推出那一刻的 R + D 线程数,精度 5 秒,不需要内核模块。
  4. 是谁? atop 能告诉你谁花了 CPU,below 每 5 秒存一份进程状态,SSAR 每分钟存一份进程快照;但只有 SSAR 的 load2p 在超阈值时按线程抓现场,并带上 D 状态线程的内核栈。
  5. 事后想到的指标还能查吗? SSAR 存的是 /proc 原文件,查询时才解析,只要文件采了,任何字段都能事后查。
  6. 代价和取舍? 见第八、九节:SSAR 的强项在 load 和线程现场,短板在 cgroup、维护活跃度和部署方式;容器化、cgroup v2 的机器上 below 更合适,要全公司统一的指标体系就看 PCP。

参考资料

Footnotes

  1. sysstat:Performance monitoring tools for LinuxGitHubgithub.com ↗sadc 采集、sar 查询。文中用 Ubuntu 24.04 的 12.6.1,发行版默认每 10 分钟采一次(systemd 定时器 *:00/10 或 cron)。https://github.com/sysstat/sysstat ↩

  2. atop:Advanced System & Process Monitoratoptool.nlatoptool.nl ↗文中用 2.10.0。默认 LOGINTERVAL=600;借助进程记账,区间内已退出的进程也会以 <名字> 的形式列出累计资源。https://www.atoptool.nl/ ↩ ↩2

  3. below: a time traveling resource monitoring toolDaniel Xu · Meta Open Source · 2021-09-21developers.facebook.com ↗开发 below 的原因:atop 缺少 cgroup 支持,增量压缩的存储格式在压力大时容易丢点;below 每个样本完整存储并用 zstd 压缩。https://developers.facebook.com/blog/post/2021/09/21/below-time-travelling-resource-monitoring-tool/ ↩ ↩2

  4. Performance Co-Pilotpcp.iopcp.io ↗pmcd + 各类 PMDA 采集,pmlogger 写归档。文中用 Ubuntu 24.04 的 6.2.0,config.default 里大部分指标 60 秒一次,进程内存类 5 分钟一次。https://pcp.io/ ↩

  5. collectlSourceForgecollectl.sourceforge.net ↗文中用 4.3.1,按 /etc/collectl.conf 的 DaemonCommands(-f … -m -F60 -s+YZ)运行:系统指标 1 秒、进程 60 秒。https://collectl.sourceforge.net/ ↩

  6. nmon for LinuxSourceForgenmon.sourceforge.io ↗文中用 16p,按常见的 nmon -f -T -s 300 -c 288(每 5 分钟一次、一天一个文件)运行。https://nmon.sourceforge.io/ ↩

  7. alibaba/tsar:Taobao System Activity ReporterGitHubgithub.com ↗C 编写、模块化,cron 每分钟 tsar --cron 一次;只有系统级指标。仓库最后一次提交是 2022-02-18。ssar 自带的 tsar2 就是照着它的输出格式写的。https://github.com/alibaba/tsar ↩

  8. anolis/ssar:SRE System Activity ReporterGitee · 2025-02-24gitee.com ↗龙蜥社区仓库,MulanPSL-2.0。文中用的是 1.0.5(最后一次提交 2025-02-24),README_zh-cn.md 是完整的中文手册。https://gitee.com/anolis/ssar ↩ ↩2 ↩3 ↩4

  9. Linux 源码:fs/proc/stat.cGitHubgithub.com ↗/proc/stat 的 procs_blocked 输出的是 nr_iowait(),只统计在等块设备 IO 的任务,不是全部 D 状态线程。https://github.com/torvalds/linux/blob/master/fs/proc/stat.c ↩

  10. Linux 源码:kernel/sched/loadavg.cGitHubgithub.com ↗calc_load():newload = load * exp + active * (FIXED_1 - exp);EXP_1 = 1884、FIXED_1 = 2048、LOAD_FREQ = 5*HZ+1 定义在 include/linux/sched/loadavg.h。https://github.com/torvalds/linux/blob/master/kernel/sched/loadavg.c ↩

  11. Linux Load Averages: Solving the MysteryBrendan Gregg · 2017-08-08brendangregg.com ↗Linux 的 load average 为什么把 D 状态(不可中断)也算进去,以及指数衰减平均的来历。https://www.brendangregg.com/blog/2017-08-08/linux-load-averages.html ↩

  12. ssar 源码:sresar/sresar.cGitee · 2025-02-24gitee.com ↗load1_thread 每 100 ms 读一次 /proc/loadavg,第一列变化时用 1884 和 164 反推 load5s,超过 CPU 核数 × load5s_threshold 就通过 eventfd 叫醒 loadrd_thread 去扫描 R / D 线程和内核栈。https://gitee.com/anolis/ssar/blob/master/sresar/sresar.c ↩ ↩2

  13. 直播回顾:准确性提升到 5 秒级,ssar 独创的 load5s 指标有多硬核?闻茂泉 · OpenAnolis 龙蜥社区 · 2022-01-26segmentfault.com ↗load5s 即内核的全局 active 值(R + D 线程数),在用户态以工程方法采集,不依赖内核模块;load2p 的六个视图;线程数耗尽和内存直接回收两个案例。https://segmentfault.com/a/1190000041343974 ↩ ↩2

  14. facebookincubator/belowGitHubgithub.com ↗Rust 编写,默认 5 秒采样,只支持 cgroup v2。文中用源码编译的 0.11.0(提交 bbddf87)。https://github.com/facebookincubator/below ↩