一文吃透 Linux 进程:从 PID、PCB 到僵尸回收、fork/exec、守护进程

这篇博客把——进程的基本概念、PID、PPID、PCB、进程状态、僵尸进程与回收、fork/exec、wait/waitpid、信号、环境变量、execvpe、守护进程——串成一条清晰的主线。配上小而能跑的示例,方便上手与排错。


1. 什么是进程(Process)

  • 进程:一个运行中的程序实例,拥有独立的地址空间、打开的文件、寄存器上下文、信号处理器、环境等。
  • 线程:共享进程的地址空间与多数资源,侧重并发执行。
  • 用户态/内核态:进程大多数时间在用户态运行,经由系统调用切入内核态完成 I/O、调度等。

2. 身份三件套:PID、PPID、进程组/会话

  • PID(Process ID):唯一标识一个进程。
  • PPID(Parent PID):父进程号。ps -o pid,ppid,cmd -p <PID>
  • 进程组/会话:用于作业控制(如前台/后台)。setsid() 可建立新会话,典型用于守护进程
ps -o pid,ppid,pgid,sid,tty,stat,cmd -p $$

3. PCB:进程控制块(task_struct)

PCB(Process Control Block) 是内核中描述进程的一整套结构信息(Linux 里是 task_struct):

  • 标识pid / tgid / ppid
  • 状态:运行/就绪/睡眠/停止/僵尸
  • CPU 上下文:寄存器、栈指针、程序计数器
  • 内存信息mm_struct(虚拟内存布局)
  • 文件系统:打开文件表、cwd、root
  • 信号:待处理信号、处理器
  • 调度:优先级、时间片、调度策略
  • 统计:CPU 时间、start_time

僵尸进程本质就是“逻辑已退出,但 PCB 残留等待父进程读取”的那块信息。


4. 进程状态与 ps/top

常见状态(psSTAT 字段):

  • R 运行/就绪
  • S 可中断睡眠(大多数 I/O 等待)
  • D 不可中断睡眠(设备 I/O)
  • T 停止(SIGSTOP/ptrace)
  • Z 僵尸(Zombie)
  • X 死亡(极短暂)
ps -e -o pid,ppid,stat,time,cmd | head
top          # 动态观察

5. 进程的生命周期:fork → exec → exit → wait

5.1 fork():复制一份进程

  • 父进程得到子进程 PID,子进程返回 0
  • 地址空间采用写时复制(COW),直到实际写入才复制页。
pid_t pid = fork();
if (pid == 0) { /* 子进程 */ }
else if (pid > 0) { /* 父进程 */ }
else { perror("fork"); }

5.2 exec*():用新程序替换自己

  • 成功后不返回,当前 PID 不变,但代码/数据段变为新程序。

  • 常用族谱:

    • execv(path, argv):绝对/相对路径 + 参数数组
    • execvp(file, argv):按 PATH 搜索
    • execve(path, argv, envp):自定义环境(最底层)
    • execvpe(file, argv, envp)GNU 扩展PATH 搜索 + 自定义环境
const char* argv[] = {"ls","-l",NULL};
execvp("ls", (char* const*)argv);
perror("execvp"); _exit(127);

execvpe = exec + v(数组参数) + p(按 PATH 搜索) + e(自定义环境)。

5.3 exit/_exit:退出进程

  • exit() 刷新 stdio 缓冲;_exit() 直接退(exec 失败时常用 _exit(127))。
  • 退出码:0 表示成功,非 0 表示错误类型。

5.4 wait()/waitpid():收割与回收

  • 不收割的子进程会变僵尸(只有 PCB 残留)。

  • waitpid(pid, &status, 0) 精确等待指定子进程;WNOHANG 非阻塞轮询。

  • 宏解析:

    • WIFEXITED(status) / WEXITSTATUS(status)
    • WIFSIGNALED(status) / WTERMSIG(status)
    • WIFSTOPPED/WSTOPSIGWIFCONTINUED
pid_t child = fork();
if (child == 0) { execlp("sleep","sleep","1",(char*)NULL); _exit(127); }
int st; pid_t r = waitpid(child, &st, 0);
if (WIFEXITED(st)) printf("exit=%d\n", WEXITSTATUS(st));

6. 僵尸进程与孤儿进程

  • 僵尸(Z):子进程退出后,父进程未 wait,PCB 残留。危害:占据 PID,积多了会影响系统。
  • 孤儿:父进程先于子进程退出,子进程会被 init/systemd 收养;当它退出时由收养者 wait不会成为僵尸。

避免僵尸

  • 正确 wait/waitpid
  • SIGCHLD 处理器循环 waitpid(-1, &st, WNOHANG)
  • signal(SIGCHLD, SIG_IGN)(系统自动回收,注意可移植性与副作用)。

7. 信号与处理器

  • 常见信号:SIGINT(Ctrl+C), SIGTERM(优雅退出), SIGKILL(不可捕获), SIGCHLD(子进程状态变化)。
  • 安装处理器建议用 sigaction 而非 signal,更可控:
static volatile sig_atomic_t running = 1;
static void on_term(int){ running = 0; }

