土法炼钢兴趣小组的算法知识备份

【操作系统百科】进程生命周期:clone → exec → exit → reap

文章导航

分类入口
os
标签入口
#process-lifecycle#do-exit#release-task#zombie#orphan#subreaper#pidfd#waitpid

目录

每个 Linux 进程都会走一遍相同的流水线:被 clone 出来、(可能)换壳 exec、运行、退出、等父进程 wait、结构体被释放。但这个流程的每一步都有很多副作用与陷阱——尤其是退出和 wait 的边界,生产环境里无数 bug 源于对它们的误解。

本文按阶段走一遍,讲清楚每一步在内核里做了什么、错在哪会让你在生产里踩到什么坑。

一、总览

stateDiagram-v2
    [*] --> Running: clone / kernel_clone
    Running --> Running: schedule / syscall
    Running --> Running: execve (换镜像,保留 task)
    Running --> Exiting: do_exit
    Exiting --> Zombie: __state=EXIT_ZOMBIE<br/>signal SIGCHLD 发父
    Zombie --> Dead: release_task (父 wait 或 subreaper)
    Dead --> [*]: RCU free task_struct
    Running --> Stopped: SIGSTOP / ptrace
    Stopped --> Running: SIGCONT

关键观察:

二、阶段 1:clone

B-09 详述 fork/clone/posix_spawn。内核侧的主干是 kernel_clone()kernel/fork.c):

  1. copy_process():分配 task_struct、copy 各种字段(按 CLONE_* flags 决定共享还是复制 mm、files、signal、cred、ns)
  2. 分配 pid(所有相关 namespace 里)
  3. 链入父子树(childrensibling 链表)
  4. 加入 runqueue(wake_up_new_task
  5. 返回 pid 给父进程

关键副作用

新 task 立刻 runnable,但未必立刻 run——取决于调度器。

三、阶段 2:execve

execve 的工作是”让当前 task 变成一个新程序”:

sys_execve → bprm_execve → search_binary_handler → load_elf_binary

关键动作:

  1. 打开新二进制(解释器 / ELF / shebang)
  2. 读头部,设置 mm_struct:用户栈、用户参数区、BSS 等
  3. flush_old_exec 清理旧 mm:解除所有 mmap、清 fd 上带 CLOEXEC 的
  4. 重置 signal handlers 为默认(SIG_DFL);阻塞集、pending 保留
  5. arch_setup_additional_pages:插入 vDSO 页
  6. 切换 credentials(setuid 二进制在此处生效)
  7. 最终 start_thread() 把 IP 设到 ELF entry,返回用户态

保留的:pid、tgid、real_parent、nsproxy、cgroup、不带 CLOEXEC 的 fd、优先级。

失效的:mm、sighand(handlers 重置)、带 CLOEXEC 的 fd、LSM context 可能变(SELinux transition)。

3.1 binfmt 链

Linux 支持多种二进制格式:

search_binary_handler 依次尝试,接受的格式处理 exec。

3.2 setuid 与 AT_SECURE

当 exec 一个 setuid 二进制时,kernel 通过 AT_SECURE auxiliary vector 告诉 dynamic linker “这是 secure run”;glibc 的 ld.so 看到后禁用 LD_PRELOAD 等。这是 setuid 攻防的起点(D-29、L-97)。

四、阶段 3:run

调度在 C 子系列专题。这里只提两个与生命周期相关的细节:

五、阶段 4:exit

进程退出路径:

sys_exit / sys_exit_group / fatal signal / page fault with no handler
    → do_exit()

do_exit 关键步骤(5.x+ 顺序有微调):

  1. 设置 PF_EXITING flag
  2. exit_signals:flush pending signal
  3. acct_update_integrals:最后一次计时
  4. exit_mm:如无 CLONE_VM 共享,释放 mm;mmap 区域走 unmap_vmas + munlock
  5. exit_sem:释放 SysV 信号量
  6. exit_shm:attach 的 SysV shm 计数减
  7. exit_files:关所有 fd
  8. exit_fs:释放 fs_struct(cwd、root)
  9. exit_task_namespaces:释放 nsproxy;如是 pid_ns init,触发 ns 销毁
  10. exit_task_work:运行注册的 task work
  11. exit_notify
    • 把子进程 reparent 到 subreaper 或 PID 1
    • 给父进程发 SIGCHLD
    • 如果父进程设了 SA_NOCLDWAIT 或忽略 SIGCHLD,直接 release,跳过 zombie
  12. __state = EXIT_ZOMBIE
  13. 最后一次 schedule(),不再返回

5.1 线程组退出:exit_group

exit_group vs exit

正常 return 0; 的 C 程序走 exit(0) → glibc 的 exit → __run_exit_handlers_exit(0) = exit_group

六、阶段 5:zombie

zombie task_struct 的角色:exit code 的临时存储 + SIGCHLD 的接收对象。直到父进程调 wait 之前:

僵尸在 ps 里显示为 Z

6.1 孤儿与 subreaper

当父进程先于子死,子变”孤儿”。传统做法:孤儿的新父是 PID 1(init 进程)。PID 1 在 wait 循环里吃掉所有孤儿。

问题:容器里 PID 1 往往是应用本身,可能不做 reap。于是 Linux 3.4 引入 subreaper

prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0);

