第五章 进程API
Interludes将涵盖系统的更多实际方面,其中尤其关注操作系统API以及如何使用它们。如果你不喜欢实践的内容,你可以跳过这些Interludes。但你应该去尝试这些,因为它们一般在现实生活中是很有用的;比如说通常公司不会雇用没有实践能力的员工。
在这Interludes中,我们讨论UNIX系统中的进程创建。 UNIX是使用一系列系统调用(fork()和exec())创建新进程的最有趣的方式之一。第三个例程wait()可以由希望等待其创建的进程完成的进程使用。我们现在更详细地介绍这些接口,还有一些简单的例子来激励我们。所以我们的问题是:
问题的核心
如何创建并控制进程 操作系统应该为进程创建和控制提供哪些接口?这些接口应如何设计,以方便使用和实用?
5.1 fork()系统调用
fork()系统调用用于创建新进程[C63]。但要预先警告:这肯定是你调用过的最奇怪的程序。更具体地说,你有一个正在运行的程序,其代码Figure 5.1所示;检查这份代码,最好是自己输入计算机中并运行!

当你运行这个程序(p1.c),你见到的如下所示:

让我们更详细地了解在p1.c中发生了什么。当它第一次开始运行时,该过程打印出一个"Hello,world"消息;该消息中包含其进程标识符,也称为PID。该进程的PID为29146;在UNIX系统中,PID用于命名进程,如果要对进程执行某些操作,例如停止运行。程序到现在为止还没有问题。
现在有意思的部分开始了。该进程调用OS提供了一种创建新进程的方法——fork()系统调用。奇怪的是:创建的进程(称为子进程)和调用fork()的进程(称为父进程)几乎一模一样。这意味着对于操作系统来说,它现在看起来像是运行程序p1的两个副本,并且都要从fork()系统调用返回。你可能会认为新创建的进程会从main()开始运行(注意,“hello,world”消息只打印出一次);相反,它开始运行的时候就像刚刚调用完fork()了一样。
你可能已经注意到:子进程不是一个完全一样的副本。具体来说,它现在具有它自己的地址空间的副本(即它自己的专用内存),它自己的寄存器,它自己的PC等等,并且它返回给fork()的调用者的值是不同的。具体来说,当父节点接收到新创建的子进程的PID时,子节点接收到返回码为零。这种区分是有用的,这样使得编写代码处理父进程和子进程两种不同情况(如上所述)很简单。
你可能还注意到:p1.c的输出是不确定性。当子进程创建时,系统中现在有两个活动的进程,也就是我们关心的父进程和子进程。假设我们在单个CPU的系统上运行,那么子进程或者父进程都有可能会在此刻运行。在我们的例子(上图)中,父进程首先打印出了它的消息。在其他情况下,相反的情况可能会发生,如我们在下图中输出中所示:

我们将在稍后详细讨论的主题——CPU调度程序,CPU调度程序会在给定时刻决定哪个进程运行;因为调度程序很复杂,我们通常不会对它选择哪个进程将首先运行做出强烈的假设。事实证明,这种非确定性导致了一些有趣的问题,特别是在多线程程序中;因此,当我们在本书的第二部分研究并发性时,我们将看到更多的非确定性。
5.2 wait()系统调用
到目前为止,我们没有做太多:刚刚创建了一个打印出消息并退出的小孩。有时,事实证明,父母等待子进程完成它已经在做的事情是非常有用的。这个任务是通过wait()系统调用(或其更完整的同级waitpid())完成的;详见figure 5.2。

在此示例(p2.c)中,父进程调用wait()来延迟其执行,直到子程序完成执行。当子进程完成后,父进程wait()返回。对上面的代码添加一个wait()调用后输出是确定的。你知道是什么吗,想想吧。
想完了看看是否和真正地输出一致:

这个代码中,我们知道子进程会先打印。为什么我们知道?首先它可能像以前一样运行,因此在父进程之前打印。但如果父进程先运行,则会立即调用wait(),该系统调用直到子进程运行并退出才返回。因此即使父进程先运行,它也会礼貌地等待孩子完成运行,直到wait()返回,然后父进程打印其消息。
5.3 最后,exec()系统调用
进程创建API的最后一个重要部分是exec()系统调用。当您要运行与调用程序不同的程序时,此系统调用很重要。如果要继续运行相同程序的副本,则仅在p2.c中调用fork()。但是你经常要运行一个不同的程序; exec()就是做这个的(figure5-3)。

在这个例子中,子进程调用execvp()来运行程序wc,这是程序计数程序。实际上它在源文件p3.c上运行wc,从而告诉我们在文件中找到了多少行,字和字节:

