fork 后接 exec,是 Unix 常见的进程启动模型之一。fork 创建子进程,成功的 execve 则替换调用进程中的程序映像。它们解决不同问题,也具有不同继承规则。在一个程序为另一个命令安排输入、输出、环境和生命周期管理时,必须分清这两个动作,否则很容易把创建新身份与替换运行内容混为一谈。

坏掉的样子

fork 不会让之后所有状态都相互独立。普通私有内存通常通过写时复制逐渐分离,但共享映射仍然共享。继承描述符也继续引用父进程对应的同一份打开文件描述,因此任一侧读取普通文件,都可能改变另一侧观察到的偏移。复制描述符表,不等于为每个文件重新独立执行 open;进程不同,也不能证明资源状态完全不同。

exec 同样不会简单保留全部启动设置。带 close-on-exec 的描述符在成功执行时关闭,其他符合条件的描述符可以继续存在。execve 接收显式环境数组,不是自动复制调用者当前环境。被捕获信号的处理方式会重置,旧内存映射也会消失,即使进程身份和若干其他属性仍然保留。需要逐项理解哪些状态跨越了替换边界。

多线程进程执行 fork 时尤其需要注意。子进程只包含发起调用的线程,却可能继承由其他、已经不在子进程中的线程持有的锁。在 exec 之前任意调用库函数,就可能死锁,因此这段间隔里的可用操作受到约束。方便理解的单线程演示,不能直接变成在线多线程服务器 fork 后随意处理业务状态的方案。

快速验证

把脚本保存为 forkexec.py,在一次性 Linux 环境中运行 python3 forkexec.py。实测使用 Linux 6.12.76 和 Python 3.11.2。样本刻意保持单线程,包含一个私有 Python 值、一份显式共享匿名映射、临时数据文件,以及传递少量结果的管道:

from pathlib import Path
from tempfile import TemporaryDirectory
import errno,json,mmap,os,sys

program=r'''
import errno,json,os,sys
original_pid,kept,dropped,report=map(int,sys.argv[1:])
assert os.getpid()==original_pid
try:os.fstat(dropped)
except OSError as error:assert error.errno==errno.EBADF
else:raise AssertionError('close-on-exec descriptor survived')
assert os.read(kept,2)==b'ef'
assert os.environ['XB_FORK_VALUE']=='child-value'
os.write(report,json.dumps({'pid_unchanged':True,'kept_descriptor':'ef','close_on_exec':'closed','explicit_environment':'child-value'}).encode())
'''
with TemporaryDirectory(prefix='xbcatm-fork-') as directory:
    data=Path(directory)/'data';data.write_bytes(b'abcdef')
    descriptor=os.open(data,os.O_RDONLY);dropped=os.dup(descriptor)
    read_end,write_end=os.pipe();os.set_inheritable(descriptor,True);os.set_inheritable(write_end,True)
    assert not os.get_inheritable(dropped)
    private=['parent'];shared=mmap.mmap(-1,8,flags=mmap.MAP_SHARED);shared[:]=b'parent!!'
    assert os.read(descriptor,2)==b'ab'
    sys.stdout.flush();pid=os.fork()
    if pid==0:
        try:
            os.close(read_end);private[0]='child';shared[:]=b'child!!!'
            assert os.read(descriptor,2)==b'cd'
            try:os.execve(str(Path(directory)/'missing-program'),['missing-program'],{})
            except FileNotFoundError:pass
            else:raise AssertionError('missing exec unexpectedly succeeded')
            os.execve(sys.executable,[sys.executable,'-c',program,str(os.getpid()),str(descriptor),str(dropped),str(write_end)],{'XB_FORK_VALUE':'child-value'})
        except BaseException as error:
            os.write(write_end,('ERROR '+repr(error)).encode());os._exit(1)
    os.close(write_end)
    try:
        waited,status=os.waitpid(pid,0);report=os.read(read_end,4096)
        assert waited==pid and os.WIFEXITED(status) and os.WEXITSTATUS(status)==0,report
        result=json.loads(report);assert result['pid_unchanged']
        assert private==['parent'] and shared[:]==b'child!!!'
        assert os.lseek(descriptor,0,os.SEEK_CUR)==6
        print('fork: private Python state diverges, MAP_SHARED changes and open-description offsets remain shared')
        print('missing exec returns to the child; successful exec keeps its PID, honors close-on-exec, and receives an explicit environment')
        print('parent reaped a successful exit with waitpid and retained its original private value')
    finally:
        shared.close()
        for fd in [descriptor,dropped,read_end]:os.close(fd)

