20 August 2012

Linux是一个多任务操作系统,可以方便的在一个控制台(或shell)下同时执行多条命令,达到这样的目标并不是一件容易的事情。本文帮助理解下面几个跟控制台有关的概念:tty/pty,control terminal,session,process group,signal;并设计实现一个多任务控制程序

bash 的多任务支持

熟悉Linux的同学都知道,bash支持同时跑多个任务,下面先简单演示一下 bash 的多任务。首先,运行一个 cat 命令,然后通过 Ctrl+Z 中断这个 cat 回到 bash:

$ cat
^Z
[1]+  Stopped                 cat

然后,我们可以再运行一个 cat 任务,这回让 cat 干点活,继续用 Ctrl+Z 中断:

$ cat /dev/urandom > /dev/null
^Z
[2]+  Stopped                 cat /dev/urandom > /dev/null

这样我们就有了两个 cat 任务在后台,通过 bash 内置的 jobs 命令可以查看后台的任务:

$ jobs
[1]-  Stopped                 cat
[2]+  Stopped                 cat /dev/urandom > /dev/null

大家可以看到,这两条命令都处于 Stopped 状态,现在我们通过 bg 命令让第二个 cat 在后台运行:

$ bg 2
[2]+ cat /dev/urandom > /dev/null &
$ jobs
[1]+  Stopped                 cat
[2]-  Running                 cat /dev/urandom > /dev/null &

这时可以看到,第二个 cat 变成蓝 Running 状态,同时我机器的 CPU 温度也飚上来了。我们还可以通过 fg 命令让第一个 cat 到前台执行:

$ fg 1
cat

现在这个 cat 又恢复到等待我的输入的状态了。这样看来,好像 multi-tasking 是一件非常简单的事情,那么请尝试回答下面几个问题:

Q1. 如何中断/继续程序的运行?
Q2. 如何防止后台运行的程序互相抢夺控制台输入?

TTY - signal 转换

首先,回答第一个问题

Q1. 如何中断/继续程序的运行?

很简单,通过两个 signal 就可以控制程序中断/运行:

$ man 7 signal
       ...
       SIGCONT   19,18,25    Cont    Continue if stopped
       SIGSTOP   17,19,23    Stop    Stop process
       ...

于是某同学就在 bash 的代码里面找关于这两个 signal 的代码,但是他只找到了 fg/bg 命令给程序发送了 SIGCONT,没有找到关于 SIGSTOP 的代码,于是有了下面的问题:

Q1.1: 什么程序给 cat 发送了 SIGSTOP 信号?

通过本小节标题也可以看出,signal 其实是 TTY 发送的:

$ stty -a
speed 38400 baud; rows 51; columns 185; line = 0;
.... susp = ^Z; ....
....
....

看到了里面的 susp = ^Z 了么,这个设置的意思就是一旦 TTY 收到了 Ctrl-Z 就会把其转换成 SIGSTOP 信号?同理我们可以设置 Ctrl-N 为挂起的快捷键

$ stty susp ^N
$ cat
^N
[1]+  Stopped                 cat

由于所有的信号都是 tty 控制的,我们也可以修改 Ctrl-C/S/Q 等所有信号快捷键的行为,那么这里又有了下面这个问题:

Q3. 哪些程序会收到 TTY 来的信号?如果当前 TTY 来的信号所有程序都能收到,那么 Ctrl-C 会不会连后台程序一起杀掉?

这个问题先卖个关子,下面介绍一下process group。

process group

这里先启动一些进程:

$ google-chrome &
[1] 26077
$ cat | cat

我在这个 bash session 里面启动了3个进程,一个 google-chrome,两个 cat。chrome 又继续启动了很多进程。为了方便理解画张图,理清 TTY,session 与 process group 之间的关系:

              ┌─────────────────────────────────────┐
              │ Session                             │
┌─────┐  One  │  ┌────────────────────────────────┐ │
│ TTY │<──on─>│  │ Process group                  │ │
└─────┘  One  │  │   bash                         │ │
              │  └────────────────────────────────┘ │
              │  ┌────────────────────────────────┐ │
              │  │ Active process group           │ │
              │  │   cat                          │ │
              │  │   cat                          │ │
              │  └────────────────────────────────┘ │
              │  ┌────────────────────────────────┐ │
              │  │ process group                  │ │
              │  │   /opt/google/chrome/chrome    │ │
              │  │    \_ /opt/google/chrome/chrome│ │
              │  │    \_ /opt/google/chrome/chrome│ │
              │  │    \_ /opt/google/chrome/chrome│ │
              │  └────────────────────────────────┘ │
              └─────────────────────────────────────┘

