本文主要介绍了终端,包括物理终端、模拟终端以及伪终端等。

两种不同的登录方式、即终端登录和网络登录。

重点是进程组、会话以及控制终端之间的联系。

最后是作业控制,作业控制是目前很多类Unix系统都支持的功能。

介绍了shell程序是如何实现作业控制的以及shell运行程序的方式。

终端简介

物理终端

1869年证券报价机被发明出来,用于远距离实时分配股票价格,证券报价机由一台打字机、一对长电缆和一台电动收报机组成。

随后证券报价机发展成为基于ASCII码的电传打字机,连接在名为Telex的大型网络中,用于传输商业电报,并没有连接到任何计算机上。

与此同时,计算机虽然大且原始,但是已经开始支持多任务和实时交互功能。因此命令行开始取代批处理模型,电传打字机被用来作为输入输出设备,因为市面上这种设备最常见。

而市面上的不同类型电传打印机都需要软件来支持,因此Unix内核来处理这些底层细节,例如字长、波特率、流控制、奇偶校验、基于行编辑的控制码等。

此时的电传打印机teletype就被成为物理终端。 如下图所示:

电传打字机通过两根电缆连接:一根用于向计算机发送指令,一根用于接收计算机的输出。这两根电缆插入计算机的串行接口(UART,通用异步接收发送装置)。

操作系统内核包含UART驱动用来处理物理线路接收到的字节数据,包括流控制、奇偶校验等。 然后将字符数据传递给行规程(line discipline)。

行规程(line descipline) 用来处转换特殊字符(退格、擦除、清除等)并将收到的数据回传给电传打字机,以便用户可以看到输入的内容。

行规程(line descipline) 同时还会对数据进行缓冲,当按下回车键时,缓冲的数据被传递给TTY相关的前台用户进程。用户可以并行执行几个程序,但每次只与一个进程进行交互,其他进程后台工作。

还有一些高级程序使用的是raw模式,该模式下需要自己来处理输入的特殊字符,而不是使用行规程(line descipline)程序。

模拟终端

今天电传打字机已经进入了博物馆,但是行规成(line desicipline)和TTY驱动保留了下来。终端不在是一个通过UART连接到计算机的物理设备,而是成为内核的一个模块。

它可以直接转发数据到TTY驱动,并将TTY驱动读取相应然后打印到屏幕上。

这种使用内核模块模拟的终端物理设备,被称为模拟终端。

现在是Linux桌面环境时代,启动系统默认会进入到登录界面,然而对于服务器版本,并不需要桌面环境,此时进行登录的命令行环境就是模拟终端。

对于每个模拟终端,在/dev下都有一个特殊的设备文件tty[n]。与该虚拟终端的交互,是通过对这个设备文件进行的读写操作,以及使用ioctl系统调用操作这个设备文件进行的。

可以使用tty命令查看当前登录的模拟终端对应的设备文件名。

$tty
/dev/tty6

Ubuntu提供了6个模拟终端,其中早期的X系统和现在GNONE都是运行模拟终端上tty2上,可以使用Ctrl+Shift+F2切换到X系统。

$ ps aux | grep tty2
blduan      5083  0.0  0.1 165468  6424 tty2     Ssl+ 17:22   0:00 /usr/libexec/gdm-wayland-session env GNOME_SHELL_SESSION_MODE=ubuntu /usr/bin/gnome-session --session=ubuntu
blduan      5087  0.0  0.3 226116 15692 tty2     Sl+  17:22   0:00 /usr/libexec/gnome-session-binary --session=ubuntu
blduan      5677  0.0  0.0  12296  2596 pts/4    S+   17:24   0:00 grep --color=auto tty2
$ ll /proc/5083/fd
total 0
dr-x------ 2 blduan blduan  0  7月 24 17:22 ./
dr-xr-xr-x 9 blduan blduan  0  7月 24 17:22 ../
lrwx------ 1 blduan blduan 64  7月 24 17:22 0 -> /dev/tty2
lrwx------ 1 blduan blduan 64  7月 24 17:22 1 -> 'socket:[106022]'
lrwx------ 1 blduan blduan 64  7月 24 17:22 2 -> 'socket:[106023]'
lrwx------ 1 blduan blduan 64  7月 24 17:22 3 -> 'anon_inode:[eventfd]'
lrwx------ 1 blduan blduan 64  7月 24 17:22 4 -> 'anon_inode:[eventfd]'
lrwx------ 1 blduan blduan 64  7月 24 17:22 5 -> 'socket:[106024]'
lrwx------ 1 blduan blduan 64  7月 24 17:22 6 -> 'anon_inode:[eventfd]'
lrwx------ 1 blduan blduan 64  7月 24 17:22 7 -> 'socket:[106026]'

下面的例子是虚拟终端tty3登录之后向虚拟终端tty4发送消息,如果tty4已经登录,则可以收到该消息。

#echo "hello, tty4" > /dev/tty4

伪终端

为了将终端模拟器从内核空间移到用户空间,并保持TTY驱动和行规程(descipline)的完整性,此时发明了伪终端(pseudo terminal, PTY)。 因此伪终端可以用来创建登录会话和提供行规程的能力。

伪终端由两个虚拟字符设备组成:master(PTM)和slave(PTS)。

通常master设备连接到终端模拟软件上(例如xterm、gnome-terminal)并且slave设备连接到正在运行中的程序上,通常是shell(例如bash)。因此slave就和物理终端表现一样。

当master端被打开时,对应的slave设备可以像任何tty设备一样使用。master和slave设备由内核连接,等效于具有TTY功能的双向管道。

伪终端主要用于网络登录服务(ssh、rlogin、telnet等)和实现终端仿真器(xterm、mobaxterm、tmux、gnome-terminal等)

以gnome-terminal为例:

  1. gnome-terminal打开伪终端的master设备/dev/ptmx,并负责监听键盘事件,通过伪终端master设备接收或发送字符到slave设备,同时在屏幕上绘制master设备的字符输出。

  2. gnome-termial会fork出一个shell子进程(bash),并让该子进程获取slave设备文件/dev/pts/[n],该子进程通过slave设备接收字符,并进行处理然后返回。

在gnome-terminal中执行tty命令,可以看到slave设备对应的设备文件:

$tty
/dev/pts/0

下面是Ubuntu22.04下gnome-terminal执行命令的过程:

  1. 系统启动时会拉起init进程。
$ ps aux | grep init
root           1  0.0  0.2 166420 11504 ?        Ss   17:50   0:01 /sbin/init auto noprompt splash
  1. init进程创建systemd子进程用来管理用户进程。
$ ps ajx | grep systemd
1     833     833     833 ?             -1 Ss    1000   0:00 /lib/systemd/systemd --user
  1. 当用户在桌面第一次打开gnome-terminal时,systemd进程会先创建gnome-terminal-server子进程,gnome-terminal-server进程fork出bash子进程以执行shell命令。systemd进程之后再调用python3来创建gnome-terminal.real子进程(即输入窗口)。
$ ps ajx  | grep gnome-terminal*
    833    1926    1099    1099 ?             -1 S     1000   0:00 /usr/bin/python3 /usr/bin/gnome-terminal --wait
   1926    1929    1099    1099 ?             -1 Sl    1000   0:00 /usr/bin/gnome-terminal.real --wait
    833    1996    1099    1099 ?             -1 S     1000   0:00 /usr/bin/python3 /usr/bin/gnome-terminal --wait
   1996    1999    1099    1099 ?             -1 Sl    1000   0:00 /usr/bin/gnome-terminal.real --wait
   833    1934    1934    1934 ?             -1 Ssl   1000   0:00 /usr/libexec/gnome-terminal-server
$ ps ajx | grep  bash
   1934    1960    1960    1960 pts/0       1960 Ss+   1000   0:00 bash
   1934    2004    2004    2004 pts/2       2004 Ss+   1000   0:00 bash
  1. 此时gome-terminal.real进程打开了伪终端的master设备,bash进程打开伪终端slave设备。当用户再gnome-terminal中输入命令之后就会被该bash执行并返回。
  2. 此时该bash的标准流都被重定向到slave设备文件中。
$ ll /proc/2004/fd
total 0
dr-x------ 2 blduan blduan  0  7月 24 18:30 ./
dr-xr-xr-x 9 blduan blduan  0  7月 24 18:30 ../
lrwx------ 1 blduan blduan 64  7月 24 18:30 0 -> /dev/pts/0
lrwx------ 1 blduan blduan 64  7月 24 18:30 1 -> /dev/pts/0
lrwx------ 1 blduan blduan 64  7月 24 18:30 2 -> /dev/pts/0
lrwx------ 1 blduan blduan 64  7月 24 18:30 255 -> /dev/pts/0
  1. 此时gnome-terminal监听键盘事件,并将收到的字符写入master设备。
  2. line discipline收到字符,进行缓冲。当收到回车键时,TTY驱动负责将缓冲的字符写入slave设备。
  3. line discipline在收到字符后,也会把字符写回master设备。gnome-terminal只会在屏幕上显示master设备回传的内容。
  4. bash从标准输入读取输入的字符(以ls -l为例)。
  5. bash fork出子进程,调用exec执行ls -l命令,结果打印到标准输出。
  6. TTY驱动将标准输出的字符写入master设备。
  7. gnome-terminal将master设备的字符渲染到屏幕上。

登录

终端登录

终端登录指的是早期物理终端和模拟终端登录的场景。

终端登录流程如下:

  1. init进程对于每个终端连接都运行一个独立的getty程序。init进程fork子进程来运行getty程序。
  2. getty进程在终端进行监听,并且输出欢迎信息(保存在/etc/issue中),并且提示输入用户名,在用户输入用户名之后使用exec调用login程序。
  3. login进程通过命令行参数接收用户名并提示用户输入密码。如果账密匹配,则启动bash;否则进程退出终止或者让用户重新输出账密(比如Ubuntu22.04)。
  4. init进程检测到login进程退出,则对当前终端重新启动getty进程。

终端登录流程

唯一的新进程是由init进程创建的(使用fork函数);getty和login只会替换进程中运行的程序(使用exec函数).

下面是Linux系统进程关系图:

# ps ajx | grep init
0     1     1     1 ?           -1 Ss       0   0:08 /sbin/init
# ps ajx | grep agetty
   1 31144 31144 31144 ttyFIQ0  31144 Ss+      0   0:00 /sbin/agetty -o -p -- \u --keep-baud 115200,38400,9600 ttyFIQ0 vt220
# 此处输入了username
# ps ajx | grep login
   1 31144 31144 31144 ttyFIQ0  31144 Ss+      0   0:00 /bin/login -p --
# 此处输入了passwd,然后fork子进程,然后exec shell
# ps ajx | grep bash
31144 32462 32462 31144 ttyFIQ0  32462 S+       0   0:00 -bash

网络登录

在终端登录中,init进程知道那些终端设备可以用来登录,并为每个终端创建一个getty进程。

但是网络登录不同,所有登录都经由内核的网络接口驱动程序,而且事先不知道会有多少个登录。因此必须等待网络连接请求的到达,而不是使一个getty进程等待一个可能的登录。

网络登录流程如下(以telnet登录为例):

  1. init进程启动inetd进程在后台运行,监听TCP/IP连接请求到达主机,当有连接请求到达时,执行fork,生成子进程执行telnetd进程。
  2. telned进程打开一个伪终端设备,并用fork分为两个进程。父进程处理通过网络连接的通信,子进程则执行login程序。父子进程通过伪终端相连接。
  3. telnetd进程将文件描述符0,1,2和socket连接,用于接收和发送接收到数据,将文件描述符3连接到伪终端的master设备,用于转发接收到的命令。
  4. login进程用于验证输入的账密,验证通过则通过fork创建子进程,然后exec bash进程(父子进程共享文件描述符,因此也会连接到伪终端的slave设备);验证失败则login进程和telnetd都退出,重新由inetd进程创建telnetd进程。

网络登录流程图

$ ps ajx | grep 2223
      1    2223    2223    2223 ?             -1 Ss       0   0:00 /usr/sbin/inetd
   2223    2313    2313    2313 ?             -1 Ss     129   0:00 in.telnetd: 192.168.1.4
# ll /proc/2313/fd
lrwx------ 1 telnetd telnetd 64  7月 28 23:12 0 -> 'socket:[57712]'
lrwx------ 1 telnetd telnetd 64  7月 28 23:12 1 -> 'socket:[57712]'
lrwx------ 1 telnetd telnetd 64  7月 28 23:12 2 -> 'socket:[57712]'
lrwx------ 1 telnetd telnetd 64  7月 28 23:12 3 -> /dev/ptmx
$ ps ajx | grep 2313
   2223    2313    2313    2313 ?             -1 Ss     129   0:00 in.telnetd: 192.168.1.4
   2313    2314    2314    2314 pts/1       2386 Ss       0   0:00 login -h 192.168.1.4 -p
# ll /proc/2314/fd
lrwx------ 1 root root   64  7月 28 23:11 0 -> /dev/pts/1
lrwx------ 1 root root   64  7月 28 23:13 1 -> /dev/pts/1
lrwx------ 1 root root   64  7月 28 23:13 2 -> /dev/pts/1
lrwx------ 1 root root   64  7月 28 23:13 3 -> 'socket:[56988]'
$ ps ajx | grep 2314
   2313    2314    2314    2314 pts/1       2386 Ss       0   0:00 login -h 192.168.1.4 -p
   2314    2386    2386    2314 pts/1       2386 S+    1000   0:00 -bash
# ll /proc/2386/fd
lrwx------ 1 blduan blduan 64  7月 28 23:14 0 -> /dev/pts/1
lrwx------ 1 blduan blduan 64  7月 28 23:14 1 -> /dev/pts/1
lrwx------ 1 blduan blduan 64  7月 28 23:14 2 -> /dev/pts/1
lrwx------ 1 blduan blduan 64  7月 28 23:14 255 -> /dev/pts/1

当通过终端或网络登陆时,我们会得到一个登录shell,其标准输入、标准输出以及标准错误要么连接到一个终端设备上要么连接到一个伪终端设备上。

进程组

每个进程除了有一个进程ID外,还属于一个进程组。

进程组是一个或多个进程的集合。通常进程组中的进程是在同一作业中结合起来的,同一进程组中的各进程接收来自同一终端的各种信号。

每个进程组有唯一的进程组ID,类似于进程ID,都是正整数,存放在pid_t数据类型中。

函数getpgrp返回调用进程的进程组ID。

#include <unistd.h>

pid_t getpgrp(void);
/* 返回调用进程的进程组ID */

下面的例子查看当前进程的进程组ID和进程ID:

#include <stdio.h>
#include <unistd.h>
int main()
{
    printf("process group id is %d\n", getpgrp());

    printf("pid is %d\n", getpid());
}
/*
process group id is 24358
pid is 24358
*/

从上面的例子可以看出,进程组ID等于创建进程组的进程ID。


每个进程组都有一个组长进程。组长进程的进程组ID等于其进程ID。

进程组组长可以创建一个进程组、创建该组中的进程,然后终止。从进程组创建开始到其中最后一个进程离开为止的时间成为进程组的生命周期。

进程组中的最后一个进程可以终止,也可以转移到另一个进程组。

进程调用setgid可以加入一个现有的进程组或者创建一个新进程组。

#include <unistd.h>

int setpgid(pid_t pid, pid_t pgid);
/* 成功返回0, 失败返回-1 */

setpgid函数将pid进程的进程组ID设置为pgid

  1. 两个参数相等,则由pid指定的进程变为进程组组长。
  2. pid==0,则使用调用进程的进程ID。
  3. pgid==0,则由pid指定的进程ID用作进程组ID。

一个进程只能为它自己或它的子进程设置进程组ID。 在它的子进程调用了exec之后,就不再更改该子进程的进程组ID。


下面的例子中子进程通过调用setpgid创建新进程组,并成为新进程组组长。

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

int main()
{
    pid_t pid;

    if ((pid = fork()) < 0)
    {
        perror("fork error:");
        return 0;
    }
    else if (pid == 0)
    {
        pid_t childPID = getpid();
        printf("child process id is %d, process group id is %d\n", childPID, getpgrp());
        if (setpgid(childPID, childPID) != 0)
        {
            perror("setpgid error: ");
            _exit(3);
        }
        printf("child process id is %d, process group id is %d\n", childPID, getpgrp());
        for (;;)
        {
            sleep(1);
        }
    }
    else
    {
        printf("parent process id is %d, process group id is %d\n", getpid(), getpgrp());
        for (;;)
        {
            sleep(1);
        }
    }
}
/*
parent process id is 27924, process group id is 27924
child process id is 27925, process group id is 27924
child process id is 27925, process group id is 27925
*/

孤儿进程组

该组中每个成员的父进程要么是该组的一个成员,要么不是该组所属会话的成员。

会话

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

进程可与调用setsid函数建立一个新会话。

#include <unistd.h>

pid_t setsid(void);

/* 成功返回进程组ID,失败返回-1。*/

setsid函数说明:

  1. 调用进程不是一个进程组的组长,则此函数创建一个新会话。
    • 该进程成为新会话的会话首进程。此时该进程是该会话的唯一进程。
    • 该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID。
    • 该进程没有控制终端。如果在调用setsid之前该进程有一个控制终端(比如login启动的bash进程),那么这种联系也被切断。
  2. 调用进程是一个进程组的组长,返回出错。通常采用的方式是父进程fork子进程,然后使父进程终止,子进程继续,可以保证子进程不是进程组组长。

会话ID指的是该会话中首进程的进程组ID。 通常bash进程ID为当前会话ID。

getsid函数可以用来获取该会话首进程的进程组ID。

#include <unistd.h>

pid_t getsid(pid_t pid);
/*  成功返回会话首进程的进程组ID,失败返回-1 */

pid==0,返回调用进程所在会话的首进程的进程组ID。如果pid不属于调用进程所在的会话,则不能得到该会话首进程的进程组ID。


下面的例子中使用fork创建子进程然后在子进程中调用setsid创建新会话,并同时创建了新进程组。子进程作为进程组的组长,也是会话的首进程。

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

int main()
{
    pid_t pid;

    if ((pid = fork()) < 0)
    {
        perror("fork error: ");
        return 0;
    }
    else if (pid == 0)
    {
        printf("current child process group id is %d, session id is %d, "
               "process id is %d\n",
            getpgrp(), getsid(0), getpid());
        if (setsid() == -1)
        {
            perror("setsid error: ");
            _exit(1);
        }
        printf("current child process group id is %d, session id is %d, "
               "process id is %d\n",
            getpgrp(), getsid(0), getpid());
    }
    else
    {
        printf("current parent process group id is %d, session id is %d, "
               "process id is %d\n",
            getpgrp(), getsid(0), getpid());
        for (;;)
            sleep(1);
        _exit(0);
    }
}
/*
current parent process group id is 4219, session id is 3509, process id is 4219
current child process group id is 4219, session id is 3509, process id is 4220
current child process group id is 4220, session id is 4220, process id is 4220
*/

执行结果如下:

$ ps -eo pgid,sid,pid,command | grep bash
   3509    3509    3509 -bash

由上面的例子可以看出,父进程是一个进程组组长,进程ID和进程组ID相同,所在进程组同时也是会话首进程为bash进程(进程ID为3509)的会话成员。

子进程在调用setsid之前,是父进程所属进程组的成员,同时也是父进程所在会话的成员。调用setsid之后,创建了进程组和会话,成为了进程组组长和会话首进程。


进程、进程组、会话是逐级递加的方式来管理进程的。

控制终端

当会话首进程打开一个尚未与会话相关联的终端设备时,只要在调用open函数时没有指定O_NOCTTY标志,System V派生系统将此终端作为控制终端分配给此会话。

当会话首进程用TIOCSCTTY作为request参数调用ioctl时,基于BSD的系统为会话分配控制终端。为使此调用成功执行,此会话不能已经有一个控制终端。(通常ioctl调用在setsid之后)

普通应用程序可以使用open文件/dev/tty来和控制终端主动通信。在内核中,该文件是控制终端的代名词。 如果进程没有控制终端,open返回失败。

会话、进程组以及控制终端的关系如下:

  1. 一个会话可以有一个控制终端,通常是终端设备或伪终端设备。
  2. 与控制终端建立连接的会话首进程被称为控制进程
  3. 一个会话中的几个进程组可被分成一个前台进程组以及一个或多个后台进程组
  4. 如果一个会话有一个控制终端,则它有一个前台进程组,其他进程组都为后台进程组。
  5. 无论何时键入终端的中断键(Ctrl+C),都会将中断信号发送至前台进程组的所有进程
  6. 如果终端接口检测到调制解调器或网络已经断开连接,则将挂断信号发送控制进程(会话首进程)

进程组、会话以及控制终端关系图如下: 进程组、会话以及进程关系图

需要由一种方法通知内核哪个进程组是前台进程组,因为终端驱动程序需要知道要将终端输入输出产生的信号发送到何处

以下几个函数提供了这种功能:

#include <unistd.h>

pid_t tcgetpgrp(int fd);
/* 成功返回前台进程组ID,失败返回-1 */
int tcsetpgrp(int fd, pid_t pgrpid);
/* 成功返回0,失败返回-1 */

tcgetpgrp函数返回前台进程组ID,它与在fd上打开的终端相关联。

如果进程有一个控制终端,则该进程可以调用tcsetpgrppgrpid指向的进程组设置为前台进程组。pgrpid值应当是在同一会话中的一个进程组的ID,fd必须引用该会话的控制终端。

#include <termios.h>

pid_t tcgetsid(int fd);
/* 成功返回会话首进程的进程组ID,等价于会话ID,失败返回-1 */

tcgetsid()函数用于获取该控制终端对应的会话ID,也即是会话首进程的进程组ID(一般情况下为bash的进程ID)。

下面的例子展示上面3个函数的用法:

#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <termios.h>
#include <unistd.h>

static void judge(void)  // 判断是否为前台或后台进程组
{
    pid_t pid;
    pid = tcgetpgrp(STDIN_FILENO);  // 与终端相关联的FD,STDIN_FILENO, tcgetpgrp返回前台进程组ID
    if (pid == -1)
    {
        perror("tcgetpgrp");
        return;
    }
    else if (pid == getpgrp())
    {
        printf("foreground\n");
    }
    else
    {
        printf("background\n");
    }
}

int main(void)
{
    pid_t spid;
    // 控制终端的会话首进程的进程组ID(会话ID),调用进程的会话首进程的进程组ID,进程组ID,进程ID,前台进程组ID
    printf("tcgetsid:%d,sid=%d,pgrp=%d,pid=%d,fgpid=%d\n", tcgetsid(STDIN_FILENO), getsid(getpid()), getpgrp(),
        getpid(), tcgetpgrp(STDIN_FILENO));
    spid = tcgetsid(STDIN_FILENO);  // 保存会话ID
    signal(SIGTTOU, SIG_IGN);
    judge(); // background
    int result;
    result = tcsetpgrp(STDIN_FILENO, getpgrp());  // 设置当前进程组为前台进程组
    printf("tcgetsid:%d,sid=%d,pgrp=%d,pid=%d,fgpid=%d\n", tcgetsid(STDIN_FILENO), getsid(getpid()), getpgrp(),
        getpid(), tcgetpgrp(STDIN_FILENO));
    if (result == -1)
    {
        perror("tcsetpgrp");
        return -1;
    }
    judge(); // foreground
    // 控制终端如果没有与之关联的前台进程,则会被操作系统释放。
    // 因此下面将控制终端的前台进程组设置为原来的进程组(一般情况下为bash进程,即会话首进程所在进程组)
    result = tcsetpgrp(STDIN_FILENO, spid);
    printf("tcgetsid:%d,sid=%d,pgrp=%d,pid=%d,fgpid=%d\n", tcgetsid(STDIN_FILENO), getsid(getpid()), getpgrp(),
        getpid(), tcgetpgrp(STDIN_FILENO));
    return 0;
}

以后台方式执行上面的程序,得到如下结果:

$ ./tcgetpgrp_demo &
[1] 174068
$ tcgetsid:173625,sid=173625,pgrp=174068,pid=174068,fgpid=173625 # 此时前台进程组为bash所在进程组
background
tcgetsid:173625,sid=173625,pgrp=174068,pid=174068,fgpid=174068 # 此时前台进程组为当前进程
foreground
tcgetsid:173625,sid=173625,pgrp=174068,pid=174068,fgpid=173625 # 此时又将bash进程设为前台进程组

[1]+  Done                    ./tcgetpgrp_demo

$ ps -o sid,pid,ppid,pgid,command
    SID     PID    PPID    PGID COMMAND
 173625  173625  173624  173625 -bash
 173625  174069  173625  174069 ps -o sid,pid,ppid,pgid,command

从结果来进行看来,

  1. bash进程的进程以及进程组ID均为173625,所以可以确定bash进程为其所在进程组的组长进程。
  2. bash进程的进程组ID与会话ID相同,所以bash进程为会话首进程,也即控制终端的控制进程。
  3. tcgetpgrp_demo在设置为前台进程组之前,其前台进程组ID与bash所在进程组ID相同,因此bash所在的进程组为前台进程组,即会话首进程所在进程组为前台进程组。
  4. tcgetpgrp_demo在设置为前台进程组之后,会话ID没变,会话首进程仍旧是bash进程,前台进程组则变为tcpgetpgrp_demo的进程组ID。

当控制终端没有前台进程组时,会被操作系统关闭。

作业控制

作业控制指的是在一个终端上启动多个作业(进程组),并且控制哪一个作业可以访问该终端以及哪些作业在后台运行。

作业指的是进程组。

作业控制有以下3个条件:

  1. 支持作业控制的shell。
  2. 内核中的终端驱动程序必须支持作业控制。
  3. 内核必须提供对某些作业控制信号的支持。

shell

从shell使用作业控制来看,用户可以在前台启动一个作业,可以在后台启动多个作业。

例如,vi main.c在前台启动了只有一个进程组成的作业;

pr *.c | lpr &
make all &

上面的命令在后台启动了两个作业,这两个作业调用的所有进程都在后台运行。

当启动后台作业时,shell会赋予一个作业标识符,并且打印一个或多个进程ID。当作业完成并且键入回车时,shell会通知作业已经完成。

$ make &
[1] 174091
$
[1]+  Done                    make

作业标识符为1,打印进程ID为174091。

终端驱动程序

有3个特殊字符可使终端驱动程序产生信号,并将信号发送至前台进程组

字符按键信号
中断字符Delete或Ctrl+CSIGINT
退出字符Ctrl+\SIGQUIT
挂起字符Ctrl+ZSIGTSTP

如果后台作业试图读终端,终端驱动程序检测到这一情况,会向后台作业发送特定信号SIGTTIN。

该信号会停止此后台作业,并且shell会向用户发出通知,然后用户就可用shell命令将该作业转为前台作业,此时该作业就可以读终端。

$ cat > temp & # 后台运行cat,从终端标准输入读取,写入到temp文件中
[1] 174439 # 作业编号1,进程ID174439
$ # 键入回车