fork 之前,父进程从 abcdef 读取 ab。子进程修改自己的 Python 列表和共享映射,再通过继承描述符读取 cd。子进程结束后,父进程列表仍为原值,共享映射却包含子进程写入的字节。这两个结果需要共同解释,才能准确描述内存继承:只展示普通变量不同,无法证明不存在共享映射。

子进程先尝试执行不存在的文件。这种普通路径缺失错误抛出 FileNotFoundError,子进程继续运行原有程序。随后它通过指定程序和显式环境,再次成功执行 Python。样本验证的是这一可恢复错误,不能把所有罕见执行失败都概括成相同恢复行为;错误发生位置和系统状态仍可能影响真实后果。

替代程序把当前 PID 与 exec 前传入的子进程 PID 比较,两者一致。exec 在子进程内部替换了代码,没有创建第三个进程。它还直接读取显式传入的实验环境值,不依赖登录 shell,也不依赖配置文件自动加载。这些检查分别验证进程身份与环境输入,避免只看到新程序输出就猜测继承方式。

保留下来的数据描述符读取 ef,说明父进程最初读取、子进程读取、替代程序读取,持续推进同一份打开描述的偏移。与此同时,被保留为不可继承的描述符,在替代程序中检查得到 EBADF。脚本只把 exec 后确实需要的描述符设为可继承,因此关闭和保留的结果都对应明确选择。新程序可能复用描述符编号;这里的 EBADF 检查假定启动期间没有其他持续打开的资源占用该编号,关闭原引用并不意味着编号永远保留为空。

父进程使用 waitpid 收集子进程状态,确认正常零退出,再通过自己的描述符观察到偏移六,随后关闭资源。等待既取得生命周期结果,也回收退出子进程。单独知道一个数字 PID,不能替代收集完成状态,更不能证明某次启动已经成功;需要把具体启动对象和实际退出结果对应起来。

明确启动时的资源所有权

应定义子进程需要哪些描述符、关闭不用的管道端,并明确选择环境值。额外继承的管道写端,即使不再实际写入,也可能在预期生产者退出后阻止读取者看到 EOF。因此描述符寿命属于通信协议本身,不能只当作程序启动后再考虑的清理细节。继承关系会直接改变另一个进程等待完成的条件。

当运行时提供的 subprocess 或 spawn 接口能够表达需求时,应优先使用受支持的启动方式。它们内部可能采用字面 fork/exec 之外的机制。对于应用而言,相关契约是接口文档中的描述符、环境和设置行为;假定所有启动器内部都遵循相同步骤,可能得出错误的可移植性或安全结论。

还要区分启动失败、程序运行失败,以及未能观察到完成。成功 exec 不会返回旧程序,但替代程序之后仍然可能以非零状态退出。应记录真实子进程结果,并保留足以识别尝试命令的上下文,同时避免记录机密环境值。这样启动监督才能给出可验证结论,而不是根据一个中间动作成功推断整个工作已完成。

来源

  • fork(2),私有内存、共享映射、继承的打开描述与线程限制,已核验 2026-09-12。
  • execve(2),映像替换、显式环境、保留 PID 与 close-on-exec,已核验 2026-09-12。
  • waitpid(2),收集子进程状态并回收已退出子进程,已核验 2026-09-12。