设置此标志的进程会成为”子孙孤儿”的接收方,代替 PID 1。systemd 用它管理 user session;supervisord 用它管理被监管进程。

七、阶段 6:wait / release

父进程调 waitpid / waitid / wait4

  1. 检查子的 state
  2. 复制 exit_code 到父栈
  3. 复制 rusage(CPU 时间等)
  4. release_task
  5. 从父的 children 链表解除
  6. 从 tasklist、pid hash 删除
  7. RCU 延迟释放 task_struct

7.1 waitpid 的标志

waitidwaitpid 的强化版:参数结构体化,返回 siginfo_tsi_codeCLD_EXITEDCLD_KILLEDCLD_DUMPEDCLD_STOPPED)。

7.2 pidfd:新一代进程引用

PID 有重用问题:你记下 pid 1234,它退了,一会儿新进程又是 1234。你 kill(1234) 可能杀到无辜。

pidfd(5.3+):pidfd_open(pid, 0) 返回一个 fd,只要 fd 活着就唯一地引用那个 task。可以:

这是容器运行时(runc、crun)、supervisor 工具最期待的 API。

sequenceDiagram
    participant P as 父
    participant K as 内核
    participant C as 子
    P->>K: clone3(CLONE_PIDFD, ...)
    K-->>P: child_pid, pidfd
    P->>K: epoll_ctl(add pidfd)
    C->>K: exit()
    K-->>C: EXIT_ZOMBIE
    K->>P: epoll 返回(pidfd readable)
    P->>K: waitid(P_PIDFD, pidfd)
    K-->>P: siginfo (exit_code)
    K->>C: release_task
    P->>K: close(pidfd)

八、systemd PID 1 的特殊性

systemd 作为 PID 1 除了做标准 init 工作,还承担:

systemd 进程自己死了(oops / OOM / bug)= 整机挂。所以 systemd 代码要比普通进程保守得多。

在容器里如果用 systemd 作为 PID 1,也要解决:

比 tini 重得多,但功能齐全。

九、生命周期里的可观测性

9.1 运行时

ps -eo pid,tid,state,wchan:24,cmd

9.2 事件

bpftrace -e 'tracepoint:sched:sched_process_exit { printf("%s[%d] exit %d\n", comm, pid, args->exit_code); }'

可以观察所有 exit 事件,适合调试 “谁杀了我的进程”。

9.3 taskstats

/proc/<pid>/status/proc/<pid>/stat/proc/<pid>/io/proc/<pid>/schedstattaskstats netlink 暴露统计。包括 CPU 用量、I/O 字节、page fault、voluntary/involuntary context switch。

十、常见生命周期问题

问题 1:服务重启后内存没回去 原因:子孤儿没 reap,僵尸占 pid 空间;重启不够因为 fd 还被老父进程持有 诊断ps auxf | grep Zls -l /proc/<pid>/fd | wc -l

问题 2:Docker stop 等了 10 秒才杀 原因:PID 1 没 SIGTERM handler,kernel 忽略 解决docker run --init,或在 Dockerfile 里用 tini 入口

问题 3:短 cron 里 fork 的子进程偶尔活着 原因:父退出前子还未 exec/启动;孤儿被 reparent 到 PID 1 继续跑 解决:在父里 wait 子,或 nohup 显式声明意图

问题 4waitpid() 永远不返回 原因:子进程还活着、子是其他线程 fork 的(PPID 不一致)、SIGCHLD 被屏蔽 诊断strace -f -p <父pid>

十一、小结

下一篇 B-13 讲信号——Unix 最拧巴、最广泛、最容易用错的 IPC 机制。


参考文献

工具


上一篇task_struct 解剖 下一篇信号:Unix 最拧巴的抽象

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-21 · os

【操作系统百科】fd 化抽象

Linux 把信号、事件、定时器、进程都变成 fd——signalfd/eventfd/timerfd/pidfd。本文讲每种 fd 的用途、与 epoll 组合、KVM 的 eventfd、systemd 的 pidfd、以及 fd 化哲学。

2026-05-10 · os

【操作系统百科】fd 表与 struct file

fd 是用户态访问文件的句柄——但 fd 的共享语义在 fork/exec/dup/CLOEXEC 下极其微妙。本文讲 files_struct、fdtable、close_range、pidfd_getfd、cloexec 默认化趋势。

2026-04-17 · os

【操作系统百科】信号:Unix 最拧巴的抽象

signal 在 Unix 里几乎等同「异步打断」,但它的 API 踩满雷:不可重入的 handler、ASS-safe 函数清单、SIGCHLD 丢失、多线程语义、SIG_DFL 的历史包袱。本文讲 kill/tgkill/rt_sigaction、signalfd、pidfd_send_signal、async-signal-safe 的真实边界,以及为什么新代码应该尽量把 signal 转成 fd。

2026-04-27 · os

【操作系统百科】内存回收

Linux 内存回收是 VM 最复杂的子系统之一。本文讲 active/inactive LRU、kswapd 与 direct reclaim、watermark 三线、swappiness 的真实含义、MGLRU 改造、memcg 回收与 PSI。


By .