我一直想把 Brendan Gregg 《Linux Load Averages: Solving the Mystery》 那篇文章里的三条核心主线真正”做出来”,而不只是停留在”背定义”的层面。于是我在一台 Dell T430(双路 Intel Xeon E5-2682 v4 @ 2.50GHz,32GB × 2 内存,运行 RockyLinux 8.10 / 4.18.0-553 内核)上,设计并执行了下面这套实验。

我想验证的三条主线:

  1. load average 不是单纯 CPU 忙碌度,而是 Linux 的”system load”。
  2. load average 既会被 runnable tasks 拉高,也会被 uninterruptible tasks(D state) 拉高。
  3. iowait 只是 CPU 统计口径里的一个字段,不能单独拿来当根因。

安全提示:这套实验我建议在实验 VM 或 lab 主机上做,不要直接在生产 root filesystem 上做。特别是 fiofsfreeze 这两组实验,都会明显制造卡顿。


0. 准备实验环境

RockyLinux 8.10 默认在 4.18.0-553 这条内核线上,足够做 Gregg 文里提到的大多数观察,包括 perfftrace/tracefs,以及可选的 BCC off-CPU 分析。

安装基础工具

1
2
3
sudo dnf install -y sysstat fio util-linux
# 可选高级观察
sudo dnf install -y bcc-tools

准备一次性实验文件系统

我不想把 rootfs 搞脏或冻住,所以用 loopback + XFS 做了一个独立挂载点:

1
2
3
4
5
sudo mkdir -p /var/tmp/loadlab /mnt/loadlab
sudo truncate -s 8G /var/tmp/loadlab/loadlab.img
LOOPDEV=$(sudo losetup -f --show /var/tmp/loadlab/loadlab.img)
sudo mkfs.xfs -f "$LOOPDEV"
sudo mount "$LOOPDEV" /mnt/loadlab

fsfreeze 适用于 ext3/4、XFS、JFS、ReiserFS 等本地文件系统;Rocky/RHEL 8 默认用 XFS,做这个实验最自然。


1. 固定一组联立观测窗口

这一步很关键。我后面不是”看单个指标”,而是做联立观测。我开了四个终端同时运行以下命令。

窗口 A:load 与 /proc/stat

1
2
3
4
5
6
7
8
9
10
watch -n 1 '
echo "=== /proc/loadavg ==="
cat /proc/loadavg
echo
echo "=== /proc/stat ==="
grep -E "procs_running|procs_blocked" /proc/stat
echo
echo "=== top ==="
top -b -n 1 | head -50
'

/proc/loadavg 的前三个数字是 1/5/15 分钟 load,第四个字段前半部分是当前 runnable scheduling entities 的数量。/proc/stat 里的 procs_runningprocs_blocked 分别给出 runnable 线程总数与当前”waiting for I/O to complete”的进程数。

窗口 B:vmstat

1
vmstat 1

r = runnable processes,b = blocked waiting for I/O to complete。这两个字段是我理解 Gregg 文章时最重要的地面对应物。

窗口 C:iostat

1
iostat -xz 1

我主要看 await(排队时间 + 服务时间)、aqu-sz(平均队列长度)、%util

窗口 D:D-state 任务

1
watch -n 1 'ps -eLo pid,tid,stat,wchan:32,comm --sort=stat | awk '\''$3 ~ /D/ {a[++n]=$0} END {print "TOTAL:", n; for(i=1;i<=n;i++) print a[i]}'\'''

psD 定义为 uninterruptible sleep (usually I/O)。注意这个”usually”很重要:D-state 常见于 I/O,但不是只可能由磁盘吞吐导致。


2. 实验一:单线程 CPU 负载——验证”1 分钟平均值不是 60 秒简单平均”

