2

Linux 终端、作业控制、守护进程

 2 years ago
source link: http://kuanghy.github.io/2020/04/02/terminal-jobs-daemon
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Linux 终端、作业控制、守护进程

Linux | Apr 2, 2020 | linux

终端(TTY)

一般意义上的终端(Terminal)是指人机交互的设备,也就是可以接受用户输入的并输出信息给用户的设备。在计算机刚出现时,终端是电传打字机(Teletype/Teletypewriter,即 TTY)和打印机,也即所谓的 物理终端

提到终端就不得不提到 控制台(Console)。控制台的概念与终端含义极其相近,现今经常用它们表示相同的东西。但在计算机发展的早期,却是不同的东西。一些数控设备(比如数控机床)的控制箱,通常会被称为控制台。所以,最初的控制台就是一个直接控制设备的面板,上面有很多控制按钮。在计算机里,把那套直接连接在电脑上的键盘和显示器就叫做控制台。而终端是通过串口连接上的,不是计算机自身的设备,而控制台是计算机本身就有的设备。一个计算机只有一个控制台,但可以连接很多的终端。计算机启动的时候,所有的信息都会显示到控制台上,而不会显示到终端上。也就是说,控制台是计算机的基本设备,而终端是附加设备。计算机操作系统中,与终端不相关的信息,比如内核消息,后台服务消息,都可以显示到控制台上,但不会显示到终端上。控制台一般由系统管理员使用,用于管理整个计算机,而普通用户则通过终端连接到计算机进行使用。

现在终端和控制台都由硬件概念,逐渐演化成了软件的概念。在现在的操作系统中,可以这样来理解控制台与终端:能直接显示系统消息的那个终端称为控制台,其他的则称为终端(控制台也是一个终端)。实际上,现在在使用 Linux 操作系统时,已经不区分控制台与终端了。因为控制台 (Console) 与终端 (Terminal) 的概念渐渐的模糊起来。在现代,键盘与显示器既可以认为是控制台,也可以认为是普通的终端。因为用户一般都对系统有很大的控制权,即可以作为普通用户,也可以作为系统管理员。所以现在的 Console 与 Terminal 含义基本一致。

最初的终端是一种文本终端 (Text Terminal),即 字符终端 (Character Terminal),只能接收和显示文本信息的终端。后来又发展出了 图形终端(Graphical Terminal),其不但可以接收和显示文本信息,也可以显示图形与图像。随着 GUI 的输出,使用传统意义上的终端的人也越来越少,逐渐被全功能显示器所取代。但 Linux 仍然保留了字符终端(实际上已经是图形终端了,只是在习惯上仍然较字符终端),通过快捷键 Ctrl+ALT+F1~F6 可以进入到六个不同的字符终端。

真正意思上的硬件终端已经消失,那么现代的操作系统是怎么与那些传统的、不兼容图形接口的命令行程序(如 GNU 工具集命令)交互的呢?因为这些程序无法直接读取键盘输入,也无法直接把结果输出到显示器上。所以现代的操作系统通过一个程序来模拟传统的终端行为,这个程序即 终端仿真器(Terminal Emulator),通常也叫做终端模拟器。对于命令行程序,终端模拟器会伪装成一个传统终端设备;而对于现代的图形接口,终端模拟器会伪装成一个 GUI 程序。

一个终端模拟器的标准工作流程是这样的:

  • 捕获用户的键盘输入
  • 将输入发送给命令行程序(程序会认为这是从一个真正的终端设备输入的)
  • 拿到命令行程序的输出结果(STDOUT 以及 STDERR)
  • 调用图形接口(比如 X11),将输出结果渲染至显示器

目前,操作系统用户所使用的所谓终端,都是模拟终端。如 GNU/Linux 中的 gnome-terminal、Konsole;MacOSX 中的 Terminal.app、iTerm2;Windows 中的 Win32 控制台、ConEmu 等等。上面提到的,在 Linux 中可以 Ctrl+ALT+F1~F6 切换六个终端,其实这也不是传统意义上的终端了,它们也是终端模拟器的一种。这些全屏的终端界面与运行在 GUI 下的终端模拟器的唯一区别就是它们是由操作系统内核直接提供的。这些由内核直接提供的终端界面被叫做 虚拟控制台 (Virtual Console),而运行在图形界面上的终端模拟器则被叫做 终端窗口 (Terminal Window)

