Atlantis
GitHub 切换暗/亮/自动模式 切换暗/亮/自动模式 切换暗/亮/自动模式 返回首页

1. 进程状态

Created At 2024-09-23 Updated on 2025-10-25

1. 前言

一般情况下,我们很难将内核变成无法自愈的状态,比如 init 进程无法回收僵尸进程~碰巧我就遇到过。

ARM64 平台的麒麟系统将内核 PAGE_SIZE 设置为 64KB,导致所有的进程内存占用量暴增,特别是 auditd,占用大量 slab 内存,经常导致系统卡死,记得当时虚拟化的大佬分析虚机内存快照时,也发现存在大量僵尸进程,并提了一嘴:这些僵尸进程已经无法被内核回收,只有重启系统了。

现在细想,准确一些的说法应该是:在卡死的前一刻,系统资源已经耗尽,init 进程无法回收僵尸进程。这些僵尸进程主要由挂载 NFS 存储时唤起的 RPC 进程组成,因为许多 mount.nfs 操作执行到一半时被 OOM Kill,导致子进程要么被 init 进程接管,要么与 mount.nfs 一起卡死,在一些还能正常登录的机器上,可以看到大量 defer func 开头的进程,这些进程无法强制终止,只有重启系统才能消灭掉。

这里就来探索下 Linux 上的进程状态。

2. 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 三种状态:

alt text

3. 如何创建出预期状态的进程

下面由易到难创建出我们所需的进程状态,R、S、I 等常见状态就不细说,下面会测试 T、D、Z 等状态。

3.1 T

以上图中的 next-sever 进程为例,我们使用 kill 向它发送 SIGSTOP,就可以切换到 T 状态,如下:

kill -STOP 94782

alt text

如果需要让进程恢复运行,使用 kill 向它发送 SIGCONT 即可:

kill -CONT 94782

3.2 D

D 状态对应的是不可中断睡眠,常见的触发原因很多,比如:磁盘 I/O、网络文件系统(NFS)操作、设备驱动程序操作、DMA 等,最简单的办法就是 dd 写文件,如下:

dd if=/dev/zero of=testfile bs=1M count=4096 oflag=direct

alt text

3.3 Z

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 等待进程退出),这样就可以稳定制造出僵尸进程:

alt text

4. 孤儿进程与僵尸进程

孤儿进程是父进程已经退出的但还在运行的子进程,这些进程会被 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

这个程序执行逻辑如下:

  1. 主进程启动,并分别启动两个子进程:orphan 进程与 zombie 进程,然后等待 10 秒退出
  2. 孤儿进程等待 20 秒退出,运行时间大于主进程,会被 init 进程收养
  3. 僵尸进程等待 1 秒退出,运行时间小于主进程,会切换到 Z 状态

alt text

alt text

程序的标准输出如下:

➜  ~ ./orphan_zombie
父进程 (PID 107392) 创建了两个子进程
将成为孤儿进程的子进程 PID: 107396
将成为僵尸进程的子进程 PID: 107397
父进程睡眠 10 秒后退出,此时查看进程状态
孤儿进程 (PID 107396) 开始运行,父进程 PID 为 107392
僵尸进程 (PID 107397) 开始运行
僵尸进程结束,但不会被立即回收
父进程退出
➜  ~ 孤儿进程 (PID 107396) 结束,此时父进程 PID 为 1

5. 僵尸进程回收

产生僵尸进程的直接原因是缺少调用 Wait 的父进程,解决方法也有很多,比如:

  1. 正确等待子进程退出:这对 Go 来说不是问题,例如 exec.Command 的 Run 方法就将 Start 和 Wait 封装在一起执行
  2. 监听 SIGCHLD 信号执行 Wait:与第一点类似,但是监听 SIGCHLD 信号获取 PID 来调用对应子进程的 Wait
  3. 双重 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 进程接管:

alt text

终端输出如下:

➜  ~ ./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)

6. 内核如何处理进程退出

内核对于进程退出的处理主要集中在 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);
}

整个流程和前面的测试结果基本吻合:

  1. 处理父进程:如果父进程已经退出,重新分配子进程给 init 进程或者一个指定的回收者进程
  2. 将任务状态设置为僵尸状态:Z 状态
  3. 处理通知逻辑:根据不同情况(是否被跟踪、是否是线程组领导者等)决定如何通知父进程
    1. 调用 do_notify_parent 发送适当的信号,使用 SIGCHLD 或自定义退出信号
    2. 确定是否可以自动回收任务
  4. 如果可以自动回收,将任务状态设置为 EXIT_DEAD 并加入待释放列表
  5. 释放和清理

7. 附加状态标志

在使用 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,并且是多线程的。