这是 Gregg 文章里最经典的一个认知点。所谓 1-minute load 不是”过去 60 秒的普通算术平均”,而是每 5 秒采样一次的指数衰减和(LOAD_FREQ = 5*HZ+1;因此单线程 CPU burner 跑满 60 秒后,1-minute load 只会接近 0.62,不会到 1.0。

操作

1
2
3
4
5
6
7
8
9
10
11
taskset -c 0 bash -c 'while :; do :; done' &
CPU1_PID=$!

for i in $(seq 1 18); do
  printf "%s  " "$(date +%T)"
  cat /proc/loadavg
  sleep 5
done

kill $CPU1_PID
wait $CPU1_PID 2>/dev/null

我观察到的现象

  • loadavg 的第一个值缓慢上升。
  • 运行满 60 秒后,它并没有到 1.00,而是接近约 0.62
  • vmstatr 接近 1;b 基本为 0。
  • iostat 没有明显 await
  • D-state 基本没有出现。

分析

这一步帮我把两个概念钉死了:

  • load 可以由 runnable demand 单独拉高
  • 1/5/15 不是简单平均,而是指数衰减”记忆”。后面我看到某台机器从高负载恢复时,1-minute load 不会立刻掉下去,就是这个原因。

3. 实验二:纯 runnable load——制造”高 load 但无 D-state”

这一步我要把”CPU runnable 负载”和”D-state 阻塞负载”做一个干净的分离。

操作

1
2
3
4
5
6
7
8
9
10
11
12
N=$(nproc)
pids=()

for i in $(seq 1 $N); do
  yes > /dev/null &
  pids+=($!)
done

sleep 90

kill "${pids[@]}"
wait "${pids[@]}" 2>/dev/null

我观察到的现象

  • loadavg 持续向 N 靠拢,但不会瞬间等于 N(指数衰减的效果很明显)。
  • vmstatr 明显上升。
  • vmstatb 保持接近 0。
  • ps 里几乎看不到 D-state。
  • iostatawait/aqu-sz 没有成为主要矛盾。

分析

这是最纯粹的 nr_running 路径。Gregg 文章前半段说得很清楚:如果系统只有 runnable load,那么 load 的含义就非常接近传统”CPU demand”。这个实验让我确认了这一点。


4. 实验三:同步阻塞 I/O——制造”CPU 不一定满,但 load 与 D-state 一起升”

操作

我在实验文件系统上跑阻塞型 I/O:

1
2
3
4
5
6
7
8
9
10
11
12
13
fio --name=sync-randwrite \
    --directory=/mnt/loadlab \
    --filename=labfio.dat \
    --size=4G \
    --rw=randwrite \
    --bs=4k \
    --direct=1 \
    --ioengine=sync \
    --iodepth=1 \
    --numjobs=32 \
    --time_based=1 \
    --runtime=120 \
    --group_reporting

我观察到的现象

  • vmstat b 上升了。
  • /proc/stat 里的 procs_blocked 也上升了。
  • ps 里出现了一些 fio 线程处于 D-state。
  • loadavg 上升,即使 CPU 使用率不一定打满。
  • iostat -xz 1awaitaqu-sz 明显上升。

分析

这一步正对应 Gregg 文中最重要的一句话:Linux load 不只追踪 runnable tasks,也追踪 uninterruptible tasks;因此磁盘或 NFS I/O 负载可以把 load 拉高。

同时我也看到了:wa 确实上升了,但它只是 CPU accounting 里的一个字段。内核 /proc 文档 明确提醒了三件事:

  1. CPU 不会真的”等 I/O”。
  2. 多核下 iowait 很难精确归因。
  3. /proc/stat 里的 iowait 在某些条件下甚至会下降。

所以我的结论是:不要把 %wa 当成唯一真相。真正更值得信的是 b / procs_blocked、D-state 数量、await / aqu-sz、以及具体阻塞栈和 wait channel。


5. 实验四:用 fsfreeze 做”不是磁盘太慢,也能进 D-state”的实验

这是整套实验里最让我建立高级直觉的一步。

5.1 为什么我选 fsfreeze

Gregg 在文章里明确指出:现代内核里进入 TASK_UNINTERRUPTIBLE 的 code path 已经不只磁盘 I/O,还包括某些锁原语。我需要一个稳定、可控、低风险的方法来证明:

D-state 不是”磁盘吞吐不够”的同义词,而是”内核路径上的不可中断等待”。

fsfreeze 的语义是冻结文件系统上的新写入请求,并让写入者阻塞直到解冻。冻结后,尝试写入(或其他会修改文件系统的调用)的进程会被阻塞在文件系统写路径入口处的 sb_start_write()/__sb_start_write()

5.2 关键机理:为什么”D 拉高 load,但 iowait / vmstat b 仍可能为 0”

在做实验之前,我先理清了几个关键概念。

load average 的精确定义

  • /proc/loadavg 明确:前三个数字是 R 状态和 D 状态任务数的指数衰减平均。
  • 内核实现:global load average = 指数衰减平均(nr_running + nr_uninterruptible),每 LOAD_FREQ = 5*HZ+1(约 5 秒)采样更新一次。

iowait 为什么可以一直很低

  • 在 fsfreeze 实验里,dd 往往在进入真实块 I/O 之前就被文件系统写入口拦住(__sb_start_write),因此可能几乎没有新的 block request 被下发。%wa 仍接近 0 并不矛盾。

为什么 vmstat b / procs_blocked 可能完全不涨

  • vmstat b 的定义:blocked waiting for I/O to complete
  • /proc/stat procs_blocked 的定义:同上。
  • fsfreeze 场景:dd 虽然进入 D,但等待的对象是”文件系统解冻/写入口许可”(percpu_rwsem_wait),不属于”waiting for I/O to complete”的统计口径,因此 b / procs_blocked 可能保持 0。

这也正是 Gregg 强调的点:Linux load average 把 TASK_UNINTERRUPTIBLE 也算进去,而现代内核中该状态不仅用于磁盘 I/O,还会被某些锁/内核路径使用,因此”load 高但 CPU/iowait 不高”是完全可能的。

5.3 实验工作流

flowchart TD
    A[准备: loopback+XFS 挂载 /mnt/loadlab] --> B[基线采样 10s]
    B --> C[fsfreeze -f /mnt/loadlab]
    C --> D[启动多路 dd 写入 frozen FS]
    D --> E[采证据: loadavg / procstat / vmstat / iostat / ps / stack]
    E --> F{观察到: D增多 + load上升 + iowait低 + r/b不涨?}
    F -->|是| G[fsfreeze -u 解冻 → dd恢复/退出]
    F -->|否| H[调大 dd 数量 / 延长冻结 / 确认写对挂载点]
    G --> I[清理 umount + losetup -d]

5.4 操作步骤

步骤一:基线采样(冻结前)

我先记录了一份基线数据:

1
2
3
4
5
date
cat /proc/loadavg
grep -E "procs_running|procs_blocked" /proc/stat
vmstat 1 3
iostat -xz 1 3

果然:load 低、D 少、procs_blocked=0vmstat b=0wa≈0

步骤二:冻结文件系统

1
sudo fsfreeze -f /mnt/loadlab

冻结后,新写与其他修改操作会被 halt,写入者阻塞直到解冻。

如果 fsfreeze -f 很慢,通常是因为要先完成/刷出正在进行的事务、脏数据与日志。

步骤三:启动多路 dd 写入(让它们卡住)

1
2
3
for i in $(seq 1 16); do
  dd if=/dev/zero of=/mnt/loadlab/frozen.$i bs=1M count=1024 oflag=dsync status=none &
done

我启动了 16 路 dd。正如预期,这些进程全部”卡住不退出”,并逐渐在 ps/top 中显示为 D。

步骤四:采集证据链

1) 确认 D 数量与 load 上升的关系

