系列综述

本系列将从 Linux 基础知识起步,理解 tini 源码,并实现一个 Golang 版本的单进程管理器。该进程管理器将作为容器的 entrypoint 进程,即容器的 1 号进程,来管理工作进程。

20221113 updated: 新增 《进程管理器(四) Go Supervisord》

本节概述

在 Linux 中实现一个可用于生产环境的 1 号进程并不容易,主要需要考虑如下问题:

  • 信号处理和转发
  • 收割子进程
  • 终端设备(tty)、会话和进程组管理
  • 正确的设置工作进程的权限

本章节主要参考:

注意:本文主要以 Linux 为例阐述,不保证其他兼容 POSIX.1 标准的操作系统(Linux、MacOS 均是、Windows 不是)有同样的能力。

进程

进程和进程树

  • 在 Linux 中,每个用户态进程都有一个父进程(1 号进程除外,1 号进程的父进程是 内核进程即 0 号进程),这样就构成了一颗根节点为 1 号进程的树。
  • 当进程 a 创建了进程 b,此时进程 a 是进程 b 的父进程,进程 b 是进程 a 的子进程。
  • 每一个进程都有一个唯一标识 ID (即 PID),该 ID 在进程退出之前永远不变。

进程的两个阶段

进程的创建是以 fork 系统调用返回 0 作为起始的,而程序的执行是以 exec 系统调用载入于一个程序开始。在本系列,我们将定义:

  • fork 到当前进程的最后一次 exec 之间称为:引导阶段。
  • 当前进程的最后一次 exec 之后称为:执行阶段。

图示如下:

fork ---> exec ---> exec ... ---> exec --->  exit
 |                                |  |        |
  --------------------------------    ————————
                  |                       |
                  v                       v
               引导阶段                  执行阶段

编写普通程序时,一般是 fork 之后 立即 exec,因此,引导阶段什么都不做。

但是在编写一个进程管理器场景,区分这两个阶段非常重要。因为,进程管理器需要在引导阶段对当前进程进行一些配置工作。

进程和权限

在 Linux 操作系统中,多数发行版的进程管理器是 systemd,作为进程管理器,其一般以 root 权限运行。当我们使用安装一个 mysql server 后,其是以 mysql 用户运行的,systemd 是如何实现的呢?

Linux 提供了一个 setuid/setgid 的系统调用,当 root 权限(CAP_SYS_ADMIN 权限)的进程调用时,将会将该进程的 uid 和 gid 设置为指定的用户和组。因此就实现了权限降级,以实现最小化权限的要求。

扩展知识(和本系列无关,仅做分享):权限升级

上文提到了权限通过 setuid/setgid 即可实现权限降级,但是如何实现权限升级呢?比如一个没有 root 的进程如何以 root 的身份执行一个程序(比如 sudo、su 命令可以创建一个拥有 root 权限的 shell 进程)。

Linux 在文件系统层面,为可执行文件提供了一种称为 设置用户/组 id 位的 flag 属性,当一个文件的属性中启用了 设置用户/组 id 位。那么一个进程使用 exec 系统调用执行该程序时,该程序的权限将变为这个可知执行文件的所属用户和所属组。

因此,一个具有 设置用户/组 id 位 的程序需要自行实现对用户的身份验证,以保证系统安全。

关于此,更多参见: 《APUE》第 4.4、8.11 章节

进程组

进程组是一个或多个进程的集合。对于一个进程组:

  • 有一个进程组 ID(pgid),其值是是该进程组组长进程的进程 ID。

关于进程组的系统调用主要有:

  • pid_t getpgrp(void) 系统调用获取当前进程所属的进程组 ID。
  • pid_t getpgid(pid_t pid) 系统调用可以获取指定进程的进程组 ID getpgid(0) 等价于 getpgrp()
  • int setpgid(pid_t pid, pid_t pgid) 可以将一个进程加入一个现有的进程组,或者创建一个新的进程组且当前调用进程作为进程组组长(setpgid(0, 0))。pid 参数只能是 0 (当前进程)、当前进程 id、子进程 id(孙子进程不行)。

进程组主要有如下几个作用:

  • 通过 kill 系统调用,给一个进程组下的所有进程发送信号。
  • 通过 waitpid 系统调用,等待该进程组中的孩子进程改变状态。
  • 在一个会话中:
    • 前台进程组中的所有进程都可以读写终端(通过标准 IO)。
    • 终端产生的信号会发送到前台进程组中的所有进程。

