Linux 进程与信号全解:fork、exec、wait 与信号处理的实战指南

每个用 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/execvel 表示参数列表,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  恢复运行

SIGKILLSIGSTOP无法被捕获、阻塞或忽略的,这是 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

动态规划完全指南:从入门到面试 10 道经典题型

2026-5-15 10:55:54

技术教程

Docker 多阶段构建实战:把镜像体积缩小 50 倍的工程姿势

2026-5-15 11:04:01

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索