struct sigaction sa = {0};
sa.sa_handler = on_term;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;            // 或 SA_RESTART
sigaction(SIGTERM, &sa, NULL);

在处理器里只做异步安全的事(设标志、写到 pipe 等),不要做 malloc、printf 等。


8. 环境变量:getenv/setenvenviron

  • getenv("PATH"):取变量。
  • setenv/unsetenv:修改变量(线程安全性比直接改 environ 好)。
  • extern char** environ;:进程的环境表("KEY=VALUE" 数组),遍历所有环境变量用它。
extern char** environ;
for (char** p = environ; *p; ++p) puts(*p);

传递给子进程execve/execvpe 的第三个参数 envp


9. I/O & 重定向 & 管道(父子通信常用)

  • 文件描述符0/1/2 分别是 stdin/stdout/stderr
  • 重定向dup2(oldfd, newfd)newfd 指向 oldfd 的对象。
  • 管道pipe(int p[2])p[0] 读、p[1] 写。配合 fork() 让父子通信:
int p[2]; pipe(p);
pid_t pid = fork();
if (pid == 0) {
  close(p[0]); dup2(p[1], 1); execlp("ls","ls","-l",(char*)NULL); _exit(127);
}
close(p[1]); char buf[4096]; ssize_t n = read(p[0], buf, sizeof buf);
write(1, buf, n); close(p[0]); waitpid(pid, NULL, 0);

10. 守护进程(Daemon)要点

典型步骤:

  1. fork() 让父进程退出(shell 认为命令结束);
  2. setsid() 脱离控制终端,成为新会话首进程;
  3. 可选再 fork() 一次,防止重新获得控制终端;
  4. chdir("/")umask(0)(或安全缺省掩码);
  5. 关闭/重定向 0/1/2/dev/null 或日志;
  6. 安装 SIGTERM 等处理器,优雅退出
  7. 日志建议用 syslog 或自行管理 log fd。

轻量骨架:

pid_t pid = fork(); if (pid<0) _exit(1); if (pid>0) _exit(0);
if (setsid()==-1) _exit(1);
pid = fork(); if (pid<0) _exit(1); if (pid>0) _exit(0);
chdir("/"); umask(0);
/* 重定向 0/1/2 到 /dev/null */

11. 调度与优先级:nice/renicesched_*

  • nice 值:-20(最高)到 19(最低),影响普通调度优先级。
  • 命令行:nice -n 10 cmdrenice 5 -p <PID>
  • 实时调度:SCHED_FIFO / SCHED_RR(需 CAP_SYS_NICE),用 sched_setscheduler()

12. 监控与排错工具小抄

  • 进程树pstree -p
  • 文件占用lsof -p <PID> / lsof <file>
  • 系统调用跟踪strace -f -p <PID> / strace cmd
  • /proc/proc/<PID>/status, fd/, cmdline, environ, stat
  • CPU/内存top, htop, pidstat, vmstat

13. 常见坑与最佳实践

  • wait 导致僵尸 → 在合适时机 waitpidSIGCHLD 收割。
  • exec 成功不返回 → 任何错误打印必须在 exec 之后的分支;成功时请 _exit(127) 只在 exec 失败后调用。
  • 相对路径误用 → 少了 / 就从 CWD 找,ENOENT 很常见。程序里不展开 ~
  • sprintf 溢出 → 用 snprintf 或直接 fprintf 到流。
  • fflush vs fsyncfflush 到内核页缓存;要“落盘”用 fsync(fileno(fp))
  • 信号处理器里做复杂事 → 只改标志/写 pipe,复杂逻辑放主循环。
  • execvpe 是 GNU 扩展 → 需要 #define _GNU_SOURCE;可用 execve 代替(不查 PATH)。

14. 迷你示例:父启动子进程、捕获输出、等待退出码

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

int main() {
    int p[2]; pipe(p);
    pid_t pid = fork();
    if (pid == 0) {
        close(p[0]);
        dup2(p[1], STDOUT_FILENO);
        execlp("echo", "echo", "hello, process", (char*)NULL);
        _exit(127);
    }

    close(p[1]);
    char buf[256]; ssize_t n = read(p[0], buf, sizeof(buf));
    if (n > 0) write(STDOUT_FILENO, buf, n);
    close(p[0]);

    int st; waitpid(pid, &st, 0);
    if (WIFEXITED(st)) printf("child exit=%d\n", WEXITSTATUS(st));
    return 0;
}

结尾:把握主线,化零为整

  • 进程是谁? —— PCB 记录一切(PID、资源、状态)。
  • 如何出生? —— fork 复制,exec 换壳。
  • 如何结束? —— exit/_exit;父用 waitpid 收尸,避免僵尸。
  • 如何被管理? —— 信号、调度、优先级、守护化、重定向、/proc、工具链。
  • 如何排错? —— ps/top/pstree/lsof/strace + 合理日志与错误码。

当你把这条链路打通,Linux 进程世界就不再神秘:它是可观察、可操控、可验证的系统工程。接下来建议你动手:把上面的“迷你示例”和“守护进程骨架”编译运行,对照 ps 和日志观察变化;再用 strace 看看系统调用,感受“内核与进程的对话”。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