进程管理器需要为其管理的进程,在其引导阶段,创建一个进程组,这样做的目的是:通过进程组可以将信号转发给该进程组中的所有信号。

会话

会话是一个或多个进程组的集合。对于一个会话:

  • 有一个会话首进程,该进程是的进程 id 为 sid,属于为会话首进程 id,或会话 ID。
  • 有一个前台进程组(关联终端后才有此概念)
  • 有 0 个或多个后台进程组(关联终端后才有此概念)

关于会话的系统调用主要有:

  • pid_t setsid(void) 系统调用 创建一个新的会话。调用的进程必须是一个进程组的组长。调用后,将发生如下事情:
    • 创建一个新的会话,该进程是该会话中的第一个进程即会话首进程,其进程 id 为该会话的会话 id (sid)。
    • 同步为该进程创建一个进程组,该进程为该进程组的组长,该进程的进程 id 为该进程组的进程组 id。
    • 该进程和调用之前的终端的联系将被切断(即标准 IO 不再指向终端设备)。
  • pid_t getsid(pid_t pid) 获取当前进程的 sid。
    • pid 为 0 时,获取当前进程的 sid。
    • pid 为非 0 时,只有 pid 在当前进程所在的 sid 中时才会返回正确结果。

会话主要最终要的作用是:和一个终端关联,即标准 IO 关联的终端是哪一个,这个与会话关联的终端称为该会话的控制终端。当然一个会话也可以不和终端关联,这种场景比较少见,一般传统的 Unix Daemon 程序 才会这么做。

进程管理器可以考虑为其管理的每个进程创建一个会话,并分配一个终端(伪终端),以更好的管理这些进程的日志(标准输出),这设计终端相关内容,参见下文终端小节。

扩展知识:孤儿进程组

定义为:某进程组存在一个进程的其进程的父进程不是改进程组的成员,也不是该会话的其他进程组的成员。

造成存在一个进程组变为孤儿进程组的原因一般是:进程组组长退出,而孩子还活着。

面对新晋孤儿进程组,内核会向该进程组的每一个处于停止状态的进程发送 SIGHUP 信号,然后再发送 SIGCONT 信号。

终端

终端 (tty, Teletype, Teletypewriter, Teleprinter) 是对一套输入输出设备的抽象,在 Linux 中,终端有字符终端和图形终端。在小节讨论的是字符终端,不会涉及图形终端相关内容。

关于终端历史演进,推荐阅读:探索终端的历史渊源

我们通过 xterm、iterm2 以及 ssh 连接到 server 中,获取的一个 shell 时,这个 shell 进程就关联了一个终端,shell 运行的进程也可以在该终端中获取输入打印输出。因此,每一个终端是被多个进程共享的资源,因此为了让各个进程合理的使用终端,产生了会话、控制终端、会话首进程、前台进程组、后台进程组等概念。

  • 在应用程序中,我们一般不会感知到终端的原因,Linux 已经把终端抽象成,我们熟知的标准 IO 了。
  • 当我们使用 shell 执行命令时,此时 shell 和正在运行的命令所在进程都和这个终端关联,也就是说,一个终端可以关联多个进程。与同一个终端关联的进程组被称为个:会话。而这个和会话关联终端被称为会话的控制终端,创建了该会话的进程被称为会话首进程
  • 当我们通过快捷键发送特殊字符时,正在运行的进程可以收到相关信号,此外只有我们输入回车,正在运行的程序才能通过标准 IO 读取到输入的数据。这说明在用户输入内容并不是直接传递到进程中的标准 IO 中的,而是在用户输入流和进程标准 IO 之间存在一套处理逻辑,来实现上述内容,这个程序被称为:终端行规程,能接收这些信号进程组称为:前台进程组
  • 当我们通过 命令 & 方式可以创建一个进程,这个进程就会在后台运行,从而不能获取到标准输入,不会接收到信号,但是仍然会向终端输出内容。这种进程所在的进程组称为:后台进程组

终端、会话、前后台进程组、会话首进程关系如下图所示(来自 APUE)。

image

在当代终端已经没有专门的物理设备了,一般通过伪终端(pty)相关系统调用通过编程创建一个虚拟终端。

