每个用 Linux 写过后端服务的人都接触过进程,但真要写出"父子进程协作 + 信号处理 + 优雅关闭"的代码,很多人会卡住 —— 因为大部分教程把 fork、exec、wait、signal 拆成四篇文章讲,从来没把它们串成一条完整的故事。这篇文章带你一次走通整条线,所有代码都是可编译的 C/shell。
进程到底是什么
在内核眼里,进程就是一个 task_struct 结构体,记录着这个程序"运行所需的一切":PID、PPID、内存映射、打开的文件描述符、寄存器快照、当前状态(运行/可中断睡眠/不可中断睡眠/僵尸/停止)、信号掩码等等。每个进程在用户空间看起来都拥有独立的 4GB(32位)或更大(64位)虚拟地址空间 —— 这是 MMU 给的幻觉。
查看进程的常用命令:
# 实时查看
top
htop # 更友好的版本
# 一次性快照
ps -ef # BSD 风格
ps aux # 另一种格式
ps -eLf # 显示线程
pstree -p # 看进程树
# 看某个进程的详细信息
cat /proc/<pid>/status # 状态、内存、上下文切换次数
cat /proc/<pid>/cmdline # 启动命令
ls -l /proc/<pid>/fd/ # 打开的文件描述符
fork:进程从哪里来
Linux 创建新进程的唯一方式(几乎)是 fork()。它把当前进程复制一份,父子共享代码段、独立的数据段(写时复制 COW)。fork 调用一次,返回两次 —— 在父进程里返回子进程的 PID,在子进程里返回 0。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
printf("before fork, pid=%d\n", getpid());
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子进程
printf("child: pid=%d ppid=%d\n", getpid(), getppid());
} else {
// 父进程
printf("parent: pid=%d child=%d\n", getpid(), pid);
wait(NULL); // 等子进程退出,否则子进程变僵尸
}
return 0;
}
编译运行:
$ gcc fork.c -o fork && ./fork
before fork, pid=12345
parent: pid=12345 child=12346
child: pid=12346 ppid=12345
写时复制 COW:fork 为什么不慢
fork 不会真的复制 4GB 内存,它只复制页表项,把所有页标记为只读,等任何一方写时再实际复制那一页。这就是写时复制。所以 fork 一个 10GB 内存的 Redis 服务也很快 —— 直到子进程开始写它的内存,才会触发实际拷贝。
exec:换个程序继续跑
fork 只是复制当前程序。真正用 fork 开启"另一个程序"需要配合 exec 系列函数 —— 它们把当前进程的代码段、数据段、堆栈整个替换成新程序,但 PID、文件描述符等保留。
// fork + exec 经典模式:shell 启动外部命令的核心
pid_t pid = fork();
if (pid == 0) {
// 子进程:用新程序替换自己
execl("/bin/ls", "ls", "-l", "/tmp", (char *)NULL);
perror("exec failed"); // exec 成功就不会返回这里
_exit(1);
} else {
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("child exited with %d\n", WEXITSTATUS(status));
}
}
exec 家族有六个函数:execl/execlp/execle/execv/execvp/execve。l 表示参数列表,v 表示参数数组,p 表示在 PATH 里搜索,e 表示传环境变量。execve 是真正的系统调用,其他都是它的包装。
wait/waitpid:回收僵尸
子进程退出后,内核保留它的退出状态等父进程来读。如果父进程一直不读,这条记录就一直在 —— 这就是僵尸进程(zombie)。
// 制造一个僵尸
pid_t pid = fork();
if (pid == 0) {
_exit(0); // 子进程立刻退出
} else {
sleep(60); // 父进程不 wait,子进程变僵尸 60 秒
}
另一边查看,会看到一个 <defunct>:
$ ps -ef | grep defunct
zhang 12347 12346 0 ... [a.out] <defunct>
正确做法:父进程要么显式 wait,要么主动忽略 SIGCHLD 信号,让内核自动回收。
// 同步等待
int status;
pid_t cpid = waitpid(pid, &status, 0);
// 非阻塞等待
waitpid(pid, &status, WNOHANG);
// "fire and forget":让内核自动回收所有子进程
signal(SIGCHLD, SIG_IGN); // POSIX 行为:不产生僵尸
孤儿进程与 init 收养
反过来,如果父进程先死了,子进程会被 PID=1 的 init 进程"收养",继续运行。这是常用的"daemonize"技巧的基础 —— fork 两次,让中间的父进程退出,最终的工作进程被 init 收养,从而脱离启动它的终端。
信号:进程之间的异步通知
信号是 Unix 最古老的 IPC 机制。每个信号对应一个数字,内核或其他进程可以发给某个进程,目标进程要么用默认动作处理,要么用自己注册的 handler 处理。
# 常见信号
SIGHUP 1 终端挂断 / 配置重载约定信号
SIGINT 2 Ctrl+C
SIGQUIT 3 Ctrl+\,会生成 core dump
SIGKILL 9 强制杀死,不可捕获、不可忽略
SIGUSR1 10 用户自定义 1
SIGSEGV 11 段错误
SIGPIPE 13 写一个对端已关闭的管道
SIGTERM 15 优雅退出请求,kill 默认发的
SIGCHLD 17 子进程状态变化
SIGSTOP 19 Ctrl+Z,不可捕获
SIGCONT 18 恢复运行
SIGKILL 和 SIGSTOP 是无法被捕获、阻塞或忽略的,这是 kill -9 一定能杀死进程的原因。其余信号都可以被自定义处理。
注册信号 handler:从 signal 到 sigaction
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
void handler(int sig) {
// 注意:handler 里能调用的函数极有限(必须 async-signal-safe)
// write 是安全的,printf 不是
const char *msg = "caught SIGINT, exiting...\n";
write(STDERR_FILENO, msg, 27);
_exit(0);
}
int main() {
// 老接口,跨平台行为不一致,不推荐
// signal(SIGINT, handler);
// 推荐:sigaction,可指定 mask 和 flags
struct sigaction sa = { 0 };
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 被打断的系统调用自动重启
sigaction(SIGINT, &sa, NULL);
while (1) {
printf("running... (pid=%d)\n", getpid());
sleep(1);
}
}
handler 里只能做有限的事
信号在程序"任意指令之间"被递送,如果当时正持有某个锁或者在 malloc 内部,handler 又去调 malloc,会立刻死锁或破坏内部状态。所以 handler 里只能调用异步信号安全函数 —— write / _exit / kill / sigaction 等。常见的不安全函数:printf / malloc / free / fopen。
实战中通常的做法是:handler 里只置一个 volatile sig_atomic_t 标志,主循环检测标志再做实际处理。
volatile sig_atomic_t should_reload = 0;
void on_sighup(int sig) { should_reload = 1; }
int main() {
struct sigaction sa = { .sa_handler = on_sighup };
sigaction(SIGHUP, &sa, NULL);
while (1) {
do_work();
if (should_reload) {
should_reload = 0;
reload_config(); // 安全:在主循环里调用,可以做任何事
}
}
}
用 kill 发送信号
# 命令行
kill -TERM 12345 # 优雅请求退出
kill -HUP nginx_pid # 通知 nginx 重新加载配置
kill -9 12345 # 强杀
# C API
#include <signal.h>
kill(pid, SIGTERM);
raise(SIGUSR1); // 给自己发
实战:优雅关闭一个服务器
把上面所有知识串起来。一个真实的网络服务收到 SIGTERM 时,应该:停止接受新连接 → 处理完已有请求 → 关闭日志文件 → 退出。下面是一个简化框架:
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
volatile sig_atomic_t shutting_down = 0;
void on_term(int sig) { shutting_down = 1; }
int main() {
struct sigaction sa = { .sa_handler = on_term };
sigemptyset(&sa.sa_mask);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
// 忽略 SIGPIPE:对端断开时,写操作返回 -1 / EPIPE,而不是杀掉自己
signal(SIGPIPE, SIG_IGN);
while (!shutting_down) {
// 接受新连接、读请求、处理、写响应
// 在循环里频繁检查 shutting_down,确保能及时退出
process_one_request();
}
fprintf(stderr, "shutting down: draining in-flight requests...\n");
drain_in_flight();
close_log_files();
return 0;
}
这套模式被几乎所有生产级服务采用(nginx、redis、postgres 都是类似套路)。要点四个:主动捕获 SIGTERM / SIGINT;handler 只置标志;循环里频繁检查标志;对 SIGPIPE 显式忽略,转而看 errno。
fork + signal 的微妙交互
fork 时,子进程会继承父进程的信号 handler 和信号掩码,但不继承未决的信号。exec 时,所有"被进程自定义处理"的信号都恢复成默认动作(因为新程序根本没注册 handler),但被设置为 SIG_IGN 的信号会保留 SIG_IGN。这两个微妙之处是大量"子进程行为不符预期"bug 的源头,值得记住。
守护进程的标准 11 步
把一个程序变成 daemon(后台守护进程)的经典步骤:
1. fork,父进程退出 → 让子进程被 init 收养
2. setsid() → 创建新会话,脱离原控制终端
3. fork 第二次 → 防止后续 open 终端时重新拿到控制终端
4. chdir("/") → 避免占着某个挂载点不能卸载
5. umask(0) → 清掉继承来的 umask
6. 关闭 0/1/2 文件描述符
7. 把 0/1/2 重定向到 /dev/null
8. 处理 PID 文件,防止重复启动
9. 注册信号 handler(SIGTERM 优雅退出,SIGHUP 重载)
10. 切换工作用户(setuid),不以 root 长跑
11. 进入主循环
现代发行版里这套事 systemd 替你做了 —— 你只需要写一个普通前台程序,加一个 .service 文件,信号、日志、重启策略全交给 systemd。但理解原理对调试线上问题至关重要。
常见排查命令汇总
strace -p <pid> # 跟踪某个进程的系统调用
ltrace -p <pid> # 跟踪它调用的库函数
lsof -p <pid> # 它打开了哪些文件、socket
gdb -p <pid> # 附加到运行中的进程
cat /proc/<pid>/stack # 看内核态栈(它阻塞在哪个内核函数)
cat /proc/<pid>/wchan # 它在等什么事件
kill -l # 列出所有信号
trap 'echo SIGTERM' TERM # shell 里自定义信号 handler
进程间通信(IPC)的几种方式
父子进程或独立进程之间需要交换数据时,Linux 提供了一组传统 IPC 机制,各有适用场景。
管道 pipe / FIFO
#include <unistd.h>
int pipefd[2];
pipe(pipefd); // pipefd[0] 读端,pipefd[1] 写端
pid_t pid = fork();
if (pid == 0) {
close(pipefd[1]); // 子进程不写
char buf[64];
int n = read(pipefd[0], buf, sizeof(buf));
write(STDOUT_FILENO, buf, n);
} else {
close(pipefd[0]); // 父进程不读
write(pipefd[1], "hello\n", 6);
wait(NULL);
}
管道是单向、只能在有亲缘关系的进程间使用的字节流。要让不相关的进程通信,用命名管道(FIFO):
# shell 里建 FIFO
mkfifo /tmp/myfifo
# 一个终端写
echo hello > /tmp/myfifo
# 另一个终端读
cat /tmp/myfifo
共享内存与信号量
// POSIX 共享内存:大数据量、低延迟
#include <sys/mman.h>
#include <fcntl.h>
int fd = shm_open("/myshm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
strcpy(p, "shared data"); // 任何 mmap 这块共享内存的进程都能看到
共享内存最快,但没有同步机制 —— 必须配合信号量或互斥锁防止数据竞争。
Unix Domain Socket
现代服务首选。和 TCP Socket 一样的 API,但不走网络栈,本机进程间速度极快。Docker、nginx 和后端的 PHP-FPM、systemd 的 socket activation 都用它。
# shell 里看本机的 UDS
ss -lx # 列出所有 Unix Domain Socket
# 服务端:nc -lU /tmp/sock
# 客户端:nc -U /tmp/sock
线程与进程的边界
线程和进程在 Linux 内核里是同一种东西 —— 都是 task_struct,只是线程之间共享地址空间。fork 创建独立地址空间的进程,clone(及 pthread)按需共享部分资源。命令行视角:
# ps 默认不显示线程
ps -eLf | grep myapp # 加 L 显示线程
top -H -p <pid> # H 切到线程视图
cat /proc/<pid>/task/*/status # 每个线程一个目录
真正影响选择的是:线程通信靠共享内存,简单但容易出竞争;进程通信靠 IPC,隔离好但开销大。Redis、nginx 选了"多进程 + 单线程事件循环"路线,正是看中了进程间的强隔离 —— 一个 worker 崩了不会带垮其他 worker。
写在最后
fork、exec、wait、signal 看着是四件事,本质是同一件:Unix 用进程作为隔离单元,用信号作为唯一的异步通知通道。一旦把"fork 出来的子进程是父进程的镜像"和"信号 handler 只能做异步信号安全的事"这两个心智模型立住,绝大多数进程相关的 bug 都能自己分析出原因。
给后端工程师的一句实用建议:服务上线前,亲手发一遍 kill -TERM / kill -HUP / kill -USR1 看你的程序怎么反应。如果发 TERM 它直接卡死或丢请求,说明你的优雅关闭只是写在简历上,没经过验证 —— 而这正是凌晨四点把你叫醒的事故来源。
—— 别看了 · 2026