这一篇紧接之前进程那部分往下走。如果你只在课本上见过 fork、exec、wait 这些概念,但对“为什么机器负载高但 CPU 空闲”“为什么断开终端服务就没了”“为什么进程老是无缘无故被杀”这些日常现象还是只停留在猜的阶段,那这篇就是冲着你来的。
我尽量不堆理论,只讲我在实际操作过程中被坑过、也受益过的那些点:STAT 列里的各种状态到底代表什么,进程组、会话和控制终端是怎么互相纠缠的,load average 的计算到底藏着什么猫腻,以及 ulimit、OOM 这些资源边界为什么能把一个好端端的进程莫名其妙推下深渊。最后给到一套平时排查进程问题最常用的“三板斧”:pidstat、strace、lsof,分别用在什么场景、各自有什么坑,都会说清楚。
1. 看懂 STAT 列:R/S/D/Z 背后都是在等什么
1.1 R/S/D 三态:可运行、可中断睡眠、不可中断睡眠
很多新手刚接触 top 的时候,第一眼只盯着 %Cpu 那行,觉得 CPU 没打满机器就没事。但只要你见过一次负载几十、CPU 却闲得发慌的场面,你就会明白进程状态这一列的重要程度远超想象。
STAT 列里出现频率最高的几个字母,含义完全不同。R 代表进程此刻要么真的跑在某个 CPU 核上,要么已经在 run queue 里排队等着被调度。S 是TASK_INTERRUPTIBLE,进程在等待某个条件,但这个等待是可以被信号打断的,比如等网络数据、等定时器、等输入输出完成。服务里大量线程长时间停在 S 状态属于常态,不代表性能有问题。
D 状态就敏感了。它对应内核里的TASK_UNINTERRUPTIBLE,指的是进程进入了内核的关键路径,此刻不允许随便被信号打断。最常见的触发点包括:进程缺页时直接从磁盘读取文件内容、块设备驱动在写数据、网络文件系统在等待远端 I/O 回包。这些场景一旦被打断,数据一致性可能会被破坏,所以内核选择把进程挂起,故意不响应普通信号。这也是为什么 D 状态的进程经常出现“kill -9 都没反应”的现象,信号确实发过去了,但进程根本没到能处理信号的地方。
所以看到一堆 D 状态进程的时候,排查方向应该优先往存储、文件系统、网络文件系统那边走,而不是怀疑计算资源不够。我踩过最典型的一次坑,就是某台机器负载飙到几十,打开 top 一看 CPU 几乎全部空闲,后来才发现罪魁祸首是分布式存储的对端故障,一堆进程阻塞在远程 I/O 等待上,表象是进程问题,根因却在存储链路。
1.2 看到 Z 状态先找它爹
僵尸进程可能是进程状态里最容易被误会的。很多人一看到 Z 就紧张,下意识想 kill -9,但实际上僵尸进程是一个已经死透了的进程。它的代码、数据、内存资源早就释放了,剩下的只是内核保留的一个 task_struct 结构,目的是让父进程能通过 wait 系列系统调用拿到子进程的退出状态。子进程已经结束,只是没人来认领这个“死亡通知”。
这就会引出一个关键结论:僵尸进程不是它自己的问题,而是它父进程的问题。父进程既没有及时调用 wait,也没有正确处理 SIGCHLD 信号,导致子进程的尸体一直挂在进程表里。排查时先执行ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'找到僵尸进程和它的 PPID,再顺着ps -o pid,cmd -p <PPID>去看看它爹在干什么。如果父进程依然健在但没处理子进程结束信号,那要处理的是父进程;如果父进程自己已经卡死或者不退出,那就把它清掉,让内核的收养机制把僵尸进程的回收工作接管过去。
还要提醒一句:偶尔看到一两个 Z 状态,不一定代表系统故障。比如某些短生命周期的一次性脚本,父进程回收不及时,过一会自己就干净了。只有在僵尸进程数量持续增长,或者父进程长时间不退出时,才需要认真介入。
1.3 利用 /proc 查进程“卡”在哪里
状态搞清楚以后,可以再进一步,直接查看进程到底卡在哪个内核函数上。Linux 在 /proc 文件系统里给每个进程都暴露了不少诊断接口。
cat /proc/<pid>/wchan能显示进程当前正在等待的内核函数名。比如看到wait_on_page_bit,说明在等内存页回写;看到sock_alloc_send_pskb,说明在等网络发送缓冲。直接用ps -o wchan:30,cmd -p <pid>也可以拿到差不多的信息,只是输出宽度要限制一下。
需要注意,普通用户看 wchan 通常会显示为 0,这跟kernel.kptr_restrict这个内核参数有关。系统为了安全限制内核符号地址的暴露,非 root 用户基本看不到有意义的函数名。如果真的需要判断死锁或者定位长时间睡眠,可以尝试看/proc/<pid>/stack,但这个文件在很多生产环境里默认被关掉了,能读到算运气好,读不到也不用强求。更常用的替代方案是结合 strace 去跟踪系统调用,这个我们后面单独讲。
2. 会话与进程组:终端断开后,进程为什么不想死也得死
2.1 fork 之后的父子链与收养机制
学过进程模型都知道 fork 会创建子进程,但很多人没认真想过:父子进程之间的这份关系在进程退出后会留下什么连带反应。
正常情况下,子进程退出后由父进程 wait 回收。但如果父进程比子进程先退出呢?子进程就会变成孤儿进程。孤儿进程不会被系统直接丢弃,内核会把它的父进程改为 PID 为 1 的进程。在传统 Linux 里就是 init 进程,在现代 systemd 系统里就是 systemd 自己,它会负责帮这些孤儿进程收尸。
这个机制在日常运维中有个常见现象:你在终端里启动了一个后台服务,终端关闭后服务似乎还在跑,但ps看它的 PPID 却变成了 1。这个进程并没有被系统直接清理掉,只是被“收养”了。但如果它原本和终端会话绑得很紧,那就要小心下面要讲的 SIGHUP 信号了。
2.2 控制终端、前台进程组与会话长度
每次用户通过 SSH 或者本地终端登录,系统会创建一个会话,并分配一个控制终端。会话里可以包含多个进程组,其中只有一个是前台进程组。你按 Ctrl+C 产生的 SIGINT 信号,默认是发给前台进程组里所有进程的,而不是只发给某一个。
当你关闭终端窗口时,控制终端对应的会话会收到挂断事件,内核会向前台进程组发送 SIGHUP 信号。默认动作是终止进程,所以很多在终端里直接启动的服务会随着窗口关闭一起消失。理解了这条链路,你就能明白为什么有些人说“我用 nohup 启动服务,怎么关了终端还在”,也能明白为什么另一些人说“我明明在后台运行了,一断开还是没了”。
看进程的归属关系可以用这条命令:ps -o pid,ppid,pgid,sid,tty,cmd -p <pid>。PID 是进程唯一标识,PPID 是父亲,PGID 是进程组,SID 是会话 ID,TTY 是控制终端。如果 TTY 显示为?,说明这个进程已经离开了终端控制,通常是由守护进程机制或 systemd 拉起来的。
2.3 nohup、setsid、systemd 三种方案的取舍
搞清楚原理之后,真正想让进程活着离开终端,有三条常见路线。
nohup 的原理最简单,它只是让进程忽略 SIGHUP。命令写法是nohup ./yourapp >/var/log/yourapp.log 2>&1 &。注意一定要重定向标准输出和标准错误,否则进程虽然不被 SIGHUP 杀掉,但依然可能因为持有终端相关文件描述符而出现读写异常。
setsid 更进一步,它会新建一个会话,让进程完全脱离当前控制终端。命令是setsid ./yourapp。对比 nohup,setsid 是真正意义上的“脱离”,不再和终端有任何关系。临时跑一些测试进程时,我优先用这个。
systemd 是长期服务的正路。通过 systemd unit 文件拉起进程,不仅能脱离终端,还能获得自动重启、资源限制、日志管理等一整套能力。它的进程关系模型建立在 cgroup 上,不再依赖传统会话结构,这也是现在绝大多数生产服务推荐的方式。
三种方式不是互斥的,但选择标准很明确:临时测试用 setsid,快速救急用 nohup,长期稳定服务必须交给 systemd。
| 方式 | 原理 | 适用场景 |
|---|---|---|
| nohup | 忽略 SIGHUP | 临时后台任务,重定向日志后可用 |
| setsid | 新建会话并脱离控制终端 | 临时跑独立进程,避免和终端互相影响 |
| systemd | 通过 cgroup 管理进程生命周期 | 长期服务、开机自启、需要资源限制 |
3. load average 高但 CPU 空闲,问题究竟在哪
3.1 计算负载不只是一个计数
load average 是很多监控系统里最显眼的一个指标,但它也是被误解最深的指标。它并不是“CPU 使用率”,也不是简单的进程排队数量。
内核在统计负载时,会把处于TASK_RUNNING状态(可运行)和TASK_UNINTERRUPTIBLE状态(不可中断睡眠)的任务数做一个带衰减的平均。这就是为什么 D 状态进程多起来的时候,load 会一路走高,CPU 却可能完全空闲。负载高并不等于 CPU 忙,它更像是“系统里等待被处理的事情很多”,而等待的对象可能是 CPU,也可能是磁盘 I/O,还可能是某个远端存储回包。
查看方式直接读/proc/loadavg就能看到三个数,分别是过去 1 分钟、5 分钟、15 分钟的均值。只看第一个数容易一惊一乍,正确做法是三个数连起来看。如果 1 分钟远高于 15 分钟,说明系统正处于短时压力峰值;如果 15 分钟也持续在高位,说明问题已经积累很久了。
3.2 一个经典排查瞬间:负载 30、CPU 空闲的存储故障
我排查过的一台节点就出现过这个经典画面:外部监控显示 load 已经到 30 左右,可登录服务器打开 top,%Cpu(s) 那一栏的 us 和 sy 加起来都不到 10%。第一反应不对劲,随后执行了这几步,排查链路大致如下。
先用ps -eo pid,stat,comm | awk '$2 ~ /D/'过滤出 D 状态进程,结果刷出来一大片,全部集中在某个访问远程存储的线程上。再用iostat -x 1看底层磁盘,发现某块盘的%util接近 100%,w_await高得离谱。继续看dmesg -T | tail,能翻到文件系统超时和 I/O 错误的相关日志。到这里根因就很清楚了:不是运算资源不足,是后端存储链路出了问题,大量进程阻塞在不可中断的内核路径上,把 load 整体抬了上去。
这个现象也解释了一个实践原则:load 高的时候,不要第一时间去 kill 进程。那些 D 状态进程可能暂时根本杀不掉,因为信号已经被挂起,要等内核路径恢复以后进程才会继续处理信号。如果因为误判直接重启机器,可能会把文件系统置于恢复修复的尴尬境地,代价更大。
3.3 与 CPU 飙升的区分方法
CPU 飙升和 D 状态飙升都会造成 load 升高,但症状有明显差异。
CPU 忙的时候,top 里的 %Cpu(s) 会很高,大量进程处于 R 状态。这时候可以用pidstat -u 1 5看每个进程的 CPU 使用率,或者用top -H -p <pid>深入线程级别,找到具体是哪个线程在烧 CPU。
D 状态飙升的时候,%Cpu(s) 通常很低,但%wa可能不低,进程状态列会密密麻麻出现 D。此时更应该关注磁盘 I/O、文件系统状态、网络存储连通性。一个快速分流的口诀就是:先看状态列是 R 多还是 D 多,再决定是往计算方向查还是往存储方向查。这个判断比盯着 load 数值本身有用得多。
4. ulimit 与 OOM:资源边界是进程猝死的最大凶手
4.1 打开文件数和进程数限制
很多服务跑着跑着突然报错“too many open files”,第一反应经常是代码有 bug,但实际上这层限制往往藏得很深。Linux 对每个进程能打开的 fd 数量是有上限的,这个上限由 ulimit 控制。
查看当前会话限制用ulimit -a,重点看 open files 和 max user processes 两项。ulimit 分软限制和硬限制,软限制是可以调整的当前值,硬限制是普通用户能拉到的天花板。日常排查时经常遇到的就是软限制默认 1024 或者 65535,对一个高并发的服务进程来说根本不够用。
临时的办法是ulimit -n 65535,但只在当前 shell 及子进程里有效。正规的做法有两个层面:用 systemd 管理服务就在 unit 文件里写LimitNOFILE=65535;给 Linux 登录用户设置就在/etc/security/limits.conf里加配置。很多数据库类服务在官方文档里都会注明这几个参数,现实里出问题的大多是中间件接管了服务却没有同步设置资源限制。
4.2 overcommit 与 OOM killer 的原理
比 fd 限制更凶猛的,是内存资源耗尽可能带来的 OOM killer。Linux 内存分配走的是 overcommit 策略,内核允许程序申请比物理内存更大的虚拟内存,因为大多数程序申请了也不会立刻用满。只有当进程真正把自己声称要用的内存全部写脏时,物理内存才会被耗尽,内核不得不启动 OOM killer 挑进程杀掉来释放内存。
这个“挑进程”的过程是有一套打分机制的。内核会给每个进程计算一个 oom_score,得分高的更容易被选中,得分低的相对安全。你还能通过oom_score_adj这个参数主动调整,范围从 -1000 到 1000,值越小越不容易被杀。常规操作是echo -800 > /proc/<pid>/oom_score_adj,在 systemd 管理的服务里则可以使用OOMScoreAdjust=-800。
要提醒的是,不要把大量进程都调到很低的分值。如果大家都觉得自己“不能杀”,OOM 发生时内核只能被迫选择那些看起来最低调、但实际上同样重要的进程下手,反而容易误伤监控进程或者关键辅助进程。我见过最尴尬的一次,数据库没被杀,反而是负责重启数据库的守护进程被盯上了。
4.3 实战中如何确认是不是内存问题
当服务突然挂掉,别急着重启。先去dmesg -T里翻一下有没有关键词Out of memory或者Killed process。如果命中,说明确实经历过 OOM,这时要把内存占用趋势和 oom_score 拉出来综合判断。
检查内存时我习惯看三块:进程自身的 RSS 是否持续增长,整个机器的内存余量是否长期紧张,以及 Swap 的使用情况。很多人一看到内存不够就加 Swap,但 Swap 恰恰可能让问题更隐蔽。进程在 Swap 里进进出出,CPU 和磁盘 I/O 都会明显上升,症状反而变得更难定位。更重要的是,加 Swap 只是延缓了内存耗尽的时间,OOM 的触发阈值没有被真正移走,优化内存占用比扩容更优先。
5. 进程诊断三板斧:pidstat、strace、lsof
5.1 pidstat——先别用 top 盯,采样一段再看
top 适合看瞬时状态,但要判断一个进程是偶发卡顿还是持续异常,我更喜欢用 pidstat 做一段时间的采样。pidstat 是 sysstat 包里的工具,大多数发行版需要额外安装,不过装完以后用起来非常顺手。
跟踪某个进程的 CPU 使用率可以用pidstat -u -p <pid> 1 10,每秒采样一次,连续 10 次。想深入线程级别可以加-t参数,这样能看到进程内部哪个线程在消耗资源。如果想看进程的磁盘 I/O,用pidstat -d 1 5,能直观看到进程单次读写的吞吐量。
pidstat 的好处不只是因为它能连续采样,更在于它把“时间维度的趋势”直接摆在面前。进程是否在某一秒突然飙高,又或者在某一秒突然归零,配合时间戳能很快判断出问题是否和特定请求、特定任务挂钩。
5.2 strace——进程在执行什么系统调用
当所有指标都正常,但进程就是不干活的时候,strace 是终极手段之一。它跟踪进程的系统调用和信号,能让你直接看到进程究竟在做什么。
简单 attach 一个进程用strace -p <pid>,但 attach 正在服务线上流量的进程要非常克制。strace 会让进程性能出现明显下降,高并发下的损耗甚至可能放大到数十倍。我的做法是先用汇总模式strace -c -p <pid>让它在后台采一段时间,拿到系统调用的统计分布,再决定要不要针对性地跟踪某类调用。
真要深入看某个路径时,命令可以写成strace -p <pid> -f -tt -T -e trace=network,file,desc。-f跟随子线程,-tt打印微秒级时间,-T显示每次系统调用耗时。这样能把进程卡到底层某个 socket 读或者某个文件锁上的过程完整呈现出来。
strace 还有一个拦路虎是权限。受kernel.yama.ptrace_scope限制,非父子关系进程默认是不允许被 attach 的,而且容器环境下还有额外的 ptrace 权限控制。遇到 Permission denied 时先检查这个内核参数,必要时临时关掉,排查完再恢复默认值。
5.3 lsof——文件、端口、已删除文件占位
最后一个常用工具是 lsof,它的作用范围比前两个更偏“把柄”方向。常见用法有三个。
第一是看某个进程打开了哪些文件或 socket:lsof -p <pid>。第二是根据端口反查占用进程:lsof -i :8080。第三是处理“磁盘空间没释放”的老大难问题:lsof +L1 | grep deleted,它能精准列出已经被删除但仍然被进程占用的文件。
磁盘不干净的案例里,十有八九是某个服务写日志时删了文件,但日志文件句柄还握在进程手里。这时候磁盘空间看着已经释放了,实际上占用的 inode 还在,只有把进程的 fd 释放掉,或者 kill 掉进程,空间才能真正回来。lsof +L1的输出只要一出现 deleted 标记,问题就基本锁定了。
lsof 有个使用禁忌:lsof +D /var/log这样的递归目录扫描非常慢,大目录下会卡很久。日常定位优先用精准的-p或者+L1,别一上来就全盘扫。
回到这篇开头说的问题:进程相关的故障,表象往往只是“某个状态不对”或者“某个进程不见了”,但根因可能横跨调度、存储、资源限制、父进程管理多个层面。我自己在排查时越来越在意的一步,不是急着动手处理,而是先把进程当前处于什么状态、它可能被什么内核路径阻塞这两个问题回答清楚。这个习惯帮我绕开了很多会引发次生事故的操作,尤其是面对 D 状态和僵尸进程时,忍住“杀掉重来”的冲动,往往才是最高效的做法。