这是 Writing a Unix Shell - Part I 的一篇中文翻译,版权属于原作者 INDRADHANUSH GUPTA。如果有的地方有更好的翻译,还望不吝赐教。如果英文尚可,建议直接查看原文。

关于 C 语言里 fork 调用,也可以看耗子叔的这篇文章

我在 RC 的研究工作之一,是写一个 Unix shell 工具,这篇文章是关于这项工具的系列文章中的第一篇。

什么是 shell?

读多人都写过关于这个问题的文章,所以这里我不会深入探讨这个定义的各种细节,用一句话总结:

Shell 是一个允许你和操作系统内核进行交互的接口。

Shell 是如何工作的?

Shell 的工作方式是解析用户输入的命令并执行。它的工作流类似于这样:

  1. 启动 shell
  2. 等待用户输入
  3. 解析用户输入
  4. 执行命令并返回结果
  5. 回到2

这里有一个重要的概念:进程。Shell 是父进程。在我们的程序中(指作者写的这个 Shell),主线程等待用户输入。然而,我们不能直接在主线程中执行用户输入的命令,因为:

  1. 一个错误的命令将会导致 Shell 停止工作。我们需要避免这种情况的发生
  2. 不同的命令应该有他们自己独立的进程块。也就是通常所说的隔离与容错。

Fork

因为上面的这两个原因,我们需要使用 fork 这个系统调用。直到我用 fork 写了大约 4 行代码,我才觉得我理解了这个命令。

调用 fork 会创建一个当前进程的副本,也就是我们知道的子进程。系统中的每一个进程都关联着一个独一无二的进程 ID。我们来看下面的代码:

fork.c

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

int
main() {
    pid_t child_pid = fork();
        
    // The child process
    if (child_pid == 0) {
        printf("### Child ###\nCurrent PID: %d and Child PID: %d\n",
               getpid(), child_pid);
    } else {
        printf("### Parent ###\nCurrent PID: %d and Child PID: %d\n",
               getpid(), child_pid);
    }
 
    return 0;
}

fork 函数会返回两次,每次对应着一个进程。表面上看很直观,不过我们还是要看一下底层到底发生了什么。

  1. 通过调用 fork 函数,我们在程序内创建了一个新的分支。不同于传统意义上的 if-else 分支,fork 创建了一份当前进程的副本,并用它创建了一个新的进程,最后会返回一个代表子进程的进程 ID
  2. 一旦 fork 函数调用成功,子进程和父进程(我们程序里的主线程)将同时运行

为了给你一个直观的感受,看下面这张图:

fork 函数创建了一个新的子进程,但与此同时,父进程并没有停止执行。子进程的开始与结束与父进程独立,反之也一样。

接着说之前先给你一个小提示:getpid 函数会返回当前进程的进程 ID.

编译执行上面的代码,会得到类似下面的输出:

### Parent ### Current PID: 85247 and Child PID: 85248 ### Child ### Current PID: 85248 and Child PID: 0

### Parent ### 下,输出当前进程 ID 为 85247 而子进程则是 85248. 注意到子进程的进程 ID 大于父进程,暗示了它的创建时间晚于父进程。

### Child ### 下,输出当前进程 ID 为 85248,与上面输出相同,并且,可以看到,输出子进程 ID 为 0.

实际运行中每次输出的进程 ID 可能并不相同。

你可能会疑惑,我们在第 9 行(实际代码里是第7行)给 child_pid 赋值,但为何在一次执行中会有两个不同的值?回想一下,调用 fork 函数创建了一个与当前进程完全相同的进程,作为结果,在父进程里,child_pid 实际上就是子进程的进程 ID,而子进程并没有创建它自己的子进程,因此子进程里的 fork 调用返回的 child_pid0.

因此,上面代码的 if-else 块里我们需要控制父进程和子进程分别可以执行什么样的代码。当返回的 child_pid0 时,意味着当前进程为子进程,对应子进程块里的代码将会被执行。如果不为0则表示当前进程为父进程,对应父进程块里的代码将会被执行。不过,这两个代码块的执行顺序并不能确定,它依赖于操作系统的调度程序。

介绍决定论

先介绍一个系统调用:sleep. Linux 用户手册里是这么写的:

sleep - suspend execution for an interval of time.

interval(间隔)的单位为秒。

我们在父进程代码块里加一句 sleep(1):

sleep_parent.c

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

int
main() {
    pid_t child_pid = fork();

    // The child process
    if (child_pid == 0) {
        printf("### Child ###\nCurrent PID: %d and Child PID: %d\n",
               getpid(), child_pid);
    } else {
        sleep(1); // Sleep for one second
        printf("### Parent ###\nCurrent PID: %d and Child PID: %d\n",
               getpid(), child_pid);
    }
 
    return 0;
}

编译执行,得到类似下面的输出:

### Child ### Current PID: 89743 and Child PID: 0

1 秒钟之后,接着输出:

### Parent ### Current PID: 89742 and Child PID: 89743

每次执行都可以看到相同的行为。这是因为我们在父进程执行了一个阻塞的 sleep 调用,此时,操作系统调度程序会发现 CPU 是可用的,因此将 CPU 资源分配给子进程。

类似的,如果你将 sleep(1) 语句添加到子进程代码块里,你会看到父进程代码块里代码的执行结果会立刻输出到控制台,并且子进程的输出会被 dumpstdout 里:

$ gcc -lreadline blog/sleep_child.c -o sleep_child && ./sleep_child
### Parent ###
Current PID: 23011 and Child PID: 23012
$ ### Child ###
Current PID: 23012 and Child PID: 0

可以从这里获取源码。

完成决定论

然而,使用 sleep 来控制进程的执行不是最好的方法,因为当你调用 sleep 暂停 n 秒时:

  1. 如何保证你等待的进程会在 n 秒之内执行完成?
  2. 如果在远小于 n 秒的时间里你等待的进程就已经执行完成,那接着空转等待接下来的时间是没有必要的。

一个更好的方法是使用 wait 系统调用(或者它的变体)。这里,我们将使用 waitpid 这个函数,它需要以下参数:

  1. 你想要等待的进程 ID
  2. 一个变量,它用于写入你想要等待的进程是如何结束的
  3. 一个可选的标志位,用来自定义 waitpid 的行为

wait.c

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

int
main() {
    pid_t child_pid;
    pid_t wait_result;
    int stat_loc;

    child_pid = fork();
        
    // The child process
    if (child_pid == 0) {
        printf("### Child ###\nCurrent PID: %d and Child PID: %d\n",
               getpid(), child_pid);
        sleep(1); // Sleep for one second
    } else {
        wait_result = waitpid(child_pid, &stat_loc, WUNTRACED);
        printf("### Parent ###\nCurrent PID: %d and Child PID: %d\n",
               getpid(), child_pid);
    }
 
    return 0;
}

编译执行后,你会看到子进程代码块会立刻执行输出并 sleep 短暂时间,父进程等待子进程执行完成后才执行自己的代码。

以上是第一部分的内容,所有的代码都可以从这里获取到,在下一篇文章里,我们将会探索如何得到用户输入的命令并执行,敬请期待!

鸣谢

感谢 Saul Pwanson 帮助我理解 fork 调用的行为,感谢 Jaseem Abid 阅读文章草稿并给出编译建议。

资源