马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
写在前面:这篇讲什么
本文梳理 Linux 进程的几种状态:从操作系统课本里的三态模型,讲到实际 Linux 内核里的 R/S/D/T/Z 这些状态字母,顺便把僵尸进程和孤儿进程的成因、危害、处理办法说清楚,中间配可运行的代码示例。
操作系统里的三态模型
经典教材把进程粗分成三种状态:
- 就绪(Ready):条件都齐了,只差 CPU。这类进程排在就绪队列里,等调度器发牌。
- 运行(Running):正在被 CPU 执行。
- 阻塞(Blocked / 等待):卡在某个条件上,得等事件完成或资源可用才能继续。比如等 I/O 完成、等锁、等网络响应。这类进程会被移进对应的等待队列。
运行、阻塞与挂起
先纠正一个常见误解:进程申请资源不只是申请 CPU。它还会用磁盘、网卡、显卡、显示器等等。申请 CPU 时排队好理解,申请这些慢设备时一样要排队——排队的东西就是进程的 task_struct(内核里的进程描述符 PCB)。
所以当进程去访问磁盘、网卡这类资源,而资源还没准备好、或者正忙着服务别人时:
- 当前进程从 runqueue(运行队列)中摘出去;
- 挂进对应设备描述结构里的 waitqueue(等待队列)。
一句话总结规律:看 PCB 现在待在哪个队列里,就决定进程是什么状态。
另一种情况是内存吃紧:系统会把某些进程的代码和数据换出到磁盘(swap),腾出物理内存,这个过程叫进程挂起。被挂起的进程在外存上,短期内也跑不起来。
状态转换看一个例子
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1) {
printf("进程[%d]正在运行...\n", getpid());
sleep(1); // 模拟阻塞
}
return 0;
}
跑起来后用 ps aux 观察,会看到这个进程大多数时候标着 S:它在 sleep,也就是挂在了等待队列上。原因是"显示器比 CPU 慢太多了"——对 CPU 而言,等 printf 刷屏的那点时间跟睡觉没区别。
Linux 里的实际状态
Linux 用整数表示进程状态,存在 task_struct 里。日常用 ps aux 或 ps axj 看到的那一列 STAT 字母,就是它的可视化形式。
| 字母 | 状态名 | 含义 |
| R | Running(运行) | 正在跑,或者虽然在跑队列里排队等着被调度也算 |
| S | Sleeping(可中断睡眠) | 在等某个事件完成,可以被信号唤醒 |
| D | Disk Sleep(不可中断睡眠) | 等 I/O 完成,睡死了连信号都叫不醒 |
| T | Stopped(停止) | 被暂停,收到信号可以恢复 |
| X | Dead(死亡) | 已终止,从进程表里移除 |
| Z | Zombie(僵尸) | 已退出,但父进程还没来读它的退出信息 |
S 与 D 的关键差别
S 是可中断睡眠,D 是不可中断睡眠,差别在"能不能被信号叫醒":
S:等的是普通事件(比如等数据从网络来),来个信号就能打断它,比如 kill 往往能把它干掉。
D:正在等内核态的 I/O 完成,处于这种状态下进程既不响应信号也不能被调度,kill -9 也杀不掉。文章里不实操模拟 D 状态,就是因为要造假 I/O 卡死,一不小心能把磁盘拖到满负荷。
生产上 D 状态进程是运维噩梦:它们既占着资源又不肯走,往往得跟着排查对应的存储或 NFS 挂载是否异常。
T 停止状态
下载、看视频、听歌时的"暂停",就能对应到 T 状态:点击暂停,对应的进程代码停止推进。调试场景下更典型——gdb 打断点后程序在断点处停住,本质上就是把进程置成了 T。
手工制造也简单:
kill -19 4026 # 把某个进程暂停(SIGSTOP)
kill -18 4026 # 恢复(SIGCONT)
或者 gdb 里:
$ gdb ./process
(gdb) l # 看代码
(gdb) b 9 # 在第九行打断点
(gdb) r # 跑起来,命中断点后进程被停住
T 也是"挂起"的一种形态,只是它停在内存里,没被换出去。
僵尸进程(Z)
为什么会变僵尸
一个进程退出时,Linux 不会直接丢进 X(死亡、资源立即可回收)状态,而是先变成 Z(僵尸)。原因很简单:进程运行期间留下的退出信息得有人来收,父进程或内核要能读到"它是正常退出还是被信号杀了、退出码是多少"。这份信息就写在这个进程的 task_struct 里。
如果父进程始终不来收,Z 状态就会一直挂着。
看一个僵尸示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
} else if (id == 0) { // 子进程
printf("子进程[%d]开始运行...\n", getpid());
sleep(5);
printf("子进程[%d]退出...\n", getpid());
exit(0);
} else { // 父进程
printf("父进程[%d]正在睡眠...\n", getpid());
sleep(30); // 故意延迟回收
}
return 0;
}
另开一个终端盯一下:
while :; do ps axj | head -1 && ps axj | grep mytest | grep -v grep; sleep 1; echo "######"; done
5 秒后子进程退出,但父进程还在睡,这时 ps 里就能看到它标着 Z。
僵尸的危害
僵尸进程已经不再运行,但它的 task_struct 还占着内核资源,对应的 PID 也不会释放。父进程迟迟不回收,进程数就会一直涨;量堆上去后,fork 新进程会失败,严重时表现为系统变慢、服务起不来。
怎么避免
- 父进程及时
wait() 或 waitpid() 回收:这是最根本的解法。
- 注册
SIGCHLD 处理函数:子进程退出时内核会给父进程发 SIGCHLD,在回调里做 waitpid(-1, NULL, WNOHANG) 循环回收,比"等主流程走到 wait 才收"更及时。
- 长生命周期进程慎用
SIG_IGN 忽略 SIGCHLD:某些平台下忽略 SIGCHLD 会让内核自动回收,可读性好;但要清楚这会影响 wait() 的可用性。
孤儿进程
父进程先退出,子进程的"父"就换成了 1 号进程(init 或 systemd),相当于被系统收养,这叫孤儿进程。
#include <stdio.h>
#include <unistd.h>
int main(void)
{
pid_t id = fork();
if (id == 0) { // 子进程
int cnt = 5;
while (1) { // 孩子不退出
printf("我是子进程,我还剩下 %ds\n", cnt--);
sleep(1);
}
} else { // 父进程
int cnt = 3;
while (cnt) {
printf("我是父进程,我:%d\n", cnt--);
sleep(1);
}
exit(0); // 父进程先走,子进程变孤儿
}
return 0;
}
盯它的脚本和上面一样。运行几秒后父进程退出,子进程仍然活着,但它的父已经变成 1 号进程了——用 ps -o ppid= 或直接 top 看 1 号进程就明白了。
两个常见的疑问
疑问一:上面例子里父进程退出了,为什么没看到它变成僵尸?
因为父进程自己的父进程是 bash。你在终端里跑程序,bash 会盯着并返回退出状态,父进程一退它就被回收了。Z → X 这个过程非常快,肉眼根本抓不到。
而自己写的代码里子进程变僵尸,是因为代码压根没写回收逻辑,父进程还活着且不去 wait,僵尸就一直挂着。
疑问二:孤儿进程怎么处理?
被 1 号进程收养后不会变僵尸,因为 init 会持续 wait 掉退出的子进程。所以它本身无害;真正需要操心的是孤儿进程里那批"死了却没人收"的孙进程——父死了、子还活着、孙跟着子,链路上每一环都要有人收。这种情况用 kill -9 直接清理就行(Ctrl+C 只能干掉前台进程,对后台孤儿无效)。
小结
进程状态这一块,记住两条线就够了:
- 理论线:就绪 → 运行 → 阻塞三态,PCB 在哪个队列就属于哪个状态;内存不足时会被换出到外存,形成挂起。
- 实战线:Linux 里用 R/S/D/T/X/Z 六个字母表示,
S 能被信号唤醒,D 不能(杀不掉,得查存储);T 是暂停(gdb 断点、kill -19);Z 是僵尸——父进程没 wait,要修就补 waitpid 或 SIGCHLD 回收;X 才是真正的死。
运维排查里的经验:看到大量 Z 先找它的父进程 PID,往往父进程就是那个忘了回收的长时间运行服务;看到大量 D 则优先查磁盘、NFS、网络存储,而不是盯着进程本身使劲 kill。
(本文由 qidao123.com企服评测-应用市场 编辑提示整理:示例基于 Linux 标准进程模型,编译可用 gcc main.c -o main,验证命令 ps aux / ps axj 已在 Ubuntu / CentOS 环境实测。) |