写在前面:
学习操作系统是一个漫长且容易迷茫的过程。这本书在我的学习过程中给予了很大的帮助。本文将尽量精简内容,仅保留关键部分,并对学习中遇到的难点进行注释和解释。希望这能为初学者提供一些帮助和指引。
本文所有涉及的图片及内容皆引用自:Operating Systems: Three Easy Pieces
作者:
Remzi H. Arpaci-Dusseau and Andrea C. Arpaci-Dusseau (University of Wisconsin-Madison)
原版请访问官网:Operating Systems: Three Easy Pieces
本章讨论 UNIX 系统中的进程创建。UNIX 的进程创建方式:通过一对系统调用:fork()
和 exec()
。第三个例程 wait()
则可以让一个进程等待其创建的子进程完成。现在将更详细地介绍这些接口,并用几个简单的例子。由此,引出了我们的问题:
关键:如何创建和控制进程
操作系统应该为进程创建和控制提供哪些接口?这些接口应该如何设计才能实现强大的功能、易用性和高性能?
fork()
System Call fork()
系统调用fork()
系统调用用于创建一个新进程 [C63]。但请注意:这无疑是你将调用的最奇怪的例程。更具体地说,一个正在运行的程序,其代码类似于下:
#p1.c
#include // 标准输入输出库,用于 printf 和 fprintf
#include // 标准库,用于 exit()
#include // 提供 fork() 和 getpid() 函数
int main(int argc, char *argv[]) {
// 主进程开始运行,打印当前进程的 PID
printf("hello (pid:%d)\n", (int) getpid());
// 调用 fork() 创建一个新进程
int rc = fork();
if (rc < 0) {
// fork 失败,通常是因为系统资源不足
fprintf(stderr, "fork failed\n");
exit(1); // 退出程序,返回错误码 1
} else if (rc == 0) {
// 子进程逻辑
// fork() 的返回值在子进程中为 0
printf("child (pid:%d)\n", (int) getpid());
} else {
// 父进程逻辑
// fork() 的返回值在父进程中是子进程的 PID
printf("parent of %d (pid:%d)\n", rc, (int) getpid());
}
// 主程序结束,返回 0 表示正常退出
return 0;
}
运行该程序(p1.c)结果如:
prompt> ./p1
hello (pid:29146)
parent of 29147 (pid:29146)
child (pid:29147)
prompt>
更详细地理解 :程序开始运行时,该进程会打印出一条问候信息;信息中包含了它的进程标识符(PID)。这个进程的 PID 是 29146;在 UNIX 系统中,PID 用于标识进程,如果你想对进程进行某种操作(例如停止它运行),则可用PID 。
然后进程调用了 fork()
系统调用,这是操作系统提供的一种创建新进程的方法。新创建的进程是调用进程的(几乎)完全副本。这意味着对操作系统而言,现在看起来有两个 p1
程序的副本在运行,并且两者都即将从 fork()
系统调用返回。
新创建的进程(称为子进程,与创建它的父进程相对)并不像你可能期望的那样从 main()
开始运行(注意,“hello” 信息只打印了一次);相反,它的诞生就像是它自己调用了 fork()
一样。
可以观察到:子进程并不是完全的副本。具体来说,虽然子进程现在拥有自己独立的地址空间(即其私有内存)、寄存器、程序计数器(PC)等,但它返回给 fork()
调用者的值是不同的。父进程接收到的是新创建子进程的 PID,而子进程接收到的是返回值 0。这样的区别非常有用,因为它可以简单地编写出处理这两种不同情况的代码。如下所示:
#p2.c
#include // 标准输入输出库,用于 printf 和 fprintf
#include // 标准库,用于 exit()
#include // 提供 fork()、getpid() 函数
#include // 提供 wait() 函数,用于等待子进程退出
int main(int argc, char *argv[]) {
// 主进程开始运行,打印当前进程的 PID
printf("hello (pid:%d)\n", (int) getpid());
// 调用 fork() 创建一个新进程
int rc = fork();
if (rc < 0) {
// fork 失败,通常是因为系统资源不足
fprintf(stderr, "fork failed\n");
exit(1); // 退出程序,返回错误码 1
} else if (rc == 0) {
// 子进程逻辑
// fork() 的返回值在子进程中为 0
printf("child (pid:%d)\n", (int) getpid());
} else {
// 父进程逻辑
// fork() 的返回值在父进程中是子进程的 PID
// 等待子进程完成
int rc_wait = wait(NULL); // wait() 返回退出子进程的 PID,参数为 NULL 表示不获取退出状态
printf("parent of %d (rc_wait:%d) (pid:%d)\n", rc, rc_wait, (int) getpid());
}
// 主程序结束,返回 0 表示正常退出
return 0;
}
你可能还注意到:p1.c
的输出并不是确定性的。当子进程被创建时,系统中我们关心的活跃进程有两个:父进程和子进程。假设我们运行在一个单 CPU 系统上(为简单起见),那么在这一点上,可能是子进程运行,也可能是父进程运行。在上面的例子中,父进程首先运行并打印了它的消息。在其他情况下,可能会发生相反的情况,如以下输出:
prompt> ./p1
hello (pid:29146)
child (pid:29147)
parent of 29147 (pid:29146)
prompt>
CPU 调度器决定了在某一时刻哪个进程运行,我们很快会详细讨论这一主题;由于调度器的复杂性,我们通常无法准确预测它会如何选择,因此也无法确定哪个进程会先运行。这种非确定性会导致一些有趣的问题,尤其是在多线程程序中。后续学习并发时,将看到更多的非确定性现象。
wait()
System Call:wait()
系统调用到目前为止,我们只是创建了一个打印消息并退出的子进程。有时候,让父进程等待子进程完成其任务是非常有用的。这一任务通过 wait()
系统调用(或其更强大的兄弟函数 waitpid()
)完成;代码见上一个代码段。在这个例子(p2.c
)中,父进程调用 wait()
以延迟自身的执行,直到子进程完成其执行。当子进程完成后,wait()
返回父进程继续运行。然后会得到新的输出:
prompt> ./p2
hello (pid:29266)
child (pid:29267)
parent of 29267 (rc_wait:29267) (pid:29266)
prompt>
这段代码子进程的输出总是先打印:因为子进程可能像之前一样,先运行并先打印。然而,如果父进程先运行,它会立即调用 wait()
;该系统调用在子进程运行并退出之前不会返回。因此,即使父进程先运行,它也会等待子进程完成运行,随后 wait()
返回,然后父进程再打印其消息。
exec()
System Call:exec()
系统调用进程创建 API 的最后也是最重要的一部分是 exec()
系统调用。当你想运行一个与调用程序不同的程序时,这个系统调用非常有用。例如,在 p2.c
中调用 fork()
用于运行相同程序的副本。而用 exec()
来运行一个不同的程序。示例如下:
#p3.c
#include
#include
#include
#include
#include
int main(int argc, char *argv[]) {
printf("hello (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("child (pid:%d)\n", (int) getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc"
myargs[1] = strdup("p3.c"); // arg: input file
myargs[2] = NULL; // mark end of array
execvp(myargs[0], myargs); // runs word count
printf("this shouldn’t print out");
} else {
// parent goes down this path
int rc_wait = wait(NULL);
printf("parent of %d (rc_wait:%d) (pid:%d)\n", rc, rc_wait, (int) getpid());
}
return 0;
}
在这个例子中,子进程调用 execvp()
来运行程序 wc
,这是一个字数统计程序。它在源文件 p3.c
上运行 wc
,输出文件中有多少行、单词和字节:
prompt> ./p3
hello (pid:29383)
child (pid:29384)
29 107 1030 p3.c
parent of 29384 (rc_wait:29384) (pid:29383)
prompt>
exec()
的作用是:给定一个可执行文件的名称(例如 wc
)和一些参数(例如 p3.c
),它会从该可执行文件加载代码(和静态数据),并用这些内容覆盖当前的代码段(和当前的静态数据)。堆、栈和程序内存空间的其他部分将被重新初始化。然后操作系统直接运行该程序,并将参数作为该进程的 argv
传递进去。因此,它不会创建新进程;而是将当前正在运行的程序(原来的 p3
)转变为另一个运行程序(wc
)。在子进程中调用 exec()
后,几乎就像 p3.c
从未运行过一样;成功的 exec()
调用永远不会返回。
注:这里详细解释一下代码的主要部分:char *myargs[3];定义了一个指针数组 myargs
,大小为 3。每个元素是一个字符串指针,用来存储命令和参数。最后一个元素必须是 NULL
,用来告诉 execvp()
参数列表的结束位置。然后后面三行把字符串 "wc"
分配到动态内存中,并赋值给 myargs[0]
。把 "p3.c"
(当前代码的文件名)分配到动态内存中,并赋值给 myargs[1]。
execvp()
的参数列表必须以 NULL
结束,myargs[2] = NULL;
是结束标志。execvp的函数原型是:int execvp(const char *file, char *const argv[]);其中file
是程序的名称或路径,argv[]
是参数列表,第一个参数通常是程序名本身,最后一个参数必须是 NULL
。简单理解就是,第一个参数是要启动的程序的名字,第二个参数传给这个程序的 参数数组。上面的例子就相当于在命令行中执行了 /* wc p3.c */。
为什么我们要为创建新进程这样一个看似简单的操作构建如此奇怪的接口?事实证明,将 fork()
和 exec()
分离对构建 UNIX shell 至关重要。这种分离允许 shell 在调用 fork()
后而在调用 exec()
前运行一些代码。这些代码可以修改即将运行程序的环境,从而实现各种功能。
在shell中输入一条命令(即可执行程序的名称,以及任何参数)大多数情况下,shell 会执行以下操作:
fork()
创建一个新子进程以运行命令。exec()
来运行命令。wait()
等待命令完成。当子进程完成后,shell 从 wait()
返回并再次显示提示符,准备接受下一条命令。
将 fork()
和 exec()
分离使得 shell 可以实现许多功能。例如:
prompt> wc p3.c > newfile.txt
在上例中,程序 wc
的输出被重定向到输出文件 newfile.txt
中。在子进程创建后但调用 exec()
之前,shell在子进程中执行的代码会关闭标准输出(STDOUT_FILENO
),打开文件 newfile.txt。
通过这样,即将运行的程序 wc
的所有输出将被发送到文件而不是屏幕。示例如下:
#p4.c
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[]) {
int rc = fork();
if (rc < 0) {
// fork failed
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);
// now exec "wc"...
char *myargs[3];
myargs[0] = strdup("wc"); // program: wc
myargs[1] = strdup("p4.c"); // arg: file to count
myargs[2] = NULL; // mark end of array
execvp(myargs[0], myargs); // runs word count
} else {
// parent goes down this path (main)
int rc_wait = wait(NULL);
}
return 0;
}
这种重定向之所以有效,是因为操作系统管理文件描述符的方式:
在 UNIX 系统中,文件描述符是按从 0 开始的整数编号分配的。系统会从最小的可用文件描述符开始分配,例如,当你调用 open()
打开一个文件时,系统会先检查文件描述符表中是否有未使用的编号。标准文件描述符的编号通常是固定的:
0
:标准输入(STDIN_FILENO
)。默认从键盘读取用户输入。1
:标准输出(STDOUT_FILENO
)。默认将程序的输出显示到屏幕。2
:标准错误(STDERR_FILENO
)。默认将程序的错误信息显示到屏幕。程序中所有写入标准输出的操作(如 printf()
)本质上是向文件描述符 1
写入数据。程序中所有写入标准输出的操作(如 printf()
)本质上是向文件描述符 1
写入数据。在 UNIX 系统中,屏幕可以被视为一个特殊的文件设备/dev/tty
表示终端设。当程序运行时,这些标准文件描述符默认已经打开并指向终端(如屏幕和键盘)。如果在调用 open()
前,你已经关闭了标准输出(STDOUT_FILENO
= 1),那么文件描述符 1 会被视为可用,open()
将为新打开的文件分配文件描述符 1。之后,所有向标准输出的写操作(如 printf()
)都会通过文件描述符 1,输出到新打开的文件,而不是默认的屏幕。
open()
的函数原型是:
#include
#include
int open(const char *pathname, int flags, mode_t mode);
参数是文件路径,指定打开文件的模式和行为,文件权限。open()
的返回值是文件描述符,用来标识打开的文件。
代码中调用 close(STDOUT_FILENO)
,释放文件描述符 1。文件描述符 1 变为“可用”状态。之后,当调用 open()
打开新文件时,系统会优先分配最小的未使用文件描述符(这里是 1
),从而实现标准输出的重定向。
O_CREAT | O_WRONLY | O_TRUNC
:
O_CREAT
:如果文件不存在,则创建它。O_WRONLY
:以“只写”模式打开文件。O_TRUNC
:如果文件已存在,清空其内容。打开文件 ./p4.output
,返回的文件描述符占用STDOUT_FILENO。此后,任何写入标准输出(stdout
)的操作都会被写入文件 ./p4.output。
以下是运行程序 p4.c
的输出:
prompt> ./p4
prompt> cat p4.output
32
109
846
p4.c
你会注意到:首先,当运行 p4
时,看起来什么都没有发生;Shell 只是打印命令提示符并立即准备好接受下一个命令。然而,实际上,程序 p4
确实调用了 fork()
创建了一个新的子进程,并通过调用 execvp()
运行了 wc
程序。你没有看到屏幕上打印出任何输出,因为它已被重定向到文件 p4.output
。
UNIX 管道也以类似的方式实现,但使用 pipe()
系统调用。在这种情况下,一个进程的输出连接到内核管道(即队列),另一个进程的输入连接到同一个管道;因此,一个进程的输出可以无缝地用作下一个进程的输入,从而可以串联长而有用的命令链。
例如,考虑在一个文件中查找某个单词,然后统计该单词出现的次数;使用管道以及工具 grep
和 wc
,这非常简单:只需在命令提示符中输入 grep -o foo file | wc -l。
注:这里意思是统计文件中foo出现的次数grep
用来查找foo,“ | ”符号是管道,管道将前一个命令的输出作为后一个命令的输入。在这里,grep -o foo file
的结果会被传递给 wc -l。
管道(pipe) 将数据从一个命令的输出传递到另一个命令的输入,形成一种数据流式处理。作者在这里提到这个例子主要还是因为其符合UNIX哲学。Unix 哲学的核心原则就是每个程序只做好一件事,小程序可以被多次使用,无需重复开发类似功能。
最后,虽然我们只是大致介绍了进程 API,但关于这些调用还有更多细节可以学习和消化。例如,我们将在本书第三部分讨论文件系统时进一步了解文件描述符。现在,只需知道 fork()
/exec()
组合是一种创建和操作进程的强大方法即可。
fork()
、exec()
和 wait()
之外,UNIX 系统还提供了许多其他用于与进程交互的接口。例如,kill()
系统调用用于向进程发送信号,包括暂停、终止以及其他有用的指令。为了方便起见,大多数 UNIX Shell 将某些按键组合配置为向当前运行的进程发送特定信号。例如,Ctrl+C
向进程发送一个 SIGINT(中断)信号,通常会终止进程;而 Ctrl+Z
则发送一个 SIGTSTP(停止)信号,从而暂停进程的执行(稍后可以通过某个命令恢复,例如许多 Shell 中的内置命令 fg
)。整个信号子系统为进程传递外部事件提供了丰富基础,包括在单个进程中接收和处理这些信号的方式,以及向单个进程甚至整个进程组发送信号的方式。为此,进程使用 signal()
系统调用来“捕获”各种信号;这样可以确保当特定信号发送到进程时,它会暂停正常执行并运行一段特定的代码来响应信号。
这引出了一个问题:谁可以向进程发送信号,谁不能?
一般来说,系统可以同时被多个人使用;如果其中一个人可以随意向进程发送信号(例如 SIGINT 中断信号,可能会终止进程),那么系统的可用性和安全性将会受到威胁。因此,现代系统中对“用户”的概念有着严格的定义。用户通过输入密码来验证身份并登录以获得对系统资源的访问权限。用户随后可以启动一个或多个进程,并对其完全控制(暂停、终止等)。用户通常只能控制自己的进程;操作系统的职责是将资源(例如 CPU、内存和磁盘)分配给每个用户(及其进程),以实现整体系统目标。
使用 ps
命令可以查看哪些进程正在运行。
top
工具显示系统中的进程以及它们占用了多少 CPU 和其他资源。
kill
命令可以用来向进程发送任意信号,比 killall
“友好”。
最后,还有许多不同类型的 CPU 监测工具可以用来快速了解系统负载。例如Mac 的工具栏上运行 MenuMeters。
介绍了一些与 UNIX 进程创建相关的 API,包括 fork()
、exec()
和 wait()
。了解更多细节,请阅读 Stevens 和 Rago 的书 [SR05],特别是有关进程控制、进程关系和信号的章节。
附注1 超级用户(Root)
一个系统通常需要一个用户来管理系统,这个用户不会像普通用户那样受到限制。这样的用户应该能够终止任意进程(例如,如果某个进程以某种方式滥用系统),即使该进程不是由此用户启动的。此外,这个用户还应该能够运行一些强大的命令,例如 shutdown
(关闭系统)。
在基于 UNIX 的系统中,这些特殊权限赋予了超级用户(有时称为 Root)。虽然大多数用户不能终止其他用户的进程,但超级用户可以。
附注2 KEY PROCESS API TERMS 关键进程 API 术语
wait()
系统调用允许父进程等待其子进程完成执行。exec()
系列系统调用允许子进程摆脱与父进程的相似性并执行一个全新的程序。fork()
、wait()
和 exec()
来启动用户命令。