题记与说明
- 日期:2026-11-05
- 类型:主题研究(topic research)
- 前缀:knowledge-h6n221ha-3
- 来源:以 Linux man-pages 项目(man7.org)为一手权威来源,共精读 10 个手册页,覆盖 fork(2)、execve(2)、signal(7)、signal-safety(7)、open(2)、fcntl(2)、setsid(2)、wait(2)、daemon(3)、daemon(7)。检索方式:WebFetch 逐页提取关键语义与原文引述(本次会话实际执行)。
来源清单
| # | 手册页 | URL |
|---|---|---|
| 1 | fork(2) | https://man7.org/linux/man-pages/man2/fork.2.html |
| 2 | execve(2) | https://man7.org/linux/man-pages/man2/execve.2.html |
| 3 | signal(7) | https://man7.org/linux/man-pages/man7/signal.7.html |
| 4 | signal-safety(7) | https://man7.org/linux/man-pages/man7/signal-safety.7.html |
| 5 | open(2)(O_CLOEXEC 节) | https://man7.org/linux/man-pages/man2/open.2.html |
| 6 | fcntl(2)(概览页) | https://man7.org/linux/man-pages/man2/fcntl.2.html |
| 7 | setsid(2) | https://man7.org/linux/man-pages/man2/setsid.2.html |
| 8 | wait(2) | https://man7.org/linux/man-pages/man2/wait.2.html |
| 9 | daemon(3) | https://man7.org/linux/man-pages/man3/daemon.3.html |
| 10 | daemon(7)(SysV vs systemd) | https://man7.org/linux/man-pages/man7/daemon.7.html |
七维提取
1. 核心问题
Linux 只提供一个"进程"抽象和一小撮系统调用,却要支撑从一次性命令、交互式 shell、多线程服务器到系统级后台服务的所有形态。这份工作要回答的问题是:
- 进程如何被创建和替换? 为什么是
fork(复制)+exec(替换)两个原语的组合,而不是一个整体的 spawn? - 异步事件如何抵达进程? 信号机制为什么被认为是"不可靠"的,其可靠性缺口在哪里?
- 打开的资源(文件描述符)如何在 fork/exec 之间流动? 谁被继承、谁被关闭、"fd 泄漏"这类经典 bug 的机制根源是什么?
- 一个进程如何把自己变成守护进程? 传统 15 步配方每一步在防什么?systemd 时代为什么说这些步骤大多不再需要?
一句话:内核在 fork/exec 边界上画的"默认继承 vs 显式重置"清单,决定了系统编程中大多数经典 bug(fd 泄漏、僵尸进程、EINTR、控制终端劫持)的形态。
2. 主要主张
- fork+exec 是组合原语而非单体 spawn。fork 把当前进程一分为二(子进程得到新 PID、单线程副本、空 pending 信号集);exec 不创建新进程——execve(2) 明言 "there is no new process; many attributes of the calling process remain unchanged (in particular, its PID)",只是把映像整个换掉。两步之间保留什么、重置什么,各有一份明确清单。
- fork 的简单性来自写时复制(COW)。fork(2):*"Under Linux, fork() is implemented using copy-on-write pages, so the only penalty that it incurs is the time and memory required to duplicate the parent's page tables, and to create a unique task structure for the child."* 复制的只是页表和任务结构,不是内存本身。
- 信号是进程级异步事件机制,但有结构性缺陷:标准信号不排队(*"Standard signals do not queue."*,同一信号多次到达只会置位一次 pending);handler 打断任意代码,只能调用 async-signal-safe 函数;
SIGKILL/SIGSTOP无法捕获。因此现代做法是把信号转化为数据事件(self-pipe、signalfd、sigwaitinfo)。 - fd 的继承语义是"默认继承、显式关闭",而正确做法是"原子地声明关闭"。execve(2):*"By default, file descriptors remain open across an execve(). File descriptors that are marked close-on-exec are closed."* 先 open 再用 fcntl 设
FD_CLOEXEC在多线程程序中有 fork/exec 竞态,O_CLOEXEC(Linux 2.6.23)用一次原子操作修掉它——fd 可能 *"unintentionally leaked"* 给被 exec 的程序。 - 守护进程配方 = fork/信号/fd 三个主题的串联惯例。daemon(7) 把 SysV 守护进程总结为 15 步(双重 fork、setsid、chdir /、umask 0、重定向 stdio 到 /dev/null、处理信号等),并断言 *"The BSD daemon() function should not be used, as it implements only a subset of these steps."*;systemd 新式守护进程则 *"none of the initialization steps recommended for SysV daemons need to be implemented"*——服务管理器接管了会话、fd 清理、日志与生命周期。
- 进程生命周期由 wait 一侧闭环:*"A child that terminates, but has not been waited for becomes a 'zombie'."*(wait(2))不收割就占内核进程表槽位;父进程死掉后由 init/最近 subreaper 收养并收割。
3. 方法
- 主题式检索:围绕"进程模型"四个子主题(创建/替换、异步事件、资源句柄、后台化)选择一手权威来源——Linux man-pages 项目手册页,而非二手教程。
- 逐页提取:对每页提取返回值语义、继承/非继承清单、错误条件、版本演进(如 O_CLOEXEC 自 2.6.23、shebang 上限 Linux 5.1 起为 255 字符)与可直接引用的原文句子。
- 交叉印证:用 setsid(2) 的 EPERM 约束解释 daemon(7) 双重 fork 的原因;用 signal-safety(7) 解释 fork(2) 中"子进程在 exec 前只能调 async-signal-safe 函数"的出处;用 wait(2) 补全 fork 的生命周期闭环。
- 七维综合:将提取结果整理为问题/主张/方法/证据/结论/局限/启发七个维度。
4. 数据与证据
关键论断与原文引述(全部来自本次实际检索的 man7.org 页面):
- COW(fork(2)):*"Under Linux, fork() is implemented using copy-on-write pages, so the only penalty that it incurs is the time and memory required to duplicate the parent's page tables, and to create a unique task structure for the child."*
- fork 失败语义(fork(2)):*"On failure, -1 is returned in the parent, no child process is created, and errno is set to indicate the error."* EAGAIN 覆盖 RLIMIT_NPROC、threads-max、pid_max、cgroup pids.max 等限额。
- 子进程不继承的东西(fork(2)):pending 信号集为空、内存锁、CPU 时间计数、alarm/setitimer 定时器、进程关联记录锁、异步 I/O 均不继承;fd 复制但共享同一 open file description(offset、状态标志共享——这是父子公司写同一文件互相看见 offset 的根源)。
- 多线程 fork(fork(2)):*"The child process is created with a single thread—the one that called fork()."* 且 *"After a fork() in a multithreaded program, the child can safely call only async-signal-safe functions"*(execve 之前)。glibc 2.24 前
execl/execle因内部用 realloc 而不是 async-signal-safe(signal-safety(7))。 - exec 不换进程(execve(2)):*"there is no new process; many attributes of the calling process remain unchanged (in particular, its PID)."* 成功后永不返回。被捕获的信号处置重置为默认;忽略/默认处置保持(SIGCHLD 被忽略时 Linux 保持忽略)。
- fd 默认跨 exec 存活(execve(2)):*"By default, file descriptors remain open across an execve(). File descriptors that are marked close-on-exec are closed."*
- shebang(execve(2)):解释器按
interpreter [optional-arg] path arg...调用;#!后长度 *"Before Linux 5.1, the limit is 127 characters. Since Linux 5.1, the limit is 255 characters."*;脚本上的 setuid 位被内核忽略;递归解释器上限 4 层(2.6.28 起)。 - argv 限制(execve(2)):E2BIG;自 2.6.23 起 argv+envp 总量限为栈限的 1/4 且 ≤ 3/4 of _STK_LIM(8 MiB),单字符串上限 32 页(MAX_ARG_STRLEN)。
- 信号三种处置(signal(7)):*"perform the default action; ignore the signal; or catch the signal with a signal handler, a programmer-defined function."* 默认动作分 Term/Ign/Core/Stop/Cont 五类。
- 不可捕获(signal(7)):*"The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored."*
- 不排队(signal(7)):*"Standard signals do not queue."*;阻塞期间多次产生只挂起一次;多个待决标准信号的投递顺序未定义;实时信号(32–64)可排队、同号按发送序、低号优先;glibc 线程实现占用两三个,SIGRTMIN 实际是 34/35,程序不得硬编码。
- EINTR/SA_RESTART(signal(7)):handler 无 SA_RESTART 时被中断的系统调用失败返回 EINTR;pause/sigsuspend、select/poll/epoll_wait、sleep 类接口永远不会被自动重启。
- 多线程信号(signal(7)):处置是进程级的(*"the disposition of a particular signal is the same for all threads"*),掩码是线程级的;*"A process-directed signal may be delivered to any one of the threads that does not currently have the signal blocked"*——内核任意挑一个未屏蔽线程。
- async-signal-safe 定义(signal-safety(7)):*"An async-signal-safe function is one that can be safely called from within a signal handler."* 反例:handler 打断半途的 printf 再调 printf,会 *"operate on inconsistent data, with unpredictable results"*。
- 僵尸(wait(2)):*"A child that terminates, but has not been waited for becomes a 'zombie'."*;*"As long as a zombie is not removed from the system via a wait, it will consume a slot in the kernel process table"*;父死由 init(或 PR_SET_CHILD_SUBREAPER 最近的 subreaper)收养并自动 wait;Linux 2.6+ 中 SIGCHLD 置 SIG_IGN 则子进程终止直接不产生僵尸,wait 将阻塞到所有子进程退出后返回 ECHILD。
- fd 标志分两层(fcntl(2)):
F_GETFD/F_SETFD是进程内 fd 私有的描述符标志(只有 FD_CLOEXEC);F_GETFL/F_SETFL是随 open file description 共享的文件状态标志;F_DUPFD_CLOEXEC复制时顺手设 CLOEXEC。POSIX.1-2024 新增 FD_CLOFORK/F_DUPFD_CLOFORK 但 *"Linux doesn't support them"*;OFD 锁(F_OFD_SETLK 等,非 POSIX)把锁所有权绑到 open file description 而非进程。 - O_CLOEXEC 竞态(open(2)):单独用 fcntl F_SETFD 设 FD_CLOEXEC 在多线程下不安全——另一线程同时 fork+execve 时,调度顺序可能导致 fd *"unintentionally leaked to"* 被 exec 的程序。O_CLOEXEC 自 Linux 2.6.23 提供,把"打开+标记关闭"合并为一次原子操作。O_NOCTTY:终端设备节点 *"will not become the process's controlling terminal even if the process does not have one."*
- setsid 约束(setsid(2)):*"setsid() creates a new session if the calling process is not a process group leader."* 已是组长则 EPERM;官方推荐模式:*"In order to be sure that setsid() will succeed, call fork(2) and have the parent _exit(3), while the child (which by definition can't be a process group leader) calls setsid()."* 会话 ID 跨 fork 继承、跨 execve 存活。
- daemon(3) 的实现与缺陷(daemon(3)):*"This function forks, and if the fork(2) succeeds, the parent calls _exit(3)"*;nochdir=0 则 chdir 到根目录;noclose=0 则 stdio 重定向到 /dev/null;但 glibc 这份 BSD 血统的实现 *"does not employ the double-fork technique"*——守护进程仍是会话首进程,在 System V 语义系统上打开未分配的终端时 *"that terminal will inadvertently become the controlling terminal for the daemon."* 标准状态:*"None."*(不在 POSIX 中)。
- 传统配方 15 步(daemon(7)):完整步骤含关闭多余 fd、重置全部信号 handler 与 sigprocmask、净化环境块、fork、子进程 setsid、再 fork 一次("can never re-acquire a terminal again")、首子进程 exit 让孙进程归 init、/dev/null 接管 stdio、umask 置 0、chdir /(避免占住挂载点)、写 PID 文件防重复启动、必要时 drop privileges、通过管道通知父进程初始化完成、原进程 exit。
- 新式守护进程(daemon(7)):systemd 已保证 *"the environment block is sanitized, that the signal handlers and mask is reset and that no left-over file descriptors are passed"*,守护进程运行在自己的会话中、stdin 接 /dev/null;应改用 stderr 日志(journald 转发,
<4>前缀表级别)、sd_notify(3) 上报READY=1/STOPPING=1/RELOADING=1、socket activation(sd_listen_fds(3) 接管套接字,支持并行启动、按需激活、重启不丢请求)、FDSTORE=1 跨重启保存 fd;单元文件 *"If possible, do not use the Type=forking setting in service files"*——fork 型守护进程反而破坏 systemd 的监督。
5. 结论
Linux 进程模型可以压缩为五块拼图,彼此咬合:
- 状态复制(fork):COW 让"复制整个地址空间"便宜到只是复制页表;但复制的是"调用线程"的快照,多线程程序 fork 出的是一个只有单线程的世界,锁可能永远锁着。
- 状态替换(exec):不换进程只换映像;PID、打开的 fd(默认)、忽略态信号处置穿越 exec,被捕获的处置、内存映射、退出 handler 全部归零。fork 与 exec 之间的窄缝里,子进程只能调 async-signal-safe 函数。
- 异步事件(信号):进程级、异步、不可靠(不排队、EINTR、handler 限制)。SIGKILL/SIGSTOP 是内核留给自己和系统管理员的不可协商项。现代程序把信号降格为"往 self-pipe/signalfd 写一个字节",让事件进入普通的事件循环。
- 资源句柄(fd):fd 数字是进程私有索引,指向共享的 open file description(offset 共享),再到 inode。fork 复制 fd、exec 保留 fd,两个"默认继承"叠加 = 经典 fd 泄漏;O_CLOEXEC 的历史证明:多线程下"两步操作"必须变成"一步原子操作"。
- 生命周期与后台化(wait/daemon/service manager):子进程终止后是僵尸,直到被 wait 或被 init/subreaper 收养;守护进程配方本质是把 1/3/4 三件事按惯例串行执行一次;systemd 把这套惯例上移为服务管理器的职责,守护进程退化为"一个被监督的普通进程"。
6. 局限
- 来源形态局限:man-pages 是按系统调用组织的参考手册,不是连贯叙事;本次未检索任何内核实现材料,COW 的页表级细节、调度器与 fork 的交互(如 copy_process)未覆盖。
- 转述误差:open(2) 的 O_CLOEXEC 竞态长句由抓取工具转述+短引(*"unintentionally leaked to"* 为原文片段),完整原文句未逐字核对。
- 未检索但已提及的机制(标注为基于已有知识,未经本次来源验证):
close_range(2)(Linux 5.9,批量关闭 fd,是 O_CLOEXEC 泄漏问题的现代补丁)、pidfd(CLONE_PIDFD,pidfd_send_signal,解决 PID 回收复用竞态)、posix_spawn(libc 层把 fork+exec+fd 映射合并为可控操作)、signalfd、clone3。 - 平台差异:所有结论基于 Linux;BSD/macOS 的 fork/exec/setsid 语义差异(如 macOS 无 daemon(3) 弃用后行为)未验证。daemon(3) 页面本身承认其行为与实现强相关("None." 标准、/dev/null 非预期设备时返回 -1 且 errno 未定)。
- 实时信号编号的 glibc/NPTL 偏移(SIGRTMIN 实为 34/35)依赖线程库版本,跨版本结论需运行时检查,本次未运行程序验证。
7. 对我的启发
- "两步变一步"是系统编程的第一设计原则:O_CLOEXEC(2.6.23)把 open+fcntl 变原子、F_DUPFD_CLOEXEC 把 dup+fcntl 变原子、
close_range把循环 close 变一次调用——凡是"先 A 再 B,中间有并发"的地方都要找原子替代。写 spawn/沙箱/agent 子进程管理代码时,fd 传递策略应默认"全 O_CLOEXEC + 显式 dup2 白名单",而不是"close 黑名单"。 - 信号处理代码的正确姿势是"降格为数据":handler 里只写 self-pipe 或置
volatile sig_atomic_t标志,真正的逻辑放进事件循环;或在专用线程 sigwaitinfo 阻塞收取。EINTR 是常态而非异常:select/epoll/sleep 永不自动重启,封装 I/O 层时要显式处理 EINTR。 - fork 只在"立刻 exec"时才是安全的:多线程程序 fork 后子进程只有单线程、锁状态不可预期,exec 前只能用 async-signal-safe 函数。需要"复制自身状态"的场景应改用线程或显式序列化,而不是 fork。
- 守护进程知识需要按年代分层使用:写独立后台程序仍要理解 15 步配方(尤其双重 fork 防"重取控制终端"与 setsid EPERM 的因果链:fork → 子必非组长 → setsid 成功 → 会话无控制终端 → 二次 fork 使进程永非会话首 → 打开终端不会成为其控制终端);而在 systemd 上写服务则是反方向——不做这些动作,改用 Type=notify + sd_notify + socket activation,绝不写 Type=forking。
- 读 man-pages 而非博客:继承/非继承清单、版本演进、POSIX 与 Linux 的分歧都写在手册页里;本次十个页面互相引用(fork→signal-safety→daemon(7)→setsid)构成了一张可靠的语义网。
附:按子主题的精读细目
A. fork:一次调用,两次返回
- 返回值三态:父得子 PID、子得 0、失败父得 -1 并置 errno(EAGAIN/ENOMEM/ENOSYS…)。
- COW 机制(原文引述见上);父子的写、mmap、munmap 互不影响("run in separate memory spaces")。
- 继承清单(选摘):fd 副本(共享 open file description)、消息队列描述符、目录流、fcntl OFD 锁与 flock 锁。
- 不继承清单(选摘):新 PID;mlock 内存锁;getrusage/times 计数;pending 信号清空;semop 信号量调整;进程型记录锁;alarm/setitimer/timer_create 定时器;PR_SET_PDEATHSIG 复位;MADV_WIPEONFORK 内存清零。
- 多线程:只有调用线程存活于子进程;exec 前仅 async-signal-safe 可用;
_Fork(3)是 async-signal-safe 变体;NPTL 的 fork 包装实际走 clone(SIGCHLD)。
B. exec:换映像不换进程
- execve 成功不返回;失败才回到调用者(E2BIG/EACCES/ETXTBSY/EFAULT…)。
- 穿越项:PID、PPID、打开的 fd(除非 CLOEXEC)、忽略/默认信号处置、进程凭证、cwd、umask、会话。
- 重置项:被捕获的处置→默认、备用信号栈、内存映射、POSIX 定时器与消息队列、atexit/on_exit handler、目录流。
- shebang:
interpreter [optional-arg] path arg...;长度上限 127→255(Linux 5.1);脚本 setuid 被忽略;解释器递归 ≤ 4。 - argv/envp:总量 ≤ 栈限 1/4(≤3/4 _STK_LIM,下限 32 页),单串 ≤ 32 页(MAX_ARG_STRLEN);argv/envp 传 NULL 是"nonstandard and nonportable misfeature"。
C. 信号:不可靠的异步事件通道
- 三种处置 + 五类默认动作(Term/Ign/Core/Stop/Cont)。
- 标准信号:1–31,不排队,pending 单实例,投递顺序未定义。
- 实时信号:SIGRTMIN..SIGRTMAX(内核 32–64,glibc 占用后 MIN=34/35),排队、同号 FIFO、低号优先,可带 sigqueue 值。
- SA_RESTART 与 EINTR;永不自动重启的接口清单(pause/sigsuspend/select/poll/epoll_wait/sleep/SysV IPC)。
- handler 约束:async-signal-safe(定义与 printf 反例);errno 保存/恢复;longjmp 跳出不安全函数后 UB。
- 多线程:处置进程级、掩码线程级、进程定向信号投给任意未屏蔽线程;线程定向信号来自硬件异常或 tgkill/pthread_kill。
- 生命周期:SIGCHLD 默认 Ign;显式 SIG_IGN(Linux 2.6+)→ 不产生僵尸;wait 返回 ECHILD;subreaper 收养链。
D. 文件描述符:两层标志与一个竞态
- 三层结构(fork(2) 页佐证共享语义):进程 fd 表(fd 数字、FD_CLOEXEC)→ open file description(offset、状态标志、驱动 I/O 属性)→ inode。
- fork:fd 复制但 description 共享 → 父子写同一文件共享 offset;exec:默认保留 fd,CLOEXEC 关闭;close 释放记录锁。
- fd 标志(F_GETFD/F_SETFD,进程私有)vs 文件状态标志(F_GETFL/F_SETFL,description 共享)——两层标志的归属不同是常见混淆点。
- 竞态与原子修法:open(O_CLOEXEC)(2.6.23)、fcntl F_DUPFD_CLOEXEC;POSIX.1-2024 的 FD_CLOFORK/F_DUPFD_CLOFORK Linux 尚不支持(fcntl(2));OFD 锁把锁所有权从"进程"改为"open file description"。
E. 守护进程:从 15 步配方到 Type=notify
- 因果链(本次综合):终端会话劫持风险 → setsid 必须成功 → fork 保组长退出 → setsid 建新会话无控制终端 → 二次 fork 使进程永非会话首 → 打开终端不会成为其控制终端(O_NOCTTY 双保险)→ chdir / 防占挂载点、umask 0 防继承掩码、/dev/null 接管 stdio 防写 TTY 阻塞/泄漏。
- daemon(3):只做 fork+setsid+chdir+重定向,无双 fork(glibc 承认),非 POSIX("None.");BSD 血统的缺陷实现,daemon(7) 明言不应使用。
- daemon(7) 15 步全文清单(见证据节);PID 文件防双启动。
- systemd 新式:不需要任何 SysV 步骤(部分步骤反而干扰监督与 fd 传递);sd_notify READY=1/STOPPING=1/RELOADING=1;socket activation(并行启动、按需激活、重启不丢请求);FDSTORE=1 保存 fd 跨重启;日志直接 fprintf(stderr) 由 journald 收;单元文件避免 Type=forking。
局限声明
本笔记基于 2026-11-05 实际检索的 10 个 man7.org 手册页。未检索内核源码/实现文档;close_range(2)、pidfd、posix_spawn、signalfd 等现代机制的描述基于已有知识并已在局限节明确标注,未逐条核对一手来源。所有平台差异结论仅对 Linux 成立。