每个 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
关键观察:
- exec 不创建新 task——同一个 task_struct,只换 mm、files(带 CLOEXEC 的)、signal handlers
- zombie 不是”卡死”,而是等父进程读 exit code 的中间态
- release 可能延迟很久——父进程不 wait 或 subreaper 没设,僵尸永存
二、阶段 1:clone
B-09 详述 fork/clone/posix_spawn。内核侧的主干是
kernel_clone()(kernel/fork.c):
copy_process():分配 task_struct、copy 各种字段(按 CLONE_* flags 决定共享还是复制 mm、files、signal、cred、ns)- 分配 pid(所有相关 namespace 里)
- 链入父子树(
children、sibling链表) - 加入 runqueue(
wake_up_new_task) - 返回 pid 给父进程
关键副作用:
- 内存:复制 mm 的页表(可能是几十 MB 的元数据)
- fd:复制 files_struct
- signal:子进程继承 pending signal 和 blocked 掩码;但 pending signal 不克隆(只属于父)
- cgroup:子进程进入父的 cgroup,除非 CLONE_INTO_CGROUP
新 task 立刻 runnable,但未必立刻 run——取决于调度器。
三、阶段 2:execve
execve 的工作是”让当前 task 变成一个新程序”:
sys_execve → bprm_execve → search_binary_handler → load_elf_binary
关键动作:
- 打开新二进制(解释器 / ELF / shebang)
- 读头部,设置 mm_struct:用户栈、用户参数区、BSS 等
- 用
flush_old_exec清理旧 mm:解除所有 mmap、清 fd 上带 CLOEXEC 的 - 重置 signal handlers 为默认(SIG_DFL);阻塞集、pending 保留
arch_setup_additional_pages:插入 vDSO 页- 切换 credentials(setuid 二进制在此处生效)
- 最终
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 支持多种二进制格式:
binfmt_elf:ELF(主力)binfmt_elf_fdpic:为 no-MMU 平台binfmt_script:#!shebangbinfmt_misc:用户可注册(qemu-user 跨架构模拟、WINE 运行 .exe)binfmt_flat:uClinux
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 子系列专题。这里只提两个与生命周期相关的细节:
- signal 递送在”从内核返回用户态”前检查——syscall 末尾、中断返回处
- 可被 TRACED——ptrace 会让 task 停住,从其他进程看是 zombie-like
五、阶段 4:exit
进程退出路径:
sys_exit / sys_exit_group / fatal signal / page fault with no handler
→ do_exit()
do_exit 关键步骤(5.x+ 顺序有微调):
- 设置
PF_EXITINGflag exit_signals:flush pending signalacct_update_integrals:最后一次计时exit_mm:如无 CLONE_VM 共享,释放 mm;mmap 区域走unmap_vmas+ munlockexit_sem:释放 SysV 信号量exit_shm:attach 的 SysV shm 计数减exit_files:关所有 fdexit_fs:释放 fs_struct(cwd、root)exit_task_namespaces:释放 nsproxy;如是 pid_ns init,触发 ns 销毁exit_task_work:运行注册的 task workexit_notify:- 把子进程 reparent 到 subreaper 或 PID 1
- 给父进程发 SIGCHLD
- 如果父进程设了
SA_NOCLDWAIT或忽略 SIGCHLD,直接 release,跳过 zombie
__state = EXIT_ZOMBIE- 最后一次
schedule(),不再返回
5.1 线程组退出:exit_group
exit_group vs exit:
exit:只退当前线程;其他 pthread 继续跑exit_group:整个 tgid 一起退;所有线程被标记 EXITING,陆续走 do_exit
正常 return 0; 的 C 程序走
exit(0) → glibc 的 exit →
__run_exit_handlers →
_exit(0) = exit_group。
六、阶段 5:zombie
zombie task_struct 的角色:exit code 的临时存储 + SIGCHLD 的接收对象。直到父进程调 wait 之前:
- task_struct 占用约 3KB
- 内核栈已释放
- mm、files、signal 都已释放
- 只剩 signal_struct、exit_code、exit_signal、rusage
僵尸在 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:
- 检查子的 state
- 复制 exit_code 到父栈
- 复制 rusage(CPU 时间等)
- 调
release_task - 从父的 children 链表解除
- 从 tasklist、pid hash 删除
- RCU 延迟释放 task_struct
7.1 waitpid 的标志
WNOHANG:非阻塞,无可等时立即返回 0WUNTRACED:连 stopped 子也报告WCONTINUED:连被 SIGCONT 恢复的子也报告__WALL、__WCLONE:historical,几乎不用
waitid 是 waitpid
的强化版:参数结构体化,返回 siginfo_t 带
si_code(CLD_EXITED、CLD_KILLED、CLD_DUMPED、CLD_STOPPED)。
7.2 pidfd:新一代进程引用
PID 有重用问题:你记下 pid 1234,它退了,一会儿新进程又是 1234。你 kill(1234) 可能杀到无辜。
pidfd(5.3+):pidfd_open(pid, 0)
返回一个 fd,只要 fd 活着就唯一地引用那个 task。可以:
pidfd_send_signal(pidfd, SIGTERM, ...)—— 不会误杀waitid(P_PIDFD, pidfd, ...)—— 用 pidfd 等epollon pidfd —— 进程退出时 fd 变可读
这是容器运行时(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 工作,还承担:
- subreaper 全系统孤儿(因为是 PID 1)
- signal 处理:SIGTERM/SIGPWR 触发 shutdown;SIGRTMIN+N 各种控制
- cgroup 管理:通过 slice/scope 组织整个系统进程树
- 服务依赖、socket activation、timer 等
systemd 进程自己死了(oops / OOM / bug)= 整机挂。所以 systemd 代码要比普通进程保守得多。
在容器里如果用 systemd 作为 PID 1,也要解决:
- 需要 /sys/fs/cgroup 读写(
--privileged或 cgroup v2 delegation) - 需要 /run、/tmp 可写 tmpfs
- journald 日志大量写 stdout
比 tini 重得多,但功能齐全。
九、生命周期里的可观测性
9.1 运行时
ps -eo pid,tid,state,wchan:24,cmdstate: R/S/D/Z/T/X 分别对应 RUNNING/INTERRUPTIBLE/UNINTERRUPTIBLE/ZOMBIE/STOPPED/DEADwchan: 睡在哪个内核函数里(D state 诊断利器)
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>/schedstat、taskstats
netlink 暴露统计。包括 CPU 用量、I/O 字节、page
fault、voluntary/involuntary context switch。
十、常见生命周期问题
问题 1:服务重启后内存没回去
原因:子孤儿没 reap,僵尸占 pid
空间;重启不够因为 fd 还被老父进程持有
诊断:ps auxf | grep Z;ls -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 显式声明意图
问题 4:waitpid()
永远不返回 原因:子进程还活着、子是其他线程
fork 的(PPID 不一致)、SIGCHLD 被屏蔽
诊断:strace -f -p <父pid>
十一、小结
- 一个进程的一生在内核里是六段流水:clone、run、(exec)、exit、zombie、release
- exec 不换 task,只换 mm
- exit 清理资源,但 task_struct 要等 wait 才释放
- subreaper + pidfd 是现代容器/服务管理的关键 API
- 生产故障多在孤儿/僵尸/signal handler 的边界
下一篇 B-13 讲信号——Unix 最拧巴、最广泛、最容易用错的 IPC 机制。
参考文献
- Linux source:
kernel/exit.c,kernel/fork.c,fs/exec.c - Kerrisk, M. The Linux Programming Interface, Ch. 26 “Monitoring child processes”
- Corbet, J. “The child_subreaper() system call.” LWN.net 2012
- Corbet, J. “A pidfd API for the whole process lifecycle.” LWN.net 2020
- Documentation/filesystems/proc.rst §3 “Per-process parameters”
- systemd documentation:
systemd.exec(5),systemd(1)
工具
strace -f -e trace=%process—— fork/exec/exit 追踪bpftrace -l 'tracepoint:sched:*'—— 所有调度事件pidwait—— 等 pidfd 的小工具(util-linux 提供)ps -Z、ps -eLo—— 扩展属性perf script—— 事件时间线
上一篇:task_struct 解剖 下一篇:信号:Unix 最拧巴的抽象
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【操作系统百科】fd 化抽象
Linux 把信号、事件、定时器、进程都变成 fd——signalfd/eventfd/timerfd/pidfd。本文讲每种 fd 的用途、与 epoll 组合、KVM 的 eventfd、systemd 的 pidfd、以及 fd 化哲学。
【操作系统百科】fd 表与 struct file
fd 是用户态访问文件的句柄——但 fd 的共享语义在 fork/exec/dup/CLOEXEC 下极其微妙。本文讲 files_struct、fdtable、close_range、pidfd_getfd、cloexec 默认化趋势。
【操作系统百科】信号:Unix 最拧巴的抽象
signal 在 Unix 里几乎等同「异步打断」,但它的 API 踩满雷:不可重入的 handler、ASS-safe 函数清单、SIGCHLD 丢失、多线程语义、SIG_DFL 的历史包袱。本文讲 kill/tgkill/rt_sigaction、signalfd、pidfd_send_signal、async-signal-safe 的真实边界,以及为什么新代码应该尽量把 signal 转成 fd。
【操作系统百科】内存回收
Linux 内存回收是 VM 最复杂的子系统之一。本文讲 active/inactive LRU、kswapd 与 direct reclaim、watermark 三线、swappiness 的真实含义、MGLRU 改造、memcg 回收与 PSI。