1940年代,电传打字机(Teletype)作为远程通信设备被广泛使用。当计算机开始需要实时交互时,这些设备被重新利用——键盘输入直接变成计算机输入,打印纸成为输出介质。Unix系统诞生时,这种"终端即电传打字机"的假设被深深嵌入设计之中。
六十多年后,物理电传打字机早已消失,但它的缩写——TTY——依然是Unix/Linux终端子系统的核心术语。当你打开一个终端窗口,运行一个命令,然后关闭窗口,为什么进程会随之终止?为什么终端复用器能让进程在SSH断开后继续运行?
答案藏在Unix终端子系统的层次结构中:进程组、会话、控制终端,以及一个关键的抽象——伪终端(PTY)。
一个信号杀死了所有进程
当SSH连接断开时,终端窗口关闭时,所有在该终端运行的进程都会收到同一个信号:SIGHUP(Signal Hang Up)。这个名字直译过来就是"挂起信号",源于调制解调器时代的电话线挂断。
SIGHUP的默认行为是终止进程。但真正的问题不是信号本身,而是谁发送了它,以及为什么。
内核发送SIGHUP的精确时刻
SIGHUP的发送机制涉及多个层次的协作:
-
终端设备检测到断开:对于SSH会话,当TCP连接断开时,sshd进程会关闭对应的PTY master端。对于本地终端模拟器(如xterm),窗口关闭同样导致PTY master被关闭。
-
TTY驱动层感知:当PTY master端被关闭,内核的TTY驱动检测到这一事件。TTY驱动维护着该终端的控制进程(controlling process)信息——通常是shell进程。
-
发送信号给会话首进程:内核向该终端的控制进程发送SIGHUP。根据POSIX标准,控制进程是指首次打开该终端设备并成为会话首进程的那个进程。
-
Shell传播信号:收到SIGHUP的shell会向其创建的所有作业(job)发送SIGHUP,这是shell进程的主动行为,而非内核行为。
sequenceDiagram
participant SSHD as sshd进程
participant PTY as PTY Master
participant TTY as TTY驱动
participant Shell as Shell进程
participant Job as 用户进程
Note over SSHD: SSH连接断开
SSHD->>PTY: close()
PTY->>TTY: 检测到master关闭
TTY->>Shell: 发送SIGHUP
Shell->>Shell: 信号处理函数
Shell->>Job: 发送SIGHUP给所有作业
Job->>Job: 默认行为:终止
这个过程的关键在于:进程与终端之间存在一种"控制关系"。这种关系由内核维护,并通过信号机制体现。
进程组与会话:两级层次结构
要理解控制关系,必须理解Unix进程的两个组织层次:进程组(Process Group)和会话(Session)。
进程组:信号发送的原子单位
进程组是一组进程的集合,它们共享同一个进程组ID(PGID)。进程组的设计初衷是为了shell的作业控制(Job Control)——当你执行 sleep 100 | grep "pattern" 这样的管道命令时,两个进程会被放入同一个进程组。
进程组的关键特性:
- 信号广播:向进程组发送信号,组内所有进程都会收到。
kill -TERM -1234会向PGID为1234的所有进程发送SIGTERM。 - 原子性操作:进程组作为整体被前台/后台调度,要么都在前台,要么都在后台。
- 生命周期独立:进程组可以比其领导者进程存活更久。组内最后一个进程退出,进程组才消失。
会话:终端控制的边界
会话是更高层次的抽象,一个会话包含多个进程组。会话的关键概念:
- 会话首进程(Session Leader):创建会话的进程,其PID成为会话ID(SID)。
- 控制终端(Controlling Terminal):一个会话最多有一个控制终端。终端可以是物理TTY、虚拟控制台(/dev/tty1),或伪终端(/dev/pts/0)。
- 前台进程组:控制终端在任何时刻只有一个前台进程组,只有前台进程组可以从终端读取输入。
- 后台进程组:会话中其他进程组都是后台进程组。后台进程组尝试从终端读取时会收到SIGTTIN信号,默认行为是暂停进程。