用户 <------> 电传打字机/终端硬件设备产生的信号 <-----------> 终端设备驱动 <-------------------------------> 终端行规程(内核) <------> 进程的标准 IO
 ‖                        ‖                                   ‖                                          ‖                       ‖
 ‖                        ‖                                   ‖                                          ‖                       ‖
 ‖                        ‖                                   ‖                                          ‖                       ‖
 ‖                        ‖                                   ‖                                          ‖                       ‖
用户 <------> 任意可以转换为 IO 流的东西(如网络IO) <------> 伪终端主设备(进程 A)<---> 伪终端从设备 <------> 终端行规程(内核) <------> 进程的标准 IO(进程 B)

上述模型进程 A 和进程 B 的核心系统调用为:

  • 进程 A
    • int posix_openpt(int oflag) 创建一个伪终端主设备,返回伪终端主设备文件描述符。
    • int grantpt(int fd) 更改从设备的权限(fd 参数为伪终端主设备文件描述符)
    • int unlockpt(int fd) 允许打开伪终端从设备(fd 参数为伪终端主设备文件描述符)
    • char *ptsname(int fd) 返回该伪终端从设备的文件名(fd 参数为伪终端主设备文件描述符)
    • fork 子进程 B
  • 子进程 B
    • int setsid() 设置新的会话
    • int open(char *name, int flag) 使用普通的 IO 函数打开伪终端从设备
    • 可选的 tcsetattr 设置终端属性
    • 可选的 ioctl 设置终端窗口尺寸
    • dup2 将伪终端分配给标准输入、标准输出、标准出错文件描述符
    • exec 执行程序

关于终端和前后台进程组、会话首进程的系统调用主要有:

  • pid_t tcgetpgrp(int fd) 返回 fd 指向终端的关联的前台进程组 id (fd 一般为 0 标准输入)。
  • int tcsetpgrp(int fd, pid_t pgrpid) 更改 fd 指向终端的关联的前台进程组 id 为 pgrpid (fd 一般为 0 标准输入,pgrpid 一般为 getpgrp 返回值,即当前进程所在进程组 id)。
  • pid_t tcgetsid(int fd) 返回 fd 指向终端的会话首进程。

信号

Linux 手册: signal(7) 文档

信号概念

信号是软件中断。当一个进程收到一个信号时,可以配置如下几种处理方式:

  • 默认行为,不同的信号有不同的默认行为,默认行为有如下几种:
    • TERM 终止进程(进程直接结束)。
    • Ign 忽略信号。
    • Core 终止进程,并触发 core dump。
    • Stop 停止进程(进程调度状态变更为 Stop,进程不会结束)。
    • Cont 如果它目前已停止,继续进程。
  • 忽略信号
  • 自定义处理函数
  • 屏蔽信号

信号异步处理

信号异步处理指的是,给某个信号设置了自定义处理函数,此时就是信号异步处理函数。

(异步信号处理和多线程一样,存在并发问题,因此不推荐,具体参见下文:中断系统调用和库函数 和 可重入函数)

配置自定义处理函数的方式

  • signal(2) 系统调用(不推荐),该函数的语义,对信号的处理可能是不可靠的。原因是早期,当 signal 注册的自定义函数是一次性的,也就是说当函数被调用后,信号处理方式就会恢复为默认行为,这样可能会造成信号丢失。所以一般的如果需要一直生效的写法就是:

    void sig_int() {
        // 这个时间段,信号处理方式恢复为默认行为,导致信号丢失
        signal(SIGINT, sig_int);
        // ...
    }
    

    但是现代 Linux 中,signal 是通过 sigaction 实现的,并不要上述写法,所以也是可靠的。

  • sigaction(2) 系统调用,细粒度的控制一个信号的行为。可以用来是实现 signal(2) 系统调用 。可以实现如下效果

    • 一旦对一个信号设置了一个动作,那么在再次显示的调用 sigaction 改变之前,将一直生效。
    • 信号处理函数处理过程中屏蔽一些信号递送,通过 sigaction.sa_mask 设置在信号处理过程中,如果触发了其他信号,将这些信号屏蔽住,直到当前信号处理函数返回后,将自动解除这些信号屏蔽,进行递送。关于信号屏蔽参见同步信号处理。

自定义处理函数调用流程

当针对一个进程配置一个自定义处理函数后,当信号被触发时,对这个自定义处理函数的调用,并不是启动一个新的线程进行处理。而是:

  1. 将当前进程的主线程暂停,将栈指针和寄存器信息保存下来。
  2. 在当前进程的主线程中执行自定义信号处理函数。
  3. 恢复主进程的上下文信息,继续执行。