每个 TTY 对应一个 session,session 是由 login (ssh/getty/…) 程序创建的,bash 仅仅是继承了这个 session。每个 session 里面有若干个 process group,每当 bash 创建(fork)一个进程的时候,在运行程序(exec*)前,都会为这个进程创建一个新的 process group。每个 process group 中只有一个 group 处于 active 状态,由 bash 控制哪个进程处于 active 状态。每个 process group 里面有若干个进程。为什么要这样设计,且听我慢慢道来。大家是否还记得前面留下的两个悬而未决的问题:

Q2. 如何防止后台运行的程序互相抢夺控制台输入?
Q3. 哪些程序会收到 TTY 来的信号?如果当前 TTY 来的信号所有程序都能收到,那么 Ctrl-C 会不会连后台程序一起杀掉?

有了 process group 的概念,回答这两个问题就非常简单了,只有处于 Active 内的进程能够获取控制台输入,并且 signal 只发送给 Active 内的进程。当有后台运行的进程想要读取控制台的时候(比如后台运行一个 cat),tty 会向其发送 SIGSTOP 信号,挂起此程序,你会发现,企图让 cat 后台运行是不可能的。当用户按下 Ctrl-C 或者 Ctrl-Z 的时候,只有前台的进程会收到相应的信号,并执行相应的操作。由于 bash 的特殊设计,用户是无法让两个程序同时读取控制台的,如果有人实在无聊,写了个程序 fork 出几个前台进程读取控制台(比如笔者),还是会出现抢占的现象:

int main() {
        if (fork())
                execlp("awk", "awk", "{ print \"parent:\" $0 }", 0);
        else
                execlp("awk", "awk", "{ print \"child:\" $0 }", 0);
}

用户的输入会随机传给 parent 或者 child,并且结果是不可预测的。

多任务控制的实现

前面介绍的多任务控制的原理,下面我们设计一个多任务控制程序,实现和 bash 一样的功能:

  • 在前台有任务执行的时候等待

set processGroups

int startCommand(char *cmd) {
  int pid = fork();
  if (pid) {
    int = waitpid(pid); // 等待命令返回(结束或者挂起)
    return status;
  } else {
    int mypid = getpid()
    pgid = setpgid(); // 建立新的 process group
    tcsetpgrp(pgid); // 设置当前 group 为 active
    processGroups.add(mypid)
    exec(cmd); // 运行命令
  }
}
  • 可以随时挂起前台执行的任务

挂起程序完全由 tty 完成,只需要在 waitpid 后面检查前台程序是结束了,还是被挂起了,如果是挂起了,需要把控制台输入权限返还给 bash:

...
switch(startCommand(cmd)) {
case exit:
  processGroups.remove(pid);
  break;
case suspend:
  break; // do nothing
}
tcsetpgrp(getpgid()); // 拿回 active process group
  • bg 命令切换后台执行任务

只需要向程序发送 SIGCONT 信号:

void bg(pid) {
  if (issuspended(pid))
    kill(pid, SIGCONT);
}
  • fg 命令把任务切换到前台

先让程序继续执行,然后转移 active process group:

int fg(pid) {
  bg(pid);
  tcsetpgrp(getpgid(pid));
  return waitpid(pid);
}
  • jobs 命令查看当前任务

for (pid in processGroups)
  print pid;

这样我们就实现了一个多任务控制程序,由于 tty 设备的存在,使得实现多任务控制轻松了很多。Windows 下是没有 tty 设备的,于是控制台程序就有很多限制,比如无法实现一个能够获取 Ctrl-C 输入的控制台程序,只有去拦截 interrupt 信号。上面这些内容估计只有 bash 的设计师才会关注,但是下面的内容就是几乎每个 linux 程序员都会关注的内容。

daemon 程序的实现

daemon 进程就是要与 TTY 划开界限,所有东西都不依赖 TTY,那么结果就非常简单了,因为 TTY 和 session 是一一对应的关系,我们新建一个 session 就等于把与原来 TTY 有关的东西完全抛开了:

void daemonize(char *cmd) {
  close(0);
  close(1);
  close(2);
  if (fork())
    exit(0);
  else {
    setsid();
    exec(cmd);
  }
}

为什么这里需要 fork ? 请查阅此文章: http://stackoverflow.com/questions/2613104/why-fork-before-setsid