图片来源: biriukov.dev
这张图展示了一个典型会话的结构:bash进程作为会话首进程(SID=100),拥有自己的进程组(PGID=100)。会话中还有两个作业(PGID=200和300),其中一个是前台进程组(标记为+),另一个是后台进程组。所有进程共享同一个控制终端/dev/pts/0。
setsid():切断终端关系的系统调用
setsid()系统调用创建一个新会话,调用进程成为新会话的首进程和新进程组的组长。关键约束:调用进程不能已经是进程组组长。
新会话的关键属性:
- 没有控制终端
- 调用进程的PID成为新会话的SID和新进程组的PGID
- 脱离原有会话的所有控制关系
这正是守护进程(daemon)的标准启动步骤之一。通过调用setsid(),守护进程彻底脱离终端控制,即使启动它的终端关闭,守护进程也不会收到SIGHUP。
伪终端:软件模拟的终端设备
物理终端已经消失,但终端接口被保留下来——通过伪终端(Pseudo Terminal,PTY)。PTY是内核提供的虚拟终端设备,它让软件能够模拟终端的行为。
Master与Slave的双端设计
PTY由两个设备端组成:
Master端:由终端模拟器(如xterm)或终端复用器(如tmux)打开和操作。向master写入的数据会出现在slave端的输入中;从master读取的数据来自slave端的输出。
Slave端:表现为一个普通的终端设备。shell或其他程序打开slave端后,获得的是一个与真实终端无异的接口——stdin、stdout、stderr都指向这个终端。
+------------------+ +------------------+
| 终端模拟器/复用器 | | Shell/用户程序 |
| (xterm/tmux) | | (bash/python) |
+--------+---------+ +--------+---------+
| ^
| 写入键盘输入 | 读取stdin
v |
+--------+---------+ +--------+---------+
| PTY Master | <-------> | PTY Slave |
| /dev/ptmx | 内核缓冲 | /dev/pts/N |
+------------------+ +------------------+
线规则:终端行为的定义者
PTY不是简单的数据通道,中间还有一个关键层:线规则(Line Discipline)。线规则定义了终端的"行为模式"。
默认的线规则是N_TTY,它提供:
规范模式(Canonical Mode):
- 行缓冲:用户输入回车后,数据才被提交给程序
- 行编辑:支持退格、删除字、清行等编辑操作
- 特殊字符处理:Ctrl+C产生SIGINT,Ctrl+Z产生SIGTSTP,Ctrl+D产生EOF
非规范模式(Raw Mode):
- 字符立即传递:每个字符都立即传递给程序
- 无特殊处理:所有控制字符都作为普通数据
终端模拟器和像vim这样的交互程序会设置raw模式,自己处理所有输入。而普通命令行程序通常使用canonical模式,享受内核提供的行编辑功能。
forkpty():创建PTY的标准模式
创建PTY并启动子进程的标准流程:
int master_fd;
pid_t pid = forkpty(&master_fd, NULL, NULL, NULL);
if (pid == 0) {
// 子进程:exec执行目标程序
execvp(program, argv);
} else {
// 父进程:通过master_fd与子进程通信
// 可以向master_fd写入数据(作为子进程的输入)
// 可以从master_fd读取数据(子进程的输出)
}
forkpty()内部执行:
- 打开/dev/ptmx获取master fd
- 获取对应的slave设备名(通过ptsname())
- 设置slave设备权限(grantpt())
- 解锁slave设备(unlockpt())
- fork子进程
- 子进程:关闭master,打开slave作为stdin/stdout/stderr,调用setsid()
- 父进程:返回master fd
子进程调用setsid()后,成为新会话的首进程。由于slave设备是其首次打开的终端,slave自动成为其控制终端。
终端复用器的核心技巧
现在回到最初的问题:为什么终端复用器能让进程在SSH断开后继续运行?
tmux的服务器-客户端架构
tmux采用服务器-客户端架构:
- tmux服务器:一个后台进程,管理所有会话、窗口、面板。它持有所有PTY的master端,以及每个面板对应的screen数据结构。
- tmux客户端:用户启动的进程,连接到服务器,负责在用户终端显示界面、转发键盘输入。
当用户执行tmux new -s mysession:
- 如果服务器不存在,启动服务器进程
- 服务器创建一个新的会话(session)
- 在会话中创建一个窗口(window),窗口包含一个面板(pane)
- 为面板创建PTY,启动shell作为slave端的进程
- 客户端连接到服务器,显示面板内容
关键分离:PTY不在SSH会话中
普通SSH会话的进程层次:
sshd -> bash (会话首进程) -> 用户程序
↑
控制终端: /dev/pts/0 (由sshd的PTY slave端提供)
SSH断开时,sshd关闭PTY master端,内核向bash发送SIGHUP,bash向其子进程传播SIGHUP。
tmux会话的进程层次:
tmux服务器 (独立会话)
|
+-> PTY master -> screen数据结构
|
+-> PTY slave -> bash -> 用户程序
↑
控制终端: /dev/pts/N (由tmux的PTY提供)
SSH会话 (独立会话)
|
+-> sshd -> tmux客户端
关键在于:用户程序的控制终端(/dev/pts/N)是由tmux服务器创建的PTY,而不是SSH连接提供的PTY。tmux服务器是一个独立会话的进程,与SSH会话完全无关。
当SSH断开:
- tmux客户端终止
- tmux服务器继续运行(它在自己的会话中,不受SSH会话影响)
- 用户程序的PTY master端仍然由tmux服务器持有
- 用户程序的控制终端仍然存在
- 没有SIGHUP发送给用户程序
screen数据结构:持久化的视觉状态
tmux不仅保持进程存活,还能保存终端的视觉状态。这通过screen数据结构实现:
每个面板(pane)都有一个关联的screen结构,它保存:
- 可见区域(view):当前显示的内容
- 历史区域(history):滚动缓冲区
- 光标位置、滚动区域
- 每个单元格的字符和属性(颜色、样式)
当子进程输出数据时:
- 数据写入PTY slave端
- 内核通过PTY将数据传递给master端
- tmux服务器从master端读取数据
- 数据经过VT100转义序列解析器
- 解析器更新screen数据结构
- 如果有客户端连接,screen内容被渲染到客户端终端
当客户端重新连接时:
- 客户端连接到tmux服务器
- 服务器将screen内容渲染到客户端终端
- 用户看到的是之前状态的完整恢复
这种设计实现了一个关键特性:进程状态与显示状态分离。进程继续运行,screen结构持续更新,无论是否有客户端查看。
nohup与disown:为什么不如终端复用器
理解了终端复用器的原理,可以更好地理解nohup和disown的局限性。
nohup:信号屏蔽
nohup的核心操作:
- 设置SIGHUP的信号处理为SIG_IGN(忽略)
- 将stdin重定向到/dev/null
- 将stdout和stderr重定向到nohup.out
SIG_IGN会被execve()继承,所以即使exec执行新程序,SIGHUP仍然被忽略。
局限性:
- 进程仍然依附于原始终端会话
- 如果进程尝试从stdin读取,会得到EOF
- 输出被追加到单一文件,无法区分不同程序
- 无法重新连接到进程的终端界面
disown:从作业表移除
disown是shell内置命令,它将进程从shell的作业表中移除。当shell退出时,不会向被disown的进程发送SIGHUP。
局限性:
- 进程仍然依附于原始终端
- 没有处理stdin/stdout/stderr
- 如果进程尝试从终端读取或写入,可能失败或阻塞
- 同样无法重新连接
为什么终端复用器更优
终端复用器从根本上改变了进程与终端的关系:
| 特性 | nohup | disown | 终端复用器 |
|---|---|---|---|
| 忽略SIGHUP | ✓ | - | 不适用 |
| 提供虚拟终端 | - | - | ✓ |
| 可重连交互界面 | - | - | ✓ |
| 多窗口支持 | - | - | ✓ |
| 输出历史记录 | - | - | ✓ |
| 进程输入可用 | - | - | ✓ |
终端复用器为进程提供了一个完整的虚拟终端环境,而nohup/disown只是在原始终端即将消失时做了一些补救措施。
终端复用器的发展历程
GNU Screen:开创者
1987年,GNU Screen发布。它是第一个真正意义上的终端复用器,提供了会话持久化、窗口管理、滚动缓冲等功能。Screen的设计影响了后续所有终端复用器。
Screen的特点:
- 单一进程架构:Screen既是服务器又是客户端
- 配置复杂:使用Ctrl+A作为默认前缀键
- GPLv3许可证:这在某些场景下成为障碍
tmux:现代化的重新设计
2007年,Nicholas Marriott开始开发tmux,目标是创建一个BSD许可的Screen替代品。2009年,tmux被纳入OpenBSD基本系统。
tmux的设计改进:
- 服务器-客户端架构:单一服务器管理所有会话,多个客户端可以连接
- BSD许可证:更宽松的许可条款
- 现代代码风格:清晰的代码结构,易于扩展
- 脚本化接口:所有操作都可以通过命令行完成
- 更灵活的窗口布局:支持垂直和水平分割
tmux的核心数据结构层次:
Server
└── Session (会话)
└── Window (窗口)
└── Pane (面板)
├── PTY (伪终端)
├── Screen (视觉状态)
└── Process (子进程)
Zellij:新一代探索
2021年,Zellij作为新一代终端复用器出现,采用Rust编写,探索了不同的设计思路:
- 内置布局系统
- 插件架构
- 更现代的UI设计
实践建议
选择终端复用器
对于大多数用户,tmux是目前最佳选择:
- 活跃的社区和持续更新
- 丰富的文档和教程
- 广泛的平台支持
- 可靠的稳定性
如果使用OpenBSD,tmux已经内置。在Linux和macOS上,可以通过包管理器安装。
基础配置
tmux的默认配置相对保守。以下是一些推荐的配置选项(~/.tmux.conf):
# 使用Ctrl+Space作为前缀键(避免与bash/emacs冲突)
set -g prefix C-Space
# 启用鼠标支持
set -g mouse on
# 增加历史缓冲区
set -g history-limit 50000
# 窗口编号从1开始(0在键盘太远)
set -g base-index 1
# 启用256色
set -g default-terminal "screen-256color"
会话管理策略
开发一个会话管理脚本可以大幅提升效率:
#!/bin/bash
# ~/bin/tmux-work.sh
tmux has-session -t work 2>/dev/null
if [ $? != 0 ]; then
# 创建新会话
tmux new-session -s work -d
# 第一个窗口:主编辑器
tmux send-keys -t work 'vim' C-m
# 第二个窗口:测试/运行
tmux new-window -t work
tmux send-keys -t work:2 'cd test' C-m
# 第三个窗口:git
tmux new-window -t work
tmux send-keys -t work:3 'git status' C-m
fi
# 连接到会话
tmux attach -t work
终端子系统的未来
终端子系统正在经历新的变革:
六元组协议:传统的终端模拟基于字节流,新一代协议(如Kitty图形协议、Sixel)支持在终端中嵌入图形。
终端复用器的角色变化:随着远程开发环境(如GitHub Codespaces、VSCode Remote)的普及,终端复用器的会话持久化变得更加重要。
现代替代方案:像Mosh(Mobile Shell)这样的工具使用UDP协议和预测性本地回显,在不可靠网络上提供更好的体验,但它解决的是不同的问题——网络延迟和中断恢复,而非会话管理。
结语
终端复用器的"魔法"并不神秘。它深刻理解Unix终端子系统的设计:
- 进程与终端的控制关系由内核维护
- SIGHUP信号在终端关闭时发送给控制进程
- 控制关系基于会话和控制终端
- 伪终端提供了软件层面的终端模拟
终端复用器通过创建独立的PTY和会话,让用户进程完全脱离SSH会话的控制。这不是绕过系统规则,而是正确地使用系统规则。
当你下次在tmux中启动一个长时间运行的进程,放心地关闭SSH连接,第二天重新连接时看到进程仍在运行——你现在知道这背后发生了什么:一个独立的服务器进程,持有一个PTY的master端,维护着一个screen数据结构,让你的程序有一个永久的"虚拟终端"可以依附。
参考资料
- Linus Åkesson. “The TTY demystified.” https://www.linusakesson.net/programming/tty/
- Michael Kerrisk. “pty(7) - Linux manual page.” https://man7.org/linux/man-pages/man7/pty.7.html
- Viacheslav Biriukov. “Process groups, jobs and sessions.” https://biriukov.dev/docs/fd-pipe-session-terminal/3-process-groups-jobs-and-sessions/
- Nicholas Marriott. “The tmux terminal multiplexer.” AsiaBSDCon 2011. https://github.com/tmux/tmux/blob/master/presentations/tmux_asiabsdcon11.pdf
- Jonathan Lam. “Understanding the tty subsystem: Overview and architecture.” https://lambdalambda.ninja/blog/54/
- W. Richard Stevens. “Advanced Programming in the UNIX Environment.” Chapter 9: Process Relationships.
- OpenBSD Journal. “Interview with Nicholas Marriott on tmux.” http://www.undeadly.org/cgi?action=article&sid=20090712190402
- GitHub tmux Wiki. “Getting Started.” https://github.com/tmux/tmux/wiki/Getting-Started