Linux 基础体系 · 第 9/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 进程、线程与信号:生命周期、父子关系、Zombie 和优雅退出

所属系列:Linux 基础体系
所属模块:三、进程与服务
标签:Linux、进程、信号、后端
适用范围:现代主流 Linux 发行版

Linux 中的进程、线程和信号经常同时出现在服务启动、故障诊断和优雅停机流程中:

  • fork() 创建子进程,execve() 替换进程映像;
  • 父进程通过 waitpid() 回收子进程;
  • 子进程退出但父进程尚未回收时,会暂时成为 Zombie;
  • SIGTERM 常用于请求服务停止,SIGKILL 则直接终止;
  • 多线程程序中,信号可能发给某个线程,也可能发给整个线程组;
  • systemd 停止服务时,信号、进程组和 cgroup 又会叠加在一起。

理解这些机制,需要先区分三个概念:进程是资源与地址空间的管理单位,线程是调度执行单位,信号是内核向进程或线程异步通知事件的机制


一、进程到底是什么

1. 进程是内核维护的执行实体

一个进程至少包含以下状态:

  1. 虚拟地址空间;
  2. 文件描述符表;
  3. 当前工作目录和根目录;
  4. 环境变量;
  5. 用户身份、组身份和权限凭据;
  6. 信号处置方式、信号屏蔽字和待处理信号;
  7. 调度状态、优先级和 CPU 亲和性;
  8. 与父进程、进程组、会话的关系;
  9. 退出状态以及等待关系。

进程通常用 PID(Process ID)标识。在 Linux 内核实现中,进程和线程都对应一个 task_struct;用户空间通常把“拥有独立地址空间的任务”称为进程,把“共享地址空间的任务”称为线程。

因此,“进程”和“线程”是用户空间抽象,不是内核完全不同的两类对象。

2. PID、TID、TGID 的关系

对于单线程进程:

PID = TID = TGID

对于多线程进程:

  • PID 通常表示线程组 ID,也就是用户常说的进程 ID;
  • TID 表示某个具体线程的内核任务 ID;
  • TGID 表示线程组 ID,通常等于主线程的 PID;
  • getpid() 返回 TGID;
  • Linux 特有的 gettid() 返回当前线程的 TID。

可以使用以下命令观察:

ps -eLf

典型输出中:

UID   PID   PPID   LWP   C NLWP STIME TTY      TIME CMD
user  1234  1000  1234   0    3 ...   ?        00:00:01 ./server
user  1234  1000  1235   0    3 ...   ?        00:00:00 ./server
user  1234  1000  1236   0    3 ...   ?        00:00:00 ./server

这里:

  • PID 是线程组 ID;
  • LWP 在 procps 工具中通常显示线程 ID;
  • NLWP 是线程数量;
  • 三个线程共享同一个地址空间和文件描述符表。

ps 的列名和默认格式可能随发行版、procps 版本变化,诊断时应结合 /proc/<pid>/task/ 验证:

ls /proc/1234/task/

每个数字目录对应一个线程 TID。


二、进程的完整生命周期

1. 从创建到退出

典型的 Unix 进程生命周期如下:

stateDiagram-v2
    [*] --> Running: fork/clone 创建
    Running --> Ready: 等待 CPU
    Ready --> Running: 被调度
    Running --> Sleeping: 阻塞 I/O、锁、定时器
    Sleeping --> Ready: 条件满足
    Running --> Zombie: exit/_exit 退出
    Zombie --> Reaped: 父进程 wait/waitpid
    Reaped --> [*]

这里的 ReadyRunningSleeping 是调度层面的状态;Zombie 是进程已经结束执行,但退出记录尚未被父进程读取的状态。

Linux 的进程状态可以通过 /proc/<pid>/statusps 查看:

ps -o pid,ppid,stat,cmd -p <PID>

常见 STAT 字符包括:

  • R:正在运行或可运行;
  • S:可中断睡眠;
  • D:不可中断睡眠,常见于等待内核 I/O;
  • T:被停止;
  • Z:Zombie;
  • I:空闲内核线程等,具体含义依工具版本而定。

这些状态不是完整的生命周期模型。例如,一个进程可能在 RS 之间频繁切换;Z 表示用户代码已经不再执行,不应理解为“仍然占用 CPU”。

2. fork():复制执行上下文

fork() 创建一个子进程。成功后,父进程和子进程都会从 fork() 的下一条指令继续执行,但返回值不同:

  • 父进程得到子进程 PID;
  • 子进程得到 0
  • 失败时父进程得到 -1,不会产生子进程。

