写点什么

终端闲思录(3)- 标准三剑客的本质

作者:黑客不够黑
  • 2023-12-22
    江苏
  • 本文字数:4527 字

    阅读完需:约 15 分钟

终端闲思录(3)- 标准三剑客的本质

标准三剑客为程序的标准输入、标准输出、标准错误


Linux 会为打开的文件分配一个非负整数来表示该文件,文件的 I/O 调用都要通过文件描述符来发起,文件描述符用来表示所有类型的已打开的文件,这包括管道(pipe)、FIFO、socket、普通文件和终端设备等。Linux 为这些类型的文件提供了统一的通用 I/O 模型,即 open、close、read、write 等系统调用接口,因此,所谓的“Linux 一切皆文件”应该更多地从通用文件 I/O 接口的角度来理解。

1 与的终端特定结合

我们所讨论的终端即是其中一种文件类型,标准三剑客表示的“0,1,2”三个文件描述符,背后的文件类型通常是终端设备,例外情况等我讲到复制文件描述符的时候再详细讨论,我们先明确一下文件描述符和文件的对应关系。


打开文件,获得文件的描述符,似乎文件和文件描述符的一对一关系是不言而喻的,但是,多个文件描述符可以指向同一打开的文件,这些文件描述符可以在相同或不同的进程打开。如图 1-1 所示:



上图展示了进程的文件描述符(file descriptor)、内核维护的系统所有打开的文件描述(open file description)以及文件 inode 之间的关系。简单介绍一下,左侧表格代表进程的文件描述符;中间表格称为 open file description table,是内核为所有打开的文件维护的一个系统级描述表;右侧表格代表 inode,可简单理解为硬盘上的文件。


从中我们可以得到以下几点信息:


  1. 在进程 A 中,文件描述符 1 和 20 都指向同一个打开文件描述,这可能是通过复制文件描述符形成的。

  2. 进程 A 的文件描述符 2 和 进程 B 的文件描述符 2 都指向同一个打开文件描述,这种情形可能是进程 A 进行 fork 调用形成的,子进程会继承父进程所有打开的文件描述符。

  3. 进程 A 的文件描述符 0 和进程 B 的文件描述符 3 分别指向不同的打开文件描述,但这些描述均指向相同的 inode,这是因为每个进程各自对同一文件发起了 open 调用,同一进程两次打开同一文件也会出现这种情况。


关于第一点复制文件描述符稍后另行展开,我们先看第二点,父进程 fork 出子进程,子进程会继承父进程所有打开的文件描述符,如果子进程稍后调用 exec 执行了其它的程序,那些没有设置O_CLOEXEC的文件描述符都会在子进程中得到保留。


shell 进程已经打开了“0,1,2”三个文件描述符,在此 shell 中执行的所有进程都会继承这三个文件描述符,如果没有特殊的变动,它们会和 shell 一样将“0,1,2”连接到终端。你是否有过这样的经历:在 shell 中执行了程序,程序进入了后台,回车后可见 shell 的提示符,说明你依然可以操作,但是进入后台的程序却时不时的输出一点内容到你的终端,如果你没遇到过,可以用下面的命令试一下:


for  ((;;)) do sleep 1; echo "hello"; done &
复制代码


如果你需要终止它,输入fg将进程拉到前台,Ctrl+C结束它,不要害怕fg之间被hello充塞,世界只是你的表象,表象虽然乱了,但内在的输入队列和输出队列依然有序运行。


之所以产生这种现象是因为没有为放到后台的进程处理标准输出和标准错误, shell fork 子进程出来解释该命令,子进程继承了 “0,1,2”文件描述符,&标志将进程送往后台,但是并没有对标准三剑客进行调整,其对应的“0,1,2”描述符依然和终端相关联,所以当echo "hello"向标准输出打印的时候,内容依然会显示在终端上。不过不用担心后台进程会干扰标准输入,shell 会确保只有前台进程才能从终端进行读取(参考进程组的内容)。


程序并不经常产生这种行为,大多数情况下我们需要在 shell 中通过重定向语法“>”来处理标准三剑客。有些支持 daemon 的程序会提供诸如-d--detach的选项在处理好标准三剑客之后启动到后台,一种典型的处理是将标准三剑客指向“/dev/null”,因为 daemon 程序通常并不需要使用终端;而 systemd 管理下的 service 通常会将标准输出和标准错误重定向到 Unix 域套接字,这些输出内容作为日志受到 journald 进程的管理。


完成这种重定向的就是 dup 家族的 dupdup2dup3 三个系统调用。使用最多的是 dup2:


#include <unistd.h>int dup2(int oldfd, int newfd);//Returns (new) file descriptor on success, or –1 on error
复制代码


dup2() 系统调用会为 oldfd 参数指定的文件描述符创建副本,副本的编号由 newfd 参数指定,所以dup(1, 20)就会产生图 1-1 中所示进程 A 的情况。shell 的重定向语法和管道就是使用 dup 实现的。


$ ./myscript > results.log 2>&1
复制代码


上面这条重定向命令被广泛使用,Bourne shell 的重定向语法“2>&1”,意在通知 shell 把标准错误重定向到标准输出,这条语法的效果大致使用如下方式实现:


fd = open("results.log", O_RDWR);if (dup2(fd, STDOUT_FILENO) != STDOUT_FILENO)    return -1;if (dup2(STDOUT_FILENO, STDERR_FILENO) != STDERR_FILENO)    return -1;// 文件描述符复制完毕,fd 可以关闭close(fd);
复制代码


这一刻,STDOUT_FILENOSTDERR_FILENO ,也就是文件描述符“1,2”与终端脱离关系,写往标准输出和标准错误的内容全部进入 results.log 文件,不会再显示在终端上。


所以,标准三剑客的本质仅仅是文件描述符,各种语言中那些能打印到终端的 I/O 函数(如‘printf’‘fmt.Print’‘system.out.println’),其底层使用的就是“0,1,2”三个文件描述符,函数最终输出到哪里要看“0,1,2”指向哪里。


还记得“Linux 一切皆文件”指得是通用文件 I/O 接口吗?文件描述符可以指向任意类型的文件,我们再来看一个管道的例子:


ls | wc -l
复制代码


要执行这条命令,shell 将 fork-exec 两个进程出来,并且创建管道,将 ls 的标准输出重定向到管道的写入端,将 wc 标准输入重定向到管道的读取端,如下图所示:



ls 和 wc 两个进程根本不知道管道的存在,它们一如既往地从标准输入读取,写入标准输出,这其中的工作是由 shell 来完成的,大概类似如下这样:


// 创建管道int pfd[2];pipe(pfd);
// 在第一个进程中复制文件描述符,重定向标准输出到管道写端if (dup2(pfd[1], STDOUT_FILENO) == -1) return -1;if (close(pfd[1]) == -1) return -1;
// 在第二个进程中复制文件描述符,重定向标准输入到管道读端if (dup2(pfd[0], STDIN_FILENO) == -1) return -1;if (close(pfd[0]) == -1) return -1;
复制代码


大多数处理文件描述符的工作都会放在 fork 之后,exec 之前。exec 之前都属于 shell 的代码范围,exec 之后就是另一个程序了。


现在可以解答开篇的第一个问题了——终端与标准三剑客的关系。标准三剑客的本质仅仅是文件描述符,描述符的指向可以是任意类型的文件,标准三剑客与终端只是在交互场景下的特定结合,并不存在必然的联系,万不能混为一谈。

2 标准三剑客从哪里来?

接下来,我们很容易产生新的疑问:我编写的程序应该如何处理标准三剑客呢?或者说该不该关注标准三剑客呢?如果要关注,什么情况下需要关注呢?


要讲清楚这个新问题需要先概括一下程序的类型,Linux 大概有两种类型的程序:


  1. 普通程序

  2. daemon 程序


daemon 程序人所共知,是运行在后台的程序,反之运行在后台的程序不一定是 daemon,在 Linux 上想成为 daemon 可是有一套繁琐流程的,你要关闭除标准三剑客之外的其它文件描述符、将信号重置到默认状态、开启新的会话、调用两次 fork 以脱离控制终端、将标准三剑客重定向到/dev/null等等,SysV Daemons 描述了传统 SysV daemon 的制作过程。


不是 daemon 的程序都属于普通程序(启动的二进制程序是主程序,即不会退出),我猜测除了系统级的程序员以外很少人会去写 daemon 了,好在世界不断变化,Systemd 简化了这些工作,大多数工作都被 Systemd 处理了,你的普通程序也能 daemon 化,但是要配合好 Systemd 你还是有些额外工作要完成,这不是本文重点,此处略去不谈。