拥有控制终端的进程都可以通过一个特殊的设备文件 /dev/tty 访问它的控制终端,进程认为 /dev/tty 就是它的控制终端。但事实上每个终端设备都对应一个不同的设备文件,/dev/tty 只是提供了一个通用的接口,一个进程要访问它的控制终端既可以通过 /dev/tty 也可以通过该终端设备所对应的设备文件来访问。可以用 tty 命令来查看当前控制终端实际指向的设备文件。也可以通过 ttyname 系统函数由文件描述符查出对应的文件名,该文件描述符必须指向一个终端设备而不能是任意文件。如:

#include <unistd.h>
#include <stdio.h>

int main()
{
    printf("fd 0: %s\n", ttyname(0));
    printf("fd 1: %s\n", ttyname(1));
    printf("fd 2: %s\n", ttyname(2));
    return 0;
}

在图形终端窗口下运行,结果如:

fd 0: /dev/pts/0
fd 1: /dev/pts/0
fd 2: /dev/pts/0

在开一个终端窗口运行,结果则如:

fd 0: /dev/pts/1
fd 1: /dev/pts/1
fd 2: /dev/pts/1

切换到字符终端 Ctrl-Alt-F1 运行,结果如:

fd 0: /dev/tty1
fd 1: /dev/tty1
fd 2: /dev/tty1

每个不同的字符终端都对一个不同的设备文件,分别是 /dev/tty1~/dev/tty6。设备文件 /dev/tty0 表示当前虚拟终端,比如切换到 Ctrl-Alt-F1 的字符终端时 /dev/tty0 就表示 /dev/tty1,切换到 Ctrl-Alt-F2 的字符终端时 /dev/tty0 就表示 /dev/tty2,就像 /dev/tty 一样也是一个通用的接口,但它不能表示图形终端窗口所对应的终端。

程序执行时会自动打开三个文件:标准输入、标准输出 和 标准错误输出,其文件描述符分别为 0, 1, 2。默认情况下(没有重定向),每个进程的标准输入、标准输出和标准错误都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到显示器上。在控制终端输入一些特殊的控制键可以给(前台)进程发送信号,如 Ctrl+C 表示 SIGINT,Ctrl+\ 表示 SIGQUIT。

用 tty 查看当前的终端设备文件,并用 lsof 查看该设备被那些进程打开:

$ tty
/dev/pts/0
$ lsof /dev/pts/0
COMMAND   PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
zsh     22773 huoty    0u   CHR  136,0      0t0    3 /dev/pts/0
zsh     22773 huoty    1u   CHR  136,0      0t0    3 /dev/pts/0
zsh     22773 huoty    2u   CHR  136,0      0t0    3 /dev/pts/0
zsh     22773 huoty   10u   CHR  136,0      0t0    3 /dev/pts/0
lsof    23297 huoty    0u   CHR  136,0      0t0    3 /dev/pts/0
lsof    23297 huoty    1u   CHR  136,0      0t0    3 /dev/pts/0
lsof    23297 huoty    2u   CHR  136,0      0t0    3 /dev/pts/0

运行 tty 与 lsof 命令时,其与控制终端的交互流程:

                   +--------------------------+    R/W     +------+
Input  ----------->|                          |<---------->| bash |
                   |          pts/0           |            +------+
Output <-----------|                          |<---------->| lsof |
                   | Foreground process group |    R/W     +------+
                   +--------------------------+

程序执行时会自动打开三个文件:标准输入、标准输出 和 标准错误输出,其文件描述符分别为 0, 1, 2。默认情况下(没有重定向),每个进程的标准输入、标准输出和标准错误都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到显示器上。在控制终端输入一些特殊的控制键可以给(前台)进程发送信号,如 Ctrl+C 表示 SIGINT,Ctrl+\ 表示 SIGQUIT。

$ tty
/dev/pts/0
$ lsof /dev/pts/0
COMMAND   PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
zsh     22773 huoty    0u   CHR  136,0      0t0    3 /dev/pts/0
zsh     22773 huoty    1u   CHR  136,0      0t0    3 /dev/pts/0
zsh     22773 huoty    2u   CHR  136,0      0t0    3 /dev/pts/0
zsh     22773 huoty   10u   CHR  136,0      0t0    3 /dev/pts/0
lsof    23297 huoty    0u   CHR  136,0      0t0    3 /dev/pts/0
lsof    23297 huoty    1u   CHR  136,0      0t0    3 /dev/pts/0
lsof    23297 huoty    2u   CHR  136,0      0t0    3 /dev/pts/0

