《操作系统导论》第5章 | 进程API

本章主要讨论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

《操作系统导论》第5章 | 进程API

上一篇:SpriteBuilder修改CCB文件中的子CCB文件需要注意的一个地方


下一篇:Win Server 2008 R2 IIS 默认只能添加一个 443 HTTPS 端口,更换证书