现代 Linux 通常通过写时复制(Copy-on-Write,COW)实现地址空间复制:

  1. 父子进程初始共享物理内存页;
  2. 页表被分别建立;
  3. 共享页被标记为只读;
  4. 任一方写入时触发缺页异常;
  5. 内核复制该页,再允许写入。

所以,fork() 并不等于立即复制整个进程内存,但它仍可能产生页表开销,并且后续写入会触发复制。

父子进程拥有不同的 PID,但通常继承或复制以下内容:

  • 文件描述符及其打开文件描述;
  • 当前目录;
  • 环境变量;
  • 信号处置方式;
  • 信号屏蔽字;
  • 地址空间的逻辑内容;
  • 用户和组身份。

文件描述符的继承有一个容易忽略的细节:父子进程的描述符整数不同进程各自维护,但它们可能指向同一个内核 open file description,因此共享文件偏移量和部分状态。

3. exec():替换进程映像,而不是创建进程

execve() 系列调用把当前进程的用户空间映像替换为新程序:

execl("/bin/echo", "echo", "hello", (char *)NULL);

成功后:

  • PID 不变;
  • 父子关系不变;
  • 当前程序的代码、数据、堆和栈被新程序替换;
  • 打开文件描述符默认保留,除非设置了 FD_CLOEXEC
  • 被捕获的信号处置方式通常恢复为默认;
  • 被忽略的信号处置通常继续保持忽略;
  • 信号屏蔽字保持不变。

exec() 成功后不会返回;只有失败时才返回 -1 并设置 errno

这就是常见的启动模式:

父进程 fork()
    ├── 子进程:exec("worker")
    └── 父进程:记录 PID,继续管理或 waitpid()

4. _exit()exit() 不同

exit(status) 是 C 库函数,会执行用户空间清理动作,例如:

  • 调用 atexit() 注册的函数;
  • 刷新标准 I/O 缓冲区;
  • 关闭由 C 库维护的流。

_exit(status) 是系统调用封装,直接结束当前进程,不执行这些 C 库清理函数。

在多线程程序中调用 fork() 后,子进程只保留调用 fork() 的那个线程。其他线程消失,但它们持有的用户态锁可能仍然处于“已加锁”状态。此时子进程在 exec() 前只能安全调用异步信号安全函数;如果子进程决定退出,通常应使用 _exit(),而不是可能再次获取内部锁的 exit()


三、父子关系:PPID、孤儿和回收

1. 父进程不是“拥有”子进程的普通对象

创建子进程的进程称为父进程,子进程的 PPID 是父进程 PID:

ps -o pid,ppid,stat,cmd -p "$PID"

父子关系主要影响:

  • 子进程退出时向谁发送 SIGCHLD
  • 谁可以通过 wait()waitpid() 读取退出状态;
  • 子进程成为孤儿后由谁接管;
  • 某些调试和权限语义。

父进程退出并不会自动杀死所有子进程。子进程会成为孤儿,内核将其重新挂接到合适的“收养者”:

  • 通常是 PID namespace 内的 PID 1;
  • 如果存在设置为 child subreaper 的进程,也可能先由该进程接管。

Linux 的 subreaper 可通过 prctl(PR_SET_CHILD_SUBREAPER, 1) 设置,常用于容器运行时或进程监督器。它不是普通父子关系的替代品,而是为多级进程监督提供收养机制。

2. 父进程退出不等于子进程退出

下面的关系是合法的:

shell
 └── supervisor
      └── worker

如果 supervisor 退出:

shell
 └── worker   # worker 被重新收养

worker 可以继续运行,也可以自行退出。是否终止它,取决于监督器、systemd、容器运行时或应用自身的策略。

因此,“杀掉父进程,所有子进程都会消失”是错误的。

3. 父进程必须等待子进程

子进程退出时,内核仍需保留少量信息:

  • 退出码;
  • 终止信号;
  • 是否被 core dump;
  • 资源使用统计;
  • PID 等必要元数据。

父进程通过以下接口读取这些信息:

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

waitpid(child_pid, &status, 0) 的基本语义是:

  1. 如果指定子进程仍在运行,父进程阻塞;
  2. 子进程退出后,内核通知父进程;
  3. 父进程读取状态;
  4. 子进程从 Zombie 状态转为完全释放。

如果父进程不需要等待具体子进程,也可以使用:

waitpid(-1, &status, 0);

其中 -1 表示等待任意可等待的子进程。


四、Zombie:已经死亡但尚未被回收的进程

1. Zombie 的精确定义

