- 5.1 fork()系统调用 The fork() System Call
- 5.2 wait()系统调用 The wait() System Call
- 5.3 最后, exec() 系统调用 Finally, The exec() System Call
- 5.4 为什么?调动API积极性 Why? Motivating The API
- 5.5 进程控制和用户 Process Control And Users
- 5.6 有用的工具 Useful Tools
- 5.7 总结 Summary
- References
- Homework (Simulation)
- Homework (Code)
- include
- include
- include
ASIDE: INTERLUDES 插曲部分将涵盖系统的更多实际方面,包括特别关注操作系统 API 以及如何使用它们。如果你不喜欢实际的东西,你可以跳过这些插曲。但是你应该喜欢实用的东西,因为它们在现实生活中通常很有用;例如,公司通常不会因为您的非实用技能而雇用您。
在这个插曲中,我们讨论了 UNIX 系统中的进程创建。UNIX 提供了一种使用一对系统调用创建新进程的最有趣的方法:fork() 和 exec()。第三个例程,wait(),可以由希望等待它创建的进程完成的进程使用。我们现在更详细地介绍这些接口,并通过一些简单的示例来激励我们。因此,我们的问题:
关键的问题: 如何创建和控制进程 操作系统应该为进程的创建和控制提供哪些接口?如何设计这些接口以实现功能强大、易于使用和高性能?
5.1 fork()系统调用 The fork() System Call
fork() 系统调用用于创建一个新进程 [C63]。但是,预先警告:这肯定是您调用过的最奇怪的例程。更具体地说,您有一个正在运行的程序,其代码如图 5.1 所示;检查代码,或者更好的是,输入它并自己运行它!
好吧,我们承认我们不确定;谁知道没人看的时候你调用什么例程?但是 fork() 很奇怪,不管你的例行调用模式有多么不寻常。
当你运行这个叫p1.c的程序,你将会看到:
让我们更详细地了解在p1.c中发生了什么。当它第一次开始运行时,进程打印出一条hello world消息;该消息中包含进程标识符(process identifier),也称为PID。进程的PID为29146;在UNIX系统中,如果人们想对进程做一些事情,比如(例如)阻止它运行,则使用PID来命名进程。到目前为止,一切顺利。
有趣的部分开始了。进程调用fork()系统调用,这是OS提供的一种创建新进程的方法。奇怪的是:被创建的进程是调用进程的(几乎)精确副本。这意味着对操作系统来说,现在看起来好像有两个程序p1正在运行,它们都将从fork()系统调用中返回。新创建的进程(称为子进程,而不是创建父进程)不会像您所期望的那样在main()上开始运行(注意,hello, world消息只打印一次);相反,它就像调用了fork()本身一样。
你可能已经注意到:这个孩子并不是一个完全的复制品。具体来说,尽管它现在有自己的地址空间副本(即自己的私有内存)、自己的寄存器、自己的PC等等,但它返回给fork()调用者的值是不同的。具体来说,当父进程接收到新创建的子进程的PID时,子进程接收到的返回码为0。这种区分很有用,因为它很容易编写处理两种不同情况的代码(如上所述)。
您可能还注意到:p1.c的输出是不确定的。当创建子进程时,现在系统中有两个我们关心的活动进程:父进程和子进程。假设我们运行在一个只有单个CPU的系统上(为了简单起见),那么子节点或父节点都可能在此时运行。在我们的例子中(上面的例子),父进程首先打印了它的消息。在其他情况下,可能会发生相反的情况,如输出跟踪所示。
CPU调度器scheduler(我们将很快详细讨论这个主题)决定在给定的时间点运行哪个进程;因为调度器是复杂的,我们通常不能对它将选择做什么,以及哪个进程将首先运行做出强有力的假设。
事实证明,这种不确定性导致了一些有趣的问题,尤其是在多线程程序中;因此,当我们在本书的第二部分研究并发性时,我们将看到更多的不确定性。
5.2 wait()系统调用 The wait() System Call
到目前为止,我们还没有做太多事情:只是创建了一个打印消息并退出的子进程。有时,事实证明,父母等待子进程完成它一直在做的事情是非常有用的。这个任务是通过 wait() 系统调用(或其更完整的同级 waitpid())完成的;详见图 5.2。
在这个例子(p2.c)中,父进程调用wait()来延迟它的执行,直到子进程完成执行。当子进程完成时,wait()返回到父进程。
向上面的代码添加wait()调用可以使输出具有确定性。你知道为什么吗?来吧,好好想想。
通过这段代码,我们现在知道子进程将总是首先打印。为什么我们知道这个?嗯,它可能像以前一样首先运行,因此在父程序之前打印。但是,如果父进程碰巧先运行,它会立即调用wait();这个系统调用直到子进程运行并退出后才会返回。因此,即使父进程先运行,它也会礼貌地等待子进程完成运行,然后wait()返回,然后父进程打印它的消息。
在一些情况下,wait()会在子进程退出之前返回;如往常一样,请阅读手册页了解更多细节。还要注意这本书中任何绝对的和非限定的语句,比如“孩子总是先打印”或“UNIX是世界上最好的东西,甚至比冰淇淋还好”。
5.3 最后, exec() 系统调用 Finally, The exec() System Call
进程创建API的最后一个重要部分是exec()系统调用。当您希望运行与调用程序不同的程序时,此系统调用非常有用。例如,在p2.c中调用fork()只有在您希望继续运行相同程序的副本时才有用。然而,通常你想运行一个不同的程序;exec()就是这样做的(图5.3)。
在Linux上,exec()有六种变体:execl、execlp()、execle()、execv()、execvp()和execvpe()。阅读手册页了解更多信息。
在本例中,子进程调用execvp()来运行程序wc,这是一个单词计数程序。实际上,它在源文件p3.c上运行wc,从而告诉我们在文件中找到了多少行、单词和字节:
fork()系统调用很奇怪;它的犯罪伙伴exec()也不那么正常。它的作用:给定一个可执行文件的名称(如wc)和一些参数(如p3.c),它从该可执行文件加载代码(和静态数据),并用它覆盖当前的代码段(和当前的静态数据);堆和栈以及程序内存空间的其他部分被重新初始化。然后,操作系统只需运行该程序,将任何参数作为该进程的argv传入。因此,它不会创建一个新的过程;相反,它将当前正在运行的程序(以前是p3)转换为另一个正在运行的程序(wc)。在子进程中执行exec()之后,几乎就像p3.c从未运行过一样;成功调用exec()永远不会返回。
Tip:正确处理(兰普森定律LAMPSON’S LAW) 正如兰普森在他备受推崇的《计算机系统设计提示》(L83)中所说的那样,要正确(Get it right)。抽象和简化都不是正确的替代方法。有时候,你只需要做正确的事,当你做了,它比其他选择要好得多。有很多方法可以设计用于进程创建的APIs;但是,fork()和exec()的组合非常简单,功能非常强大。在这里,UNIX设计人员做对了。因为兰普森总是正确的,我们以他的荣誉命名这项法律。
5.4 为什么?调动API积极性 Why? Motivating The API
当然,您可能会有一个大问题:为什么我们要构建这样一个奇怪的接口,而创建一个新进程的简单行为应该是什么?事实证明,fork()和exec()的分离在构建UNIX shell中是必不可少的,因为它让shell在调用fork()之后但在调用exec()之前运行代码;这段代码可以改变即将运行的程序的环境,从而可以轻松构建各种有趣的特性。
shell只是一个用户程序。它会向您显示一个提示,然后等待您在其中输入一些内容。然后你输入一个命令(例如,一个可执行程序的名称,加上任何参数)给它;在大多数情况下,shell会找出可执行文件系统中的位置,调用fork()来创建一个新的子进程来运行命令,调用exec()的某些变体来运行命令,然后通过调用wait()来等待命令完成。当子程序完成时,shell从wait()返回并再次打印一个提示符,准备执行下一个命令。
有很多shells;比如Tcsh, bash和ZSH。你应该选择一个,阅读它的手册页,并了解更多;所有UNIX专家都这样做。
fork()和exec()的分离使得shell可以很容易地完成一大堆有用的事情。例如:
在上面的例子中,程序wc的输出被重定向(redirected)到输出文件newfile.txt中(大于号表示重定向)。shell完成此任务的方法非常简单:当创建子对象时,在调用exec()之前,shell关闭标准输出(standard output)并打开文件newfile.txt。这样,即将运行的程序wc的任何输出都将被发送到文件而不是屏幕。
图5.4(第8页)显示了一个正是这样做的程序。这个重定向工作的原因是基于操作系统如何管理文件描述符的假设。具体来说,UNIX系统从0开始寻找空闲的文件描述符。在这种情况下,STDOUT_FILENO将是第一个可用的,因此在调用open()时被分配。子进程随后对标准输出文件描述符的写入,例如通过printf()这样的函数,将被透明地转向到新打开的文件,而不是屏幕。
下面是运行p4.c程序的输出:
你会(至少)注意到关于这个输出的两个有趣的花絮。首先,当 p4 运行时,看起来好像什么都没发生;shell 只是打印命令提示符,并立即准备好执行下一个命令。然而,事实并非如此。程序 p4 确实调用了 fork() 来创建一个新子进程,然后通过调用 execvp() 运行 wc 程序。您没有看到任何输出打印到屏幕上,因为它已被重定向到文件 p4.output。其次,您可以看到,当我们 cat 输出文件时,找到了运行 wc 的所有预期输出。酷,对吧?
UNIX管道也以类似的方式实现,但是使用pipe()系统调用。在这种情况下,一个进程的输出连接到内核管道(pipe)(例如队列),而另一个进程的输入连接到同一管道;因此,一个进程的输出可以无缝地用作下一个进程的输入,并且可以将长而有用的命令链串在一起。举个简单的例子,考虑在文件中查找一个单词,然后计算这个单词出现了多少次;有了管道和实用工具grep和wc,就很容易了;只要在命令提示符中输入grep -o foo file | wc -l,就会看到结果。
最后,虽然我们只是在高层次上勾勒出进程 API,但关于这些调用的更多细节有待学习和消化;例如,当我们在本书的第三部分讨论文件系统时,我们将学习更多关于文件描述符的知识。现在,只要说 fork()/exec() 组合是创建和操作进程的强大方法就足够了。
5.5 进程控制和用户 Process Control And Users
除了 fork()、exec() 和 wait() 之外,还有许多其他接口用于与 UNIX 系统中的进程进行交互。例如,kill() 系统调用用于向进程发送信号(signals),包括暂停、死亡和其他有用命令的指令。为方便起见,在大多数 UNIX shell 中,某些按键组合被配置为向当前运行的进程传递特定信号;例如,control-c 向进程发送 SIGINT(中断)(通常终止它),control-z 发送 SIGTSTP(停止)信号,从而在执行过程中暂停进程(您可以稍后使用命令恢复它,例如,在许多 shell 中都可以找到 fg 内置命令)。
整个信号子系统提供了一个丰富的基础设施来向进程传递外部事件,包括在单个进程中接收和处理这些信号的方法,以及向单个进程和整个进程(process groups)组发送信号的方法。要使用这种形式的通信,进程应该使用 signal() 系统调用来“捕捉”各种信号;这样做可以确保当一个特定的信号被传递给一个进程时,它会暂停它的正常执行并运行一段特定的代码来响应这个信号。阅读别处 [SR05] 以了解有关信号及其复杂性的更多信息。
这自然会提出一个问题:谁可以向进程发送信号,谁不能?一般来说,我们使用的系统可以有多人同时使用;如果这些人中的一个人可以任意发送诸如 SIGINT 之类的信号(中断进程,可能会终止它),则系统的可用性和安全性将受到损害。结果,现代系统包括用户(user)概念的强烈概念。用户在输入密码以建立凭据后,登录以访问系统资源。然后用户可以启动一个或多个进程,并对它们进行完全控制(暂停它们、杀死它们等)。用户一般只能控制自己的进程;操作系统的工作是将资源(例如 CPU、内存和磁盘)分配给每个用户(及其进程)以满足整个系统目标。
ASIDE: RTFM — READ THE MAN PAGES 在本书中,当提到特定的系统调用或库调用时,我们会多次告诉您阅读手册页(manual pages),或简称手册页(man pages)。手册页是UNIX系统中存在的原始文档形式;要意识到它们是在所谓的网络存在之前被创造出来的。 花一些时间阅读手册页是一个系统程序员成长的关键步骤;在这些页面中隐藏着大量有用的花絮。一些特别有用的页面是您正在使用的shell的手册页(例如,tcsh或bash),当然对于您的程序进行的任何系统调用(以便查看存在什么返回值和错误条件)。 最后,阅读手册页可以避免一些尴尬。当你问同事关于fork()的一些复杂性时,他们可能会简单地回答:RTFM。这是你的同事温柔地敦促你阅读手册的方式。RTFM中的F只是为短语增添了一点色彩……
5.6 有用的工具 Useful Tools
还有许多有用的命令行工具。例如,使用ps命令可以查看哪些进程正在运行;请阅读手册页以获取一些传递给ps的有用标志。工具top也非常有用,因为它显示了系统的进程以及它们消耗了多少CPU和其他资源。有趣的是,许多次当你运行它时,top声称它是最浪费资源的;也许这有点自大狂。kill命令可以用来向进程发送任意信号,用户友好一点的killall命令也可以。一定要小心使用;如果您意外地杀死您的窗口管理器,您坐在前面的计算机可能会变得相当难以使用。
最后,您可以使用许多不同类型的CPU仪表来快速了解系统上的负载;例如,我们总是让MenuMeters(来自《Raging Menace》软件)运行在我们的Macintosh工具栏上,这样我们就可以随时看到CPU的使用情况。一般来说,关于正在发生的事情的信息越多越好。
Aside:超级用户(root) 系统通常需要一个能够管理系统的用户,并且不像大多数用户那样受到限制。这样的用户应该能够杀死任意的进程(例如,如果它以某种方式滥用系统),即使该进程不是由该用户启动的。这样的用户还应该能够运行强大的命令,如shutdown(这无疑会关闭系统)。在基于unix的系统中,这些特殊能力被赋予超级用户superuser(有时称为root)。虽然大多数用户不能杀死其他用户进程,但超级用户可以。root就像蜘蛛侠,力量越大责任越大[QI15]。因此,为了提高安全性security(并避免代价高昂的错误),通常最好是常规用户regular user;如果你确实需要root,小心行事,因为计算机世界的所有破坏性力量现在都在你的指尖。
5.7 总结 Summary
我们已经介绍了一些处理UNIX进程创建的APIs: fork()、exec()和wait()。然而,我们只是触及了表面。更多细节,请阅读Stevens和Rago [SR05],当然,特别是关于过程控制、过程关系和信号的章节;从其中的智慧中可以提取很多东西。
虽然我们对UNIX进程API的热情仍然很高,但我们也应该注意到,这种积极的态度并不统一。例如,来自微软、波士顿大学和瑞士ETH的系统研究人员最近发表的一篇论文详细介绍了fork()的一些问题,并提倡使用其他更简单的进程创建APIs,如spawn() [B+19]。阅读它,以及它提到的相关工作,了解这个不同的优势。虽然一般来说,相信这本书是好的,但也要记住作者有自己的观点;这些观点可能(总是)没有你想的那么广泛。
ASIDE: 关键进程API术语
- 每个进程都有一个名称;在大多数系统中,该名称是一个称为进程ID process ID (PID)的数字 。
- UNIX系统中使用fork()系统调用来创建一个新进程。创造者被称为父进程 parent;新创建的进程称为子进程 child。正如现实生活中有时发生的那样[J16],子进程几乎是父进程的一个完全相同的副本。
- wait()系统调用允许父进程等待其子进程完成执行。
- exec() 系列系统调用允许子进程摆脱与父进程的相似性,并执行一个全新的程序。
- UNIX shell通常使用fork()、wait()和exec()来启动用户命令;fork和exec的分离支持诸如输入/输出重定向 input/output redirection、管道 pipes 和其他很酷的特性,所有这些都不会改变正在运行的程序。
- 进程控制以信号 signals 的形式存在,信号可以导致任务停止、继续,甚至终止。
- 哪些进程可以由特定的人控制,封装在用户 user 的概念中;操作系统允许多个用户进入系统,并确保用户只能控制自己的进程。
- 超级用户 superuser 可以控制所有进程(实际上还可以做许多其他事情);这个角色应该不经常使用,并且出于安全原因要谨慎使用。
References
[B+19] “A fork() in the road” by Andrew Baumann, Jonathan Appavoo, Orran Krieger, Tim-
othy Roscoe. HotOS ’19, Bertinoro, Italy. A fun paper full of fork()ing rage. Read it to get an
opposing viewpoint on the UNIX process API. Presented at the always lively HotOS workshop, where
systems researchers go to present extreme opinions in the hopes of pushing the community in new di-
rections.
[C63] “A Multiprocessor System Design” by 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” by 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.
[J16] “They could be twins!” by Phoebe Jackson-Edwards. The Daily Mail. March 1, 2016.. This
hard-hitting piece of journalism shows a bunch of weirdly similar child/parent photos and is frankly kind
of mesmerizing. Go ahead, waste two minutes of your life and check it out. But don’t forget to come
back here! This, in a microcosm, is the danger of surfing the web.
[L83] “Hints for Computer Systems Design” by Butler Lampson. ACM Operating Systems
Review, Volume 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.
[QI15] “With Great Power Comes Great Responsibility” by The Quote Investigator. Available:
https://quoteinvestigator.com/2015/07/23/great-power. The quote investigator
concludes that the earliest mention of this concept is 1793, in a collection of decrees made at the French
National Convention. The specific quote: “Ils doivent envisager qu’une grande responsabilit est la
suite insparable d’un grand pouvoir”, which roughly translates to “They must consider that great
responsibility follows inseparably from great power.” Only in 1962 did the following words appear in
Spider-Man: “…with great power there must also come–great responsibility!” So it looks like the French
Revolution gets credit for this one, not Stan Lee. Sorry, Stan.
[SR05] “Advanced Programming in the UNIX Environment” by W. Richard Stevens, 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.
Homework (Simulation)
本模拟作业的重点是fork.py,这是一个简单的进程创建模拟器,它展示了进程在单个家族树中是如何关联的。有关如何运行模拟器的详细信息,请阅读相关的README。
Questions
- 运行 ./fork.py -s 10,查看执行了哪些操作。您能预测进程树在每个步骤中的样子吗?使用-c标志来检查你的答案。尝试一些不同的随机种子(-s)或添加更多动作(-a)来掌握它。
- 模拟器为您提供的一种控制是fork百分比,由 -f 标志控制。它越高,下一个动作越有可能是fork;它越低,动作越有可能exit。使用大量操作(例如,-a 100)运行模拟器,并将fork百分比从 0.1 更改为 0.9。您认为随着百分比的变化,最终的进程树会是什么样子?用 -c 检查你的答案。
- 现在,通过使用-t标志切换输出(例如,运行./fork.py -t)。给定一组进程树,你能说出采取了哪些操作吗?
- 需要注意的一件有趣的事情是当子进程退出时会发生什么;进程树中的子进程会发生什么?为了研究这个,让我们创建一个具体的例子:./fork.py -A a+b,b+c,c+d,c+e,c-。这个例子有进程 a create b ,它依次创建 c ,然后创建 d 和 e 。但是,然后, c 退出。您认为退出后的进程树应该是什么样的?如果使用 -R 标志怎么办?详细了解孤儿进程(orphaned processes)会发生什么,以添加更多内容。
- 最后一个要研究的标志是-F标志,它跳过中间步骤,只要求填充最终的进程树。运行./fork.py -F,看看是否可以通过查看生成的一系列操作来写下最终的树。用不同的随机种子试几次。
- 最后,同时使用 -t 和 -F。这显示了最终的进程树,但随后要求您填写发生的操作。通过查看树,您能确定发生的确切操作吗?在哪些情况下你能说出来?在哪说不出来?尝试一些不同的随机种子来深入研究这个问题。
笔记
操作系统通常创建一个或几个初始过程来让事情进行;例如,在Unix上,初始化进程被称为init,当系统运行时,它会生成其他进程。
ASIDE: 编程作业 编码作业是一些小练习,您可以编写代码在真机上运行,以获得一些基本操作系统 API 的经验。毕竟,您(可能)是一名计算机科学家,因此应该喜欢编码,对吗?如果你不这样做,只有 CS 理论,但这很难。当然,要真正成为专家,你得花不少时间在机器上做黑客;确实,找各种借口写一些代码,看看它是如何工作的。花时间,成为你知道你可以成为的明智大师。
Homework (Code)
在本作业中,您将对刚刚阅读的流程管理APIs有一些熟悉。别担心,它比听起来更有趣!如果您能找到尽可能多的时间来编写一些代码,那么通常情况下您的情况会好得多,所以为什么不现在就开始呢?
Questions
- 编写一个调用fork()的程序。在调用fork()之前,让主进程访问一个变量(例如,x),并将其值设置为某个值(例如,100)。子进程中的变量值是多少?当子节点和父节点都改变了x的值时,变量会发生什么?
- 编写一个程序来打开一个文件(使用open()系统调用),然后调用fork()来创建一个新进程。子程序和父程序都可以访问open()返回的文件描述符吗?当它们并发地写入文件时,也就是在同一时间,会发生什么呢?
- 使用fork()编写另一个程序。子进程应该打印hello;父进程应该打印goodbye。您应该尝试确保子进程总是先打印;你能在不调用parent中的wait()的情况下做到这一点吗?
```c
include
include
include
int main() { int rc = vfork(); if (rc < 0) { fprintf(stderr, “fork failed”); exit(1); } else if (rc == 0) { printf(“hello\n”); exit(1); } else { printf(“goodbye\n”); } return 0; }
```
使用vfork,可以在子进程执行结束后再执行父进程。
或者使用sleep等来延迟。
- 编写一个调用fork()的程序,然后调用某种形式的exec()来运行程序/bin/ls.看看是否可以尝试exec()的所有变体,包括(在Linux上)execl()、execle()、execlp()、execv()、execvp()和execvpe()。你认为为什么同一个基本调用有这么多变体?
有多种变体是为了要适应不同的调用形式和环境要求。
- 现在编写一个程序,使用wait()来等待子进程在父进程中完成。wait()返回什么?如果在子进程中使用wait()会发生什么?
父进程使用wait()返回子进程id,子进程由于本身没有子进程,所以返回-1。
- 对前面的程序稍加修改,这次用waitpid()代替wait()。waitpid()什么时候有用?
waitpid在进程本身有子进程的时候有用。
- 编写一个创建子进程的程序,然后在子进程中关闭标准输出(STDOUT_FILENO)。如果子进程在关闭描述符后调用printf()来打印一些输出,会发生什么?
没有任何输出
- 编写一个程序,创建两个子程序,并使用pipe()系统调用将一个子程序的标准输出连接到另一个子程序的标准输入。