Linux 基础体系 · 第 9/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Linux 进程、线程与信号:生命周期、父子关系、Zombie 和优雅退出
所属系列:Linux 基础体系
所属模块:三、进程与服务
标签:Linux、进程、信号、后端
适用范围:现代主流 Linux 发行版
Linux 中的进程、线程和信号经常同时出现在服务启动、故障诊断和优雅停机流程中:
fork()创建子进程,execve()替换进程映像;- 父进程通过
waitpid()回收子进程; - 子进程退出但父进程尚未回收时,会暂时成为 Zombie;
SIGTERM常用于请求服务停止,SIGKILL则直接终止;- 多线程程序中,信号可能发给某个线程,也可能发给整个线程组;
- systemd 停止服务时,信号、进程组和 cgroup 又会叠加在一起。
理解这些机制,需要先区分三个概念:进程是资源与地址空间的管理单位,线程是调度执行单位,信号是内核向进程或线程异步通知事件的机制。
一、进程到底是什么
1. 进程是内核维护的执行实体
一个进程至少包含以下状态:
- 虚拟地址空间;
- 文件描述符表;
- 当前工作目录和根目录;
- 环境变量;
- 用户身份、组身份和权限凭据;
- 信号处置方式、信号屏蔽字和待处理信号;
- 调度状态、优先级和 CPU 亲和性;
- 与父进程、进程组、会话的关系;
- 退出状态以及等待关系。
进程通常用 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 --> [*]
这里的 Ready、Running、Sleeping 是调度层面的状态;Zombie 是进程已经结束执行,但退出记录尚未被父进程读取的状态。
Linux 的进程状态可以通过 /proc/<pid>/status 或 ps 查看:
ps -o pid,ppid,stat,cmd -p <PID>
常见 STAT 字符包括:
R:正在运行或可运行;S:可中断睡眠;D:不可中断睡眠,常见于等待内核 I/O;T:被停止;Z:Zombie;I:空闲内核线程等,具体含义依工具版本而定。
这些状态不是完整的生命周期模型。例如,一个进程可能在 R 和 S 之间频繁切换;Z 表示用户代码已经不再执行,不应理解为“仍然占用 CPU”。
2. fork():复制执行上下文
fork() 创建一个子进程。成功后,父进程和子进程都会从 fork() 的下一条指令继续执行,但返回值不同:
- 父进程得到子进程 PID;
- 子进程得到
0; - 失败时父进程得到
-1,不会产生子进程。
现代 Linux 通常通过写时复制(Copy-on-Write,COW)实现地址空间复制:
- 父子进程初始共享物理内存页;
- 页表被分别建立;
- 共享页被标记为只读;
- 任一方写入时触发缺页异常;
- 内核复制该页,再允许写入。
所以,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) 的基本语义是:
- 如果指定子进程仍在运行,父进程阻塞;
- 子进程退出后,内核通知父进程;
- 父进程读取状态;
- 子进程从 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
通常不会改变它的状态。
正确处理路径是:
- 找出 Zombie 的父进程;
- 让父进程调用
wait()或waitpid(); - 如果父进程失效,让 Zombie 被重新收养;
- 新的收养者必须能够回收它。
如果父进程长期不等待,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() 改变大多数信号的处置方式。
SIGKILL 和 SIGSTOP 不能被捕获、阻塞或忽略。不存在“程序优雅地处理 SIGKILL”这一机制。
1. 信号处置、屏蔽字和待处理集合
每个进程或线程相关的信号状态至少包含三个概念:
信号处置(disposition)
说明收到某信号时怎么处理:
- 默认动作;
- 忽略;
- 调用用户定义的处理器。
可以用 sigaction() 设置。
信号屏蔽字(signal mask)
表示暂时不允许递送哪些信号。被屏蔽的信号不会丢失,通常会变为待处理状态。
线程有各自的信号屏蔽字。
待处理信号(pending signals)
信号已经产生,但还没有被递送到能够处理它的执行上下文。
标准信号通常不排队。例如,在一个 SIGTERM 已经处于待处理状态时,再发送多个 SIGTERM,一般不会得到多个独立的待处理记录。
实时信号 SIGRTMIN 到 SIGRTMAX 支持排队,并携带更明确的顺序和 sigqueue() 数据,但可用编号和资源限制需要通过运行时接口确认,不能硬编码成所有发行版都相同。
2. 进程定向和线程定向
发送信号的方式不同,目标语义也不同:
kill -TERM 1234
通常是向 PID 代表的进程线程组发送进程定向信号。
在 C/POSIX 线程程序中:
pthread_kill(thread, SIGUSR1);
是向指定线程发送信号。
进程定向信号最终会递送给线程组中某个未屏蔽该信号的线程。具体选择不是应用层应依赖的稳定调度规则。
线程定向信号则只能由指定线程接收。SIGSTOP、SIGKILL 等进程级强制信号的实际效果仍然是终止或停止整个进程线程组。
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”。
七、一个可运行的父子进程与优雅退出示例
下面的程序演示:
- 父进程创建子进程;
- 子进程安装
SIGTERM处理器并运行工作循环; - 父进程同步等待
SIGTERM、SIGINT和SIGCHLD; - 收到停止请求后,向子进程发送
SIGTERM; - 父进程通过
waitpid()回收子进程; - 如果子进程不退出,父进程最终可以升级为
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 后,合理顺序通常是:
- 将状态改为 draining;
- 监听 socket 不再接受新连接;
- 对已经建立的连接决定是完成、拒绝还是设置截止时间;
- 停止生产新后台任务;
- 等待工作线程和子进程;
- 关闭资源;
- 进程退出。
如果只是设置一个全局布尔变量,但主循环仍然不断 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,将信号作为 poll、epoll 可等待的文件描述符事件,但它需要正确设置阻塞字,并且是 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)
直到返回 0 或 ECHILD。
此外,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 -H、pidstat -t、vmstat 或 perf 判断,不能仅凭线程数量推导负载。
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 服务,可以把核心原则压缩为以下因果关系:
- 用
fork()创建子进程,用exec()启动新程序; - 用
pthread_create()创建共享地址空间的线程; - 不要把线程错误隔离能力当成进程隔离能力;
- 父进程必须持续回收所有可等待子进程;
SIGTERM应被视为停止请求,处理器只通知主控制流;- 关闭接收入口,再排空正在执行的任务;
- 子进程退出后必须
waitpid(),否则可能成为 Zombie; - 超时后才升级到
SIGKILL,并接受无法清理的事实; - 需要管理整组进程时使用经过设计的进程组或 cgroup;
- 用
/proc、ps、pstree、strace验证实际状态,不凭命令名称或进程数量猜测行为。
掌握这些关系后,ps 中的 Z、systemd 的停止超时、服务收到 SIGTERM 却仍不退出,以及多线程程序中信号处理不稳定等问题,就可以还原为具体的状态转换和等待关系,而不是把它们视为互不相关的 Linux “奇怪现象”。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Linux 主机安全加固:最小化、SELinux/AppArmor、审计与漏洞治理
- 下一篇:systemd 服务管理:Unit、依赖、重启、资源限制和安全沙箱
- 延伸:Linux CPU 调度与负载:运行队列、上下文切换、Load 和软中断
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论