记一次真实的负载虚高修复:为 hinas(Hi3798MV100)重编译 4.4.35 内核,让 load average 不再被 D 线程绑架
关键词:hinas · Hi3798MV100/200/300 · HiSTBLinuxV100R005C00 · 内核 4.4.35 · load average · D 状态线程
一、现象:一台"永远满负载"的 NAS
设备基于 hinas 项目(机顶盒改造的 NAS,底层是华为开源的HiSTBLinuxV100R005C00SDK,对应 Hi3798MV100/200/300 系列 SoC),内核版本4.4.35_ecoo_81082668。
拿到手第一眼就不对劲:
$ uptime 12:16:16 up 6 days, 23:32, 0 users, load average: 6.06, 6.05, 6.01负载常年卡在 6.0 附近,一分钟、五分钟、十五分钟三档全部贴满,但 CPU 明明是空闲的——top里没有哪个进程吃 CPU,系统响应也正常。监控系统却因为 load average 告警刷屏,完全没法用。
负载 6.0 意味着平均有 6 个任务永远在排队,可这台 NAS 明明闲得很。这不符合直觉,必须查。
二、定位:6 个常年 D 状态的内核线程
先看谁在"跑":
$ ps -eo state,pid,ppid,comm,wchan:40,args | awk 'NR==1 || $1 ~ /D/' S PID PPID COMMAND WCHAN COMMAND D 756 2 log_udisk_task msleep [log_udisk_task] D 1175 2 HI_HDMI_kThread msleep [HI_HDMI_kThread] D 1176 2 HI_HDMI_kCEC msleep [HI_HDMI_kCEC] D 1234 2 HI_VPSS_Process VPSS_OSAL_WaitEvent [HI_VPSS_Process] D 1253 2 cpu_avs msleep [cpu_avs] D 1256 2 temperature_con msleep [temperature_con]正好 6 个 D 状态线程,一个不多一个不少,和负载数字 6.0 完全吻合。它们是海思 SDK 的常驻内核线程:
| PID | 线程 | wchan | 职责 |
|---|---|---|---|
| 756 | log_udisk_task | msleep | 日志 U 盘轮询 |
| 1175 | HI_HDMI_kThread | msleep | HDMI 内核线程 |
| 1176 | HI_HDMI_kCEC | msleep | HDMI CEC 控制 |
| 1234 | HI_VPSS_Process | VPSS_OSAL_WaitEvent | 视频处理子系统,等待事件 |
| 1253 | cpu_avs | msleep | CPU 自适应电压调节 |
| 1256 | temperature_con | msleep | 温度监控 |
注意它们的 wchan:msleep、WaitEvent——它们根本没有在干活,是在睡觉,靠超时醒来检查一下再睡回去。这是海思 SDK 的轮询设计,不是卡死,也不是故障。
那问题出在哪?出在**"睡觉"的方式**上。
三、根因:内核把 D 状态线程算进了 load average
msleep()底层是TASK_UNINTERRUPTIBLE(不可中断睡眠)睡眠,也就是D 状态。而 Linux 从 2.6 起,load average 的统计口径是:
load average ≈ nr_running(可运行) + nr_uninterruptible(不可中断睡眠)看内核源码kernel/sched/loadavg.c,注释写得很直白:
/* * The global load average is an exponentially decaying average of * nr_running + nr_uninterruptible. */核心统计函数:
longcalc_load_fold_active(structrq*this_rq){longnr_active,delta=0;nr_active=this_rq->nr_running;nr_active+=(long)this_rq->nr_uninterruptible;// ← D 线程在这里被数进来if(nr_active!=this_rq->calc_load_active){delta=nr_active-this_rq->calc_load_active;this_rq->calc_load_active=nr_active;}returndelta;}nr_uninterruptible的语义是"正在等待 IO 完成"的线程,设计初衷是让负载反映 IO 饥饿。但海思 SDK 这些轮询线程根本不是等 IO,只是用不可中断睡眠当延时器,于是它们被永远算进了活跃数。SDK 里这种写法一多,负载就成了摆设。
结论:这是 hinas 沿用的海思 SDK 内核的一个统计口径缺陷——D 状态线程污染 load average,导致 NAS 监控失效。
四、方案对比:两条路
问题定性清楚后,摆在我们面前有两条路:一条在用户空间"替换",一条在内核里"修改"。都实际验证过,结论是内核补丁更彻底,但用户空间方案作为快速止血同样值得一提。
方案 A:用户空间替换——伪造 /proc/loadavg(不动内核,5 分钟)
思路:内核不改一个字,在用户空间做一个"干净的 loadavg"文件,然后用mount --bind把它盖到/proc/loadavg上,所有读负载的程序(uptime、监控、脚本)看到的都是修正后的数字。
守护脚本每 5 秒做一次:
# 读 /proc/stat 的 procs_running:系统当前真正可运行的线程数,天然不含 D 线程running=$(awk'/procs_running/{print $2}'/proc/stat)# 照抄内核指数衰减公式(EXP_1/EXP_5/EXP_15 = 1884/2014/2037,FIXED_1 = 2048)load1=$(((load1*1884+running*164)>>11))load5=$(((load5*2014+running*34)>>11))load15=$(((load15*2037+running*11)>>11))把结果写成文件,然后:
mount--bind/tmp/fake_loadavg /proc/loadavg优点:不编译、不动内核、即时生效、umount /proc/loadavg一秒还原。
缺点:治标不治本——骗的是"读负载的人",不是修统计口径;重启后要靠 rc 脚本重新挂载;系统里多一个常驻守护进程要维护;任何绕过/proc/loadavg直接读内核avenrun的途径都盖不住。
方案 B:内核补丁——改 loadavg.c 统计口径(最终采用)
既然是内核口径问题,就在内核里修:load average 只统计可运行任务(running),不再把 D 状态线程算进去。既保留"负载反映 CPU 争抢"的本意,又不会被 SDK 的轮询线程绑架。
对比小结
| 维度 | A 用户空间替换 | B 内核补丁(采用) |
|---|---|---|
| 改动范围 | 守护脚本 + bind mount | kernel/sched/loadavg.c一行 |
| 是否需要编译 | 否 | 是(约 40~60 分钟) |
| 是否需要重启 | 否,即时生效 | 是 |
| 可逆性 | umount即还原 | 保留旧内核镜像即可回滚 |
| 治本程度 | 治标(覆盖展示层) | 治本(修正统计口径) |
| 维护成本 | 常驻进程 + 重启重挂 | 无,一次编译长期有效 |
| 风险 | 极低 | 低(配置正确 + 留回滚即可) |
最终选择方案 B:对 hinas 这种要 7×24 稳定跑的 NAS,修统计口径才是一劳永逸,而且补丁极小、风险可控。方案 A 留作应急止血手段,文章后半部分展开方案 B 的完整实施。
五、最终采用:内核补丁,只统计"真正在跑"的任务
补丁很小,改动kernel/sched/loadavg.c一处:
--- a/kernel/sched/loadavg.c +++ b/kernel/sched/loadavg.c @@ -113,7 +113,7 @@ long calc_load_fold_active(struct rq *this_rq) long nr_active, delta = 0; nr_active = this_rq->nr_running; - nr_active += (long)this_rq->nr_uninterruptible; + /* PATCH: drop D-state (uninterruptible) tasks from loadavg */ if (nr_active != this_rq->calc_load_active) {calc_load_fold_active()是所有 CPU 每 5 秒(LOAD_FREQ)向全局计数calc_load_tasks折入活跃数的唯一入口,calc_global_load()再拿它做 1/5/15 分钟的指数衰减(系数EXP_1/EXP_5/EXP_15)。所以只改这一处,三档负载同时修正,而且不影响调度器任何逻辑——CFS 公平调度走的是 PELT/runnable统计,不依赖loadavg。
六、重编译部署流程(基于 HiSTBLinuxV100R005C00 SDK)
hinas 的 SDK 本体是闭源发布的,但它的基座就是华为开源的 HiSTBLinux SDK 仓库(HiSTBLinuxV100R005C00SPC060,4.4.35 完整内核),结构和开源版一致:内核源码在source/kernel/,配置用 SDK 自带的hi3798mv100_defconfig。整个流程如下:
- 准备工具链:SDK 自带
tools/交叉编译工具链(armv7,glibc),解压即用,无需额外安装。 - 解包 SDK 源码,进入
source/kernel/,确认版本:$ head -3 Makefile VERSION = 4 PATCHLEVEL = 4 SUBLEVEL = 35 - 应用补丁:把上面的 diff 打进
kernel/sched/loadavg.c。 - 配置:用 SDK 的
hi3798mv100_defconfig作为基准配置(沿用厂商全部驱动与海思模块,CONFIG_IKCONFIG等保持原样,不引入任何新功能,只带一行行为变更)。 - 编译:
4 核机器全量编译约 40~60 分钟。老内核编译很快,瓶颈只在磁盘 IO。$ make ARCH=arm hi3798mv100_defconfig $ make ARCH=arm CROSS_COMPILE=arm-hisiv500-linux- -j4 zImage modules - 打包替换:生成新的
zImage与内核模块,更新 rootfs 对应目录,旧内核镜像与旧模块目录完整保留(见下节回滚),改好启动参数后重启。 - 验证:见下一节。
七、验证:负载回归正常
重启后:
$ uptime 09:41:02 up 0 min, 1 user, load average: 0.06, 0.11, 0.08负载从 6.0x 掉到0.1x 量级,与系统真实活动(/proc/stat的procs_running只有个位数)吻合。再观察 15 分钟窗口:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| loadavg(1/5/15) | 6.06 / 6.05 / 6.01 | 0.06 / 0.11 / 0.08 |
| D 状态线程 | 6 个(照常存在) | 6 个(照常存在,不再计入负载) |
| CPU 利用率 | 个位数 % | 个位数 %(无变化) |
| 监控告警 | loadavg 误报刷屏 | 全部消失 |
注意 D 线程依然存在、依然在轮询——我们修的是统计口径,不是干掉它们,行为零改变,只是它们不再污染负载数字。运行一周,监控稳定,无异常。
八、回滚与风险控制
- 回滚:启动时保留旧内核镜像(
zImage.bak)与旧模块目录,出问题改回启动参数即可,30 秒内回到原状。 - 风险:补丁只改一处加法运算,不涉及调度、内存、驱动;最坏情况是负载数值口径变化,不影响任何内核功能。
- 已知代价:负载将不再反映 IO 饥饿(纯 D 状态等 IO 的场景),对 NAS 这种本机盘阵场景影响可忽略,监控改为叠加磁盘 IO 指标兜底。
九、结语
这轮排查的本质:海思 SDK 内核把"轮询延时"用不可中断睡眠实现,导致loadavg口径被 D 线程污染,连累 hinas 这类机顶盒 NAS 的监控体系。修复只花了一行补丁 + 一次重编译——定位问题比解决问题难得多。
对 hinas 社区:希望这个修复能合并进后续版本。设备底子是华为开源的 HiSTBLinuxV100R005C00,任何基于它的盒子(NAS、电视盒、软路由)只要负载常年虚高,都可以照这个思路处理:
ps -eo state,comm,wchan找 D 线程,数一下个数,和 loadavg 对一下;- 对上了就是口径问题,不是性能问题;
- 一行补丁,重编译,完事。
相关开源参考:
- 海思 HiSTBLinux SDK:
github.com/JasonFreeLab/HiSTBLinuxV100R005C00SPC060(Hi3798MV100/200/300) - 4.4.35 完整内核(支持 Docker):
github.com/07bug/HiSTBLinuxV100R005C00SPC060