虚拟终端的数目是有限的,虚拟终端一般就是 /dev/tty1~/dev/tty6 六个。然而网络终端或图形终端窗口的数目却是不受限制的,这是通过 伪终端(Pseudo TTY) 实现的。一套伪终端由一个主设备(PTY Master)和一个从设备(PTY Slave)组成。主设备在概念上相当于键盘和显示器,只不过它不是真正的硬件而是一个内核模块,操作它的也不是用户而是另外一个进程。从设备和 /dev/tty1 这样的终端设备模块类似,只不过它的底层驱动程序不是访问硬件而是访问主设备。

特殊设备 /dev/ptmx 用于创建一对 master、slave 伪终端设备的文件。当一个进程打开它时,获得了一个 master 的文件描述符(file descriptor),同时在 /dev/pts 下创建了一个 slave 设备文件(如 /dev/pts/0, /dev/pts/1)。master 端是更接近用户显示器、键盘的一端,slave 端是在虚拟终端上运行的 CLI(Command Line Interface,命令行接口)程序。Linux 的伪终端驱动程序,会把 master端(如键盘)写入的数据 转发给 slave 端供程序输入,把 程序写入 slave 端的数据 转发给 master 端供(显示器驱动等)读取。如 ssh 远程登录的数据传输流程:

+----------+       +------------+
| Keyboard |------>|            |
+----------+       |  Terminal  |
| Monitor  |<------|            |
+----------+       +------------+
                         |
                         |  ssh protocol
                         |
                         ↓
                   +------------+
                   |            |
                   | ssh server |--------------------------+
                   |            |           fork           |
                   +------------+                          |
                       |   ↑                               |
                       |   |                               |
                 write |   | read                          |
                       |   |                               |
                 +-----|---|-------------------+           |
                 |     |   |                   |           ↓
                 |     ↓   |      +-------+    |       +-------+
                 |   +--------+   | pts/0 |<---------->| shell |
                 |   |        |   +-------+    |       +-------+
                 |   |  ptmx  |<->| pts/1 |<---------->| shell |
                 |   |        |   +-------+    |       +-------+
                 |   +--------+   | pts/2 |<---------->| shell |
                 |                +-------+    |       +-------+
                 |    Kernel                   |
                 +-----------------------------+

关于终端和伪终端,可以简单的做如下理解:

  • 真正的硬件终端基本上已经看不到了,现在所说的终端、伪终端都是软件仿真终端(即终端模拟软件)
  • 一些连接了键盘和显示器的系统中,可以接触到运行在内核态的软件仿真终端(tty1-tty6)
  • 通过 GUI 的终端软件窗口或者 SSH 远程登录等使用的都是伪终端

用户从终端登录系统时,大致会经历如下的过程:

  • 如果是字符界面登录,则由 init 进程调用 getty 来处理登录请求;如果是网络登录,如 ssh,则由 sshd 守护进程来处理登录请求
  • getty/sshd 进程调用 setsid 函数创建一个新的 Session,该进程称为 Session Leader,该进程的 pid 即为 Session 的 id
  • getty 打开终端设备(如 /dev/tty1)作为控制终端;如果是非字符终端登录,则由相应的进程(如 sshd)打开一个伪终端设备,然后再 fork 一次,一分为二:父进程操作伪终端主设备,子进程将伪终端从设备作为控制终端。然后,进程都会将文件描述符 0、1、2 指向打开的控制终端。用了控制终端,进程就可以与用户交互了,接着就提示用户输入账户名
  • 输入账户后,进程通过调用 exec 变成 login 进程,然后提示输入密码,如果密码正确,则再调用 exec 变成 shell 进程。用户最终通过 shell 与操作系统交互

Shell 叫进程分为不同的 作业(Job) 或者 进程组(Process Group) 来进行控制,作业可以在 shell 的前台或者后台运行,这称为 作业控制(Job Control)。一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,Shell可以同时运行一个前台作业和任意多个后台作业。如:

$ proc1 | proc2 &
$ proc3 | proc4 | proc5

其中 proc1 和 proc2 属于同一个后台进程组,proc3、proc4、proc5 属于同一个前台进程组,Shell 进程本身属于一个单独的进程组。这些进程组的控制终端相同,它们属于同一个 Session。Session(会话)可以看作是一个若干进程组的集合。当用户在控制终端输入特殊的控制键(例如 Ctrl-C)时,内核会发送相应的信号(例如 SIGINT)给前台进程组的所有进程。

