操作系统导论读书笔记
目录
- 虚拟化
- 抽象:进程
- 抽象:进程概念
- 进程创建:更多细节
- 插叙:进程API
- fork()系统调用
- wait()系统调用
- 最后是exec()系统调用
- 为什么这样设计API
- 其他API
- 机制:受限直接执行
- 基本技巧:受限直接执行
- 问题1:受限制的操作
虚拟化
抽象:进程
本章讨论操作系统提供的基本的抽象—— 进程。进程的非正式定义非常简单:进程就是运行中的程序。程序本身是没有生命周期的,它只是存在磁盘上面的一些指令(也可能是一些静态数据)。是操作系统让这些字节运行起来,让程序发挥作用。
虽然只有少量的物理CPU可用,但是操作系统如何提供几乎有无数个CPU可用的假象?操作系统通过虚拟化(virtualizing)CPU来提供这种假象。通过让一个进程只运行一个时间片,然后切换到其他进程,操作系统提供了存在多个虚拟CPU的假象。这就是时分共享(time sharing)CPU技术,允许用户如愿运行多个并发进程。潜在的开销就是性能损失,因为如果CPU必须共享,每个进程的运行就会慢一点。
要实现CPU的虚拟化,要实现得好,操作系统就需要一些低级机制以及一些高级智能。我们将低级机制称为机制(mechanism)。机制是一些低级方法或协议,实现了所需的功能。例如,我们稍后将学习如何实现 上下文切换(context switch) ,它让操作系统能够停止运行一个程序,并开始在给定的CPU上运行另一个程序。所有现代操作系统都采用了这种分时机制。
在这些机制之上,操作系统中有一些智能以策略(policy)的形式存在。策略是在操作系统内做出某种决定的算法。例如,给定一组可能的程序要在CPU上运行,操作系统应该运行哪个程序?操作系统中的调度策略(scheduling policy)会做出这样的决定,可能利用历史信息(例如,哪个程序在最后一分钟运行得更多?)、工作负载知识(例如,运行什么类型的程序?)以及性能指标 (例如,系统是否针对交互式性能或吞吐量进行优化?)来做出决定。
抽象:进程概念
操作系统为正在运行的程序提供的抽象,就是所谓的进程(process)。正如我们上面所说的,一个进程只是一个正在运行的程序。在任何时刻,我们都可以清点它在执行过程中访问或影响的系统的不同部分,从而概括一个进程。
进程的机器状态有一个明显组成部分,就是它的内存。指令存在内存中。正在运行的程序读取和写入的数据也在内存中。因此进程可以访问的内存(称为地址空间,address space)是该进程的一部分。进程的机器状态的另一部分是寄存器。许多指令明确地读取或更新寄存器,因此显然,它们对于执行该进程很重要。
请注意,有一些非常特殊的寄存器构成了该机器状态的一部分。例如,程序计数器(Program Counter,PC)(有时称为指令指针,Instruction Pointer或IP)告诉我们程序当前正在执行哪个指令;类似地,栈指针(stack pointer)和相关的帧指针(frame pointer)用于管理函数参数栈、局部变量和返回地址。
进程创建:更多细节
操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,加载到进程的地址空间中。程序最初以某种可执行格式驻留在磁盘上(disk,或者在某些现代系统中,在基于闪存的SSD上)。因此,将程序和静态数据加载到内存中的过程,需要操作系统从磁盘读取这些字节,并将它们放在内存中的某处。在早期的(或简单的)操作系统中,加载过程尽早(eagerly)完成,即在运行程序之前全部完成。现代操作系统惰性(lazily)执行该过程,即仅在程序执行期间需要加载的代码或数据片段,才会加载。
操作系统也可能为程序的堆(heap)分配一些内存。在C程序中,堆用于显式请求的动态分配数据。程序通过调用malloc()来请求这样的空间,并通过调用free()来明确地释放它。数据结构(如链表、散列表、树和其他有趣的数据结构)需要堆。起初堆会很小。随着程序运行,通过malloc()库API请求更多内存,操作系统可能会参与分配更多内存给进程,以满足这些调用。
插叙:进程API
本章将讨论UNIX系统中的进程创建。UNIX系统采用了一种非常有趣的创建新进程的方式,即通过一对系统调用:fork()和exec()。进程还可以通过第三个系统调用wait(),来等待其创建的子进程执行完成。
fork()系统调用
系统调用fork()用于创建新进程[C63]。但要小心,这可能是你使用过的最奇怪的接口:
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4
5 int
6 main(int argc, char *argv[])
7 {
8 printf("hello world (pid:%d)\n", (int) getpid());
9 int rc = fork();
10 if (rc < 0) { // fork failed; exit
11 fprintf(stderr, "fork failed\n");
12 exit(1);
13 } else if (rc == 0) { // child (new process)
14 printf("hello, I am child (pid:%d)\n", (int) getpid());
15 } else { // parent goes down this path (main)
16 printf("hello, I am parent of %d (pid:%d)\n",
17 rc, (int) getpid());
18 }
19 return 0;
20 }
进程调用了fork()系统调用,这是操作系统提供的创建新进程的方法。新创建的进程几乎与调用进程完全一样,对操作系统来说,这时看起来有两个完全一样的p1程序在运行,并都从fork()系统调用中返回。新创建的进程称为子进程(child),原来的进程称为父进程(parent)。子进程不会从main()函数开始执行(因此hello world信息只输出了一次),而是直接从fork()系统调用返回,就好像是它自己调用了fork()。
你可能已经注意到,子进程并不是完全拷贝了父进程。具体来说,虽然它拥有自己的地址空间(即拥有自己的私有内存)、寄存器、程序计数器等,但是它从fork()返回的值是不同的。父进程获得的返回值是新创建子进程的PID,而子进程获得的返回值是0。这个差别非常重要,因为这样就很容易编写代码处理两种不同的情况(像上面那样)。
CPU调度程序(scheduler)决定了某个时刻哪个进程被执行,我们稍后将详细介绍这部分内容。由于CPU调度程序非常复杂,所以我们不能假设哪个进程会先运行。事实表明,这种不确定性(non-determinism)会导致一些很有趣的问题,特别是在多线程程序(multi-threaded program)中。
wait()系统调用
到目前为止,我们没有做太多事情:只是创建了一个子进程,打印了一些信息并退出。事实表明,有时候父进程需要等待子进程执行完毕,这很有用。这项任务由wait()系统调用(或者更完整的兄弟接口waitpid())。
最后是exec()系统调用
最后是exec()系统调用,它也是创建进程API的一个重要部分。这个系统调用可以让子进程执行与父进程不同的程序。例如,在p2.c中调用fork(),这只是在你想运行相同程序的拷贝时有用。但是,我们常常想运行不同的程序,exec()正好做这样的事。
fork()系统调用很奇怪,它的伙伴exec()也不一般。给定可执行程序的名称(如wc)及需要的参数(如p3.c)后,exec()会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过argv传递给该进程。因此,它并没有创建新进程,而是直接将当前运行的程序(以前的p3)替换为不同的运行程序(wc)。子进程执行exec()之后,几乎就像p3.c从未运行过一样。对exec()的成功调用永远不会返回。
为什么这样设计API
当然,你的心中可能有一个大大的问号:为什么设计如此奇怪的接口,来完成简单的、创建新进程的任务?好吧,事实证明,这种分离fork()及exec()的做法在构建UNIX shell的时候非常有用,因为这给了shell在fork之后exec之前运行代码的机会,这些代码可以在运行新程序前改变环境,从而让一系列有趣的功能很容易实现。
fork()和exec()的分离,让shell可以方便地实现很多有用的功能。比如:
prompt> wc p3.c > newfile.txt
。在上面的例子中,wc的输出结果被重定向(redirect)到文件newfile.txt中(通过newfile.txt之前的大于号来指明重定向)。shell实现结果重定向的方式也很简单,当完成子进程的创建后,shell在调用exec()之前先关闭了标准输出(standard output),打开了文件newfile.txt。这样,即将运行的程序wc的输出结果就被发送到该文件,而不是打印在屏幕上。
NIX管道也是用类似的方式实现的,但用的是pipe()系统调用。在这种情况下,一个进程的输出被链接到了一个内核管道(pipe)上(队列),另一个进程的输入也被连接到了同一个管道上。因此,前一个进程的输出无缝地作为后一个进程的输入,许多命令可以用这种方式串联在一起,共同完成某项任务。比如通过将grep、wc命令用管道连接可以完成从一个文件中查找某个词,并统计其出现次数的功能:grep -o foo file | wc -l。
其他API
除了上面提到的fork()、exec()和wait()之外,在UNIX中还有其他许多与进程交互的方式。比如可以通过kill()系统调用向进程发送信号(signal),包括要求进程睡眠、终止或其他有用的指令。实际上,整个信号子系统提供了一套丰富的向进程传递外部事件的途径,包括接受和执行这些信号。
机制:受限直接执行
为了虚拟化CPU,操作系统需要以某种方式让许多任务共享物理CPU,让它们看起来像是同时运行。基本思想很简单:运行一个进程一段时间,然后运行另一个进程,如此轮换。通过以这种方式时分共享(time sharing)CPU,就实现了虚拟化。
然而,在构建这样的虚拟化机制时存在一些挑战。第一个是性能:如何在不增加系统开销的情况下实现虚拟化?第二个是控制权:如何有效地运行进程,同时保留对CPU的控制?控制权对于操作系统尤为重要,因为操作系统负责资源管理。如果没有控制权,一个进程可以简单地无限制运行并接管机器,或访问没有权限的信息。因此,在保持控制权的同时获得高性能,这是构建操作系统的主要挑战之一。
操作系统必须以高性能的方式虚拟化CPU,同时保持对系统的控制。为此,需要硬件和操作系统支持。操作系统通常会明智地利用硬件支持,以便高效地实现其工作。
基本技巧:受限直接执行
为了使程序尽可能快地运行,操作系统开发人员想出了一种技术——我们称之为受限的直接执行(limited direct execution)。这个概念的“直接执行”部分很简单:只需直接在CPU上运行程序即可。因此,当OS希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点(main()函数或类似的),跳转到那里,并开始运行用户的代码。
但是,这种方法在我们的虚拟化CPU时产生了一些问题。第一个问题很简单:如果我们只运行一个程序,操作系统怎么能确保程序不做任何我们不希望它做的事,同时仍然高效地运行它?第二个问题:当我们运行一个进程时,操作系统如何让它停下来并切换到另一个进程,从而实现虚拟化CPU所需的时分共享?