1. 进程状态
一般情况下,我们很难将内核变成无法自愈的状态,比如 init 进程无法回收僵尸进程~碰巧我就遇到过。
ARM64 平台的麒麟系统将内核 PAGE_SIZE 设置为 64KB,导致所有的进程内存占用量暴增,特别是 auditd,占用大量 slab 内存,经常导致系统卡死,记得当时虚拟化的大佬分析虚机内存快照时,也发现存在大量僵尸进程,并提了一嘴:这些僵尸进程已经无法被内核回收,只有重启系统了。
现在细想,准确一些的说法应该是:在卡死的前一刻,系统资源已经耗尽,init 进程无法回收僵尸进程。这些僵尸进程主要由挂载 NFS 存储时唤起的 RPC 进程组成,因为许多 mount.nfs 操作执行到一半时被 OOM Kill,导致子进程要么被 init 进程接管,要么与 mount.nfs 一起卡死,在一些还能正常登录的机器上,可以看到大量 defer func 开头的进程,这些进程无法强制终止,只有重启系统才能消灭掉。
这里就来探索下 Linux 上的进程状态。
从 top 命令的帮助手册可以看到,进程状态有以下几种:
| 状态 | 缩写 | 描述 | 可能的转换 |
|---|---|---|---|
| 运行 | R | 进程正在运行或在运行队列中等待运行 | R → S, R → D, R → Z, R → T |
| 可中断睡眠 | S | 进程正在等待某个事件完成(如网络 I/O) | S → R, S → D, S → Z |
| 不可中断睡眠 | D | 进程正在等待 I/O 完成,不响应信号(如磁盘 I/O) | D → R, D → Z |
| 僵尸 | Z | 子进程已终止,等待父进程回收 | Z → 进程被回收并从系统中移除 |
| 停止 | T | 进程已停止执行,通常是因为收到 SIGSTOP 信号 | T → R |
| 空闲 | I | 内核线程空闲(不适用于普通进程) | I → R |
R 与 S 可能是我们最常见的用户态状态,I 则常见于内核线程,下面是一个 htop 输出截图,可以看到 R、S、I 三种状态:

下面由易到难创建出我们所需的进程状态,R、S、I 等常见状态就不细说,下面会测试 T、D、Z 等状态。
以上图中的 next-sever 进程为例,我们使用 kill 向它发送 SIGSTOP,就可以切换到 T 状态,如下:
kill -STOP 94782

如果需要让进程恢复运行,使用 kill 向它发送 SIGCONT 即可:
kill -CONT 94782
D 状态对应的是不可中断睡眠,常见的触发原因很多,比如:磁盘 I/O、网络文件系统(NFS)操作、设备驱动程序操作、DMA 等,最简单的办法就是 dd 写文件,如下:
dd if=/dev/zero of=testfile bs=1M count=4096 oflag=direct

Z 状态进程对应的是僵尸进程,该状态也是进程销毁前必然经历的状态,我们可以使用 Go 写一个程序,让子进程处于已退出与回收前:
package main
import (
"fmt"
"os"
"os/exec"
"time"
)
func main() {
// 创建子进程,休眠 1 秒退出
cmd := exec.Command("sleep", "1")
// 启动子进程
err := cmd.Start()
if err != nil {
fmt.Printf("Error starting command: %s\n", err)
os.Exit(1)
}
// 获取子进程的 PID
childPid := cmd.Process.Pid
fmt.Printf("Child process started with PID: %d\n", childPid)
// 等待 30 秒退出
time.Sleep(30 * time.Second)
}
我们使用 exec 包启动了一个 sleep 1 进程,然后直接休眠(不执行 wait 等待进程退出),这样就可以稳定制造出僵尸进程:

孤儿进程是父进程已经退出的但还在运行的子进程,这些进程会被 init 进程自动接管,继续正常运行直到结束。
僵尸进程是已经结束运行,但其父进程尚未回收其资源的进程,如果父进程已经退出,init 进程会代为回收僵尸进程,像上面的例子中 Go 的主进程退出后,sleep 1 僵尸进程就会被 init 进程接管并回收。
下面使用 Go 实现一个程序,演示孤儿进程与僵尸进程:
package main
import (
"fmt"
"os"
"os/exec"
"time"
)
func main() {
if len(os.Args) == 1 {
// 父进程
// 创建一个会成为孤儿进程的子进程
orphanCmd := exec.Command(os.Args[0], "orphan")
orphanCmd.Stdout = os.Stdout
orphanCmd.Stderr = os.Stderr
orphanCmd.Start()
// 创建一个会成为僵尸进程的子进程
zombieCmd := exec.Command(os.Args[0], "zombie")
zombieCmd.Stdout = os.Stdout
zombieCmd.Stderr = os.Stderr
zombieCmd.Start()
fmt.Printf("父进程 (PID %d) 创建了两个子进程\n", os.Getpid())
fmt.Printf("将成为孤儿进程的子进程 PID: %d\n", orphanCmd.Process.Pid)
fmt.Printf("将成为僵尸进程的子进程 PID: %d\n", zombieCmd.Process.Pid)
fmt.Println("父进程睡眠 10 秒后退出,此时查看进程状态")
time.Sleep(10 * time.Second)
fmt.Println("父进程退出")
} else if os.Args[1] == "orphan" {
// 将成为孤儿进程的子进程
fmt.Printf("孤儿进程 (PID %d) 开始运行,父进程 PID 为 %d\n", os.Getpid(), os.Getppid())
time.Sleep(20 * time.Second)
fmt.Printf("孤儿进程 (PID %d) 结束,此时父进程 PID 为 %d\n", os.Getpid(), os.Getppid())
} else if os.Args[1] == "zombie" {
// 将成为僵尸进程的子进程
fmt.Printf("僵尸进程 (PID %d) 开始运行\n", os.Getpid())
time.Sleep(1 * time.Second)
fmt.Println("僵尸进程结束,但不会被立即回收")
}
}
编译成可执行文件:
go build -o orphan_zombie main.go
这个程序执行逻辑如下:
- 主进程启动,并分别启动两个子进程:orphan 进程与 zombie 进程,然后等待 10 秒退出
- 孤儿进程等待 20 秒退出,运行时间大于主进程,会被 init 进程收养
- 僵尸进程等待 1 秒退出,运行时间小于主进程,会切换到 Z 状态


程序的标准输出如下:
➜ ~ ./orphan_zombie
父进程 (PID 107392) 创建了两个子进程
将成为孤儿进程的子进程 PID: 107396
将成为僵尸进程的子进程 PID: 107397
父进程睡眠 10 秒后退出,此时查看进程状态
孤儿进程 (PID 107396) 开始运行,父进程 PID 为 107392
僵尸进程 (PID 107397) 开始运行
僵尸进程结束,但不会被立即回收
父进程退出
➜ ~ 孤儿进程 (PID 107396) 结束,此时父进程 PID 为 1
产生僵尸进程的直接原因是缺少调用 Wait 的父进程,解决方法也有很多,比如:
- 正确等待子进程退出:这对 Go 来说不是问题,例如 exec.Command 的 Run 方法就将 Start 和 Wait 封装在一起执行
- 监听 SIGCHLD 信号执行 Wait:与第一点类似,但是监听 SIGCHLD 信号获取 PID 来调用对应子进程的 Wait
- 双重 fork:创建守护进程的经典方法,将孙进程直接托管给 init 进程,init 进程会在孙进程退出时自动调用 Wait 回收资源
我认为第一种与第二种方法对于 Go 这种天生支持并发的语言没有差异,只要多开一个 goroutine 运行子进程或者监听 SIGCHLD 信号即可,我更偏向于第三种方式,让 init 进程接管子进程,下面是双重 fork 的例子:
package main
import (
"fmt"
"os"
"os/exec"
"time"
)
func main() {
if len(os.Args) == 1 {
// 第一次 fork
cmd := exec.Command(os.Args[0], "child")
// 设置输出重定向
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
fmt.Println("Parent process exiting")
return
}
if os.Args[1] == "child" {
// 第二次 fork
cmd := exec.Command(os.Args[0], "grandchild")
// 设置输出重定向
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
fmt.Println("Child process exiting")
return
}
if os.Args[1] == "grandchild" {
// 孙进程逻辑
fmt.Println("Grandchild process started")
performDaemonTask()
}
}
func performDaemonTask() {
pid := os.Getpid()
count := 0
for count < 3 {
fmt.Printf("[PID %d] Daemon is running (count: %d)\n", pid, count)
count++
time.Sleep(5 * time.Second)
}
}
编译成可执行文件:
go build -o dual_fork main.go
执行后可以看到第二次 fork 产生的孙进程已经被 init 进程接管:

终端输出如下:
➜ ~ ./dual_fork
Parent process exiting
Child process exiting
➜ ~ Grandchild process started
[PID 107800] Daemon is running (count: 0)
[PID 107800] Daemon is running (count: 1)
[PID 107800] Daemon is running (count: 2)
内核对于进程退出的处理主要集中在 kernle/exit.c 的 do_exit 函数中,其中 exit_notify 包含我们关注的孤儿进程与僵尸进程相关内容,如下:
/*
* Send signals to all our closest relatives so that they know
* to properly mourn us..
*/
static void exit_notify(struct task_struct *tsk, int group_dead)
{
bool autoreap;
struct task_struct *p, *n;
LIST_HEAD(dead);
write_lock_irq(&tasklist_lock);
forget_original_parent(tsk, &dead);
if (group_dead)
kill_orphaned_pgrp(tsk->group_leader, NULL);
tsk->exit_state = EXIT_ZOMBIE;
/*
* sub-thread or delay_group_leader(), wake up the
* PIDFD_THREAD waiters.
*/
if (!thread_group_empty(tsk))
do_notify_pidfd(tsk);
if (unlikely(tsk->ptrace)) {
int sig = thread_group_leader(tsk) &&
thread_group_empty(tsk) &&
!ptrace_reparented(tsk) ?
tsk->exit_signal : SIGCHLD;
autoreap = do_notify_parent(tsk, sig);
} else if (thread_group_leader(tsk)) {
autoreap = thread_group_empty(tsk) &&
do_notify_parent(tsk, tsk->exit_signal);
} else {
autoreap = true;
}
if (autoreap) {
tsk->exit_state = EXIT_DEAD;
list_add(&tsk->ptrace_entry, &dead);
}
/* mt-exec, de_thread() is waiting for group leader */
if (unlikely(tsk->signal->notify_count < 0))
wake_up_process(tsk->signal->group_exec_task);
write_unlock_irq(&tasklist_lock);
list_for_each_entry_safe(p, n, &dead, ptrace_entry) {
list_del_init(&p->ptrace_entry);
release_task(p);
}
}
这里的 forget_original_parent 用于 init 进程接管子进程:
/*
* This does two things:
*
* A. Make init inherit all the child processes
* B. Check to see if any process groups have become orphaned
* as a result of our exiting, and if they have any stopped
* jobs, send them a SIGHUP and then a SIGCONT. (POSIX 3.2.2.2)
*/
static void forget_original_parent(struct task_struct *father,
struct list_head *dead)
{
struct task_struct *p, *t, *reaper;
if (unlikely(!list_empty(&father->ptraced)))
exit_ptrace(father, dead);
/* Can drop and reacquire tasklist_lock */
reaper = find_child_reaper(father, dead);
if (list_empty(&father->children))
return;
reaper = find_new_reaper(father, reaper);
list_for_each_entry(p, &father->children, sibling) {
for_each_thread(p, t) {
RCU_INIT_POINTER(t->real_parent, reaper);
BUG_ON((!t->ptrace) != (rcu_access_pointer(t->parent) == father));
if (likely(!t->ptrace))
t->parent = t->real_parent;
if (t->pdeath_signal)
group_send_sig_info(t->pdeath_signal,
SEND_SIG_NOINFO, t,
PIDTYPE_TGID);
}
/*
* If this is a threaded reparent there is no need to
* notify anyone anything has happened.
*/
if (!same_thread_group(reaper, father))
reparent_leader(father, p, dead);
}
list_splice_tail_init(&father->children, &reaper->children);
}
整个流程和前面的测试结果基本吻合:
- 处理父进程:如果父进程已经退出,重新分配子进程给 init 进程或者一个指定的回收者进程
- 将任务状态设置为僵尸状态:Z 状态
- 处理通知逻辑:根据不同情况(是否被跟踪、是否是线程组领导者等)决定如何通知父进程
- 调用 do_notify_parent 发送适当的信号,使用 SIGCHLD 或自定义退出信号
- 确定是否可以自动回收任务
- 如果可以自动回收,将任务状态设置为 EXIT_DEAD 并加入待释放列表
- 释放和清理
在使用 ps aux 命令检查进程时,进程状态后还会附加标志字符,比如 Ss,具体含义如下:
| 标志 | 名称 | 描述 |
|---|---|---|
| < | high-priority | 进程具有较高的优先级(nice 值为负) |
| N | low-priority | 进程具有较低的优先级(nice 值为正) |
| L | has pages locked in memory | 进程已将部分页面锁定在内存中 |
| s | session leader | 进程是会话(session)的领导者 |
| l | is multi-threaded | 进程是多线程的 |
| + | foreground process group | 进程在前台进程组中 |
这里检查一下 Linux 系统上运行的 gost 进程(一个由 Go 编写的代理工具),如下:
➜ ~ ps aux | grep gost
root 6163 0.0 0.4 734228 16436 ? Ssl Oct01 1:12 /usr/bin/gost -C /etc/gost/config.json
可以看到该进程是 session leader,并且是多线程的。