& 表示将进程放到后台运行。另外,使用 jbos, bg, fg 等命令可以对作业进行查看和控制。

通过用户登录创建的进程,在运行结束或用户注销时终止。如通过终端登录创建的进程,在用户注销或网络中断时,通过终端 shell 启动的所有子进程都会受到 SIGHUP 信号,SIGHUP 信号的默认处理动作是退出进程。但系统中有一些服务进程不受用户登录注销的影响,它们一直在运行着,这样的进程被称为 守护进程(Daemon)

试着用 ps axj 命令查看系统中的进程:(参数 a 表示不仅列当前用户的进程,也列出所有其他用户的进程,参数 x 表示不仅列有控制终端的进程,也列出所有无控制终端的进程,参数 j 表示列出与作业控制相关的信息)

$ ps axj
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     1     1     1 ?           -1 Ss       0   0:12 /sbin/init splash
    0     2     0     0 ?           -1 S        0   0:00 [kthreadd]
    2     3     0     0 ?           -1 I<       0   0:00 [rcu_gp]
    2     4     0     0 ?           -1 I<       0   0:00 [rcu_par_gp]
    2     6     0     0 ?           -1 I<       0   0:00 [kworker/0:0H-events_highpri]
    2     9     0     0 ?           -1 I<       0   0:00 [mm_percpu_wq]
    2    10     0     0 ?           -1 S        0   0:02 [ksoftirqd/0]
...
22771 22773 22773 22773 pts/0    28246 Ss    1000   0:03 -zsh
...
22773 28246 28246 22773 pts/0    28246 R+    1000   0:00 ps axj

TPGID 为 -1 的进程都是没有控制终端的进程,也就是守护进程(TPGID 指前台进程组 ID)。在 COMMAND 一列用 [] 括起来的名字表示内核线程,这些线程在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常采用以 k 开头的名字,表示 Kernel。init 进程第一个用户级进程,其有许多很重要的任务,如启动 getty(用于用户登录);udevd负责维护 /dev 目录下的设备文件;acpid 负责电源管理;syslogd 负责维护 /var/log 下的日志文件。可以看出,守护进程通常采用以 d 结尾的名字,表示 Daemon。

SIGHUP 信号在用户终端连接(正常或非正常)结束时发出, 通常是在终端的控制进程结束时, 通知同一 Session 内的各个作业。系统对 SIGHUP 信号的默认处理是终止收到该信号的进程。所以要创建一个守护进程,就需要脱离原来的 Session,并且不能有控制终端。

创建守护进程最关键的一步是调用 setsid 函数创建一个新的 Session,并成为 Session Leader。

#include <unistd.h>

pid_t setsid(void);

该函数调用成功时返回新创建的 Session 的 id(其实也就是当前进程的 id),出错返回 -1。注意,调用这个函数之前,当前进程不允许是进程组的 Leader,否则该函数返回 -1。要保证当前进程不是进程组的 Leader 也很容易,只要先 fork 再调用 setsid 就行了。fork 创建的子进程和父进程在同一个进程组中,进程组的 Leader 必然是该组的第一个进程,所以子进程不可能是该组的第一个进程,在子进程中调用 setsid 就不会有问题了。

成功调用 setsid 函数的结果是:

  • 创建一个新的 Session,当前进程成为 Session Leader,当前进程的 id 就是 Session 的 id
  • 创建一个新的进程组,当前进程成为进程组的 Leader,当前进程的 id 就是进程组的 id
  • 如果当前进程原本有一个控制终端,则它失去这个控制终端,成为一个没有控制终端的进程。所谓失去控制终端是指,原来的控制终端仍然是打开的,仍然可以读写,但只是一个普通的打开文件而不是控制终端了
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>

void daemonize(void)
{
	pid_t  pid;

	/*
	 * Become a session leader to lose controlling TTY.
	 */
	if ((pid = fork()) < 0) {
		perror("fork");
		exit(1);
	} else if (pid != 0) /* parent */
		exit(0);
	setsid();

	/*
	 * Change the current working directory to the root.
	 */
	if (chdir("/") < 0) {
		perror("chdir");
		exit(1);
	}

	/*
	 * Attach file descriptors 0, 1, and 2 to /dev/null.
	 */
	close(0);
	open("/dev/null", O_RDWR);
	dup2(0, 1);
	dup2(0, 2);
}

int main(void)
{
	daemonize();
	while(1);
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK