我一直想把 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 内核)上,设计并执行了下面这套实验。
我想验证的三条主线:
load average不是单纯 CPU 忙碌度,而是 Linux 的”system load”。load average既会被 runnable tasks 拉高,也会被 uninterruptible tasks(D state) 拉高。iowait只是 CPU 统计口径里的一个字段,不能单独拿来当根因。
安全提示:这套实验我建议在实验 VM 或 lab 主机上做,不要直接在生产 root filesystem 上做。特别是
fio和fsfreeze这两组实验,都会明显制造卡顿。
0. 准备实验环境
RockyLinux 8.10 默认在 4.18.0-553 这条内核线上,足够做 Gregg 文里提到的大多数观察,包括 perf、ftrace/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_running 和 procs_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]}'\'''
ps 将 D 定义为 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。
vmstat中r接近 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(指数衰减的效果很明显)。vmstat的r明显上升。vmstat的b保持接近 0。ps里几乎看不到 D-state。iostat的await/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 1的await、aqu-sz明显上升。
分析
这一步正对应 Gregg 文中最重要的一句话:Linux load 不只追踪 runnable tasks,也追踪 uninterruptible tasks;因此磁盘或 NFS I/O 负载可以把 load 拉高。
同时我也看到了:wa 确实上升了,但它只是 CPU accounting 里的一个字段。内核 /proc 文档 明确提醒了三件事:
- CPU 不会真的”等 I/O”。
- 多核下 iowait 很难精确归因。
/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=0、vmstat b=0、wa≈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=0与procs_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 场景的处置策略:止血 → 改进 → 修复
立即止血
- 立刻解冻(最优先):
sudo fsfreeze -u /mnt/loadlab。冻结语义就是”写者阻塞直到解冻”,所以解冻是最快恢复方式。 - 解冻后再 kill:D 中
kill -9不会让它立刻退;解冻后醒来才会处理信号。 - 避免冻结根分区:可能让系统服务/日志/登录都异常。
中期改进
- 降低脏页高水位:
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) | freeze、superblock、__sb_start_write、percpu_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_issue与block_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 界面上:
- 控制面板 → 文件服务 → NFS:确认已启用 NFS 服务(建议 NFSv3 或 NFSv4 都可以)。
- 控制面板 → 共享文件夹:选一个共享文件夹(例如
nfslab),点 编辑 → NFS 权限,添加一条规则:- 服务器名称/IP:
<T430_IP>(或*用于测试) - 权限:读写
- Squash:
映射为 admin或无映射(实验无所谓) - 安全性:
sys
- 服务器名称/IP:
确认后,群晖会导出类似 /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=0、wa≈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
ls、dd、df、find、stat 全部卡住并出现在 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)。- 被阻塞的
ls、dd、df等命令开始继续执行或报错退出。 - 如果之前发送过
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_running、procs_blocked |
procs_blocked 只统计”waiting for I/O complete”,冻结等待不一定计入 |
| vmstat | vmstat 1 |
r、b、wa |
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 1 看 r/b;grep 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-sz、wchan、/proc/<pid>/stack、tracepoint / off-CPU 栈。
结论 4:D-state 的成因要按 code path 分层理解
我至少要把它分成三类:
| 类别 | 说明 | 代表性 wchan/stack |
|---|---|---|
| 真实块设备 / FS I/O 路径阻塞 | 磁盘吞吐不足 | 块层函数、writeback 相关 |
| 文件系统控制路径阻塞 | freeze、writeback、metadata path | __sb_start_write、freeze 相关 |
| 内核锁 / 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
参考资料
- Brendan Gregg - Linux Load Averages: Solving the Mystery
- proc_loadavg(5) - Linux manual page
- vmstat(8) - Linux manual page
- iostat(1) - Linux manual page
- ps(1) - Linux manual page
- The /proc Filesystem — Linux Kernel documentation
- proc_pid_stat(5) - Linux manual page
- proc_pid_stack(5) - Linux manual page
- ftrace - Function Tracer — Linux Kernel documentation
- fsfreeze(8) — Arch manual pages
- Rocky Linux Release and Version Guide