1
2
cat /proc/loadavg
watch -n 1 'ps -eLo pid,tid,stat,wchan:32,comm --sort=stat | awk '\''$3 ~ /D/ {a[++n]=$0} END {print "TOTAL:", n; for(i=1;i<=n;i++) print a[i]}'\'''

我观察到:

  • D_count 接近我启动的 dd 数量(16)。
  • loadavg 的 1-min 值开始上升(注意它每 ~5 秒更新一次,且是指数衰减平均)。
  • 第四字段中 runnable 可以为 0,但 load 仍然在升高。

2) 对照:为什么 iowait / vmstat r,b 仍几乎为 0

1
2
vmstat 1 5
grep -E "procs_running|procs_blocked" /proc/stat

我观察到:

  • vmstat r=0(dd 不 runnable)。
  • vmstat b=0procs_blocked=0(等待”解冻许可”不计入”waiting for I/O complete”的口径)。
  • %wa 仍低(冻结后未持续产生 I/O)。

这个结果与我之前理清的机理完全吻合。

3) 证明 dd 卡在 __sb_start_write/percpu_rwsem_wait

1
2
3
PID=$(ps -eLo stat,pid,comm | awk '$1 ~ /^D/ && $3=="dd" {print $2; exit}')
echo "PID=$PID"
sudo cat /proc/$PID/stack

我看到的栈形态:

1
2
3
4
5
6
percpu_rwsem_wait
__percpu_down_read
__sb_start_write
vfs_write
ksys_write
...

这与内核文档描述的”冻结时 sb_start_write 等待 thaw”完全吻合。等待点是 superblock 侧的 percpu rwsem,而不是块层 I/O 完成等待。

4) 定位 dd 到底在写哪个文件与挂载点

