double-fork 是什么鬼?

Lobsters Hottest 工具

摘要

一篇开发日志,解释了用于创建健壮的 POSIX 守护进程的 double-fork 技术,涵盖了在 Zig 中构建 zmx 时的 fork、会话和控制终端。

<p><a href="https://lobste.rs/s/qwytlf/what_double_fork">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/31 14:48

# 什么是双重 fork? 来源:https://bower.sh/what-the-double-fork 2026\-07\-30·erock 的开发者日志(https://bower.sh/) > 为 zmx 打造一个健壮的 posix 守护进程 --- • 我把 zmx 当作一个多方面的学习练习。我想更深入地了解 zig、posix、守护进程、pty、libghostty 和终端。创建一个能融入我核心开发工具链的程序只是一个副产品。在研究如何创建守护进程时,我在 abduco 的实现深处发现了双重 fork。 • 在双重 fork 技术背后,隐藏着一连串对 Linux 如何管理进程至关重要的术语和概念。这篇文章是我浮出水面的记录,也是当我日后阅读 zmx 并问出“什么是双重 fork?”时的一份备忘。 ## 什么是守护进程? • 守护进程属于一个没有控制终端的会话。例如,当你用 ctrl\-z\+bg 或 {cmd} & 将进程放到后台时,该会话仍然有一个控制终端,这意味着它可以控制那些后台进程。这对守护进程来说并不理想。 • 还有其他方法可以将进程放到后台并使其与终端断开连接,各有不同程度的优缺点: ## fork 在程序中如何工作? • 在程序中使用 fork\(2\) 时,它会创建一个子进程,同时调用 fork 的父进程仍然继续运行。在构建 zmx 时,这一点对我来说很难理解。就好像程序在那一行精确地克隆了自己,然后两个副本都从那里独立继续执行。通常,创建守护进程时,你会使用 fork 产生的子进程,然后终止父进程。 • `` fork() │ ┌────────────┴────────────┐ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ │ 父进程 │ │ 子进程 │ │ PID = 42 │ │ PID = 55 │ │ PPID = 10 │ │ PPID = 42 │ │ │ │ │ │ (从 fork 处 │ │ (从 fork 处 │ │ 继续执行) │ │ 继续执行) │ └─────────────────┘ └─────────────────┘ `` • fork 点处内存相同(写时复制) • 打开的文件描述符相同(共享偏移量) • fork\(\) 的返回值不同: • 父进程 → 子进程的 PID \(55\) • 两者从这里开始独立运行 ## 什么是会话? • 在 Linux 中,会话是进程组的容器。 • 进程组是进程的容器,这些进程从同一个终端接收信号。 • 一个会话中最多只能有一个进程组位于前台。 • 它接收信号并且可以写入终端。 • 会话中所有其他进程组都是后台进程组,如果它们写入或读取终端,SIGTTOU/SIGTTIN 会停止它们。 • `` session/ ├── process group (前台) │ ├── process │ ├── process │ └── process └── process group (后台) ├── process └── process `` ## 什么是控制终端? • 只有会话首进程才能为该会话创建控制终端。 • 一个会话最多只能有一个控制终端。 • 打开终端的会话首进程成为控制进程。 ## 为什么要双重 fork? • 启动守护进程时,你通常会通过 setsid\(\) 将 fork 出的子进程设置为会话首进程,这会创建一个新会话并移除当前的控制终端。这很重要,因为我们不希望守护进程拥有控制终端,否则当控制终端关闭时,它可能会收到关闭信号。 • 然而,如果第一次 fork 的子进程同时也是守护进程,那么从技术上讲,守护进程有可能打开一个终端设备(例如 open\("/dev/console", O\_RDWR\)),然后它就会获得一个控制终端!控制终端会使守护进程暴露于终端产生的信号(例如 SIGINT)或终端断开时的 SIGHUP,这可能会杀死守护进程。 • 第一次 fork 产生的子进程保证不是进程组组长,因此 setsid\(\) 必定成功。通过第二次 fork,孙进程(守护进程)不再是会话首进程。根据 POSIX,只有会话首进程才能获得控制终端。 • 显然这“有点偏执”,在 Linux 上也是有争议的,因为会话首进程只有在实现定义的条件下才会获得控制终端。但双重 fork 是保证守护进程永远无法获得控制终端的可移植方式,无论给定的 POSIX 实现行为如何。所以我们把它融入了 zmx。 • `` PID=42 SID=10 PGID=10 ← 原始进程 (PG 首进程, 有 tty) │ │ fork #1 ├──────────┐ │ exit │ PID=55 SID=10 PGID=10 (不是 PG 首进程) ✝ │ │ setsid() ▼ PID=55 SID=55 PGID=55 (会话首进程, 无 tty) │ │ fork #2 ├──────────┐ │ exit │ PID=73 SID=55 PGID=55 ✝ │ PID≠SID → 无法获得 tty ▼ 守护进程 ✓ `` ## 超越双重 fork • 双重 fork 使守护进程脱离任何控制终端,但这只是健壮守护进程的一部分。守护进程还需要清理从启动它的父进程那里继承的一切,比如文件描述符、工作目录和标准流。 • 在 zmx 中,第二次 fork 之后,守护进程还会做两件事: • 首先,它通过 dup2\(2\) 将 stdin、stdout 和 stderr 重定向到 /dev/null。这一点至关重要:如果父进程是由像 bats 这样的测试框架启动的,这些文件描述符可能是管道。只要守护进程还持有写端,测试框架就会挂起等待 EOF。重定向到 /dev/null 可以关闭这个循环。 • 其次,它关闭从 3 到 64 的所有继承文件描述符(跳过调用者明确希望保留的任何描述符,比如服务器套接字)。这会释放守护进程不需要的资源,并防止在更高编号的 FD 上出现同样的挂起问题。 • 我们 \*不\* 调用 chdir,因为在当前工作目录打开 shell 是 zmx 的一个特性 • 我们 \*不\* 调用 umask,主要是因为从未遇到过问题 • `` [1] fork() ── 子进程不是 PG 首进程 [2] setsid() ── 新会话, 去掉 tty, 成为首进程 [3] fork() ── 守护进程不是首进程, 无法获得 tty ─────────── 双重 fork 结束, 清理开始 ─────────── [4] chdir("/") ── 不固定挂载点 [5] umask(0) ── 可预测的文件创建模式 [6] dup2 /dev/null ── stdin/stdout/stderr → /dev/null [7] close fds 3-64 ── 释放继承的资源 `` ## 守护进程化的首选方式 • 理想情况下,你应该能够使用操作系统的监督程序(如 systemd、openrc、runit 等)来创建守护进程。这对 zmx 来说不是一个好选择,因为我希望它能同时在 Linux 和 Mac 上可移植。我还希望 zmx 能在 attach 时自动将会话守护进程化,从而“开箱即用”。所以在 zmx 的情况下,当我们 fork 时,子进程成为守护进程,而父进程成为通过 unix 套接字连接守护进程的客户端。 ## 参考 最后更新:2026\-07\-30

相似文章

管道、Fork 和僵尸进程

Hacker News Top

本文来自哈佛大学的 CS 61 课程,涵盖了 Unix 中的管道、Fork 和僵尸进程概念,解释了在关闭时管道如何自动终止程序,以及如何使用管道实现对子进程的阻塞等待。

httpx2 - Pydantic 的分支

Lobsters Hottest

Pydantic 复刻了 httpx HTTP 库,创建了 httpx2,以解决维护问题。原始分支 httpxyz 对此表示欢迎,并鼓励社区支持 httpx2。