[1]+  Stopped                 cat > temp # 打印后台作业状态,由于后台作业读取终端,产生SIGTTIN信号
$ fg %1 # 后台作业转为前台
cat > temp # 前台作业读取数据
Hello
$ cat temp
Hello

结果分析:

  1. shell在后台运行cat进程(shell的子进程)。
  2. cat进程读其标准输入时,终端驱动程序检测到其为后台作业,于是向该进程发送SIGTTIN信号。
  3. shell检测到cat进程状态改变,通知用户作业已被停止。
  4. 用户用shell的fg命令将该停止的作业送入前台运行(具体方法是shell进程调用tcsetpgrp,并发送继续信号SIGCONT给该进程组)。
  5. 该作业现在是前台作业,可以读控制终端。

stty tostop可以禁止后台作业输出到控制终端

下面的例子展示后台作业写终端的情况

$ cat temp &
[3] 174627
$

[3]+  Stopped                 cat temp
$ fg %3
cat temp
Hello

结果分析:

  1. 后台启动cat作业,试图写标准输出(标准输出连接到终端)时,终端驱动程序识别出该写操作来自后台作业,会向该作业发送SIGTTOU信号,cat进程阻塞。
  2. 用户使用shell的fg %3命令将作业3转入前台,然后该作业继续执行,向终端写入数据。

shell执行程序

就是否支持作业控制来说,shell程序可以分为两类,一类是不支持作业控制的,比如Bourne shell,另一类是支持作业控制的,比如GNU Bash shell。

无作业控制

  1. 无论是前台or后台执行命令,都会和shell进程处于同一进程组。
  2. 后台进程组如果没有自己重定向标准输入,则shell会自动将后台进程的标准输入重定向到/dev/null,读操作会直接产生文件结束。

支持作业控制

前台执行ps命令结果如下:

$ ps -o pid,ppid,pgid,sid,tpgid,comm
    PID    PPID    PGID     SID   TPGID COMMAND
 173625  173624  173625  173625  174507 bash
 174507  173625  174507  173625  174507 ps

由前台执行ps命令的结果可以看出

  1. shell将前台作业ps放入它自己的进程组(174507)。
  2. ps是进程组的组长进程,也是唯一进程。
  3. ps进程组为前台进程组,具有控制终端,而登录shell进程在执行ps命令时转入后台,称为后台进程组。
  4. 这两个进程组都属于同一会话(173625),会话首进程一直都是shell进程(173625)。

后台执行ps命令结果如下:

$ ps -o pid,ppid,pgid,sid,tpgid,comm &
[1] 174545
$   PID    PPID    PGID     SID   TPGID COMMAND
 174532  174531  174532  174532  174532 bash
 174545  174532  174545  174532  174532 ps

[1]+  Done                    ps -o pid,ppid,pgid,sid,tpgid,comm

有后台执行ps命令的结果可以看出

  1. shell进程同样将后台作业ps放入其自己的进程组中(174545)。
  2. 前台进程组是登录shell所在的进程组(174532),即会话首进程所在的进程组。
$ ps -o pid,ppid,pgid,sid,tpgid,comm | cat
    PID    PPID    PGID     SID   TPGID COMMAND
 174532  174531  174532  174532  174567 bash
 174567  174532  174567  174532  174567 ps # ps与bash同处于同一前台进程组,父进程都是shell进程
 174568  174532  174567  174532  174567 cat
 $ ps -o pid,ppid,pgid,sid,tpgid,comm | cat &
[1] 174571
$   PID    PPID    PGID     SID   TPGID COMMAND
 174532  174531  174532  174532  174532 bash
 174570  174532  174570  174532  174532 ps # ps与bash同处于同一后台进程组,父进程都是shell进程
 174571  174532  174570  174532  174532 cat

[1]+  Done                    ps -o pid,ppid,pgid,sid,tpgid,comm | cat

版权声明: 本文为 InfoQ 作者【swordholder】的原创文章。 原文链接:【https://xie.infoq.cn/article/a6153354865c225bdce5bd55e】。文章转载请联系作者。 http://www.linusakesson.net/programming/tty/index.php