Zombie(僵尸进程)是:

子进程已经调用 exit()_exit() 或因信号终止,已经不再执行用户代码,但父进程尚未通过等待接口读取其退出状态。

它不是“卡死的进程”,也不是“仍在后台运行的进程”。

可以用以下命令观察:

ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'

可能看到:

 4321  4000 Z    [worker] <defunct>

<defunct> 表示进程已经结束,只剩待回收的退出记录。

2. Zombie 为什么不能用 kill -9 消灭

SIGKILL 只能终止仍在执行的任务。Zombie 已经没有用户态执行上下文,因此:

kill -9 4321

通常不会改变它的状态。

正确处理路径是:

  1. 找出 Zombie 的父进程;
  2. 让父进程调用 wait()waitpid()
  3. 如果父进程失效,让 Zombie 被重新收养;
  4. 新的收养者必须能够回收它。

如果父进程长期不等待,Zombie 数量会持续增加,最终消耗 PID 表项,导致无法创建新进程。Zombie 通常只占少量内核元数据,但大量积累仍是服务故障。

3. 为什么父进程会产生 Zombie

常见原因包括:

  • 父进程完全没有调用 wait()
  • 只等待了部分子进程;
  • SIGCHLD 处理器中错误处理了 waitpid()
  • 子进程数量动态变化,但回收逻辑只处理了一次;
  • 多线程程序中某个线程改变了信号处置或等待策略;
  • 父进程在阻塞调用中被信号打断,却忽略了 EINTR
  • 使用双重 fork 或守护化代码时,误以为“脱离父进程”就不需要管理退出状态。

处理 SIGCHLD 时通常应循环回收:

for (;;) {
    pid_t r = waitpid(-1, &status, WNOHANG);
    if (r > 0) {
        /* 处理一个已经退出的子进程 */
        continue;
    }
    if (r == 0) {
        /* 当前没有已经退出的子进程 */
        break;
    }
    if (errno == EINTR) {
        continue;
    }
    if (errno == ECHILD) {
        break;
    }
    /* 其他错误需要记录并处理 */
    break;
}

循环是必要的,因为多个子进程可能在父进程处理前同时退出,而传统标准信号不保证“一次通知对应一个退出事件”。


五、信号是什么

信号是内核或其他进程向目标进程、线程组或特定线程发送的异步事件通知。

常见信号包括:

信号 默认动作 常见用途
SIGTERM 终止 请求程序正常退出
SIGINT 终止 终端中的中断,如 Ctrl-C
SIGQUIT 终止并生成 core 终端退出请求、诊断
SIGKILL 强制终止 无法捕获、阻塞或忽略
SIGSTOP 停止 无法捕获、阻塞或忽略
SIGCONT 继续 恢复被停止任务
SIGHUP 默认终止 终端断开,也常用于重新加载配置
SIGCHLD 忽略或通知语义 子进程状态变化
SIGPIPE 默认终止 向已关闭的管道或 socket 写入

默认动作由信号定义决定,但程序可以通过 sigaction() 改变大多数信号的处置方式。

SIGKILLSIGSTOP 不能被捕获、阻塞或忽略。不存在“程序优雅地处理 SIGKILL”这一机制。

1. 信号处置、屏蔽字和待处理集合

每个进程或线程相关的信号状态至少包含三个概念:

信号处置(disposition)

说明收到某信号时怎么处理:

  • 默认动作;
  • 忽略;
  • 调用用户定义的处理器。

可以用 sigaction() 设置。

信号屏蔽字(signal mask)

表示暂时不允许递送哪些信号。被屏蔽的信号不会丢失,通常会变为待处理状态。

线程有各自的信号屏蔽字。

待处理信号(pending signals)

信号已经产生,但还没有被递送到能够处理它的执行上下文。

标准信号通常不排队。例如,在一个 SIGTERM 已经处于待处理状态时,再发送多个 SIGTERM,一般不会得到多个独立的待处理记录。

实时信号 SIGRTMINSIGRTMAX 支持排队,并携带更明确的顺序和 sigqueue() 数据,但可用编号和资源限制需要通过运行时接口确认,不能硬编码成所有发行版都相同。

2. 进程定向和线程定向

发送信号的方式不同,目标语义也不同:

kill -TERM 1234

通常是向 PID 代表的进程线程组发送进程定向信号。

在 C/POSIX 线程程序中:

pthread_kill(thread, SIGUSR1);

是向指定线程发送信号。

进程定向信号最终会递送给线程组中某个未屏蔽该信号的线程。具体选择不是应用层应依赖的稳定调度规则。

