插叙-进程 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
为什么设计如此奇怪的接口来完成简单的、创建新进程的任务?事实证明,这种分离 fork
和 exec
的做法在构建 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
命令所能接受的参数。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.