double-fork 是什么鬼?
摘要
一篇开发日志,解释了用于创建健壮的 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 和僵尸进程
本文来自哈佛大学的 CS 61 课程,涵盖了 Unix 中的管道、Fork 和僵尸进程概念,解释了在关闭时管道如何自动终止程序,以及如何使用管道实现对子进程的阻塞等待。
@0xblacklight: https://x.com/0xblacklight/status/2055000745251070368
上下文分叉是一种编码代理使用的技术,用于回退对话历史,让开发者能够重用高质量上下文,节省时间和令牌,并探索不同的设计路径。本指南解释了如何有效使用上下文分叉。
Spork: 可当作fork使用的posix_spawn
Spork是一个C语言库,提供基于posix_spawn的fork替代方案,在进程创建方面具有更好的性能和安全性。
Buz – 使用现代Zig的Bun分支,实现亚1秒增量构建
Buz是Bun JavaScript运行时的分支,使用现代Zig,实现了小于1秒的增量构建。
httpx2 - Pydantic 的分支
Pydantic 复刻了 httpx HTTP 库,创建了 httpx2,以解决维护问题。原始分支 httpxyz 对此表示欢迎,并鼓励社区支持 httpx2。