SysV daemon 将标准三剑客重定向到/dev/null,此举意味着一个自觉的 daemon 应该考虑自己的标准输出(既然是 daemon,标准输入自然是不需要的)。如果有日志输出,应该将内容写到文件,你要非往标准输出打,就将标准输出重定向到文件或者其它地方,这些工作需要你自己完成。


Systemd 会主动将标准输入重定向到/dev/null,标准输出和标准错误重定向到systemd-journald.service,你的普通程序也可以被 Systemd 管理而不用担心标准三剑客的问题。


不过复杂有复杂的好处,简单有简单的取舍,SysV Daemons 除了在 SysV 下管理之外,你还可以在交互式 shell 下直接运行使程序进入后台,而普通程序想进入后台,除了使用 SysVinit 和 Systemd 管理之外,只能用nohupsetsiddisown这些命令来达到长期后台运行的目。


经过第 4 节的论述,我们会得出这样的结论:程序自身以及 fork 该程序的父进程都有机会去修改标准三剑客的指向。所以,我们还想捋清楚一点:如果什么都不做,在各种常见情况下启动的程序其标准三剑客的默认继承是怎样的。


SysVinit 和 Systemd 的 1 号进程无控制终端,其标准三剑客连接到/dev/null;交互式 shell 有控制终端,其标准三剑客连接到 shell 所在终端。明确这两点是因为各种服务管理系统的 init 进程和 shell 是我们运行程序的父进程,不做任何处理的话,子进程会继承父进程的标准三剑客。根据程序类型、运行方式、服务管理系统的不同,大致可分成以下 6 种情况:


(1)SysVinit + 普通程序

  1. 普通进程如果被 SysVinit 管理,将继承 init 进程的设置,即标准三剑客连接到/dev/null,这种情况下如果你打印东西到标准输出,所有内容都会被丢进黑洞。拯救方法就是使用 shell 提供的重定向功能将标准输出和标准错误重定向到文件,因为 init 是 fork 一个 shell 来执行 init.d 中的脚本的。

  2. 顺带一提,init 是为每个 init.d 的脚本都 fork 一个 shell 进程来解释执行的,脚本中的命令又要被 shell fork 执行,其性能上的负担可见一斑,这就是其被 Systemd 取代的原因。


(2)SysVinit + SysV daemon

  1. 这种情况前文已述,继承的肯定是标准三剑客连接到/dev/null,但 SysV daemon 通常都会自行设置。


(3)shell + SysV daemon

  1. 交互式 shell 下执行 daemon 程序,不会继承 shell 的设置,daemon 的目的就是丢弃控制终端,重建会话。


(4)shell + 普通程序

  1. 交互式 shell 下执行普通程序,会继承 shell 的设置,标准三剑客连接到 shell 所在的控制终端。


(5)Systemd + 普通程序

  1. 这种情况前文已述,Systemd 会主动将标准输入重定向到/dev/null,标准输出和标准错误重定向到systemd-journald.service,相当于 systemd.service 中设置 type=simple 或者 type=exec,需要启动的程序本身是主进程。

  2. 值得一提的是,Systemd 管理下的一些传统支持 daemon 的程序都要加上特定的参数,使程序启动到前台,比如 sshd:

  3. -D When this option is specified, sshd will not detach and does not become a daemon.


(6)Systemd + SysV daemon

  1. 这是新瓶装旧酒,用 Systemd 来管理传统的 daemon,在 service 文件里需要配置为:type=forking,但 Systemd 明确表示不鼓励这样使用,也就是说在程序设计上务必向 Systemd 的规则靠拢。

  2. 标准三剑客就自不待言了,我的地盘我做主。


后台运行的本质是脱离控制终端,运行在新的会话下,从而忽略终端的SIGHUP信号,以此获得永生!所以你会发现,标准三剑客是程序后台化的一个绊脚石,系统的不同部分在不同层次上给出了各自的解决方案。


发布于: 刚刚阅读数: 6
用户头像

感而后应,迫而后动,不得已而后起 2018-11-20 加入

非著名程序员,任职过测试,前端,devops,DBA、Go 后端开发等等 个人网站: https://liupzmin.com 联系方式: liupzmin@gmail.com

评论

发布
暂无评论
终端闲思录(3)- 标准三剑客的本质_终端_黑客不够黑_InfoQ写作社区