当一个程序执行时发生了什么?
一个正在运行的程序做一件非常简单的事情:它执行指令。数百万(这些天,甚至数十亿)乘以每一秒,处理器从内存中取一条指令,解码它(例如,找出这指令),并执行它(也就是说,它做它应该做的事,喜欢把两个数字加起来,访问内存,检查条件,跳转到一个函数,等等)。这条指令执行完后,处理器继续执行下一条指令,如此类推,直到程序最终完成为止。
因此,我们刚刚描述了冯诺依曼计算模型的基础。听起来很简单,对吧?但在这门课中,我们将学习到,当一个程序运行时,许多其他疯狂的事情正在进行,其主要目标是使系统易于使用。
事实上,有一大堆软件可以让程序更容易地运行(甚至让你看起来可以同时运行多个程序),允许程序共享内存,允许程序与设备交互,以及其他类似的有趣的事情。这一大堆软件就叫做操作系统(operating system,OS),因为它负责确保系统以易于使用的方式正确、高效地运行。
问题的关键在于: 如何虚拟化资源 我们将在本书中回答的一个核心问题非常简单:操作系统如何虚拟化资源?这是我们问题的症结所在。为什么操作系统会这样做不是主要的问题,因为答案应该是显而易见的:它使系统更容易使用。因此,我们将重点关注如何实现:操作系统实现了哪些机制和策略来实现虚拟化?操作系统是如何做到如此高效的?需要哪些硬件支持? 我们将使用“关键的问题”,在阴影框中,如这个,作为一种方式,我们试图解决的具体问题,以构建一个操作系统。因此,在一个特定主题的注释中,你可能会发现一个或多个关键问题(是的,这是适当的复数),它突出了问题。当然,这一章中的细节会给出解决方案,或者至少给出解决方案的基本参数。
操作系统做到这一点的主要方式是通过一种我们称之为虚拟化的通用技术。也就是说,操作系统获取物理资源(如处理器、内存或磁盘),并将其转换为更通用、更强大、更易于使用的虚拟形式。因此,我们有时将操作系统称为虚拟机器。
当然,为了允许用户告诉操作系统要做什么,从而利用虚拟机的特性(如运行程序、分配内存或访问文件),操作系统还提供了一些可以调用的接口(APIs)。实际上,一个典型的操作系统会导出几百个系统调用(system calls),供应用程序使用。因为操作系统提供这些调用来运行程序、访问内存和设备以及其他相关操作,所以我们有时也说操作系统为应用程序提供了一个标准库。
最后,由于虚拟化允许许多程序运行(从而共享CPU),允许许多程序并发地(concurrently)访问它们自己的指令和数据(从而共享内存),允许许多程序访问设备(从而共享磁盘等等),所以操作系统有时被称为资源管理器。每个CPU、内存和磁盘都是系统的资源;因此,操作系统的角色是管理这些资源,高效、公平地完成这些工作,或者实际上考虑到许多其他可能的目标。为了更好地理解操作系统的角色,让我们看一些示例。
2.1 CPU虚拟化 Virtualizing The CPU
图2.1描述了我们的第一个程序。它没有什么作用。实际上,它所做的就是调用Spin(),这个函数会重复检查时间并在运行一秒钟后返回。然后,它打印出用户在命令行上传递的字符串,并不断重复。
假设我们将这个文件保存为CPU.c,并决定在一个只有单个处理器(有时我们称之为CPU)的系统上编译并运行它。下面是我们将要看到的:
不是很有趣的运行,系统开始运行程序,它反复检查时间,直到一秒过去。一旦过了一秒,代码打印用户传入的输入字符串(在本例中是字母A),并继续。注意,该程序将永远运行,通过按“Control-c”(在基于unix的系统中,这将终止在前台运行的程序),我们可以停止程序。
现在,让我们做同样的事情,但这一次,让我们运行这个程序的许多不同的实例。图2.2显示了这个稍微复杂一点的示例的结果。
好吧,现在事情变得更有趣了。尽管我们只有一个处理器,但不知怎么的,这四个程序似乎同时在运行!这种奇迹是如何发生的?
结果是操作系统,在硬件的帮助下,控制了这种错觉,即,系统拥有大量虚拟cpu的错觉。将一个CPU(或一小部分CPU)变成看似无限数量的CPU,从而允许许多程序同时运行,这就是我们所说的CPU虚拟化,这也是本书第一部分的重点。
当然,要运行和停止程序,或者告诉操作系统运行哪些程序,需要有一些接口(APIs),您可以使用这些接口将您的愿望传达给操作系统。我们将在本书中讨论这些APIs;事实上,它们是大多数用户与操作系统交互的主要方式。
您可能还注意到,一次运行多个程序的能力会引发各种各样的新问题。例如,如果两个程序想在特定时间运行,应该运行哪个?这个问题可以通过操作系统的策略来回答;在一个操作系统中,许多不同的地方使用策略来回答这些类型的问题,因此我们将在了解操作系统实现的基本机制(例如一次运行多个程序的能力)的同时研究它们。因此,操作系统的角色是资源管理器。
请注意我们如何使用 & 符号同时运行四个进程。这样做会在 zsh shell 的后台运行一个任务,这意味着用户能够立即发出他们的下一个命令,在这种情况下是另一个要运行的程序。如果您使用不同的 shell(例如 tcsh),它的工作方式略有不同;有关详细信息,请在线阅读文档。
2.2 内存虚拟化 Virtualizing Memory
现在让我们来看看内存。现代机器提出的物理内存模型非常简单。内存只是一个字节数组;要读存储器,必须指定一个地址以便能够访问存储在那里的数据;要写入(或更新)内存,还必须指定要写入到给定地址的数据。
程序运行时一直在访问内存。程序将其所有数据结构保存在内存中,并通过各种指令访问它们,例如加载和存储或其他显式指令在执行其工作时访问内存。不要忘记程序的每条指令也在内存中;因此在每次取指令时都会访问内存。
让我们看一个程序(见图2.3),它通过调用malloc()分配一些内存。这个程序的输出可以在这里找到:
这个程序做了几件事。首先,它分配一些内存(第a1行)。然后,它打印出内存的地址(a2),然后将数字0放入新分配的内存(a3)的第一个插槽中。最后,它进行循环,延迟一秒钟并递增存储在p中地址的值。对于每一条打印语句,它还打印出运行程序的进程标识符(PID)。这个PID对于每个运行的进程是唯一的。
同样,第一个结果也不是很有趣。新分配的内存在地址0x200000。当程序运行时,它会缓慢地更新值并打印出结果。
现在,我们再次运行同一程序的多个实例,看看会发生什么(图 2.4)。我们从示例中看到,每个正在运行的程序都在相同的地址(0x200000)分配了内存,但每个程序似乎都在独立更新 0x200000 处的值!就好像每个正在运行的程序都有自己的私有内存,而不是与其他正在运行的程序共享相同的物理内存。
要使本例生效,您需要确保禁用地址空间随机化;事实证明,随机化可以很好地防御某些类型的安全漏洞。请自己阅读更多关于它的内容,特别是如果您想学习如何通过堆栈粉碎攻击闯入计算机系统。并不是说我们会推荐这样的事情……
事实上,当操作系统虚拟化内存时,这就是正在发生的事情。每个进程访问自己的私有虚拟地址空间(有时也称为其地址空间),OS以某种方式将其映射到机器的物理内存中。一个正在运行的程序中的内存引用不会影响其他进程(或操作系统本身)的地址空间;对于正在运行的程序来说,它拥有自己的物理内存。然而,实际情况是物理内存是由操作系统管理的共享资源。这一切是如何完成的也是本书第一部分的主题,即虚拟化主题。
2.3 并发 Concurrency
本书的另一个主题是并发(concurrency)。我们使用这个概念术语来指代在同一个程序中同时(即同时)处理许多事情时出现并且必须解决的许多问题。并发问题首先出现在操作系统本身;正如您在上述虚拟化示例中所见,操作系统同时处理许多事情,首先运行一个进程,然后运行另一个进程,依此类推。事实证明,这样做会导致一些深刻而有趣的问题。
不幸的是,并发性的问题不再局限于操作系统本身。事实上,现代多线程程序也存在同样的问题。让我们用一个多线程程序的例子来演示(图2.5)。
尽管您现在可能还不能完全理解这个示例(我们将在后面的章节中,在书中关于并发的部分中学习更多关于它的内容),但其基本思想很简单。主程序使用Pthread_create()创建两个线程。您可以将线程看作与其他函数在相同的内存空间中运行的函数,同时有多个线程处于活动状态。在这个例子中,每个线程开始在一个名为worker()的例程中运行,在这个例程中,它只是在一个循环中增加counter loops次。
实际调用应该是小写的pthread_create();大写版本是我们自己封装过的,它调用pthread_create()并确保返回代码表明调用成功。有关详细信息,请参阅代码。
下面是将变量loops的输入值设置为1000时运行该程序的记录。loops的值决定了两个workers在循环中增加共享counter的次数。当程序运行时循环的值设置为1000,您希望counter的最终值是多少?
正如您可能猜到的,当两个线程完成时,counter的最终值是2000,因为每个线程将counter增加1000次。实际上,当循环的输入值被设置为N时,我们会期望程序的最终输出为2N。但事实证明,生活并非如此简单。让我们运行相同的程序,但是使用更高的for loops值,看看会发生什么?
在这次运行中,当我们给输入值 100000 时,不是得到最终值 200000,而是首先得到 143012。然后,当我们第二次运行程序时,我们不仅再次得到错误的值,而且与上次不同的值。事实上,如果你用高loops值一遍又一遍地运行程序,你可能会发现有时你甚至会得到正确的答案!那么为什么会发生这种情况呢?
事实证明,产生这些奇怪和不寻常结果的原因与指令的执行方式有关,每次执行一个指令。不幸的是,上面程序的一个关键部分,即共享counter是递增的,需要三个指令:一个将counter的值从内存加载到寄存器中,一个将其递增,还有一个将其存储回内存中。因为这三条指令不是原子地(atomically)执行的(同时执行),所以可能会发生奇怪的事情。我们将在本书的第二部分详细讨论并发性问题。
问题的关键: 如何建立正确的并发程序 当在同一个内存空间中有许多并发执行的线程时,我们如何构建一个正确工作的程序?操作系统需要哪些基本元素?硬件应该提供什么机制?我们如何使用它们来解决并发问题。
2.4 持久化 Persistence
这门课的第三个主题是持久化。在系统内存中,数据很容易丢失,因为DRAM等设备以易失性的方式存储值;当断电或系统崩溃时,内存中的所有数据都会丢失。因此,我们需要硬件和软件能够持久地存储数据;这样的存储对于任何系统都是至关重要的,因为用户非常关心他们的数据。
硬件以某种输入/输出或I/O设备的形式出现;在现代系统中,硬盘驱动器是长期存在的信息的常见存储库,尽管固态驱动器(ssd)也在这一领域取得了进展。
操作系统中通常用来管理磁盘的软件称为文件系统;因此,它负责以可靠和有效的方式将用户创建的任何文件存储在系统的磁盘上。
与操作系统为CPU和内存提供的抽象不同,操作系统不会为每个应用程序创建一个私有的虚拟化磁盘。相反,它假设用户经常想要共享文件中的信息。例如,在编写C程序时,你可能首先使用编辑器(例如Emacs7)来创建和编辑C文件(emacs -nw main.c)。一旦完成,你可以使用编译器将源代码转换为可执行文件(例如,gcc -o main main.c)。完成后,可以运行新的可执行文件(例如,./main)。因此,您可以看到文件是如何跨不同进程共享的。首先,Emacs创建一个文件作为编译器的输入;编译器使用该输入文件来创建一个新的可执行文件(在许多步骤中采用编译器过程来了解细节);最后,运行新的可执行文件。一个新的程序就这样诞生了!
您应该使用Emacs。如果您正在使用vi,那么您可能有问题。如果您使用的不是真正的代码编辑器,那就更糟糕了。
为了更好地理解这一点,让我们看一些代码。图2.6给出了创建包含字符串”hello world”的文件(/tmp/file)的代码。
为了完成这个任务,程序对操作系统进行了三次调用。第一个是调用open(),打开文件并创建它;第二个是write(),将一些数据写入文件;第三个是close(),它只是关闭文件,从而表示程序不会再向它写入任何数据。这些系统调用被路由到操作系统中称为文件系统的部分,然后文件系统处理请求并向用户返回某种错误代码。
您可能想知道,为了实际写入磁盘,操作系统做了什么。我们可以给你看,但你得答应先闭上眼睛;就是那么不愉快。文件系统必须完成相当多的工作:首先确定新数据将驻留在磁盘的哪个位置,然后在文件系统维护的各种结构中跟踪它。这样做需要向底层存储设备发出I/O请求,以读取现有结构或更新(写)它们。任何编写过设备驱动程序的人都知道,让设备代表您做一些事情是一个复杂而详细的过程。它需要对底层设备接口及其确切语义有深入的了解。幸运的是,操作系统提供了一种通过系统调用访问设备的标准和简单方法。因此,操作系统有时被视为一个标准库。
设备驱动程序是操作系统中的一些代码,它知道如何处理特定的设备。稍后我们将更多地讨论设备和设备驱动程序。
当然,关于如何访问设备以及文件系统如何在设备上持久地管理数据还有很多细节。出于性能原因,大多数文件系统首先将此类写入延迟一段时间,希望将它们批处理到更大的组中。为了处理写过程中系统崩溃的问题,大多数文件系统都采用了某种复杂的写协议,比如日志记录(journaling)或写时复制(copy-on-write),仔细地对磁盘的写进行排序,以确保如果在写过程中发生故障,系统可以随后恢复到合理的状态。
为了使不同的常用操作更有效,文件系统使用了许多不同的数据结构和访问方法,从简单的列表到复杂的b-树。如果这一切还不合理,那就好了!在这本关于持久性的书的第三部分中,我们将详细讨论所有这些内容,我们将一般地讨论设备和I/O,然后详细讨论磁盘、RAIDs和文件系统。
问题的关键: 如何持久地存储数据 文件系统是操作系统中负责管理持久数据的部分。要正确地做到这一点需要什么技术?需要什么样的机制和策略来实现高性能?在硬件和软件出现故障时,如何实现可靠性
2.5 设计目标 Design Goals
现在您对操作系统的实际功能有了一些了解:它占用物理资源,如CPU、内存或磁盘,并对它们进行虚拟化。它处理与并发性相关的棘手和棘手的问题。而且它会持久地存储文件,从而使它们在长期内是安全的。考虑到我们想要构建这样一个系统,我们想要在脑海中有一些目标,以帮助我们集中设计和实现,并在必要时做出权衡;找到正确的权衡是构建系统的关键。
最基本的目标之一是建立一些抽象,以使系统方便和易于使用。抽象是我们在计算机科学中所做的一切的基础。抽象使编写大型程序成为可能,将其划分为易于理解的小块,使用 C 等高级语言编写这样的程序而不考虑汇编,编写汇编代码而不考虑逻辑门,以及构建一个没有过多考虑晶体管的处理器。抽象是如此基本,以至于有时我们会忘记它的重要性,但我们不会在这里;因此,在每一节中,我们将讨论一些随着时间的推移而发展起来的主要抽象,让您可以思考操作系统的各个部分。
你们中的一些人可能反对将 C 称为高级语言。请记住,这是一门操作系统课程,我们很高兴不必一直在汇编中编码!
设计和实现操作系统的目标之一是提供高性能;换句话说,我们的目标是最小化操作系统的开销。虚拟化和使系统易于使用是值得的,但不是不惜任何代价;因此,我们必须努力在没有过多开销的情况下提供虚拟化和其他操作系统特性。这些开销以多种形式出现:额外的时间(更多的指令)和额外的空间(内存或磁盘上)。如果有可能,我们将寻求使其中一个或两个最小化的解决方案。”然而,完美不是总能达到的,我们将学会注意并(在适当的地方)容忍它。
另一个目标是在应用程序之间以及操作系统和应用程序之间提供保护。因为我们希望允许多个程序同时运行,我们希望确保其中一个恶意或意外的不良行为不会伤害到其他人;我们当然不希望一个应用程序能够伤害操作系统本身(因为那会影响系统上运行的所有程序)。保护是操作系统基本原则之一的核心,即隔离;将进程彼此隔离是保护的关键,因此是操作系统必须做的许多事情的基础。
操作系统还必须不停地运行;当它失败时,系统上运行的所有应用程序也会失败。由于这种依赖性,操作系统常常努力提供高度的可靠性。随着操作系统变得越来越复杂(有时包含数百万行代码),构建一个可靠的操作系统是一个相当大的挑战,事实上,该领域正在进行的许多研究(包括我们自己的一些工作[BS+09, SS+10])都专注于这个问题。
其他目标也有意义:在我们这个日益绿色的世界里,能源效率很重要;针对恶意应用程序的安全(实际上是保护的扩展)至关重要,尤其是在这个高度网络化的时代;随着操作系统运行在越来越小的设备上,移动性变得越来越重要。根据系统的使用方式,操作系统将有不同的目标,因此可能以至少略有不同的方式实现。然而,正如我们将看到的,我们将介绍的关于如何构建操作系统的许多原则在各种不同的设备上都是有用的。
2.6 一些历史
2.7 总结
Summary
因此,我们对操作系统进行了介绍。今天的操作系统使系统相对容易使用,实际上,你今天使用的所有操作系统都受到了我们将在整本书中讨论的发展的影响。
不幸的是,由于时间的限制,有许多操作系统的部分我们不会在本书中介绍。例如,在操作系统中有大量的网络代码;我们把它留给你去上网络课来学习更多。同样,图像设备也非常重要;参加图形课程,在这个方向扩展你的知识。最后,一些操作系统书籍对安全做了大量的讨论;我们将在操作系统必须提供运行程序之间的保护,并给予用户保护他们的文件的能力的意义上这样做,但我们不会深入探讨可能在安全课程中发现的安全问题。
这是在安全课程中发现的。但是,我们将讨论许多重要的主题,包括CPU和内存虚拟化的基础知识、并发性以及通过设备和文件系统实现的持久性。别担心!虽然有很多地方要讲,但大部分都很酷,在这条路的尽头,你会对计算机系统如何真正工作有一个新的认识。现在开始工作吧!
Homework
这本书的大部分章节(最终,所有的章节)在最后都有家庭作业部分。做这些作业是很重要的,因为每一项作业都能让你,作为读者,对这一章中提出的概念获得更多的经验。
有两种类型的家庭作业。第一种是基于模拟。计算机系统的模拟只是一个简单的程序,它假装执行真实系统所做的一些有趣的部分,然后报告一些输出指标以显示系统的行为方式。例如,硬盘驱动器模拟器可能会处理一系列请求,模拟它们需要多长时间才能得到具有某些性能特征的硬盘驱动器的服务,然后报告请求的平均延迟。
模拟很酷的一点是,它们可以让您轻松探索系统的行为方式,而无需运行真实系统。事实上,它们甚至可以让您创建现实世界中不存在的系统(例如,具有超乎想象的快速性能的硬盘驱动器),从而看到未来技术的潜在影响。
当然,模拟并非没有缺点。就其本质而言,模拟只是真实系统行为的近似。如果忽略了真实世界行为的一个重要方面,模拟将报告糟糕的结果。因此,对模拟的结果总是要持怀疑态度。最后,系统在现实世界中的行为才是最重要的。
第二种作业需要与现实世界的代码进行交互。其中一些作业侧重于测量,而其他作业只需要一些小规模的开发和实验。这两种方法都只是对您应该进入的更大领域的小小探索,也就是如何在基于unix的系统上用C编写系统代码。事实上,更大规模的项目,超出这些家庭作业,需要推动你在这个方向;因此,除了做作业,我们强烈建议你做项目来巩固你的系统技能。一些项目请参阅此页面(https://github.com/remzi-arpacidusseau/ostep-projects)。
要完成这些作业,您可能必须在一台基于unix的机器上,运行Linux、macOS或其他类似的系统。它还应该安装一个C编译器(例如,gcc)和Python。您还应该知道如何在某种真正的代码编辑器中编辑代码。