1940年代,电传打字机(Teletype)作为远程通信设备被广泛使用。当计算机开始需要实时交互时,这些设备被重新利用——键盘输入直接变成计算机输入,打印纸成为输出介质。Unix系统诞生时,这种"终端即电传打字机"的假设被深深嵌入设计之中。

六十多年后,物理电传打字机早已消失,但它的缩写——TTY——依然是Unix/Linux终端子系统的核心术语。当你打开一个终端窗口,运行一个命令,然后关闭窗口,为什么进程会随之终止?为什么终端复用器能让进程在SSH断开后继续运行?

答案藏在Unix终端子系统的层次结构中:进程组、会话、控制终端,以及一个关键的抽象——伪终端(PTY)。

一个信号杀死了所有进程

当SSH连接断开时,终端窗口关闭时,所有在该终端运行的进程都会收到同一个信号:SIGHUP(Signal Hang Up)。这个名字直译过来就是"挂起信号",源于调制解调器时代的电话线挂断。

SIGHUP的默认行为是终止进程。但真正的问题不是信号本身,而是谁发送了它,以及为什么。

内核发送SIGHUP的精确时刻

SIGHUP的发送机制涉及多个层次的协作:

  1. 终端设备检测到断开:对于SSH会话,当TCP连接断开时,sshd进程会关闭对应的PTY master端。对于本地终端模拟器(如xterm),窗口关闭同样导致PTY master被关闭。

  2. TTY驱动层感知:当PTY master端被关闭,内核的TTY驱动检测到这一事件。TTY驱动维护着该终端的控制进程(controlling process)信息——通常是shell进程。

  3. 发送信号给会话首进程:内核向该终端的控制进程发送SIGHUP。根据POSIX标准,控制进程是指首次打开该终端设备并成为会话首进程的那个进程。

  4. 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()内部执行:

  1. 打开/dev/ptmx获取master fd
  2. 获取对应的slave设备名(通过ptsname())
  3. 设置slave设备权限(grantpt())
  4. 解锁slave设备(unlockpt())
  5. fork子进程
  6. 子进程:关闭master,打开slave作为stdin/stdout/stderr,调用setsid()
  7. 父进程:返回master fd

子进程调用setsid()后,成为新会话的首进程。由于slave设备是其首次打开的终端,slave自动成为其控制终端。

终端复用器的核心技巧

现在回到最初的问题:为什么终端复用器能让进程在SSH断开后继续运行?

tmux的服务器-客户端架构

tmux采用服务器-客户端架构:

  • tmux服务器:一个后台进程,管理所有会话、窗口、面板。它持有所有PTY的master端,以及每个面板对应的screen数据结构。
  • tmux客户端:用户启动的进程,连接到服务器,负责在用户终端显示界面、转发键盘输入。

当用户执行tmux new -s mysession

  1. 如果服务器不存在,启动服务器进程
  2. 服务器创建一个新的会话(session)
  3. 在会话中创建一个窗口(window),窗口包含一个面板(pane)
  4. 为面板创建PTY,启动shell作为slave端的进程
  5. 客户端连接到服务器,显示面板内容

关键分离: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断开:

  1. tmux客户端终止
  2. tmux服务器继续运行(它在自己的会话中,不受SSH会话影响)
  3. 用户程序的PTY master端仍然由tmux服务器持有
  4. 用户程序的控制终端仍然存在
  5. 没有SIGHUP发送给用户程序

screen数据结构:持久化的视觉状态

tmux不仅保持进程存活,还能保存终端的视觉状态。这通过screen数据结构实现:

每个面板(pane)都有一个关联的screen结构,它保存:

  • 可见区域(view):当前显示的内容
  • 历史区域(history):滚动缓冲区
  • 光标位置、滚动区域
  • 每个单元格的字符和属性(颜色、样式)

当子进程输出数据时:

  1. 数据写入PTY slave端
  2. 内核通过PTY将数据传递给master端
  3. tmux服务器从master端读取数据
  4. 数据经过VT100转义序列解析器
  5. 解析器更新screen数据结构
  6. 如果有客户端连接,screen内容被渲染到客户端终端

当客户端重新连接时:

  1. 客户端连接到tmux服务器
  2. 服务器将screen内容渲染到客户端终端
  3. 用户看到的是之前状态的完整恢复

这种设计实现了一个关键特性:进程状态与显示状态分离。进程继续运行,screen结构持续更新,无论是否有客户端查看。

nohup与disown:为什么不如终端复用器

理解了终端复用器的原理,可以更好地理解nohup和disown的局限性。

nohup:信号屏蔽

nohup的核心操作:

  1. 设置SIGHUP的信号处理为SIG_IGN(忽略)
  2. 将stdin重定向到/dev/null
  3. 将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数据结构,让你的程序有一个永久的"虚拟终端"可以依附。


参考资料

  1. Linus Åkesson. “The TTY demystified.” https://www.linusakesson.net/programming/tty/
  2. Michael Kerrisk. “pty(7) - Linux manual page.” https://man7.org/linux/man-pages/man7/pty.7.html
  3. Viacheslav Biriukov. “Process groups, jobs and sessions.” https://biriukov.dev/docs/fd-pipe-session-terminal/3-process-groups-jobs-and-sessions/
  4. Nicholas Marriott. “The tmux terminal multiplexer.” AsiaBSDCon 2011. https://github.com/tmux/tmux/blob/master/presentations/tmux_asiabsdcon11.pdf
  5. Jonathan Lam. “Understanding the tty subsystem: Overview and architecture.” https://lambdalambda.ninja/blog/54/
  6. W. Richard Stevens. “Advanced Programming in the UNIX Environment.” Chapter 9: Process Relationships.
  7. OpenBSD Journal. “Interview with Nicholas Marriott on tmux.” http://www.undeadly.org/cgi?action=article&sid=20090712190402
  8. GitHub tmux Wiki. “Getting Started.” https://github.com/tmux/tmux/wiki/Getting-Started