中断系统调用和库函数

假设在信号被触发时,当前进程正在调用阻塞性的系统调用时(如:read、write),这些系统调用可能被中断,此时这些函数有如下两种可选的行为:

  • 自动重启,当使用 signal 注册自定义处理函数,或者 sigaction 设置了 SA_RESTART 标志时。
  • 返回 EINTR 失败,sigaction 未设置 SA_RESTART 标志时。

更多参见: Interruption of system calls and library functions by signal handlers

可重入函数

在信号处理函数的编写和普通函数的编写有额外的要求,即不可调用不可重入函数。

可重入函数,即异步信号安全函数。和多线程的线程安全概念类似,更多参见:signal-safety(7)

信号同步处理

信号未决和信号集、信号屏蔽

在信号产生(generation)到信号递送(delivery),之前有一个状态,被称为信号未决(pending)。

在信号异步处理过程中吗,信号未决基本无感,在信号同步处理过程中,可以实现让某信号长时间的处于信号未决的状态,然后通过调用某些函数让这些信号处于已递送的状态。实现这种效果的行为被称为信号屏蔽。换句话说:

  • 当一个信号被屏蔽之后,不管之前是该信号配置了处理函数还是忽略还是默认行为,该信号将不会触发任何行为。
  • 当一个信号被取消屏蔽后,如果存在一个处于未决状态的信号,则这个信号将会立即触发配置的行为(可能是忽略、默认行为、自定义处理函数)。

Linux 支持屏蔽一批信号,标识这一批信号的的概念是信号集(具体实现上是一个位图), 相关系统调用如下所示:

#include <signal.h>

int sigemptyset(sigset_t *set); // 将参数修改为空的信号集
int sigfillset(sigset_t *set);  // 赋值为将系统使用的所有信号机
int sigaddset(sigset_t *set, int signum); // 添加一个信号
int sigdelset(sigset_t *set, int signum); // 删除一个信号
int sigismember(const sigset_t *set, int signum); // 判断一个信号是否在该信号集中

屏蔽一批信号的系统调用为 sigprocmask(2) 系统调用

#include <signal.h>
int sigprocmask(int how, const sigset_t *restrict set,
                sigset_t *restrict oldset);
  • how 为如何修改
    • SIG_BLOCK 添加
    • SIG_UNBLOCK 删除
    • SIG_SETMASK 覆盖
  • set 带设置的信号机,如果为 NULL 则不会更改
  • oset 返回修改前的被屏蔽的信号集

该系统调用的原理是读取和更改内核中当前进程的信号屏蔽字。

标准信号和实时信号

在 Linux 中,信号分为如下两类

  • 标准信号,即 POSIX.1 定义的信号。针对每一个标准信号,每个进程只会存在一个未决的信号,也就是说:
    • 当某个标准信号被屏蔽后,且收到一个该信号,此时该信号状态是未决的。
    • 此后,在解除屏蔽之前,又收到了该信号多次(这些信号会被丢弃)。
    • 在解除屏蔽后,该信号只会递送一次。
  • 实时信号,SIGRTMINSIGRTMAX 之间的信号。针对每一个标准信号,每个进程可以存在多个未决的信号,也就是说:
    • 当某个标准信号被屏蔽后,且收到一个该信号,此时该信号状态是未决的。
    • 此后,在解除屏蔽之前,又收到了该信号多次(进行排队)。
    • 在解除屏蔽后,该信号会递送多次次。

递送信号

除了上文描述的,解除一个信号的屏蔽,来让未决的信号递送外,还可以通过如下系统调用和库函数,将信号设置为已递送(这些函数调用后,相当于消费掉了这个信号,解除屏蔽后,不会再重复递送了,即不会触发任何行为)。

注意:sigpending(2) 系统调用 可以获取到所有处于未决状态的信号,但是不会将这些信号设置为已递送。

信号继承

在 Linux 中,一个进程的 引导阶段 (fork) 和 执行阶段 (exec) 对上面信号的配置的继承是不一样的

  • 引导阶段 (fork),当前进程和父进程的信号处理器完全一样,信号屏蔽字完全一样。
  • 执行阶段 (exec),和 fork 阶段对比:
    • 相同的是:
      • 信号处理器为 ignore 和 默认的信号。
      • 信号屏蔽字。
    • 不同的是:
      • 信号处理器为 自定义函数 的信号其信号处理器将恢复为默认。

