本章主要讨论UNIX系统中的进程创建。UNIX系统采用了一种非常有趣的创建新进程的方式,即通过一对系统调用:fork()
和exec()
。进程还可以通过第三个系统调用wait()
,来等待其创建的子进程执行完成。
fork()
系统调用
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
printf("hello world (pid:%d)\n", (int)getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int)getpid());
} else { // parent goes down this path (main)
printf("hello, I am parent of %d (pid:%d)\n", rc, (int)getpid());
}
return 0;
}
运行这段程序,得到如下输出:
hello world (pid:62775)
hello, I am parent of 62779 (pid:62775)
hello, I am child (pid:62779)
当它刚开始运行时,进程输出一条hello world信息,以及自己的进程描述符(process identifier,PID)。该进程的PID是62775。在UNIX系统中,如果要操作某个进程(如终止进程),就要通过PID来指明。紧接着进程调用了fork()系统调用,这是操作系统提供的创建新进程的方法。新创建的进程几乎与调用进程完全一样,对操作系统来说,这时看起来有两个完全一样的p1程序在运行,并都从fork()系统调用中返回。新创建的进程称为子进程(child),原来的进程称为父进程(parent)。子进程不会从main()函数开始执行(因此hello world信息只输出了一次),而是直接从fork()系统调用返回,就好像是它自己调用了fork()。
子进程并不是完全拷贝了父进程。虽然它拥有自己的地址空间(即拥有自己的私有内存)、寄存器、程序计数器等,但是它从fork()返回的值是不同的。父进程获得的返回值是新创建子进程的PID,而子进程获得的返回值是0。这个差别非常重要,因为这样就很容易编写代码处理两种不同的情况。这段程序的输出不是确定的,因为CPU的调度程序决定了某个时刻哪个进程被执行,而调度程序又比较复杂,所以我们不能假设哪个进程先运行。
wait()
系统调用
有时候,父进程需要等待子进程执行完毕。这项任务由wait()
系统调用(或者更完整的接口waitpid()
)来完成:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
printf("hello world (pid:%d)\n", (int)getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int)getpid());
} else { // parent goes down this path (main)
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", rc, wc,
(int)getpid());
}
return 0;
}
上面的代码增加了wait()
调用,因此输出结果也变得确定了。因为子进程可能先运行,先于父进程输出结果。但是,如果父进程碰巧先运行,它会马上调用wait()
。该系统调用会在子进程运行结束后才返回。因此,即使父进程先运行,它也会等待子进程运行完毕,然后wait()
返回,接着父进程才输出自己的信息。
hello world (pid:10532)
hello, I am child (pid:10533)
hello, I am parent of 10533 (wc:10533) (pid:10532)
exec()
系统调用
exec()
系统调用可以让子进程执行与父进程不同的程序。如下所示,子进程调用execvp()
来运行字符计数程序wc。实际上,它针对源代码文件p3.c运行wc,从而告诉我们该文件有多少行、多少单词,以及多少字节。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
printf("hello world (pid:%d)\n", (int)getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int)getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p3.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
printf("this shouldn‘t print out");
} else { // parent goes down this path (main)
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", rc, wc,
(int)getpid());
}
return 0;
}
上述代码的输出结果:
hello world (pid:25414)
hello, I am child (pid:25415)
27 119 880 p3.c
hello, I am parent of 25415 (wc:25415) (pid:25414)
给定可执行程序的名称(如wc)及需要的参数(如p3.c)后,exec()
会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过argv
传递给该进程。因此,它并没有创建新进程,而是直接将当前运行的程序(以前的p3)替换为不同的运行程序(wc)。子进程执行exec()
之后,几乎就像p3.c从未运行过一样。对exec()
的成功调用永远不会返回。
为什么这样设计进程API
分离fork()
和exec()
的做法在构建UNIX shell时非常有用。shell是一个用户程序,它首先显示一个提示符,然后等待用户输入。你可以向它输入一个命令(一个可执行程序的名称及需要的参数),大多数情况下,shell可以在文件系统中找到这个可执行程序,调用fork()
创建新进程,并调用exec()
的某个变体来执行这个可执行程序,调用wait()
等待该命令完成。子进程执行结束后,shell从wait()
返回并再次输出一个提示符,等待用户输入下一条命令。
$ wc p3.c > newfile.txt
在上面的例子中,wc的输出结果被重定向到文件newfile.txt中。shell实现结果重定向的方式也很简单,当完成子进程的创建后,shell在调用exec()
之前先关闭了标准输出,打开了文件newfile.txt。这样,即将运行的程序wc的输出结果就被发送到该文件,而不是打印在屏幕上。
下面的程序展示了重定向的工作原理。具体来说,UNIX系统从0开始寻找可以使用的文件描述符。在这个例子中,STDOUT_FILENO
将成为第一个可用的文件描述符,因此在open()
被调用时,得到赋值。然后子进程向标准输出文件描述符的写入,都会被透明地转向新打开的文件而非屏幕。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <assert.h>
#include <sys/wait.h>
#include <sys/stat.h>
int main(int argc, char *argv[]) {
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child: redirect standard output to a file
close(STDOUT_FILENO);
open("./p4.output", O_CREAT | O_WRONLY | O_TRUNC, S_IRWXU);
char *myargs[3]; // now exec "wc"...
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p4.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
} else { // parent goes down this path (original process)
int wc = wait(NULL);
assert(wc >= 0);
}
return 0;
}
上述程序的运行结果:
$ ./p4
$ cat p4.output
31 120 877 p4.c
首先,当运行p4程序后,好像什么也没有发生。shell只是打印了命令提示符,等待用户的下一个命令。但事实并非如此,p4确实调用了fork()
来创建新的子进程,之后调用execvp()
来执行wc。屏幕上没有看到输出,是由于结果被重定向到文件p4.output。其次,当用cat命令打印输出文件时,能看到运行wc的所有预期输出。
UNIX管道也是用类似的方式实现的,但用的是pipe()
系统调用。在这种情况下,一个进程的输出被链接到了一个内核管道上(队列),另一个进程的输入也被连接到了同一个管道上。因此,前一个进程的输出无缝地作为后一个进程的输入,许多命令可以用这种方式串联在一起,共同完成某项任务。比如通过将grep、wc命令用管道连接可以完成从一个文件中查找某个词,并统计其出现次数的功能:grep -o foo file | wc -l
。