线程定向信号则只能由指定线程接收。SIGSTOPSIGKILL 等进程级强制信号的实际效果仍然是终止或停止整个进程线程组。

3. 信号处理器能做什么

信号处理器运行在被中断线程的执行上下文中。它可能在任意指令之间发生,因此只能做非常有限的工作。

通常安全的处理方式是:

static volatile sig_atomic_t stop_requested = 0;

static void on_term(int signo)
{
    stop_requested = 1;
}

主循环再检查 stop_requested,执行真正的清理:

while (!stop_requested) {
    /* 接收请求、处理任务 */
}

/* 在普通控制流中关闭监听器、停止线程、刷新数据 */

原因是大多数库函数并非异步信号安全。例如,在处理器中调用 printf()malloc()、复杂日志框架或带锁的容器操作,可能与被中断的代码竞争内部锁,造成死锁或堆损坏。

POSIX 明确列出了一组异步信号安全函数,write() 通常属于其中之一。因此更复杂的程序可以采用:

  • 处理器只写入一个管道;
  • 使用 signalfd 将信号转为文件描述符事件;
  • 使用 sigwaitinfo() / sigtimedwait() 在专门线程中同步等待信号。

SA_RESTART 可以让部分被信号打断的系统调用自动重启,但不是所有系统调用都保证重启。即使设置了 SA_RESTART,程序仍应正确处理 EINTR


六、线程和进程的生命周期差异

1. 线程共享资源,但不共享全部状态

同一进程中的线程通常共享:

  • 虚拟地址空间;
  • 全局变量和堆;
  • 文件描述符表;
  • 当前工作目录;
  • 用户和组身份;
  • 信号处置方式。

每个线程通常独有:

  • 栈;
  • 寄存器上下文;
  • 调度状态;
  • 线程 ID;
  • 信号屏蔽字;
  • errno 等线程局部数据;
  • pthread 线程取消状态和清理处理器。

线程共享地址空间,所以一个线程写坏内存,可能直接破坏整个进程。相反,进程之间默认隔离,一个进程的普通指针不能直接访问另一个进程的地址空间。

2. 线程退出和进程退出

一个线程可以通过:

pthread_exit(value);

退出,其他线程继续运行。

主线程从线程函数返回,通常等价于该线程结束,而不一定结束整个进程。只有从 main() 返回或调用 exit() 等进程级退出路径时,整个进程才会结束。

如果任意线程调用:

exit(status);

则整个进程都会退出。

如果调用 Linux 的 _exit(),glibc 通常会使用能够结束整个线程组的进程级退出语义;不要把它当作“只结束当前 pthread”的接口。只想结束当前 POSIX 线程时,应使用线程函数返回或 pthread_exit()

3. 多线程程序中的 fork()

POSIX 规定,多线程进程调用 fork() 后,子进程只保留调用线程。其他线程不复制,但它们可能曾经持有:

  • malloc 内部锁;
  • stdio 锁;
  • pthread mutex;
  • 日志库或运行时库锁。

因此,子进程在 fork()exec() 之间只能调用异步信号安全函数。典型安全模式是:

多线程父进程
    └── fork()
          └── 子进程:关闭必要 fd,检查错误,exec()

如果 exec() 失败,子进程应使用 _exit(127) 等方式退出,而不是调用复杂的清理逻辑。

需要额外区分:

  • fork() 是进程创建接口;
  • pthread_create() 创建共享地址空间的线程;
  • Linux 的 clone() 提供更细粒度的共享选项,是实现线程、容器和部分运行时机制的底层接口。

直接使用 clone() 需要同时处理栈、信号、线程组和退出语义,不应把它简单当作“更快的 fork”。


七、一个可运行的父子进程与优雅退出示例

下面的程序演示:

  1. 父进程创建子进程;
  2. 子进程安装 SIGTERM 处理器并运行工作循环;
  3. 父进程同步等待 SIGTERMSIGINTSIGCHLD
  4. 收到停止请求后,向子进程发送 SIGTERM
  5. 父进程通过 waitpid() 回收子进程;
  6. 如果子进程不退出,父进程最终可以升级为 SIGKILL

示例使用 sigwait(),因此信号处理逻辑在普通线程控制流中执行,不需要在异步处理器中调用复杂函数。

#define _POSIX_C_SOURCE 200809L

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

static volatile sig_atomic_t child_stop = 0;

static void child_on_term(int signo)
{
    (void)signo;
    child_stop = 1;
}