不止fork()系统调用很奇怪的,它的小伙伴exec()也不正常。它的功能:给定可执行文件(例如wc)的名称和一些参数(例如p3.c),它从该可执行文件加载代码(和静态数据)并用它覆盖其当前代码段(和当前静态数据);程序的堆栈和其他部分的内存空间被重新初始化。然后操作系统运行该程序,所有传入的参数作为该进程的argv。因此它不会创建一个新的过程;而是将当前运行的程序(以前称为p3)转换为不同的运行程序(wc)。在子进程的exec()之后,不会再回到p3.c中的代码,就像p3.c从不没有运行,exec()调用成功后不会返回。
5.4 为什么?
你可能会遇到一个很大的疑问:为什么要建立一个这样一个奇怪的接口呢,创建一个新进程的行为到底是要干什么呢?事实证明,fork()和exec()的分离对于构建UNIX shell是至关重要的,因为它允许shell在调用fork()之后但在调用exec()之前运行代码;这些代码可以改变即将运行的程序的环境,从而使得能够容易地构建各种有意思的环境。
shell是一个用户程序,它向你显示提示符,等待你键入其中的内容。然后,键入命令(可执行程序的名称,加上任何参数);在大多数情况下,shell计算可执行文件所在的文件系统,调用fork()创建一个新的子进程来运行该命令,调用exec()来运行该命令,通过调用wait()然后等待命令执行完毕。当子进程结束时,shell从wait()返回,并再次打印出一个提示,准备下一个命令。
fork()和exec()的分离使shell比较优雅地做很多事情。例如:
$ wc p3.c > newfile.txt
在上面的示例中,程序wc的输出被重定向到输出文件newfile.txt(大于标志是指示重定向的方向)。 shell完成这个任务的方式非常简单:当创建子进程时,在调用exec()之前,shell关闭标准输出并打开文件newfile.txt。这样做了以后,即将运行的程序wc的所有输出都将发送到文件而不是屏幕。

Figure5.4显示的程序就是这样做的,重定向可以工作的原因是操作系统管理了文件描述符。具体来说,UNIX系统从零开始寻找可用的文件描述符。在这种情况下,STDOUT_FILENO将是第一个可用的,所以在调用open()时得到分配。子进程对标准输出文件描述符的后续写入,例如例如printf()等例程将被透明地定向到新打开的文件而不是屏幕。
这是运行p4.c程序的输出:

你会注意到(至少)关于这个输出的两个有趣的事情。首先,当p4运行时,看起来好像什么都没有发生; shell只是打印命令提示符,并立即准备下一个命令。但情况并非如此,程序p4确实调用了fork()来创建一个新的子进程,然后通过调用execvp()运行wc程序。你没有看到任何输出打印到屏幕,因为它被重定向到文件p4.output。其次,当你查看输出文件时,会发现wc运行后的所有输出。
UNIX管道以类似的方式实现,但是使用pipe()系统调用。在这种情况下,一个进程的输出连接到内核管道(即队列),另一个进程的输入连接到同一个管道;因此,一个进程的输出无缝地被用作下一个的输入,并且可以将很多的命令串起来。作为一个简单的例子,考虑在一个文件中寻找一个单词,然后计算该单词出现多少次;基于管道和命令grep和wc很容易实现,只需键入grep -o foo file | wc -l 到命令提示符,你就会发现结果。
最后,虽然我们在上层描述了进程API,但是还有更多关于这些调用的细节需要学习;我们将在本书第三部分中讨论文件系统时,详细描述文件描述符。现在,足以说fork()/exec()组合是创建和操作进程的有效方式。
5.5 API的其他方面
除了fork(),exec()和wait()之外,还有许多其他接口用于与UNIX系统中的进程进行交互。例如,kill()系统调用用于向进程发送信号,包括进入睡眠,销毁和其他有用的指令。事实上,整个信号子系统提供了一个丰富的基础设施,可以将外部事件提供给进程,包括接收和处理这些信号的方法。
还有许多命令行工具也很有用。例如,使用ps命令可以查看正在运行中的所有进程;阅读man中的一些有用的参数,以传递给ps。top命令也非常有用,它显示了系统的进程以及他们吃多少CPU和其他资源。最后,你可以使用许多不同种类的CPU流量计来快速了解系统上的负载;例如,我们始终保持在Macintosh工具栏上运行的MenuMeters(来自Raging Menace软件),因此任何时候我们都可以看到使用了多少CPU。一般来说,关于运行的信息越多越好。
5.6 总结
我们介绍了一些处理UNIX进程创建的API:fork(),exec()和wait(),但是我们只是简单地讨论了这些。更详细的内容,请阅读Stevens和Rago [SR05],特别是进程控制,进程关系和信号的章节,有很多从智慧的精髓。
参考
[C63] “A Multiprocessor System Design” Melvin E. Conway
AFIPS ’63 Fall Joint Computer Conference New York, USA 1963 An early paper on how to design multiprocessing systems; may be the first place the term fork() was used in the discussion of spawning new processes.
[DV66] “Programming Semantics for Multiprogrammed Computations” Jack B. Dennis and Earl C. Van Horn
Communications of the ACM, Volume 9, Number 3, March 1966 A classic paper that outlines the basics of multiprogrammed computer systems. Undoubtedly had great influence on Project MAC, Multics, and eventually UNIX.
[L83] “Hints for Computer Systems Design” Butler Lampson
ACM Operating Systems Review, 15:5, October 1983 Lampson’s famous hints on how to design computer systems. You should read it at some point in your life, and probably at many points in your life.
[SR05] “Advanced Programming in the UNIX Environment” W. Richard Stevens and Stephen A. Rago Addison-Wesley, 2005
All nuances and subtleties of using UNIX APIs are found herein. Buy this book! Read it! And most importantly, live it.