1
2
3
sudo ls -l /proc/$PID/fd | head
sudo readlink -f /proc/$PID/fd/* | head
findmnt -T /mnt/loadlab/frozen.1

我用这一步排除了”dd 并没写到被冻结的文件系统”导致现象不一致的可能。

5) 验证 kill -9 对 D-state 进程不生效

1
2
3
sudo kill -9 $PID
sleep 1
ps -p $PID -o pid,stat,comm

果然仍然是 D。原因:TASK_UNINTERRUPTIBLE 不会因为信号而提前返回;只有等待条件满足(例如解冻后被显式唤醒)才会返回可运行态,届时挂起的 SIGKILL 才会被处理。

6) 可选:用 tracefs 证明”冻结后 dd 阶段几乎无 block I/O”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 若不存在则挂载
sudo mount -t tracefs nodev /sys/kernel/tracing 2>/dev/null || true

# 清空并开启 block 事件
echo 0 | sudo tee /sys/kernel/tracing/tracing_on >/dev/null
echo   | sudo tee /sys/kernel/tracing/trace >/dev/null
echo 1 | sudo tee /sys/kernel/tracing/events/block/block_rq_issue/enable >/dev/null
echo 1 | sudo tee /sys/kernel/tracing/events/block/block_rq_complete/enable >/dev/null
echo 1 | sudo tee /sys/kernel/tracing/tracing_on >/dev/null

sleep 5

echo 0 | sudo tee /sys/kernel/tracing/tracing_on >/dev/null
sudo tail -50 /sys/kernel/tracing/trace

我看到的结果:冻结”完成之后”,block 事件很稀少甚至没有。这进一步支持了”dd 卡住并非在等待块 I/O 完成”的判断。

7) 可选:看 dmesg 是否出现 hung task

1
dmesg -T | tail -200

如果 dd 在 D 中持续超过 hung task 超时,内核可能打印”task blocked for more than … seconds”并附带 backtrace,常能直接看到 __sb_start_write

步骤五:解冻并收尾

1
sudo fsfreeze -u /mnt/loadlab

一旦解冻,被阻塞的写操作继续推进;之前发送的 kill -9 在它们醒来后果然生效了,进程退出了。

5.5 这个实验让我明白的原理

fsfreeze 实验训练了我区分两个层次:

层次 含义 典型表现
I/O device slow 块设备本身吞吐不足 iostat await/aqu-sz 高,vmstat b
kernel code path / filesystem path / wait point slow 文件系统或锁路径阻塞 D-state 增多、load 升高,但 iostat/vmstat b 可能不高

两者都可能让任务进 D-state,并都可能把 load 拉高。Gregg 文里后半段专门强调了这件事:现代 Linux 的 TASK_UNINTERRUPTIBLE code path 已经很多,包含某些锁等待。

5.6 时间关系图:freeze、__sb_start_write 阻塞与 loadavg 采样如何错位

sequenceDiagram
    participant U as 用户态 dd
    participant V as VFS(vfs_write)
    participant S as Superblock gate(__sb_start_write)
    participant K as Scheduler/loadavg
    participant P as /proc观测(vmstat/proc_stat)

    Note over K: loadavg 每 ~5秒更新一次 (LOAD_FREQ=5*HZ+1)
    U->>V: write()
    V->>S: __sb_start_write()
    Note over S: 若已冻结: 等待 thaw (percpu_rwsem_wait)
    S-->>U: 线程进入 TASK_UNINTERRUPTIBLE (D)
    P-->>P: vmstat 每 1s 采样 r/b;procs_blocked 只计 waiting for I/O complete
    K-->>K: 采样 nr_running+nr_uninterruptible → loadavg 上升
    Note over P: iowait 可能仍低:冻结后阶段无持续 block I/O

5.7 fsfreeze 场景的处置策略:止血 → 改进 → 修复

立即止血

  1. 立刻解冻(最优先):sudo fsfreeze -u /mnt/loadlab。冻结语义就是”写者阻塞直到解冻”,所以解冻是最快恢复方式。
  2. 解冻后再 kill:D 中 kill -9 不会让它立刻退;解冻后醒来才会处理信号。
  3. 避免冻结根分区:可能让系统服务/日志/登录都异常。

中期改进

  • 降低脏页高水位vm.dirty_* 直接影响冻结前 flush 的时长与冲击。
  • 用 cgroup 做隔离/限流:对做快照/备份的后台任务限流,减少”冻结窗口压力”。
  • 把”冻结”当成必须显式受控的运维动作:快照/备份链路要做超时、失败回滚(确保一定 thaw)。

长期修复

  • 尽量避免在高并发在线写入场景使用文件系统级冻结,转用更细粒度的一致性策略(数据库 checkpoint、逻辑快照、WAL/redo 机制)。
  • 业务写路径尽量减少同步阻塞点fsync/小同步写过密),采用异步/批量化。
  • 可选:启用 PSI 做”真正 stall”监控。RHEL8 系常默认禁用 PSI(CONFIG_PSI_DEFAULT_DISABLED),可用内核参数 psi=1 启用。

6. 实验五:把 D-state 的成因抓出来

想真正掌握这类问题,我必须从”看数值”走到”看 code path”。

抓 wait channel

我拿了一个 D-state PID:

1
2
PID=<某个D状态进程PID>
ps -o pid,tid,stat,wchan:32,comm,args -p $PID -L

wchan 对应的是任务当前在内核里睡眠的等待点。

抓内核栈

1
sudo cat /proc/$PID/stack

/proc/pid/stack 提供该进程的符号化 kernel stack(前提是内核启用了 CONFIG_STACKTRACE)。

我的读法总结

实验场景 典型栈特征 含义
实验三(fio 同步 I/O) 块层、文件系统、写回相关函数 “真 I/O 路径阻塞”
实验四(fsfreeze) freezesuperblock__sb_start_writepercpu_rwsem_wait “文件系统控制路径阻塞”
真实故障 rwsem_*mutex_*down_* “某些锁原语走 TASK_UNINTERRUPTIBLE”

Gregg 文章甚至给出了 rwsem_down_read_failed() 这类例子。这一步是整套实验里最接近”实战排障能力”的部分。


7. 实验六:用 ftrace 看块层 issue/complete

我想把 iostat await 背后的”issue 到 complete 之间到底发生了什么”变成可见事件。

如果 /sys/kernel/tracing 不存在,先挂载 tracefs

1
sudo mount -t tracefs nodev /sys/kernel/tracing 2>/dev/null || true

fio 运行期间开始追踪

1
2
3
4
5
6
7
8
9
10
echo 0 | sudo tee /sys/kernel/tracing/tracing_on
echo | sudo tee /sys/kernel/tracing/trace
echo 1 | sudo tee /sys/kernel/tracing/events/block/block_rq_issue/enable
echo 1 | sudo tee /sys/kernel/tracing/events/block/block_rq_complete/enable
echo 1 | sudo tee /sys/kernel/tracing/tracing_on

sleep 10

echo 0 | sudo tee /sys/kernel/tracing/tracing_on
sudo head -200 /sys/kernel/tracing/trace

分析

  • await 是平均值。Tracepoint 是单个 request 的生命周期事件。
  • 当我把 block_rq_issueblock_rq_complete 对上时,我真正理解了:loadavg 上去的背后,不是”磁盘慢”四个字,而是一批请求在排队、完成、唤醒线程,再次提交请求。

8. 实验七:按 Gregg 的思路做 off-CPU / uninterruptible 分析

Gregg 在文章里展示的是只过滤 TASK_UNINTERRUPTIBLE 的 off-CPU flame graph。他还明确写了:offcputime.py --state 2 需要 Linux 4.8+。Rocky 8.10 的 4.18 内核满足该前提。

我安装了 bcc-tools 后执行:

1
sudo /usr/share/bcc/tools/offcputime -K --state 2 -f 30 > /tmp/unint.stacks

如果还准备了 FlameGraph 脚本,可以继续转成 flame graph。即便不做图,这一步也已经把”TASK_UNINTERRUPTIBLE 的 off-CPU time”单独抓出来了。

对我而言,这一步的意义是:我不再只是说”有 D-state”,而是能说”D-state 的时间主要耗在这些内核路径上”。


9. 实验八:NFS hard mount——用群晖 NAS 复现”网络存储导致 D-state 与高 load”

Gregg 文中明确说过,Linux load average 可以被 disk 或 NFS I/O workload 拉高。我手头有一台群晖 NAS 和这台 Dell T430,正好可以做一个非常贴近真实故障的 NFS 实验。

9.1 实验拓扑

1
2
3
4
5
6
┌─────────────────────┐          ┌─────────────────────┐
│   群晖 NAS           │          │   Dell T430         │
│   IP: <NAS_IP>      │◄────────►│   IP: <T430_IP>     │
│   导出: /volume1/xx  │  局域网   │   挂载: /mnt/nfslab │
│   NFS Server        │          │   NFS Client        │
└─────────────────────┘          └─────────────────────┘

核心思路:在 Dell T430 上用 iptables 单向阻断到群晖的 NFS 流量,模拟”NFS 服务端不可达”。这比拔网线安全得多——随时 iptables -D 就能恢复。

9.2 群晖侧:确认 NFS 导出

在群晖 DSM 界面上:

  1. 控制面板 → 文件服务 → NFS:确认已启用 NFS 服务(建议 NFSv3 或 NFSv4 都可以)。
  2. 控制面板 → 共享文件夹:选一个共享文件夹(例如 nfslab),点 编辑 → NFS 权限,添加一条规则:
    • 服务器名称/IP:<T430_IP>(或 * 用于测试)
    • 权限:读写
    • Squash:映射为 admin无映射(实验无所谓)
    • 安全性:sys

确认后,群晖会导出类似 /volume1/nfslab 的路径。

在 Dell T430 上可以验证:

1
showmount -e <NAS_IP>

应该能看到导出列表。

9.3 Dell T430 侧:挂载 NFS

1
2
sudo mkdir -p /mnt/nfslab
sudo mount -t nfs <NAS_IP>:/volume1/nfslab /mnt/nfslab

关键点:Linux NFS 默认就是 hard mount。也就是说,当 NFS 服务端不可达时,客户端的 NFS 操作会无限期挂起(进入 D-state),直到服务端恢复。这正是我们实验所需要的行为。

可以用 mount | grep nfslab 确认挂载选项,通常会看到 hard 或者没有显式的 soft(默认即为 hard)。

先做一个简单的读写测试,确认 NFS 正常工作:

1
2
3
echo "NFS test" | sudo tee /mnt/nfslab/test.txt
cat /mnt/nfslab/test.txt
ls -la /mnt/nfslab/

9.4 基线采样

在执行阻断之前,我先记录了一份基线(四个观测窗口保持运行):

1
2
3
4
5
date
cat /proc/loadavg
grep -E "procs_running|procs_blocked" /proc/stat
vmstat 1 3
iostat -xz 1 3

此时一切正常:load 低、D-state 为 0、vmstat b=0wa≈0

9.5 制造故障:用 iptables 阻断 NFS 流量

这是实验的核心操作。我用 iptables 在 Dell T430 上阻断到群晖的所有流量:

1
2
sudo iptables -A OUTPUT -j DROP -d <NAS_IP>
sudo iptables -A INPUT -j DROP -s <NAS_IP>

为什么用 iptables 而不是拔网线:iptables 只阻断到特定 IP 的流量,不影响 SSH 连接和其他网络服务。而且恢复只需一条命令,不存在”拔了线进不去机器”的风险。

此时 NFS 连接实际上还没有断——Linux NFS 客户端有超时和重试机制。我需要制造一些 NFS 操作来触发阻塞。

9.6 触发阻塞:启动多路 NFS 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
# 启动多个会访问 NFS 的操作
for i in $(seq 1 8); do
  ls -laR /mnt/nfslab/ &
done

for i in $(seq 1 8); do
  dd if=/dev/zero of=/mnt/nfslab/block.$i bs=1M count=100 oflag=dsync status=none &
done

# 再来几个 stat/find 操作
df /mnt/nfslab &
find /mnt/nfslab -type f &
stat /mnt/nfslab/test.txt &

这些操作全部会卡住——NFS hard mount 下,客户端会不断重试 RPC 请求,进程进入 D-state。

9.7 我观察到的现象

我的实验实际经历了三个阶段。

阶段一:iptables 阻断后启动 ls / dd → load 上升,iowait 保持 0

lsdddffindstat 全部卡住并出现在 D-state 列表中。wchan 显示的是 NFS/RPC 相关的等待点。

  • loadavg 持续上升。
  • iowait 一直保持 0
  • vmstat b 也没有上升。
  • 本地 iostat 完全平静。

这与 fsfreeze 实验的现象高度一致:D-state 拉高了 load,但由于没有真实的块 I/O 被下发(NFS RPC 在网络层就被 iptables 丢掉了,根本没有走到块设备层),所以 iowait 和 vmstat b 都不会响应。

阶段二:放开 iptables → iowait 开始上升

当我执行 iptables -D 恢复到群晖的网络连通后,NFS 客户端与服务端重新建立通信,之前积压的大量 NFS RPC 请求开始被实际处理。此时:

  • iowait 开始上升——因为 NFS 请求真正开始在群晖侧执行 I/O,客户端也开始有真正的”等待 I/O 完成”。
  • D-state 进程开始逐步醒来。
  • vmstat b 上升。
  • Load 开始缓慢下降(但指数衰减使它不会立刻归零)。

阶段三:iowait 升到 18 时再次用 iptables 阻断 → iowait 稳定在 18

我观察到 iowait 上升到约 18 的时候,再次执行 iptables 阻断规则。此时:

  • iowait 稳定在了 18,不再继续上升,也不下降
  • D-state 再次增多,load 再次开始上升。

这个现象非常有意思。iowait 的内核统计逻辑是:当某个 CPU 处于 idle 且至少有一个该 CPU 上的任务在等待 I/O 完成时,该 CPU 的 idle 时间会被计入 iowait 而非 idle。当我第二次阻断网络后:

  • 已经提交到群晖的那批 NFS 请求不会再有响应回来,所以这些请求的”等待 I/O 完成”状态会维持。
  • 但也不会有新的 I/O 被下发(新操作在 RPC 层就被阻住了)。
  • 因此 iowait 既不涨(没有新 I/O 加入等待队列)也不降(旧的等待还没完成),稳定在了阻断那一刻的值。

窗口 C(iostat)

  • 自始至终,本地磁盘的 await%util 几乎没有变化。这是最关键的对比点:load 上升了,D-state 大量出现了,但本地磁盘完全没有压力。

9.8 抓内核栈:看 NFS D-state 到底卡在哪里

1
2
3
4
PID=$(ps -eLo stat,pid,comm | awk '$1 ~ /^D/ && ($3=="ls" || $3=="dd" || $3=="df") {print $2; exit}')
echo "PID=$PID"
sudo cat /proc/$PID/stack
cat /proc/$PID/wchan

我看到的栈与前面实验完全不同。典型的 NFS D-state 栈会包含类似以下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
[<0>] rpc_wait_bit_killable+0x1e/0xa0 [sunrpc]
[<0>] __rpc_execute+0x10b/0x460 [sunrpc]
[<0>] rpc_execute+0xc7/0x100 [sunrpc]
[<0>] rpc_run_task+0x13c/0x160 [sunrpc]
[<0>] rpc_call_sync+0x50/0xa0 [sunrpc]
[<0>] nfs3_rpc_wrapper+0x20/0xa0 [nfsv3]
[<0>] nfs3_proc_getattr+0x72/0xb0 [nfsv3]
[<0>] __nfs_revalidate_inode+0xe2/0x290 [nfs]
[<0>] nfs_getattr+0x1d2/0x400 [nfs]
[<0>] vfs_statx+0x8a/0xe0
[<0>] __do_sys_statx+0x3b/0x80
[<0>] do_syscall_64+0x5b/0x1b0
[<0>] entry_SYSCALL_64_after_hwframe+0x61/0xc6

这些栈清楚地表明:进程不是在等本地磁盘,而是在等 NFS RPC 请求的响应。RPC 层在不断重试发送到群晖的请求,但由于 iptables 把包全丢了,响应永远不会回来,进程就一直卡在 D-state。

9.9 与前面实验的对比

维度 实验三(fio 本地 I/O) 实验四(fsfreeze) 实验八(NFS 阻断)
load 上升
D-state 出现
本地 iostat 异常 ✅ await/aqu-sz 高 ❌ 几乎无 I/O ❌ 本地磁盘完全正常
vmstat b 上升 ❌ 可能为 0 ⚠️ 视内核统计口径,实验未上升
iowait 上升 ⚠️ 实验未上升
典型 wchan/stack 块层函数 __sb_start_write NFS/RPC 函数
阻塞原因 磁盘吞吐不足 文件系统冻结互斥 网络不可达 / NFS 服务端无响应
kill -9 是否有效 通常有效 ❌ 等解冻 ⚠️ 取决于 wait path 是否 killable

这张表清楚地说明:三种完全不同的根因,都能制造出 D-state 和高 load,但它们在 iostat、vmstat、内核栈上的表现截然不同

9.10 恢复:删除 iptables 规则

1
2
sudo iptables -D OUTPUT -d <NAS_IP> -j DROP
sudo iptables -D INPUT -s <NAS_IP> -j DROP

恢复后,NFS 客户端会自动重新建立连接,之前卡住的操作会逐步恢复。我观察到:

  • D-state 进程开始消失。
  • loadavg 开始下降(但由于指数衰减,不会立刻降到 0)。
  • 被阻塞的 lsdddf 等命令开始继续执行或报错退出。
  • 如果之前发送过 kill -9,进程在恢复通信后会处理信号并退出。

实验结束后卸载 NFS:

1
sudo umount /mnt/nfslab

如果 umount 卡住(因为还有进程在使用该挂载点),可以用 sudo umount -l /mnt/nfslab 做 lazy unmount。

9.11 这个实验让我明白的原理

这一步彻底帮我摆脱了”D-state = 本地磁盘一定打满”的误解。在 IC 设计环境中,大量 EDA 工具的项目数据存放在 NFS 上,NFS 服务端故障、网络抖动、交换机拥塞等问题都可能导致大量客户端进程进入 D-state,表现为”服务器 load 飙升但 CPU 和本地磁盘都看起来正常”。

我现在的排障思路是:load 高 → D-state 多 → 看 stack → 区分是本地块设备、文件系统路径、还是网络存储(NFS/CIFS)的问题。NFS 的 stack 里一定会出现 rpc_*nfs_* 这类函数,非常容易辨认。


10. 工具与指标对照表

工具/文件 命令 关键字段 fsfreeze 实验中的典型解读
load average cat /proc/loadavg 前三项均值;第四项 runnable/total load 上升即代表 nr_running+nr_uninterruptible 均值上升;runnable 可为 0
runnable/blocked 计数 grep -E 'procs_running\|procs_blocked' /proc/stat procs_runningprocs_blocked procs_blocked 只统计”waiting for I/O complete”,冻结等待不一定计入
vmstat vmstat 1 rbwa b 同”waiting for I/O complete”;冻结等待时 b 可能为 0
iostat iostat -xz 1 await%util 冻结后阶段可能几乎无新 I/O
进程状态 ps -eLo stat,pid,tid,... D D = 不可中断睡眠;可由 I/O 或锁/冻结路径触发
等待点 cat /proc/$PID/wchan 符号名 看到 __sb_start_write / percpu_rwsem_wait 可定性为冻结互斥等待
内核栈 cat /proc/$PID/stack 调用链 __sb_start_write vs 块层函数来区分等待类型
tracefs 事件 /sys/kernel/tracing/... block_rq_issue/complete 冻结后无持续 block 事件 ≈ 不是在等块 I/O 完成
dmesg dmesg -T hung task / backtrace 冻结太久可能报 hung task 并显示等待栈

11. 我的优先级诊断清单

在生产环境中遇到”load 高”的告警时,我现在会按以下顺序逐步排查:

顺序 目的 命令
1 判断 load 是否来自 D-state cat /proc/loadavg + ps -eLo stat \| awk '$1~/^D/'
2 区分 runnable vs blocked vmstat 1r/bgrep procs_ /proc/stat
3 定位 D-state 的 code path sudo cat /proc/<pid>/stack
4 证明是否真有块 I/O 活动 iostat -xz 1;可选 tracefs block 事件
5 查日志/超时 dmesg -T \| tail -200
6 应急处置 如果确认是 freeze:fsfreeze -u <mountpoint>;NFS 则排查网络/服务端

12. 我做完这套实验后形成的结论

结论 1:load average 是”系统 demand”,不是简单 CPU utilization

Gregg 文章和 /proc/loadavg man page 都非常明确:Linux load 统计 runnable tasks + D-state tasks。

结论 2:高 load 要先拆成两半看

我现在碰到 load 告警,会先问自己:

  • r 高,还是 b 高?
  • procs_running 在涨,还是 D-state 数在涨?

结论 3:iowait 只能当辅证,不能当裁判

内核 /proc 文档已经把它说得很死:iowait 不可靠,不能单独代表真实阻塞程度。真正更有解释力的是:b/procs_blocked、D-state 数量、await/aqu-szwchan/proc/<pid>/stack、tracepoint / off-CPU 栈。

结论 4:D-state 的成因要按 code path 分层理解

我至少要把它分成三类:

类别 说明 代表性 wchan/stack
真实块设备 / FS I/O 路径阻塞 磁盘吞吐不足 块层函数、writeback 相关
文件系统控制路径阻塞 freeze、writeback、metadata path __sb_start_writefreeze 相关
内核锁 / wait primitive 阻塞 某些 rwsem、mutex rwsem_down_*mutex_lock_*

13. 实验记录表模板

每个实验我都记了这些字段:

字段 说明
时间点 记录采样时刻
/proc/loadavg 1/5/15 分钟值 + runnable/total
vmstat: r b wa runnable / blocked / iowait
/proc/stat: procs_running procs_blocked 内核统计
iostat: await aqu-sz %util I/O 等待与队列
D-state 线程数 ps 统计
代表性 wchan 等待点函数名
代表性 /proc/<pid>/stack 顶部 5-10 行 内核调用栈
结论 runnable load / uninterruptible load / 混合

做了两三轮之后,我对 load / iowait / D-state 的关系建立了非常牢固的直觉。


14. 实验结束后的清理

1
2
3
4
5
sudo umount -l /mnt/loadlab
sudo losetup -d "$LOOPDEV"
sudo rm -f /var/tmp/loadlab/loadlab.img

sudo umount -l /mnt/nfslab

参考资料