插叙-进程 API

关键问题:如何创建并控制进程

fork 系统调用

系统调用 fork 用户创建新的进程,但要小心,这可能是你使用过的最奇怪的接口。具体来说,你可以运行一个程序,如下面的代码所示。

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <unistd.h>
4
5    int
6    main(int argc, char *argv[])
7    {
8        printf("hello world (pid:%d)\n", (int) getpid());
9        int rc = fork();
10       if (rc < 0) {        // fork failed; exit
11           fprintf(stderr, "fork failed\n");
12           exit(1);
13       } else if (rc == 0) { // child (new process)
14           printf("hello, I am child (pid:%d)\n", (int) getpid());
15       } else {             // parent goes down this path (main)
16           printf("hello, I am parent of %d (pid:%d)\n",
17                   rc, (int) getpid());
18       }
19       return 0;
20    }

运行这段程序你将看到以下输出:

prompt> ./p1
hello world (pid:29146)
hello, I am parent of 29147 (pid:29146) 
hello, I am child (pid:29147)
prompt>

让我们更详细的了解以下上面的程序发生了什么。当它开始运行时,进程输出一条 hello workd 信息,以及自己的进程描述符(PID)。该进程的 PID 是 29146。在 UNIX 系统中,如果要操作某个进程,就要通过 PID 来指明。到目前为止一切正常。

紧接着有趣的事情发生了。进程调用了 fork 系统调用,这是操作系统提供了创建新进程的方法。新创建的进程几乎与调用进程完全一样,对操作系统来说,这时看起来有两个完全一样的的程序在运行,并都从 fork 系统调用中返回。新创建的进程称为子进程,原来的进程称为父进程。子进程不会从 main 方法开始执行,而是直接从 fork 系统调用返回,就好像是它自己调用了 fork

你可能已经注意到,子进程并不是完全拷贝了父进程。具体来说,虽然它拥有自己的地址空间(自己的私有内存)、寄存器、程序计数器等,但是它从 fork 返回的值是不同的。父进程获得的返回值是新创建子进程的 PID,而子进程获得的返回值是 0。这个差别非常重要,因为这样很容易来编写代码以处理两种不同的情况。

你可能还会注意到,该程序的输出是不确定的。子进程被创建后,我们就需要关心系统中的两个活动进程了:子进程和父进程。假设我们在单个 CPU 的系统上运行,那么子进程或父进程在此时都有可能运行。在上面的例子中,父进程先运行并输出信息。在其他情况下,子进程可能先运行,会有以下输出结果:

prompt> ./p1
hello world (pid:29146) 
hello, I am child (pid:29147)
hello, I am parent of 29147 (pid:29146) 
prompt>

CPU 调度程序决定了某个时刻哪个进程应该被执行,我们稍后将详细介绍这部分内容。由于 CPU 调度程序非常复杂,所以我们不能假设哪个进程会首先执行。事实表明,这种不确定性会导致一些有趣的问题,特别是在多线程程序中。

wait 系统调用

到目前为止,我们只是创建了一个进程,打印信息然后退出。事实表明,有时候父进程需要等待子进程执行完毕。这项任务由 wait 系统调用(或更完整的接口 waitpid)来完成。

1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/wait.h>
5
6    int
7    main(int argc, char *argv[])
8    {
9        printf("hello world (pid:%d)\n", (int) getpid());
10       int rc = fork();
11       if (rc < 0) {        // fork failed; exit
12           fprintf(stderr, "fork failed\n");
13           exit(1);
14       } else if (rc == 0) { // child (new process)
15           printf("hello, I am child (pid:%d)\n", (int) getpid());
16       } else {    // parent goes down this path (main)
17           int wc = wait(NULL);
18           printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
19                   rc, wc, (int) getpid());
20       }
21       return 0;
22    }

在上面的例子中,父进程调用 wait,延迟自己的执行,直到子进程执行完毕。当子进程结束时,wait 才返回父进程。以下是输出结果:

prompt> ./p2
hello world (pid:29266) 
hello, I am child (pid:29267)
hello, I am parent of 29267 (wc:29267) (pid:29266) 
prompt>

通过这段代码,现在我们知道子进程总是先输出结果。其实,子进程可能只是碰巧先运行,因此会先于父进程输出结果。但是,如果碰上父进程先运行,它会立即调用 wait。该系统调用会在子进程运行结束后才返回。因此,即使父进程先于子进程运行,它也会礼貌的等待子进程运行完毕,然后 wait 返回,接着父进程才输出自己的信息。

exec 系统调用

它是创建进程 API 的重要部分。该系统调用可以让子进程执行与父进程完全不同的程序。比如上面提到的 fork,只有你想运行与父进程相同程序的考培时才有用。但是,我们经常需要运行不同的程序。

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <unistd.h>
4    #include <string.h>
5    #include <sys/wait.h>
6
7    int
8    main(int argc, char *argv[])
9    {
10       printf("hello world (pid:%d)\n", (int) getpid());
11       int rc = fork();
12       if (rc < 0) {        // fork failed; exit
13           fprintf(stderr, "fork failed\n");
14           exit(1);
15       } else if (rc == 0) { // child (new process)
16           printf("hello, I am child (pid:%d)\n", (int) getpid());
17           char *myargs[3];
18           myargs[0] = strdup("wc");   // program: "wc" (word count)
19           myargs[1] = strdup("p3.c"); // argument: file to count
20           myargs[2] = NULL;          // marks end of array
21           execvp(myargs[0], myargs); // runs word count
22           printf("this shouldn't print out");
23       } else {    // parent goes down this path (main)
24           int wc = wait(NULL);
25           printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
26                   rc, wc, (int) getpid());
27       }
28       return 0;
29	  }

输出结果为:

prompt> ./p3
hello world (pid:29383) 
hello, I am child (pid:29384)
      29     107   1030 p3.c
hello, I am parent of 29384 (wc:29384) (pid:29383)
prompt>

在上面的例子中,子进程调用 execvp 来运行字符计数程序 wc。实际上,它针对源代码文件 p3.c 运行 wc,从而告诉我们该文件有多少行、多少单词以及多少字节。

fork 系统调用很奇怪,它的伙伴 exec 也不简单。给定可执行程序的名称以及需要的参数后,exec 会从可执行程序中加载代码和静态数据,并用它覆盖自己的代码段,堆、栈以及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过 argv 传递给该进程。因此,它并没有创建新进程,而是直接将当前运行的程序替换为不同的运行程序。子进程执行 exec 后,几乎就像 p3.c 从未运行过一样。对 exec 的成功调用永远不会返回。

为什么这样设计 API

为什么设计如此奇怪的接口来完成简单的、创建新进程的任务?事实证明,这种分离 forkexec 的做法在构建 UNIX shell 的时候非常有用,因此这给了 shell 在 fork 之后 exec 执行前运行代码的机会,这些代码可以在运行新程序前改变环境,从而让一系列有趣的功能得以实现。

提示:重要的是做对事(LAMPSON 定律) Lampson 在他的著名论文《Hints for Computer Systems Design》中曾经说过:“做对事。抽象和简化不能替代做对事。”有时你必须做正确的事,当你这样做时,总是好过其他方案。有许多方式来设计创建进程的 API,但 fork 和 exec 的组合既简单又极其强大。因此 UNIX 的设计师们做对了。

shell 也是一个用户程序,它首先显示一个提示符,然后等待用户输入。你可以向它输入一个命令,大多数情况下,shell 可以在文件系统中找到这个可执行程序,调用 fork 来创建新的进程,并调用 exec 的某个变体来执行这个可执行程序,调用 wait 等待该命令完成。子进程执行结束后,shell 从 wait 返回并再次输出一个提示符,等待用户输入下一条命令。

fork 和 exec 的分离,然 shell 可以方便的实现很多有用的功能。比如:

prompt> wc p3.c > newfile.txt

在上面的例子中,wc 的输出结果被重定向到文件 newfile.txt 中。shell 实现重定向的方式很简单,当完成子进程的创建后,shell 在滴啊用 exec 之前先关闭了标准输出,打开了文件 newfile.txt。这样,即将运行的程序 wc 的输出结果就被发送到该文件,而不是打印在屏幕上。

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <unistd.h>
4    #include <string.h>
5    #include <fcntl.h>
6    #include <sys/wait.h>
7
8    int
9    main(int argc, char *argv[])
10   {
11       int rc = fork();
12       if (rc < 0) {    // fork failed; exit
13           fprintf(stderr, "fork failed\n");
14           exit(1);
15       } else if (rc == 0) { // child: redirect standard output to a file
16           close(STDOUT_FILENO);
17           open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
18
19           // now exec "wc"...
20           char *myargs[3];
21           myargs[0] = strdup("wc");       // program: "wc" (word count)
22           myargs[1] = strdup("p4.c");      // argument: file to count
23           myargs[2] = NULL;               // marks end of array
24           execvp(myargs[0], myargs);     // runs word count
25       } else {                           // parent goes down this path (main)
26           int wc = wait(NULL);
27       }
28       return 0;
29   }
prompt> ./p4
prompt> cat p4.output
      32     109    846 p4.c
prompt>

上面的代码展示了这样做的一个程序。重定向的工作原理,是基于对操作系统管理文件描述符方式的假设。具体来说,UNIX 系统从 0 开始寻找可以使用的文件描述符。在该例子中,STDOUT_FILENO 将成为第一个可用的文件描述符,因此在 open 被调用时,得到赋值。然后子进程像标准输出文件描述符的写入,都会被透明的转向新打开的文件,而不是屏幕。

关于这个输出,你至少会注意到两个有趣的地方。首先,当运行完 p4 程序后,好像什么也没有发生。shell 只是打印了提示符,等待用户的下一个命令。但事实并非如此,p4 确实调用了 fork 来创建新的子进程,之后调用 execvp 来执行 wc。屏幕上没有看到输出,是由于结果被重定向到 p4.output。其次,当用 cat 命令打印输出文件时,能看到运行 wc 的所有预期输出。

UNIX 管道也是用类似的方式实现的,但用的是 pipe 系统调用。在这种情况下,一个进程的输出被链接到了一个内核管道(pipe)上(队列),另一个进程的输入也被连接到了同一个管道上。因此,前一个进程的输出无缝的作为后一个进程的输入,许多命令可以用这种方式串联在一起,共同完成某项任务。比如通过将 grep、wc 命令用管道连接可以完成从一个文件查找某个词,并统计其出现次数的可能:grep -o foo file | wc -l

最后,我们刚才只是从较高层面上简单介绍了进程 API,关于这些系统调用的细节,还有更多的内容需要学习和理解。

其他 API

除了上面提到过的 API,在 UNIX 中还有其他许多与进程交互的方式。比如可以通过 kill 系统调用向进程发送信号,包括要求进程睡眠、终止或其他有用的指令。实际上,整个信号子系统提供了一套丰富的向进程传递外部事件的途径,包括接受和执行这些信号。

此外还有很多非常有用的命令行工具。比如通过 ps 命令来查看当前运行的进程,阅读 man 手册来了解 ps 命令所能接受的参数。