作业控制信号

POSIX.1 定义了 6 个作业控制相关的信号。

  • SIGCHLD 子进程已停止或者终止,父进程将接收到该信号,默认行为为忽略。
  • SIGCONT 如果进程已停止,则使其继续运行,默认行为为继续该进程。
  • SIGSTOP 停止信号(不能被捕捉、忽略或屏蔽),默认行为为停止该进程。
  • SIGTSTP (Ctrl + Z)交互式停止信号,默认行为为停止该进程。
  • SIGTTIN 后台进程组成员读控制终端,默认行为停止。
  • SIGTTOU 后台进程组成员写控制终端,默认行为停止(仅当 tty 被设置为 TOSTOP 时才会发生,即停止后台进程组的输出(在 shell 中可以通过 stty tostop 命令关闭))。

注意

  • 当一个进程产生四种为停止信号时(SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU),处于未决状态的 SIGCONT 的将被丢弃。
  • 当一个进程产生 SIGCONT 时,处于未决状态的四种停止信号时(SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU)将被丢弃。
  • SIGCONT 默认行为为继续,但是如果当前进程本身就继续,则该信号不会做任何事情。

Shell 原理简述

  • shell 进程会不会创建会话,会话的创建取决于创建 shell 的进程(sshd、xterm 等)是否在其引导阶段分配,一般会分配一个。
  • 创建一个后台进程组:执行 cmd1 & ,通过 setpgid 系统调用创建。
  • 将一个后台进程组切换到前台:fg <jobid> 命令,通过 tcsetpgrp 系统调用将某后台进程组切换到前台。
  • 将一个前台进程组的进程切换到后台,并继续运行:
    • 输入 ctrl + z 发送 SIGTSTP 让该进程组的进程停止。
    • shell 进程接收到 SIGCHLD 信号,了解到进程子进程的状态变化,通过 tcsetpgrp 系统调用将 shell 所在进程组设置到前台,并打印提示输出和命令提示符。
    • 通过 bg 命令,向后台进程组发送 SIGCONT 信号,让其继续运行。
  • 管道符连接命令:ps -o pid,ppid,pgid,sid,tpgid,comm | cat | cat
    • bourne shellsh (debian 中的 sh 实际上是 dash,并不是 bourne shell)流程如下所示:
      • shell 主进程 fork 一个 c 进程, c 进程准备两个管道: ab, bc
      • c 进程 fork a 进程, a 进程 dup2 其标准输出为 ab 的输入端; exec ps
      • c 进程 fork b 进程, b 进程 dup2 其标准输入为 ab 的输出端,dup2 其标准输出为 bc 的输入端; exec cat
      • c 进程, dup2 其标准输入为 bc 的输出端, exec cat
      • a,b,c 进程依次退出,c 进程退出时,shell 主进程将收到 c 的 SIGCHLD 信号,且 waitpid 返回。
      • shell 记录 c 进程的退出吗。
    • bourne-agent shellbash (debian 中的 sh 实际上是 dash,并不是 bourne shell)流程如下所示:
      • shell 主进程,准备两个管道(pipe): ab, bc
      • shell 主进程 fork a 进程,主进程和 a 同时调用 setpgid 为 a 进程创建一个新的进程组(同时调用目的是防止出现时序问题)。
      • shell 主进程 fork b, c 进程,随后 b, c 进程加入进程 a 所在的进程组。
      • a 进程 dup2 其标准输出为 ab 的输入端; exec ps
      • b 进程 dup2 其标准输入为 ab 的输出端,dup2 其标准输出为 bc 的输入端; exec cat
      • c 进程, dup2 其标准输入为 bc 的输出端, exec cat
      • a,b,c 进程依次退出,shell 主进程将收到这些进程的 SIGCHLD 信号,且 waitpid 返回。
      • shell 记录 c 进程的退出吗。

其他更多参见:APUE 第 9.8 章、9.9 章。

1 号进程

1 号进程在 Linux 中的职责是系统的进程管理器,因此和普通进程相比,其有如下不同点:

  • 当一个普通进程的父进程先退出的,那么该进程的父进程将变为 1 号进程。因此 1 号进程必须实现对僵尸进程的收割(调用 waitpid)。
  • 1 号进程的所有信号的默认处理行为是忽略(除了 SIGKILL、SIGSTOP 外)。因此必须显式的注册信号处理函数或者通过信号同步处理的方式对信号进行合适的处理。