static void child_work(void)
{
    struct sigaction sa;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sa.sa_handler = child_on_term;

    if (sigaction(SIGTERM, &sa, NULL) == -1) {
        _exit(111);
    }

    printf("child: started, pid=%ld\n", (long)getpid());
    fflush(stdout);

    while (!child_stop) {
        sleep(1);
        printf("child: working\n");
        fflush(stdout);
    }

    printf("child: graceful cleanup\n");
    fflush(stdout);

    /*
     * 这里执行关闭监听 socket、停止工作线程、
     * 刷新必要数据等清理工作。
     */
    _exit(0);
}

int main(void)
{
    sigset_t set;
    pid_t child;
    int signo;
    int status;

    sigemptyset(&set);
    sigaddset(&set, SIGTERM);
    sigaddset(&set, SIGINT);
    sigaddset(&set, SIGCHLD);

    /*
     * 在 fork 前阻塞这些信号,避免父进程还没有进入
     * sigwait() 时就发生信号竞态。
     */
    if (sigprocmask(SIG_BLOCK, &set, NULL) == -1) {
        perror("sigprocmask");
        return 1;
    }

    child = fork();
    if (child == -1) {
        perror("fork");
        return 1;
    }

    if (child == 0) {
        /*
         * 子进程不应继承父进程用于 sigwait 的屏蔽状态。
         * 先恢复默认屏蔽字,再安装自己的 SIGTERM 处理器。
         */
        sigset_t empty;
        sigemptyset(&empty);

        if (sigprocmask(SIG_SETMASK, &empty, NULL) == -1) {
            _exit(112);
        }

        child_work();
        _exit(0);
    }

    printf("parent: child pid=%ld\n", (long)child);
    fflush(stdout);

    for (;;) {
        if (sigwait(&set, &signo) != 0) {
            fprintf(stderr, "sigwait failed\n");
            break;
        }

        if (signo == SIGTERM || signo == SIGINT) {
            printf("parent: requesting child shutdown\n");
            fflush(stdout);

            if (kill(child, SIGTERM) == -1 && errno != ESRCH) {
                perror("kill(SIGTERM)");
            }

            /*
             * 真实服务应在这里使用单调时钟实现超时:
             * 超时后 kill(child, SIGKILL),然后仍然 waitpid。
             */
            for (;;) {
                pid_t r = waitpid(child, &status, 0);

                if (r == child) {
                    goto child_reaped;
                }
                if (r == -1 && errno == EINTR) {
                    continue;
                }
                if (r == -1) {
                    perror("waitpid");
                    return 1;
                }
            }
        }

        if (signo == SIGCHLD) {
            /*
             * SIGCHLD 只表示“状态可能变化”,
             * 不能假设一次 SIGCHLD 只对应一个子进程。
             */
            for (;;) {
                pid_t r = waitpid(child, &status, WNOHANG);

                if (r == 0) {
                    break;
                }
                if (r == child) {
                    goto child_reaped;
                }
                if (r == -1 && errno == EINTR) {
                    continue;
                }
                if (r == -1 && errno == ECHILD) {
                    goto child_reaped;
                }
                if (r == -1) {
                    perror("waitpid(WNOHANG)");
                    return 1;
                }
            }
        }
    }

child_reaped:
    if (WIFEXITED(status)) {
        printf("parent: child exited with code %d\n",
               WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("parent: child killed by signal %d\n",
               WTERMSIG(status));
    }

    return 0;
}

编译和运行:

cc -std=c11 -Wall -Wextra -O2 lifecycle.c -o lifecycle
./lifecycle

另一个终端执行:

kill -TERM "$(pgrep -n lifecycle)"

实际测试时,pgrep -n lifecycle 可能匹配父进程或子进程,生产环境不应依赖模糊匹配。更可靠的做法是根据程序打印的 PID,或者使用 shell 保存 $!

./lifecycle &
parent_pid=$!
kill -TERM "$parent_pid"
wait "$parent_pid"

一个正常的退出路径类似:

parent: child pid=8120
child: started, pid=8120
child: working
child: working
parent: requesting child shutdown
child: graceful cleanup
parent: child exited with code 0

这个示例中的关键因果关系是:

SIGTERM
  → 父进程请求子进程停止
  → 子进程处理停止标志
  → 子进程执行清理并 _exit(0)
  → 内核产生 SIGCHLD
  → 父进程 waitpid()
  → 子进程不再是 Zombie

如果父进程只发送 SIGTERM 而从不 waitpid(),子进程仍可能在退出后短暂或长期保持 Zombie。


八、优雅退出不是“收到 SIGTERM 后立即退出”

1. 优雅退出的状态机

一个服务的优雅退出通常包含多个阶段:

sequenceDiagram
    participant M as 管理器/systemd
    participant P as 主进程
    participant W as 工作线程/子进程
    participant C as 客户端

    M->>P: SIGTERM
    P->>P: 设置 stopping 状态
    P->>C: 停止接受新请求
    P->>W: 停止派发新任务
    P->>W: 等待正在执行的任务
    W-->>P: 任务完成
    P->>P: 关闭监听器、刷新必要数据
    P-->>M: 退出
    M->>P: 超时后 SIGKILL

关键点是:停止接收新工作和完成已有工作必须分开处理

例如 HTTP 服务收到 SIGTERM 后,合理顺序通常是:

  1. 将状态改为 draining;
  2. 监听 socket 不再接受新连接;
  3. 对已经建立的连接决定是完成、拒绝还是设置截止时间;
  4. 停止生产新后台任务;
  5. 等待工作线程和子进程;
  6. 关闭资源;
  7. 进程退出。

如果只是设置一个全局布尔变量,但主循环仍然不断 accept(),就不构成真正的优雅退出。

2. 信号处理器中的“清理”必须谨慎

错误示例:

void handler(int signo)
{
    printf("stopping\n");
    free(global_buffer);
    pthread_mutex_lock(&mutex);
    fclose(log_file);
    exit(0);
}

问题包括:

  • printf() 可能持有 stdio 内部锁;
  • free() 可能被信号打断时再次进入分配器;
  • pthread_mutex_lock() 不是异步信号安全操作;
  • fclose() 可能访问复杂库状态;
  • exit() 会执行任意 atexit() 清理,可能再次遇到锁。

正确思路是处理器只设置标志,或者写一个字节通知事件循环:

static int notify_fd = -1;

static void on_term(int signo)
{
    char byte = (char)signo;
    (void)write(notify_fd, &byte, 1);
}

write() 的安全性仍受文件描述符状态、管道容量和错误处理影响,因此实际代码应检查返回值或使用非阻塞管道。Linux 程序也可以使用 signalfd,将信号作为 pollepoll 可等待的文件描述符事件,但它需要正确设置阻塞字,并且是 Linux 特有接口,不是可移植 POSIX 方案。


九、退出状态和 waitpid() 的解码

进程退出状态不是简单的“一个整数传给父进程后原样返回”。父进程必须使用宏解析:

if (WIFEXITED(status)) {
    int code = WEXITSTATUS(status);
}

if (WIFSIGNALED(status)) {
    int sig = WTERMSIG(status);
    int dumped = WCOREDUMP(status); /* Linux 扩展,需注意可移植性 */
}

主要情况:

  • WIFEXITED(status):进程正常调用 exit() 或从 main() 返回;
  • WEXITSTATUS(status):获取低位退出码;
  • WIFSIGNALED(status):进程因未处理信号终止;
  • WTERMSIG(status):获取终止信号;
  • WIFSTOPPED(status):进程被停止,通常需要 WUNTRACED
  • WIFCONTINUED(status):进程继续运行,通常需要 WCONTINUED

Shell 常见的 128 + signal_number 退出码是约定俗成的展示方式,不是内核把信号转换成普通退出码的规范。例如,程序被 SIGTERM 终止时,shell 可能显示 143,因为 128 + 15 = 143


十、进程组、会话和“杀掉一棵进程树”

1. PID 不是唯一的管理范围

后台服务常包含:

主进程
 ├── worker-1
 ├── worker-2
 └── helper

如果只执行:

kill -TERM <主进程PID>

只能向这个 PID 对应的目标发送信号,不保证所有子进程都收到信号。

Unix 还提供:

  • 进程组(process group);
  • 会话(session);
  • 终端控制关系。

向负 PID 发送信号时,含义是向进程组发送:

kill -TERM -1234

这表示向进程组 ID 为 1234 的进程组发送 SIGTERM,不是向 PID 为 -1234 的普通进程发送信号。

必须先确认进程组关系:

ps -o pid,ppid,pgid,sid,stat,cmd -p 1234

不能凭经验把普通 PID 改成负数;如果进程组设计错误,可能误伤同组的其他任务。

2. kill 的权限和语义

kill 这个名字容易造成误解,它不仅用于终止进程:

kill -0 1234

0 信号不实际发送信号,只用于检查目标是否存在以及当前用户是否具有发送信号的权限。

发送信号通常要求:

  • 发送者和目标具有合适的 UID 关系;
  • 或发送者拥有 CAP_KILL
  • 用户命名空间、容器和安全策略可能改变权限判断。

因此,“PID 存在但 kill 失败”可能是权限问题,而不是进程不在。


十一、标准信号的几个边界

1. SIGTERM 不是可靠消息队列

SIGTERM 只表达“请求终止”这一类事件,不携带任务 ID,也不保证多次发送对应多次回调。

如果应用需要传递多个独立事件,可以考虑:

  • 管道或 Unix domain socket;
  • eventfd;
  • 消息队列;
  • Linux signalfd
  • 实时信号和 sigqueue()

但实时信号也受系统资源限制,不应把它替代为高吞吐任务队列。

2. SIGCHLD 不是子进程清单

收到 SIGCHLD 后,只能得出“至少有一个子进程状态可能发生变化”。正确做法是循环调用:

waitpid(-1, &status, WNOHANG)

直到返回 0ECHILD

此外,SIGCHLD 可能因以下情况产生:

  • 子进程退出;
  • 子进程停止;
  • 子进程继续运行;
  • 使用了相应的 waitpid() 选项。

不能把每个 SIGCHLD 都直接当作“某子进程已退出”。

3. SIGPIPE 的处理

向已经关闭的 pipe 或 socket 写入时,默认可能收到 SIGPIPE。服务程序常见的处理方式是:

signal(SIGPIPE, SIG_IGN);

或者使用:

send(fd, buf, len, MSG_NOSIGNAL);

但忽略 SIGPIPE 后,写操作会返回 EPIPE,应用必须检查并处理这个错误。忽略信号不等于忽略写失败。


十二、systemd 如何参与进程退出

使用 systemd 管理服务时,systemd 不只是执行一条启动命令,它还管理 Unit 的生命周期、依赖关系、超时和资源范围。

典型服务单元:

[Service]
ExecStart=/usr/local/bin/example-server
Type=simple
Restart=on-failure
TimeoutStopSec=30s
KillSignal=SIGTERM
KillMode=control-group

这些配置的含义需要区分:

  • Type=simple:进程启动后,systemd 通常将 ExecStart 对应进程视为主进程;
  • KillSignal=SIGTERM:停止服务时首先使用的信号;
  • TimeoutStopSec=30s:等待停止完成的时间;
  • KillMode=control-group:停止时面向服务 cgroup 中的进程,而不是只关注一个 PID;
  • Restart=on-failure:异常失败时按条件重启。

systemd 的常见停止路径是:

systemctl stop example.service
    → 向服务发送 SIGTERM
    → 等待 TimeoutStopSec
    → 仍未退出则发送 SIGKILL

实际行为还受 Unit 类型、systemd 版本、配置覆盖和 cgroup 状态影响。服务程序必须正确响应 SIGTERM,否则最终可能被 SIGKILL 强制终止,无法完成应用层清理。

检查服务状态:

systemctl status example.service
journalctl -u example.service -b
systemctl show example.service \
    -p MainPID -p ControlGroup -p KillMode -p TimeoutStopUSec

生产环境中,TimeoutStopSec 不是“保证程序一定有这么多时间”的事务协议。主机关闭、管理员发送 SIGKILL、OOM、内核故障或容器运行时策略,都可能让程序来不及完成清理。因此,不能只在退出阶段保存唯一副本的数据;重要数据应在正常处理过程中持久化。


十三、诊断进程、线程和 Zombie

1. 查看进程树和父子关系

pstree -ap <PID>

或:

ps -eo pid,ppid,pgid,sid,stat,lstart,cmd --forest

重点观察:

  • PID
  • PPID
  • PGID
  • SID
  • STAT
  • 命令行是否发生了 exec 替换。

如果 PPID 变成 1 或某个 subreaper 的 PID,说明原父进程已经退出或进程被重新收养。

2. 查看线程

ps -L -p <PID> -o pid,tid,stat,psr,pcpu,comm

或者:

ls /proc/<PID>/task/
cat /proc/<PID>/task/<TID>/status

如果只有一个线程但 CPU 很高,问题可能是单线程计算;如果线程数很多但 CPU 使用率不高,可能是锁竞争、I/O 阻塞或大量睡眠线程。CPU 调度、运行队列和上下文切换需要结合 top -Hpidstat -tvmstatperf 判断,不能仅凭线程数量推导负载。

3. 查看信号状态

grep -E 'State|SigPnd|ShdPnd|SigBlk|SigIgn|SigCgt' \
    /proc/<PID>/status

这些字段通常以十六进制位图表示:

  • SigPnd:线程私有待处理信号;
  • ShdPnd:线程组共享待处理信号;
  • SigBlk:被阻塞的信号;
  • SigIgn:被忽略的信号;
  • SigCgt:被捕获的信号。

位图需要按信号编号解码,不能直接把十六进制值当作信号编号。

4. 跟踪创建、执行、退出和等待

strace -f -e trace=process,signal,wait4 -p <PID>

可以观察:

  • clone()fork()vfork()
  • execve()
  • kill()tgkill()
  • wait4()
  • 信号递送和系统调用被打断。

附加 strace 需要相应权限,并且会改变时序和性能;不要在高负载生产服务上无评估地长期启用。


十四、常见误解与失败路径

误解一:Zombie 可以通过 kill -9 解决

错误原因:Zombie 已经没有执行代码。应该让父进程回收,或者处理失效父进程和 subreaper 的回收逻辑。

误解二:发送 SIGTERM 后进程一定会退出

错误原因:进程可以捕获、忽略或阻塞 SIGTERM,也可能卡在不可中断睡眠、锁竞争或错误的退出流程中。SIGTERM 是请求,不是强制保证。

误解三:SIGKILL 能保证数据安全

错误原因:SIGKILL 不允许用户态清理。进程可能来不及刷新应用缓冲区、提交事务或释放临时锁。数据一致性必须由正常运行期间的持久化和事务机制保证。

误解四:父进程退出后子进程一定被杀死

错误原因:孤儿进程会被重新收养。需要使用进程组、cgroup、systemd 或应用级监督机制明确管理整个工作集合。

误解五:一个 SIGCHLD 对应一个子进程

错误原因:标准信号可能合并,且 SIGCHLD 还可能表示停止或继续。必须循环 waitpid(-1, ..., WNOHANG)

误解六:信号处理器中可以调用任意函数

错误原因:信号可能打断被中断线程正在持有库内部锁的时刻。处理器应尽量只设置 sig_atomic_t 标志、执行异步信号安全操作,或通知主循环。

误解七:kill <PID> 会杀掉服务的所有工作进程

错误原因:它只针对指定目标。服务可能启动了多个子进程、创建了不同进程组,或者子进程已经被重新收养。systemd 的 cgroup 管理通常比手写 PID 遍历更可靠。


十五、把生命周期串起来

一个典型后端服务的完整关系可以表示为:

systemd
  │
  │ ExecStart
  ▼
主进程
  │ fork/clone
  ├── 工作子进程
  ├── 工作子进程
  └── 多个线程

启动阶段:

systemd 启动主进程
    → 主进程创建线程或子进程
    → 父进程保存 PID 并建立回收逻辑
    → 子进程可能 exec 为独立程序

运行阶段:

线程处理请求
子进程执行后台任务
文件描述符、内存和信号处置按进程/线程规则共享或隔离

停止阶段:

systemd 发送 SIGTERM
    → 主进程进入 draining
    → 停止接收新任务
    → 通知线程和子进程
    → wait/join 回收执行实体
    → 退出

异常阶段:

进程崩溃或被信号终止
    → 父进程收到 SIGCHLD
    → waitpid 读取状态
    → 根据 WIFEXITED/WIFSIGNALED 判断原因
    → 决定重启、降级或报警

如果最后缺少 wait(),退出的子进程就可能留下 Zombie;如果停止阶段直接使用 SIGKILL,服务则可能跳过所有应用层清理。


十六、实践中的最小原则

针对普通 Linux 服务,可以把核心原则压缩为以下因果关系:

  1. fork() 创建子进程,用 exec() 启动新程序;
  2. pthread_create() 创建共享地址空间的线程;
  3. 不要把线程错误隔离能力当成进程隔离能力;
  4. 父进程必须持续回收所有可等待子进程;
  5. SIGTERM 应被视为停止请求,处理器只通知主控制流;
  6. 关闭接收入口,再排空正在执行的任务;
  7. 子进程退出后必须 waitpid(),否则可能成为 Zombie;
  8. 超时后才升级到 SIGKILL,并接受无法清理的事实;
  9. 需要管理整组进程时使用经过设计的进程组或 cgroup;
  10. /procpspstreestrace 验证实际状态,不凭命令名称或进程数量猜测行为。

掌握这些关系后,ps 中的 Z、systemd 的停止超时、服务收到 SIGTERM 却仍不退出,以及多线程程序中信号处理不稳定等问题,就可以还原为具体的状态转换和等待关系,而不是把它们视为互不相关的 Linux “奇怪现象”。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。