P1:第 1 讲 操作系统是什么 - RubatoTheEmber - BV1L541117gr
好的,欢迎大家来到 162。
我是这学期的讲师之一,稍后 Anthony Joseph 也会发言。我们很遗憾必须以虚拟的方式开始学期,但这就是现状。我们会尽快转为面对面授课。那么,话不多说。对于那些在讲座中有问题的人,可以随时提问。
请把问题放在聊天框里,我会留意聊天。这么一来,我们就能确保一切正常进行。无论如何,欢迎来到 CS162。今天我们会聊一聊我们要做的事情。我们将讨论什么是操作系统,什么不是操作系统。
我们希望能说服你们,为什么操作系统是如此令人兴奋的学习内容。当然,我们也会说一下这门课的运作方式。互动很重要,这是我们鼓励的。虽然在 Zoom 上有点难,特别是在第一天,大家都还在适应。
我们大致回顾一下我们将要涵盖的材料。但特别是当我们回到面对面授课时,我会鼓励大家,Anthony 也会鼓励大家。你们都可以提出问题。好了,我们从这张已经有 20 年历史的图片开始。一个更新版的图片可能会更加疯狂。但有谁知道这是什么吗?是的。
如果你仔细看,会看到它写着“互联网”,对吧?
有趣的是,人们可以很容易地认为,互联网是人类文明中最伟大的成果之一。其原因在于,它基本上将全球的计算机组件连接成了一个庞大的系统。因此,复杂度开始带来各种有趣的现象。
这种新兴的行为,我们稍后会谈到,但显然,安全问题也是其中的一部分。所以,当你思考互联网时,它真的令人惊讶。它基本上将这些汽车、手机、云计算等所有东西联系在一个庞大的系统中,而所有的人当然也包括其中。
如果你真的绘制出随着时间推移的人数和 IP 地址的数量,会发现它基本上一直是以这种指数形式增长的,从一开始就是这样,对吧?
1995 年左右,或者说是网络的起点。看起来,互联网地址将只有在每个人至少拥有一个地址时才会饱和,可能还会有很多个地址。当然,你们可能已经很熟悉 IPv4,这个 32 位地址空间,实际上早已达到饱和状态,而 IPv6 可能终于会普及。这个话题可能是另一节课的内容。另一个值得注意的是,全球范围内有这个庞大的系统。
它包含了许多组件,但我们也拥有各种不同的元素,对吧?
我们涵盖了从大型机到工作站,再到个人电脑等所有设备。这个普遍的趋势非常有趣。我们从每人一台计算机的情况开始,到现在每个人可能拥有数百或数千台计算机。所以,这有时被称为物联网,指的是你车上的传感器等。
还有你家中的墙壁等等。这在过去几十年里发生了令人难以置信的变化,现在我们甚至没有意识到互联网上的所有东西,它们在监控我们,这引发了各种隐私问题。但是,这个令人震惊的曲线,在对数线性尺度上向下移动的趋势,。
当人们谈论事物增长的速度时,这一点常常没有被指出。而且,时间尺度的范围也是令人吃惊的,对吧?
从半纳秒或皮秒级别的CAF引用,到跨越全球传输所需的毫秒,甚至在某些情况下是秒级时间。这么广泛的时间范围,最终操作系统,作为这门课程的核心,将不得不处理这些问题。所以这也非常有趣。好的。
所以我们现在正处于一个非常激动人心的时代,显然你们选了正确的课程来学习这个。操作系统本身正是这一切的核心。因此,它们基本上把所有这些令人难以置信的进步,我刚才展示给你们的那些小东西,许多东西,甚至一些非常大的东西,都整合成一个完整的系统。
正是操作系统让我们能够管理这一切。因为如果没有它,你实际上是无法使用这些东西的。好的,而操作系统,正如我们在本学期将要学习的,它几乎为应用程序和程序员提供了统一的抽象,尽管硬件各不相同。
它被广泛应用。它使你能够管理在多个应用程序之间共享的资源,同时还能够处理安全问题。而一些关键的构建模块,我们将在本学期深入讨论的内容,诸如进程、线程、并发、调度、协调、所有这些内容、地址空间、保护和安全等。
这些你可能听过的词汇,你可能并没有深入理解它们真正的含义以及它们是如何实现的。对我来说,这门课程的一个令人兴奋的地方是,由于我们将与一个真实的操作系统一起工作,稍后我们会修改一个真正的操作系统。
你会近距离观察到这些构建模块如何实际被实现,然后,当然,如何将它们整合在一起。好了,举个例子,你每天都做的事情。你拿出手机,搜索一些内容。好的,这很常见。然而,背后发生了什么呢?其实,发生的是:
例如,手机会连接到DNS服务,DNS是层级化的,因此可能有多个服务器连接以获取响应。它的作用是将你可读的人类服务名称转化为IP地址,之后可以用来通过互联网不断跳转到某个地方。
可能是一个数据中心,你将在入口点遇到负载均衡器。你完全没有意识到,负载均衡器会选择下一个跳点来转发这个信息。然后,也许有一些定制的服务器在忙于执行你的搜索或寻找信息。
找到你感兴趣的信息。可能会生成一些广告,并且其他的合成也会发生在最终网页上,生成一个包含你结果的页面,也许还有一些广告,然后这些结果会通过同样的路径回传,通常是通过互联网返回到手机。这件你每天都会利用的简单事情,实际上有很多非常有趣的环节。
有时候,当我过于深入地思考发生了什么时,我会感到震惊,所有的东西竟然能正常运作。我认为,随着我们深入探讨这些内容,了解所有这些必须协调工作的事情以便你的查询能正常工作,或是其他更有趣的内容时,我希望你能感受到一些惊奇。
我也从中感到惊奇。我喜欢系统,并且我认为像这样的一个大系统能够正常运作,实在是太神奇了。我希望安东尼和我能给你一些关于它如何运作的感觉。那么,操作系统是什么呢?嗯,这并不是每个人都能清楚理解的。那么我是什么意思呢?我指的是:
不同的人有不同的看法。如果你查看关于操作系统是什么的所有想法或民意调查的所有回应,你可能会听到其中的一些内容。它进行内存管理,管理输入输出,调度CPU,也许它还进行通信,也许它处理多任务或多程序。所以,它有很多组成部分。
但这并不是操作系统的定义。它的作用是什么呢?像文件系统或多媒体支持,视频处理等功能,如何解释这些部分呢?
那么用户界面呢?互联网浏览器呢?
这是操作系统的一部分吗?我这里有一个小小的笑脸,因为曾经微软宣称浏览器是他们操作系统的一个基本部分,引发了各种关于这一点是否成立的讨论。这种争论经常会出现,挺有意思的。你可能会问,这对于学术界的人才有趣吗?
这对于学术界的人来说肯定有趣,但有时反垄断问题也会介入。如果微软宣称互联网浏览器是他们操作系统的一部分,而结果每个人都只使用他们的浏览器,这可能会涉及一些反垄断问题。因此,不管怎样,我希望在接下来的讨论中至少能给你提供正确的指引。
如果有人给你一个操作系统的定义,经过一段时间你至少会有一个评估他们说法的方法。所以,实际上并没有一个被普遍接受的操作系统定义。有时人们会说,操作系统就是厂商在你订购操作系统时提供的所有东西。
也许人们会同意的一点是,操作系统有一个叫做内核的部分,通常是计算机上始终运行的唯一程序,它像是操作系统的核心,可能其他的东西要么是随操作系统一起发布的系统程序,要么就是应用程序。这种定义还不错,但我们会在后续进一步探讨。
所以操作系统的定义也可以包括像是层次结构。举个例子,他们可能会说操作系统中有一层特殊的软件,提供通过便捷的抽象访问硬件资源,这样复杂的硬件就变得容易使用了。也许它还保护对资源的访问。
也许它提供了这些资源的安全性和认证,确保只有授权的人可以使用它们。也许它提供了通信功能,例如TCP/IP协议,你肯定听说过,我们会在课程进行时进一步讨论,但也许这些抽象层次也是操作系统的一部分。
所以你可以这样看待,底层实际上是硬件,操作系统是介于应用程序(这些是你想要完成的任务)和复杂杂乱的底层硬件之间的一个层。
那么“操作”这个词是什么意思呢?在很久以前,人们打电话的方式是这样的。你会与一个总机接线员通话,他或她实际上会将电话插到接线板上。
曾经有计算机操作员,也许那就是“操作员”一词的来源,但今天,当人们谈论操作系统时,他们实际上是在谈论那种协调所有事物的软体,就像我们所说的那样。
这基本上就是我们接下来要讨论的内容。那么,系统部分是什么呢?
所以在这里操作的是操作硬件的那个东西。
系统部分也是一个有趣的问题,那么什么是一个系统呢?
许多相互关联的部分通常构成一个系统。每个部分都可能与其他部分互动,当系统足够庞大时,有时你最终得到的实际交互结果并不是你预期的。这也是拥有一个系统时的一个有趣方面,就是当它开始变得……
新兴行为是我们在这门课程中将要讨论的内容之一,实际上这与驯服这种新兴行为有关,真正的鲁棒性需要一种工程思维方式。我希望安东尼和我能够给你们提供一种工程思维的良好体验。所以你必须非常仔细地处理错误。你需要小心恶意或不小心的用户,而且你必须非常小心。
当你开始进入锁定机制时,我们将设计以确保正确性,而不是设计某个东西并多次测试,期望它是正确的。所以我们将设计一个正确的系统。所以系统编程将是这门课程中非常重要的一部分,我们将会……
为了让你们从一开始就能有所了解,我们首先从用户的角度来看如何与像Linux或Unix风格的操作系统进行交互,给你们一种从上方看系统的感觉,然后我们将深入并迅速地探索系统底层的内容。
比如说,我们可以讨论硬件与软件的接口,这实际上可以被看作是虚拟化的问题。所以我们有一个在上面运行的程序,而下面有复杂的硬件,我们希望能让软件访问这个硬件,以便执行受控的操作。你们应该都已经学过61C了,假设你们都知道硬件里都包含了什么。
系统中有一个处理器,有一些内存可以存储许多不同类型的数据。也许有一部分内存是专门为操作系统保留的,每个不同的颜色这里可能代表着一个不同的进程,而那个处理器有一组寄存器。这些寄存器可以用来访问内存,因此可能会存储地址。
这些寄存器也可以存储到内存中,以便我们可以暂停一个进程,运行另一个任务,这将是我们即将讨论的多路复用多任务的一部分。当然,这里有一个缓存,能加速处理,我们也会稍微谈一下这个。页面表让我们能够进行地址转换,从而获取虚拟内存,然后,你知道,一旦你……
低于处理器内存级别的所有硬件都变得非常有趣。你知道存储设备、网络、显示器等各种设备,所有这些设备通过总线连接,控制器让你能够控制这一切。所以,如果你真正去看计算机内部,你会看到很多东西,然后当你将其扩展到互联网时……
这里还有更多的东西,问题的关键是如何编程。复杂性太高了,你知道,这就是架构指令在这里发挥作用的地方。好吧,它基本上是在处理器内部进行操作,并提供足够的标准化,以便程序可以希望在其上运行,但它也……
这将是操作系统的作用,它将为我们提供干净的边界和虚拟化视图,以帮助应用程序更容易地编写。因此,你在61c课程中学到的是机器结构、C语言,而操作系统将会把你在61c中学到的所有内容抽象化,以帮助应用程序的编写更加简便。
如果我们进一步讨论操作系统中包含的内容,我们可能会从它的幻觉性质开始谈起,因为操作系统提供了清晰且易于使用的抽象。
物理资源的管理。如果我们回看之前的幻灯片,所有这些内容——好吧,许多不同的设备,不同的处理器,不同的能力,有些处理器有GPU,有些没有——所有这些复杂性都需要以一种干净的方式进行虚拟化,使得编写程序变得可能。
程序,这实际上就是解决方案。操作系统提供了一种干净且易于使用的资源抽象,而这些抽象是你在使用时可能不太会考虑的。比如你们中那些写过并编译过程序的人,可能会想到像无限内存这样的东西,因为你们不太担心马上就会用完内存,你可能会觉得……
文件、用户和消息,这些都是在底层硬件中并不存在的东西,它们是由操作系统作为易于使用的抽象体组成的。那么,如何实现机器的虚拟化呢?我将这台机器给压缩成了一个模型,操作系统将在硬件之上运行,成为那一关键的软件部分。
并使得应用程序易于工作。例如,我们从操作系统中得到什么?好吧,取代单个处理器(即 CPU),我们得到线程;取代那些没有很好地绑定在一起的字节的内存,我们得到地址空间,并且它们有保护域;取代存储中的随机块,我们得到。
文件和一些有趣的讲座将会介绍存储设备,以及我们如何从中构建出一个良好的文件系统。这样,取而代之的是通过以太网发送的随机消息,这些消息可能随时丢失,我们可以获得干净的插座抽象,它们是消息队列,你可以将消息放入一侧,它们会保证在另一侧出现,等等。
硬件的虚拟化视图是我们可以拥有一个进程,这是一个由操作系统提供的具有受限权限的执行环境,而这个进程是一个干净的容器。它有一个地址空间,这是一个干净的存储块;它有多个线程,即使在单个 CPU 上也能很好地多路复用;它有这些文件,它有这些插座。
当你编写程序时,你是在写进程抽象,而不是这些复杂的硬件组件。所以你将编译后的程序放在进程上。好吧,它链接了一些系统库,一旦你编译、链接并开始运行,它实际上就变成了进程。所以,嗯,稍微阐述一下这个概念。
随着学习的深入,你将能够更清楚地区分程序和进程。例如,程序是一个潜在的东西,一旦实例化并开始运行,它就变成了进程。所以,应用程序机器就是操作系统提供的进程抽象。
每个正在运行的程序都有自己的进程,而进程提供了这个良好的接口。它比原始硬件要好,所以这就是我们从这些幻灯片中得到的结论。而这门课的一个有趣之处在于,我们不仅会讨论这些抽象概念,还会展示它们是如何实现的,你将亲眼看到。
我们将展示如何从硬件的底层位、块和复杂性,过渡到一个实际上可以在不同机器上使用的编程环境。程序员的视角是,程序将在进程中运行,因此他们编写一些程序,编译它们,链接一些库,结果就是你得到了。
进程是可以在各种硬件实例上,甚至在不同操作系统之间运行的程序的抽象。我们稍后会讨论如何实现这一点,然后,编译器将帮助我们把C语言程序转换为二进制,这实际上是底层机器所需要的。
我希望你们中的很多人还记得61C课程中的C语言以及编译和链接的概念。你们需要迅速掌握这些内容,因为我们将在这门课中以较快的节奏开始,稍后我会提到,我们将会有一个C语言复习环节,时间还未确定,但可能会在本周末或下周进行。
我们将尽快让你开始运行基本程序、编译代码并构建操作系统,以便你在学习其他内容时,能够顺利进行。现在在聊天中有个问题问到:如果没有上过61C,我能做得好吗?可以的,在某些情况下可能会有些挑战。
你知道,如果你没有上过61C课程并且打算直接学习这门课,我建议你尽量找到一套幻灯片并快速复习,因为你需要了解一些关于CPU如何工作、C语言的基础知识等等。这不是不可能完成的,过去几年的教学经验表明是可以做到的。
61C课程并不足以让你真正掌握C语言的工作知识,因此我们在前几周为你们提供了大量资源,希望能够帮助你们迅速跟上C语言的进度。希望我已经回答了这个问题。那么,什么是进程呢?请记住,进程是我们的虚拟化环境,进程……
进程由地址空间、一个或多个控制线程在地址空间内执行,以及各种附加的系统状态(如打开的文件、套接字等)组成。安东尼也提到过,你可以在课程资源页面找到很棒的电子书,这是正确的。感谢安东尼提到这一点,大家应该去查看资源页面。
伯克利图书馆数字图书馆拥有所有O’Reilly书籍的授权,这些书籍很酷,封面上有动物图案。我们在C语言入门书籍上发布了几本不错的书,你可以从资源页面上找到它们,只要你是从伯克利的IP地址访问,或者你通过虚拟VPN连接到伯克利以进入图书馆,就应该能够访问这些资源。
举个例子,进程是由一个或多个控制线程和附加的系统状态组成的。例如,如果你使用的是Mac,查看所有进程的集合时可能会看到这样的情况,或者你可以在Windows上使用任务管理器,或者在Linux上运行ps -aux,这将列出所有进程。有趣的是要注意。
即使在空闲时,你的机器上也有很多进程在运行,这是很有趣的。你应该看看你的机器上到底有多少进程在运行。好的,很多,而且大部分进程并不是一直在运行,它们大部分时间处于休眠状态,但它们会在某些事件发生时被唤醒,运行一段时间后再回到休眠状态,这样你会更清楚了解它是如何工作的。
这一切都会在我们接下来的课程中讲解清楚,所以操作系统对世界的看法实际上是这样的。
操作系统控制着下面的硬件,并提供了一系列进程,每个进程都有自己的地址空间、线程、文件、套接字等,彼此之间是独立的,因此这是一个虚拟化环境,我们将在接下来的讲解中告诉你它是如何工作的。好的,到目前为止有任何问题吗?
好的,所以操作系统……好问题,线程是否唯一属于进程?每个进程都有一组线程在其中运行。地址空间指的是在进程内部可以访问的所有地址的集合。我们接下来会更加正式地说明这一点,它是与其他进程的地址空间相互隔离的。
默认情况下,除非你特别打开通道,否则进程一可以访问的地址和底层内存实际上与进程二能够访问的内存是不同的,因此地址空间将成为我们保护进程之间相互隔离的关键概念之一。好的,每个进程都有一组线程。
每个进程都有一个独特的地址空间。还有一个问题,单核机器能同时运行多个进程吗?完全可以。好的,我们会教你如何实现的。所以,即使你只有一个单核处理器,你仍然可以运行多个进程,它们看起来像是同时运行的,这就是并发,我们将展示如何通过添加多个核心来实现这一点。
这只是允许更多的进程真正同时运行,而不是看似同时运行。好的,所以我们看到这里有一个问题,进程中的文件是否与硬盘上的文件不同?答案是,你在进程中看到的是一组打开的文件,这些文件最终存储在磁盘上。
磁盘上的任何进程文件都由磁盘支持,我们将在深入讨论文件系统时进一步探讨。嗯,操作系统将硬件接口转化为应用程序接口,为每个进程提供独立运行的程序。所以,接下来…
这就是所谓的虚拟化底层硬件的幻觉。
这样,当程序运行时,它将有一个更加干净的容器来执行。
另外,操作系统做的另一件事是,在提供干净、无限内存等虚拟化的同时,它充当了裁判的角色,管理资源的保护、隔离和共享,处理诸如资源分配和进程间通信等问题。嗯,所以这种裁判角色实际上是操作系统最重要的功能之一。
正如我所说,操作系统会保护进程,防止它们互相覆盖内存或干扰对方。好的,如果某些文件只有特定用户可以访问,那么…
操作系统将保护那些文件不被其他用户访问等。所以,我展示的…
为了更清楚地说明,我现在有一个棕色进程和一个绿色进程。高亮显示和颜色实际上在展示这些是不同的保护域。举个例子,现在回到那个问题,单核机器是否可以运行多个进程?我的回答是可以,我们来看看这个想法。所以我们有一个…
例如,单核处理器目前正在运行在棕色领域中。或者进程,所以现在有一个特定的线程正在执行,而当前的线程拥有一些寄存器和内存,这就是这个保护域。由于现在是棕色进程处于活动状态,如果它尝试访问…
所以,如果某部分内存是进程二独占的,那就不允许访问。这是保护的一部分。因此,如果我们想切换,假设我们现在正在运行棕色线程在棕色进程中,我们想给绿色进程一些时间运行,我们该怎么办?我们基本上需要打包所有棕色进程的状态,…
加载绿色进程的状态。这里我展示的是内存,记住,内存是硬件部分,除了由棕色进程独占的内存块之外,还有一些绿色内存块是由进程二独占的。所以,如果这个棕色线程试图访问这些内存,它将会遇到所谓的分段错误(segmentation fault)或页面错误(page fault)。
我们将展示它们之间的区别,然后它们会失败,好吧。因此,操作系统的一部分工作就是跟踪谁拥有什么资源,如果我们即将切换并让绿色进程运行一段时间,我们需要做什么呢?我们需要将当前的寄存器版本存储到操作系统内存中。
内存可能处于一种线程结构中,然后我们需要加载绿色状态,并且我们必须更改地址空间,使其对绿色进程可访问,然后在操作系统中执行所有这些操作,之后让进程重新运行。现在,绿色进程已经运行了,好吧。关于一个处理器上的单核是否能做到这一点的问题,答案是:是的,切换操作就是指从。
将处理器从运行棕色进程切换到运行绿色进程是操作系统的一部分。好吧,我们会更详细地讨论这一点,现在我们来谈谈保护机制。为了更清楚地说明这一点,举个例子,绿色进程正在运行,因为它被加载到处理器中,现在它试图访问许多资源,包括棕色资源。
内存、部分操作系统内存,也许还有一些存储。注意,所有这些内容可能会被禁止访问,因此绿色进程不应该访问棕色进程的内存,绿色进程不应该能够修改操作系统内存,除非有一些后续会谈到的例外情况。绿色进程也不应该访问磁盘,除非它确实应该拥有那些数据。
在某些情况下,操作系统会介入并终止进程,以防它进行不允许的访问。你将得到一个叫做分段错误(segmentation fault)的错误,那个进程将会被停止,并且通常会输出一些调试状态。好吧,有人对此有问题吗?
我看到Anthony也在聊天中回答一些问题,这很好。好吧,那么我猜问题是关于VirtualBox的,我们让Anthony继续处理这些问题。
回到我们关于保护的讨论,现在我展示了一些进程,嗯,一个处理器或核心,我们称之为一个核心,现在会有一个保护边界,嗯,所有这些东西——磁盘、显示器、网络、总线,所有这些内容——都会有一个保护边界,由操作系统组成,以确保这些进程只访问它们应该访问的内容。
也就是说,操作系统将进程彼此隔离,操作系统自己也会与其他进程隔离。嗯,这意味着一个进程不能破坏操作系统的内存,尽管在我展示的这个例子中,所有的内容都运行在同一个处理器上,我们仍然可以做到所有的保护。
所以,嗯,现在继续前进。
这就是裁判的概念,嗯,最终来说,操作系统,嗯,没错,这是一个好问题。
操作系统本身是否是一个进程呢?嗯,是也不是,这是一个不同的人可能会有不同看法的问题。所谓“进程”是什么意思呢?我可以告诉你,操作系统确实有多个线程在运行,并且它们的权限比所有进程线程都要高。因此,操作系统内部有很多不同的事情在并行发生。
它们对更多的内容有控制权,嗯,通常我们不称之为操作系统的一部分,但肯定有操作系统线程在运行,嗯,你可以在一两周后再问我这个问题,也许我们可以就此展开更多的讨论,但总的来说,为了结束这个话题,我不认为操作系统是一个进程,嗯,就我讨论的方式而言,操作系统是一个特殊的存在。
拥有一大堆线程,这些线程都在非常高的权限下工作,帮助生成对所有上层进程的虚拟视图,所以最终操作系统的概念就像是“胶水”一样,操作系统将这些东西连接在一起。
操作系统,嗯,像是一个魔术师和裁判,嗯,这些功能通常没有人会反对将其视为操作系统的一部分。真正有趣的是,一旦我们开始谈论“胶水”这个概念,嗯,这时候才会有一些分歧。胶水实际上是指那些帮助程序员更容易使用底层硬件的东西。嗯,胶水的真正意义是关于通用性的。
提供诸如存储和文件处理、提供窗口系统、提供网络等服务,嗯,你实际上可以从伯克利传输数据到北京然后再返回,并且它们实际上能成功传输。这些是程序员认为有用的服务,不同的操作系统,甚至相同操作系统的不同版本可能在这些服务上有所不同。
提供的服务,好的,通常,操作系统提供库和运行中的服务,帮助程序员更容易地使用系统,好的,嗯,关于“核心是否有专用硬件来运行操作系统线程”的问题,答案通常是否定的,好的,通常发生的情况是,运行用户级进程的核心会在需要时开始运行操作系统的线程。
定时器响起,程序开始运行,或者当操作系统中的进程(例如运行用户代码或应用程序代码的进程)进行系统调用时,事情就会开始运行,操作系统的线程会在核心上运行,等等。所以,我们会深入探讨更多细节,好的,一旦我们开始深入研究,你会对这一过程有更清晰的了解,嗯,所以……
这是个好问题,我现在想回答一下,就是操作系统如何信任一个进程,确保它会将控制权返回给操作系统?答案是,它并不信任,好的,事实上,操作系统通过控制定时器来实现这一点,定时器会自动触发并将控制权转交给操作系统,无论用户在做什么。因此,用户程序可能正在忙于计算最后的……
圆周率数字,这是我最喜欢做的事情之一,呃,操作系统仍然会在定时器响起时继续工作,好的,呃,我们稍后会详细讨论系统调用(syscalls)等内容,嗯,但在这里简单回答一下,系统调用不是定时器,系统调用是用户程序实际访问服务时发生的情况,所以当它进行文件系统的读写操作时,那就是系统调用。
系统调用,我们会展示它是如何工作的,所以,I/O,正如我们一直提到的,是一个重要的部分。
这就是操作系统所处理的内容,实际上,它将底层硬件的复杂性转化为一个清晰的抽象层。好,比如,当我们启动一个程序时,呃,我们可能会从存储中提取程序的部分内容并将其加载到内存中,以便它可以开始执行,或者当我们与远程的北京进行通信时……
伯克利,呃,操作系统将帮助协调 I/O,这样我们认为我们在做的事情,发送的字节流,实际上会作为一组数据包通过无线或有线网络传输。中间经过许多网络跳跃,最终到达目的地,比如北京。这些都由操作系统处理,好吗?这就是操作系统提供的另一项常见服务。
典型的,好的,呃,还有很多 I/O 服务,呃,最后我们有了外观和感觉。
一台典型的机器,你们都知道,你们有手机,有笔记本电脑,它提供了一组窗口、剪切和粘贴等功能,这通常是窗口的外观和感觉。在许多情况下,至少会有一些库提供这种窗口功能,有时这种窗口功能是在操作系统中实现的,我们将在课程中进一步讨论这一点。
另外,顺便说一下,Windows NT在2000年代初是一个微内核操作系统,实际上将窗口管理和所有其他内容放在了内核之外。最终这变得太昂贵,从时间的角度来看,结果微软将窗口管理重新放回了操作系统的内核中,这实际上对时间管理上有很大帮助。
使其变得不太稳定,因此关于某些内容是否在操作系统内部或外部的问题是一个有趣的问题,我们将在本学期继续讨论这个问题。关于基于时间的多路复用的替代方案,提了一个很好的问题。嗯,你们都应该看看聊天,安东尼提到了操作系统实际上是。
在时间和空间上进行多路复用,思考这一点的方式实际上是内存,注意到这一点。内存有一些专门分配给不同进程的部分,如果我们有多个核心,那么我们实际上可以让不同的核心同时处理内存或存储器的不同部分,操作系统将会管理这一切。
好的,顺便说一下,如果你们中的一些人担心这变得太复杂,太快了,我会告诉你们,我们一开始将抛开多核的概念,先考虑一个进程在一个核心上运行,或者说一个核心上运行多个进程,以帮助你们更好地理解这一点,之后我们再引入多核。
一旦你理解了如何让一个核心工作,添加其他核心其实是非常容易的,而且操作系统通常还会处理电池管理、电源管理等等。好吧,所以有很多管理工作需要做。
所以如果我们将这些放在一起,操作系统是什么?它是一个裁判,一个魔术师,还有粘合剂。好吧,这三样东西将在本学期多次出现,随着我们讨论操作系统的不同方面,很多情况下你们实际上是,嗯,让我看看,没错,你们实际上将实现这三者中的部分功能在操作系统中。
你们的团队将在学期中继续开展工作,因此你们将获得一个非常好的内视图,了解这些东西是如何由操作系统提供的。好吧,那么为什么要选61c课程,除了它很酷,授课的教授也很酷之外,可能还有其他原因,对吧?其中之一是,你们中的一些人可能会在职业生涯中设计和构建操作系统。
现在这种情况比以往任何时候都更为可能,因为我们正在获得这些嵌入式的物联网风格的设备,在某些情况下,人们从零开始设计自己的操作系统小模块,因为这些设备的资源有限,或者你可能会在初创公司深度参与Linux的开发。总之,了解它们的工作原理非常重要。
你们中的许多人,甚至可以说所有人,都将会创建一些大型系统,这些系统将会运用到我们今天讨论的核心概念。所以当你们设计下一个大系统、云计算系统或其他项目时,了解底层发生了什么,对你们的工作将非常有帮助。无论你是从事硬件还是软件开发,掌握这些概念都会对你们有很大帮助。
在162课程中,这将非常有帮助。有一个好问题是,为什么不使用Rust?Rust语言非常强大,但学习曲线较陡,它是一个非常棒的语言。不过我们现在还是决定使用C语言,因为它在操作系统领域更加常见。如果你们有兴趣,我非常乐意在办公时间和你们讨论Rust。
充分理解操作系统的应用非常重要,理解得越透彻,你就能越好地使用它们。那么现在让我简单介绍一下我们自己。我是John Kubital,大多数人叫我Kuby,可能是因为他们不太能发我的姓。我有硬件设计的背景,曾在麻省理工学院参与过Alwatt项目的工作。
研究生,给你看看我的芯片,这是我设计的通信和内存管理单元。这是最早的并行系统之一,集成了共享内存和消息传递两种技术。我有扎实的操作系统背景,因此曾在麻省理工学院的Athena项目中作为操作系统开发者工作,做过设备驱动和网络文件系统的开发。
我曾参与过集群高可用性系统的开发。Tessellation是我帮助开发的一个操作系统,属于伯克利大学的PAR实验室项目。我在这里做了很多对等系统的工作。我在伯克利的第一个项目是OceanStore项目,在这个项目中,我们讨论了如何存储。
我们已经存储了几千年的数据,并且开发了一些有趣的对等系统,例如Tapestry和Bamboo。这些系统在云存储领域具有开创意义,远在云计算兴起之前,Athenaic项目就已经出现了,他还参与了OceanStore项目,这在当时非常有趣。此外,我还从事量子计算的研究,尽管这与目前的内容关联不大。
我们正在探索量子计算机架构以及为设计量子计算机而开发的CAD工具。最近,我参与了伯克利实验室智能边缘的Swarm实验室,我有一个名为全球数据平面的项目,此外还涉及到存储容器,即数据胶囊。我们将详细讨论这个项目,安东尼也参与了这个项目,我们有很多内容要分享。
我们正在研究边缘机器人学,有一个项目我们称之为雾计算机器人学。这部分内容涉及到数据胶囊,直观的理解是这些存储容器,你可以看到它们出现在奥克兰港口,我在这里已经坐了六个月。基本上,这是我们设计数据胶囊的灵感来源,这些容器是经过加密保护的。
数据的硬化容器,我们会在学期的后续讲座中详细讨论这个话题。那么,去吧。安东尼,他是谁?在那里,他就是我,我是安东尼·约瑟夫,我是EKES的教授,我坐在RISE实验室,是RISE实验室的一部分,我最近承担了工程学院方学院工程领导力研究所的院系主任角色。
正如库比提到的,我在多个不同的研究领域工作,我是雾计算机器人学和GDP的一部分,我还从事机器学习,尤其是安全机器学习。这意味着你在面对敌对方的情况下使用机器学习,你需要做出决策,比如判断某个程序是否是恶意软件,而敌对方知道你正在使用机器学习。
机器学习者正在尝试操控他们的答案,例如将恶意软件错误分类为非恶意,我还与Detour Security网络安全测试平台合作,这是全球最大的公共网络安全测试平台,行业、政府和研究人员都在使用它,而且它也被用于许多不同大学和学院的教学。
我之前参与过多个系统领域的项目。例如,Moden是一个替代Pandas Python库的工具,它提供了最高达100倍的性能提升,并且允许你利用现代多核CPU和我们机器上拥有的数十到数百GB的内存。我还从事云计算工作。
Patchy Mesos是我们在伯克利开发的一个项目,它是一个让你能够管理运行在大型集群上的应用程序的项目。我还从事计算生物学,我们开发了处理整个人类基因组序列的管道,每个序列的大小为250GB,这是一个庞大的数据量,尤其在你处理人口规模的数据时。
比如说,10,000个基因组,你将会处理从千兆字节到太字节级别的数据。所以我们让处理这些大数据集变得非常高效,正如之前提到的,我们一起做了一个名为tapestry的项目,涉及到点对点网络,我还从事过移动计算、无线和蜂窝网络等方面的工作。此外,我也参与了一些外部活动。
混合授课模式是我非常熟悉的。2015年和2016年,我们开发了五门不同的课程,教授如何处理大数据和使用Apache Spark,注册人数超过24万人,完成率达到了11%到15%,这非常令人惊讶,因为通常完成率只有3%左右。我还是一家生命科学领域创业公司的联合创始人。
我们在iGenomics工作,基本上是将我们在amp实验室和rise项目中所做的工作应用于计算生物学,并将这项技术商业化。我们致力于罕见病的治疗,所以接下来我将把话题转回给kuby,并且在我们失去Anthony之前,告诉你们,我们还没有确定具体的替代计划,但你们将会参加一些讲座。
这也是来自Anthony的,他将在这里提供很大的帮助,也会向我学习。
好的,我们还安排了一些助教,你们将会非常熟悉他们。由于一切都转为虚拟形式,我想在我们第一次面对面的课程上,他们会到场,我们会介绍他们,你们将在这些课程中与他们互动。
课程的前几周已经过去了,我觉得应该提一下关于注册的事情,我们收到了很多电子邮件,不幸的是,这门课程这个学期的名额非常有限,比以往任何时候都要紧张。目前课程的报名人数是327人。然而,我们正在努力确保增加另一个班级,这样可能会允许更多的学生加入。
所以请注意观察,但请不要通过邮件联系我们要求特殊调整等待名单的顺序,因为Anthony和我其实并不控制人选的顺序。不过,这个名额非常有限,正如我之前说的,我们可能会再开设一个班级。需要特别注意的是,这是一门早期退课截止的课程。
退课截止日是第二周的星期五,也就是28号。如果你知道自己不打算认真上这门课,请尽早退课,因为系里会继续招收其他学生填补空缺,这样就能让其他有意愿加入课程的同学进来,我们会关闭注册。
可能在接下来的几周内你会被从候补名单中移除,我们希望确保每个有机会的人都能进入课程。如果你在前两周坚持下来,理论上,退课就会更难了。我们这么做的原因是因为你会加入项目小组,第三周初我们会要求你组建你的团队并……
我们希望确保团队保持有效运作,同时避免成员流失,所以请仔细考虑这个问题。如果你打算在接下来的两周内加入项目小组并继续参与,确保你有长期参与的打算。好吧,话说回来,我们会继续努力让更多人加入,如果你在候补名单上,或者是并行注册,或者……
你得做功课,因为如果你不做,可能就会被我们允许进入课程,但到时候你会跟不上进度。我每年教这门课,每学期都会有这样的人——他们忘记自己还在候补名单上,没做任何功课,而且有点以为自己不在班级里,直到有时几周后才发现……
课程进行到这个阶段时,如果你还在班级里,那时候想退课就有点麻烦了。所以如果你在候补名单上,而且不打算继续上这门课的话,记得完成任务,确保你能退出候补名单。好了,关于这个问题有没有什么疑问?至于关于 Piazza 的问题,所有有机会进入课程的人……
如果你可以访问 Piazza 和我们的自动评分系统,顺便提一句,这并不意味着你已经在课程中,只有在正式注册的情况下,才算真正加入课程。你需要去大学的官方网站上查看自己是否已经注册上课,这能告诉你……
无论你是否已经在课堂上,但如果有可能进入课程,请确保完成作业,好吗?我们对这个问题都清楚了吗?好了,关于基础设施,网站是 cs162.ecs.berkeley.edu,我们也有 cs162.org,Piazza 链接可以从主页找到。我们会暂时将讲座录像放在主页上, 希望能够……
课程会继续进行下去,但我们都希望很快能恢复面对面的授课。好了,我看到有人在问课程日历的事,如果你在主页上看不到课程日历,那很可能是因为你处于隐身模式,你需要以学生身份登录。好的,我们的教材是……
《安德森与达林操作系统原理与实践》是一本非常不错的教科书,我们认为它是其中最好的之一。不同的人有不同的学习方式,阅读这本书的建议章节可能对你有帮助,我也会推荐它,因为它确实是一本很好的书。
我们还有一些额外的补充材料,你可以在操作系统《三大难题》一书中找到,还有 Robert Love 编写的《Linux开发》第三版。我们还有一些其他内容,链接在资源页面上,还有一些 Riley 的书籍,可以帮助你学习。好的,还有 C 语言教学的书籍,这些书也可以在页面上找到。
资源页面也有,我偶尔还会放一些研究论文在资源页上,并在整个学期中引用它们,给你一点关于这些想法来源的背景。好的,我们的教学大纲如下,我们将会讨论很多内容。
操作系统概念如何作为系统程序员进行导航,所以我们将从像是什么的内容开始。进程、输入输出、网络、虚拟机,然后我们将学习并发性。今天我们收到很多问题,比如一个 CPU 是否能同时运行多个进程等。我们已经多次回答这个问题,答案是肯定的,我们将向你展示基本上如何实现这一点。
线程、调度、锁、死锁、可扩展性、公平性等。从一个极端来看,是如何在单个处理器上实现并发性,另一极端则是如何使用锁等进行编程。因此,将有几个星期,我们会带你走过这些概念,其中一部分内容是关于地址空间的。
我们还将讨论虚拟内存和地址转换、保护与共享,这些都是操作系统中的 61 C 概念,我们将会提醒你这些内容。我们还将讨论文件系统,令人着迷的是,你知道如何从磁盘上的单个块转换到你可以认为是包含视频等内容的文件吗?这也涉及到如何在操作系统中处理这些。
目录具有长路径名的部分,我们将会详细讨论这些细节和不同的文件系统。所以我们将从输入输出设备开始,然后逐步讲解到文件、事务、数据库等内容。然后在学期的后期,我们将开始讨论大型分布式系统。我今天一开始就谈到了互联网的奇妙之处。
你知道将世界上每个设备都连接到互联网,一旦你完成了这个任务,那就是一个庞大的分布式系统。虽然这门课从小系统开始,我们会首先研究单节点的小系统,但我们将逐步扩展,涵盖各种分布式系统协议,如RPC、NFS、分布式哈希表,我们还将讨论一致性、可扩展性和多播等问题。
当然,云计算贯穿其中,尤其是在学期结束时,我们将讨论关于可靠性和安全性的话题,涉及容错保护、日志记录以及这些如何帮助确保系统可靠性。此外,我们还将讨论云计算相关内容。好的,这是一门通过实践学习的课程,所以做好不仅要完成学术任务的准备。
你们将进行编程并以团队形式工作,因此我们既有个人作业,要求你们单独完成,这有助于你们掌握行业工具和个人技术,例如套接字编程、线程编程等,也有小组项目。请注意,作业零和项目零非常重要,因为它们是在学期的前几周完成的。
所以你们应该查看课程安排,我们将立刻开始作业零,并且项目零不需要小组参与,它将由你们自己完成。这些作业零和项目零都是关于你们熟悉我们的基础设施,确保你们能快速适应并迅速开始这门课程。小组项目也是如此。
其他组的成员实际上将分为四人一组,你将在第三周开始时告诉我们你所在的组,组内成员是谁,以及你对分配小组的偏好等等。接着,你将与组员一起在整个学期内合作,开发操作系统内部的一些功能。
好的,这些小组项目每组有四名成员,我通常认为应该是四人一组,不可以是三人,也不可以是五人。你们将组成四人小组,基本上是学习现实世界中事物如何运作。我们还需要确保你们所有人都在同一小组,或者至少有相同的TA,这样我们就能确保顺利进行。
在某些情况下,我们可能需要稍微分散一些人手,但其实这门课主要是关于确保你们每个人都有一个TA,TA能够了解你们的进度,确保沟通与合作顺畅。因此,我会不断强调这一点,Anthony也会在学期中不断提醒大家,你们需要定期举行会议。
和你的小组一起工作,而本学期令人期待的是,我们希望一切顺利。我们将首次面对面见面,几乎已经有两年没有这样做了,真正坐在咖啡馆里。好吧,现在我们有那家很棒的小咖啡馆,或者你知道,类似的地方,和小组成员交流对于顺利的团队互动至关重要。
好的,你们将一起完成设计文档,实际上,我真的很强调面对面的交流。因为无论我们的社交媒体应用程序(Slack、Messenger、Facebook等)有多好,它们真的不能替代面对面的交流。我多年来遇到过很多不协调的团队,它们的共同点就是没有定期见面,你知道的。
有些人仅仅使用电子邮件,然后当其他伙伴没有理解时就会感到沮丧等等。所以计划好定期与团队见面。好吧,有一个问题是,讨论课是不是只在周四和周五进行?实际上,现在周三、周四和周五都有讨论课,我们正在努力调整,因为我们希望所有内容都安排在周五。
如果可能的话,这样会让你在第一部分学到的内容更容易理解,特别是与讲座相关的内容。但如果你有强烈的偏好,想要选择其他时间而不是周五,请一定在 Piazza 上给我们留言。现在我们确实有一些小组安排在周三和周四,每个小组成员都应该有明确的责任,你们将在每个项目结束时评估队友的表现。
比如说,大家没有表达清楚自己的观点或者怎么做,关于这个我们会在项目一结束时再讨论。你们将基本上学习如何作为一个团队合作,通常那些试图按任务分配的团队,哦,你做一二三,我做四五六,通常并不奏效,你们会看到为什么到了后面会这样。
因为你知道,你们会分开,各自走自己的路,然后回来时没有很好地融合在一起。然后你就会慌了,开始琢磨接下来该怎么办。所以有一个问题是:如果我们是面对面的,内容还会被录制吗?我们已经准备好了屏幕录制,所以是的,我认为没问题。还有一点是,即使我们面对面,我们可能还是会用类似 Zoom 这样的方式,我们现在还不确定。
这将取决于资源的安排,即使内容已经录制,你也应该尽量参加讲座,因为那样你可以提问,了解进展情况。你知道,团队项目还涉及到与导师的沟通,就像你在真实世界中的工作一样,那将是你的助教。我们会进一步讨论如何使这一切顺利进行。
小组项目对于这门课至关重要,它们是现实的一小部分。好的,所以大家开始吧,不管你是在候补名单上还是在并行注册,只要有机会进入这门课,就立刻开始做作业零。如果现在还没有发布,它今天应该会发布,我想,项目零会在下周初开始。
也请尽早开始做这些,好的,哦,听说助教们已经发布了作业零,太好了。这将帮助你们做一些事情,比如设置 GitHub 账户、虚拟机环境、熟悉所有工具、如何提交给自动评分系统等。好的,前两周你们可以参加任何一个小组。我想我们会在 Piazza 上提供更多细节,但一旦我们分组。
小组出勤将是强制性的,因为我们希望确保助教了解你们的进展,同时参加小组课也很重要,这样你们可以得到问题的解答,因为我们不希望有人迷失在课程中。这是我们“没有学生掉队”模式的核心,好的。项目将要求你们使用 C 编程。
你们需要对指针感到舒适,必须对内存管理和使用 GDB 调试感到自如。所以这将比你们在 61C 课程中遇到的代码库要大得多、复杂得多。我们还没有准确发布具体时间,但它将在未来两周内发布,那就是关于 C 语言的复习课程,旨在帮助你们。
给你们提供了一些需要思考的内容,但我们也在课程网站上提供了一些资源。我们有关于 Git 和 C 的电子书,还有一本仍在 Beta 阶段的编程参考手册,是由伯克利的同学们编写的。它基本上是在阶梯(ladder)和第一部分的两节内容上,也会涉及一些编程和调试的复习,好的,这是初步的安排。
评分占比为 36%,包括三次期中考试,每次占 12%。初步安排的日期是星期四,分别是 2月17日、3月17日、4月28日。具体时间尚未确定,将根据我们能找到的教室安排。而期中考试主要会集中在那个时间段内的内容。所以可以理解为本学期将被分为三个部分,但我们还会进一步细分。
假设你们记得之前期中考试的所有内容,因为可能会有基于之前内容的背景问题。所以期中考试当然会集中在新材料上,但你们也可能会被问到和之前的材料相关的内容,好的。项目占 36%,作业占 18%,等等。参与度也很重要,这也是另一个因素。
参加课程的理由,好的,等等。所以我们总是必须谈到的另一件事是个人诚信和学生荣誉守则,作为加州大学伯克利分校社区的一员,我必须以诚实、正直和尊重他人的态度行事。好的,解释一下,你必须小心,明白吗?不要把你的答案直接给别人,也不要抄袭别人的答案,因为我们会对这些进行检查。
我们有软件工具来发现这种情况,这也是非常不诚实的。好的,向另一个小组解释概念可能是可以的,概念性地讨论算法或测试策略可能是可以的,与其他小组讨论调试方法也许是可以的,在线搜索通用算法这些可能是可以的。好的,问题是共享代码或测试用例是不允许的。
其他小组,嗯,抄袭或查看其他小组的代码,好的,抄袭或查看往年在线的代码或测试用例,嗯,帮助其他小组调试代码,好的,我们过去曾遇到过这样的情况:一个小组的人坐下来帮助另一个小组调试代码,结果两个小组都卷入了作弊丑闻。
所以你不想这么做,嗯,这也不诚实。好的,如果你不确定,当然可以问你的助教某些事情是否合适,但我们将对项目提交和作业提交与往年的进行对比。所以,做自己的工作吧,这样能让大家都更轻松,谢谢。
你知道,不要让朋友陷入不该帮忙的尴尬境地。好的,我们遇到过学生因为被强迫帮忙而陷入困境,因为他们把自己的答案给了别人。我们只是希望你不要这么做,好的,不要让朋友陷入这种境地。好,所以讲座的目标是互动的,好的,现在在聊天中提问,但随着我们继续进行,你将有机会,嗯,真正提问。
好的,嗯,你知道,我们快结束了,还有几分钟。但,嗯,除非他们真的,我暂停一下,看看是否还有其他关于行政事务的问题。有一个人问他们是否应该开始寻找小组,尽管他们在等候名单上。你知道,我会关注你在等候名单的位置,也许。
开始考虑一下你可能想要合作的伙伴,嗯,你知道我们直到等候名单关闭后才会形成小组。但是,嗯,认识一些你可能想要合作的人是没有坏处的。如果你在等候名单上,说明你还没有进入课堂,但仍有可能被选上,也有可能不会。所以,嗯,问题是参与分数。
仅仅从课程出勤来看,嗯,就是课程出勤,嗯,是办公室时间,嗯,可能的话,课堂出勤,嗯,也许我们会做这个,嗯,我们会通知你们的,但只要参与,我们会在旁边观察,好吗?所以今天的总结,嗯,你知道,是什么让操作系统变得如此令人兴奋。
挑战性,嗯,我喜欢这张图片,因为这实际上是在谈论世界作为一个单一的巨大系统,嗯,从小型内存传感器开始,这是一只微型,嗯,我不知道算不算昆虫,但它可以行走,实际上是由硅制成的,嗯,汽车,你知道,最近在谈论特斯拉时,它们一直都在连接,并且不断接收更新。
你有大型云系统,你有巨大的集群,实际上,世界就是一个巨大的系统,这令人兴奋,因为,嗯,首先,如何让它运作,其次,随着大量事物相互连接,系统的复杂性开始呈现出一些你没有预料到的有趣行为。
那种行为有时候是好的,有时候是不好的,嗯,所以你知道我们在这门课上做的大多数事情就是讨论如何控制复杂性,试图从中获得可预测的反应,但是,嗯,它依然非常有趣,好吗?嗯,你知道,另一件事是。
现在,有时,真正的技术趋势与摩尔定律有关,嗯,你大概从你来到伯克利的那天就听说过摩尔定律,如果你仔细看看,真的很有意思,如果你看看左边的这张图,嗯,戈登·摩尔曾在4004芯片还是最先进的技术时,在一次会议上被问到,未来会发生什么,他画了几张图。
在对数线性尺度上的几个点,他画了一条线,并说这将会发生。现在,从某种角度看,这显然是荒谬的,因为在对数线性尺度上的几个点意味着画一条直线就预测了指数增长,但它是对的,嗯,多年来,摩尔定律一直是它,嗯,这非常令人兴奋,因为我们经历了这种指数增长的翻倍。
晶体管的数量持续增长了很多年,嗯,最近有所放缓,但这意味着单个组件的复杂性正在以极快的速度增长,嗯,导致世界系统的复杂性也在快速增长,这就需要良好的操作系统设计,并且你们可以在这方面提供帮助,好吗?嗯,沿着这条线的一个大挑战。
你可能在2002年左右看到过这种情况,我们当时将摩尔定律转化为不断增长的CPU性能,所以你可以懒惰地说,嗯,这个时代的电脑对于我的需求来说不够快,但是没关系,因为我只需要再等几年。由于性能每18个月翻倍,这一切都可以接受。有趣的是。
然而,到了2002年左右,这种方式不再奏效,原因有很多,我们进入了多核时代。突然之间,我们不得不弄清楚如何构建大型并行系统,这也催生了对新编程方式的各种需求。因此,另一个问题就是功率密度。如果你看一下数据,实际上我们不必担心,安东尼刚刚说了,你不需要担心。
参与的方式虽然是虚拟的,但另一个挑战是功率密度。实际上,如果你不减缓硬件变得越来越快的速度,你可能就得在笔记本电脑上放置火箭喷嘴一样的功率,这显然不是一个好主意。因此,多核的出现实际上是在想方设法利用这些晶体管的另一种方式。
到2007年左右,人们开始讨论具有多个核心的大芯片。例如,这是2007年发布的英特尔80核芯片,2010年发布的芯片有24个模块,每个模块包含两个核心,现在56核乘以2的配置已经出现在高端服务器中。那么,问题就来了,如何编程这些东西呢?这是长期以来的一个笑话,我有这么多的核心,我该如何利用它们。
用两个显示器来处理视频和音频,一个用于文字处理,一个用于浏览器,剩下的用来做病毒检查,就是这样。事实上,这开始推动了并行计算的需求。在这个阶段,我们完全拥抱了并行计算,这是你的一部分。
有趣的地方在于,摩尔定律已经接近尾声,我们不再每18个月或甚至24个月就能获得两倍的晶体管数量了,这实际上改变了一些事情,方式也发生了变化。我们将讨论更多的内容,看看未来会发生什么。
随着存储容量的不断增长,这里展示的是存储容量的对数线性图。比如我最喜欢的SSD之一,适配三英寸半的形态因子,它的容量是,抱歉,应该是多达数TB,甚至上百TB的存储容量,百TB的存储,竟然可以放在一块三英寸半的硬盘里。我们将进一步讨论这个话题。
但百TB的存储容量可不是一个小数目,而三英寸半的硬盘也不大。所以可以说,我们正走向一个拥有巨量存储的未来,我们将深入讨论如何保护这些存储,以免它们丢失。随着社会日益数字化,存储变得越来越重要。
保护和连接,这意味着我们面临着所有这些安全漏洞,我们将讨论。
随着网络的容量不断增加,嗯,你知道,这意味着我们能够以更快的速度与世界的遥远角落进行通信。这也意味着我们所连接的系统做的事情会越来越多,速度也会越来越快,而这可能是一个非常快速的失败方式,或者是一个非常快速的成功方式。
额外的功能取决于你现在的设计方式,问题是你是否需要。161这门课你不一定需要参加,但161是一门很棒的课程,所以我强烈推荐在这门课之前或之后参加。好吧,如果你愿意,我们可以再详细讨论一下。并且不仅仅是PC是连接的。
当然是互联网,嗯,还有许多手机,但即使是手机也不是现在的主流,越来越多的小型设备,嗯,你知道,我们每个人都有一个Fitbit,或者Apple Watch,我们身边有各种小型传感器。好吧,这实际上又是这一张幻灯片。现在我们已经进入了物联网的时代。
每个人每天都有成百上千的CPU在为他们工作,好吧,那操作系统是什么呢?
再说一次,操作系统是资源管理的裁判,是保护和隔离的守护者,它是一个幻术师,提供简洁易用的抽象层,它是粘合剂,提供常见的服务。在整个学期中,我们会一直思考这一点,好吧,我们也将会教你很多相关的内容。那么我将在这里结束。嗯,请记住,在接下来的学习过程中,每当我说“总的来说”,通常是…
人们会走神,但实际上我希望你在那个时候重新集中注意力。这样我可以给你一个我们讨论内容的简短总结。所以,总的来说,我们想讨论的是提供方便的抽象层来处理多样化的硬件,我们将协调资源,并利用一些关键的硬件机制保护用户互不干扰,从而简化应用程序。
开发提供标准服务,我们将提供故障容器、故障隔离、故障恢复,而CS 162将会把许多不同领域的内容结合在一起。我期待和大家一起深入学习,也希望这学期我们能够度过一个美好的时光。谢谢,谢谢大家的发言,谢谢。
[silence]。
P10:第10讲:调度概念与经典策略 - RubatoTheEmber - BV1L541117gr
好的,欢迎大家来到第一次期中后的讲座。
是的,你们成功了。所以我们实际上将稍微改变一下话题。让我确认一下,大家能看到我的幻灯片吗?虚拟空间里的朋友们也能看到吗?好的,如果你记得,我想回顾一下我们在最后做的一些事情,确保同步部分可以结束。所以,首先,如果你记得的话。
上次我们更详细地讨论了信号量。信号量有两个使用方式。信号量有一个初始值,这是在分配时设置的。如果你将这个值设置为 1,实际上你得到了一个互斥锁,或者称为二进制信号量,你可以用它来进行锁定。所以你会像这样使用它,做 P 操作时把它减到 1,做 V 操作时把它加到 1。
如果两个人同时尝试做信号量 P 操作,其中一个会被挂起。这是一个纯粹的原子操作。所以两个线程不可能意外地同时通过这个操作。好的,它会原子性地递减这个值。如果你想递减到零以下,它会让你进入休眠状态。我们还看到了通过将初始值设置为零,我们可以实现调度约束。
这可能让你进行一个联合操作。所以你把它设为零,然后尝试做一个 P 操作来让自己进入休眠状态。其他任何人都可以用一个 V 操作把你唤醒。好的,最后,这就是线程连接的例子,假设原始的“鹦鹉线程”进入休眠,然后结束的子线程执行 V 操作把它唤醒。好的。
然后最后我们给出了可乐机的有界缓冲区解决方案。这是一个很好的三信号量的例子。如果你记得,我们之所以有三个信号量,是因为我们有两个条件和一个锁。这两个条件是关于限制可乐机中最大可乐罐数量的,同时也在说明不能让可乐罐数量变成负数,所以我们在两个方面都做了限制。好的,如果你记得,代码大概是这样的,用于生产者和消费者。
这里的关键模式是,我们总是像队列一样保护一些东西,因为这些操作如果多个线程同时进入可能会出现问题,这就是为什么我们在它们周围加上新的 Texas。然后我们在空槽上执行一个信号量的 P 操作,以确保生产者进入之前有东西,消费者则是相反的操作。
所以,如果你看这里的红色部分,是用来保护队列的临界区。然后我们有这个信号,当生产者终于生产了东西,它会唤醒某些可能正在休眠的线程。
当消费者最终把罐子倒空时,他们可能会唤醒生产者。好吧,在我们离开之前,我想看看有没有最后的问题。信号量在操作系统中非常常见。好,好的。那么,然而,我们说过有更好的东西。好吧,一个监视器,它是一个锁和零个或多个条件变量。
其实,如果你只有零个条件变量,那就不太有趣了。条件变量专门是一个等待队列,你可以在持有锁的情况下去休眠,这就是关键的概念。好的,和其他任何等待队列不同的是,在条件变量的等待队列中,你先抓住锁,检查条件,然后如果不满足就去休眠。正是这种奇特的差异或者API的不同,使得它们变得非常强大且易于使用。
通常有三种操作,它们根据不同的包有不同的名字,但大致是等待、信号和广播。规则是,进行这三种操作时,始终要持有锁。现在,我知道很多人会问,为什么我在做信号或广播时也必须持有锁。这个问题有很多原因,具体取决于调度包是否允许这样做,但现在我们只说。
始终在所有操作中持有锁。好吧,尤其是在这门课中。接口上有什么问题吗?所以我们给了你这个典型的结构,如果你在考虑一个监视器程序,这是一个思考的方式。你抓住锁。你检查条件是否满足。如果不满足,你就去休眠。注意,这个等待操作总是会发生。
就像一个信号量P,它可能不会让你休眠,而一个等待操作总是会让你休眠。这是关键的接口方面。然后,当你醒来时,使用典型的Mesa调度,你总是必须重新检查你的条件。然后你可能以某种方式保留你找到的资源,然后你可以解锁,结束时你再锁一次,并发送信号表示你完成了。这是一个模式。好吧,我们在读者-写者示例中做到了这一点。好的。
这是读者-写者示例,它可以有一个写者或多个读者,但读者和写者永远不会同时存在。好的,实际上与考试题目无关,不管是什么,三C题中也许你不能有两个写者。好的,所以每次只有一个写者。那就是我们这里代码的动机。
这是一个读者代码,你抓住锁。看到了模式吗?你检查并确保没有写者在活动或者休眠。如果有,你就变成一个等待的写者,然后去休眠。当你醒来时,你不再是一个等待的写者。
你会一直循环,直到系统中没有任何写者,在这种情况下,你就会执行++操作,表示你是一个活跃的读者,释放锁并访问数据库。然后在签出时,你再次获取锁,减少计数,表示你不再是一个等待中的读者。接着如果没有剩余的读者,就会触发某些条件。
那些是活跃的,但如果有等待的写者,你就会通过信号唤醒它们,然后最终释放锁。好的。那么,为什么我们在这个时候释放锁?有人记得吗?是什么原因?没错。记住,这个进入条件并不是阻止访问数据库。它的作用是保护数据库的进入条件。
我们需要释放锁,这样其他线程才能进入并自行分类。写者的情况类似,但稍有不同。好的。那么在这里,我们对写者的进入条件是:不能有任何活跃的写者或活跃的读者。如果有任何一种情况,我们就会进入睡眠状态。否则,我们会醒来并进行访问。
这是一种修改样式的访问。然后,当我们再次获取锁时,如果没有其他等待中的写者,我们就不再是一个活跃的写者。如果有等待中的写者,我们会唤醒其中一个。如果有等待中的读者,我们则会唤醒所有读者。然后我们退出。好的。
所以,再次说明,为什么我们在这里使用广播而不是信号。可能有多个读者,唤醒他们所有人,对吧?好的,有没有问题?是的。没错,你在这里说的对,绝对是这样。所以在这里,向上,唤醒。然后我们会有多个等待中的写者。啊,嗯。
你刚刚预测了我接下来几张幻灯片的内容。那么问题是,难道我们不能直接去掉检查,并且无论如何就唤醒一个写者吗?我们会探讨这个问题。是的,因为在那种情况下,发生的事情是它们会在那个循环中检查。
现在注意到,通过执行这组if和else语句,我们确保了优先给写者而不是读者。这时我们会广泛地发出信号给写者,然后再广播给读者。尚不清楚是否某个读者会在写者之前醒来,若发生这种情况,我们实际上会违反我们政策中的优先级规则。
这总是先让写者去执行。就正确性而言,这是没问题的。好的,它不会违反“一个写者或多个读者”的正确性条件,但它会违反政策。似乎这个特定的代码符合的政策是写者优先于读者。好的,很好。那么这实际上导致了,为什么要给写者优先权。
事实上,这里有几个原因。其一是,如果你查看典型的跟踪,读取操作通常比写入操作多,所以优先考虑写者并不是坏事。另一个原因是,你可以想象,写者使数据更加更新,因此给读者提供了获取更新数据的机会。
好的。这不是强制要求。你可以使用任何其他策略,事实上,我们过去也曾在其他考试中这样做过。这里的问题是为什么要再次释放锁,我假设问这个问题的人在虚拟空间中是在谈论在这里释放锁的事。
答案是我们必须释放上面的锁,以便其他线程可以进入并进行分类。好的,因为如果我们持有锁,那么进入的其他线程就无法被分类为读者或写者。好的。现在,问题是这里有几个问题,一个是读者是否会饿死。你怎么看?这会导致饥饿现象吗?
它与不正确的条件不同。谁认为它可能不对?为什么?为什么它会开始?继续。没错。所以,如果写者持续不断,读者可能永远无法得到机会。是的。好的,如果这是你使用的条件,你可能需要稍微调整代码的设计,但监视器足够强大来处理这种情况。如果我们取消了条件检查,刚才我们问过这个问题,假如我们重绘出这个呢?
好的。那这样做还会正确吗?是的,依然正确,因为有了 while 循环。好的,入口处就是这样,这也是为什么Mesa比“马语义”(horse semantics)更受欢迎的原因之一,因为你可以稍微松懈一些。好的,只要你唤醒的线程比需要的多,你仍然会是正确的。这种情况的危险是你可能唤醒的线程不够,接着系统就会死锁。
好的,所以我们不想这样做。实际上,我想这算是一个活锁,稍后我们会更具体地讲解。在接下来的几节课中,然后我们可以将这个信号转变为广播,唤醒所有的写者。为什么这样做还是正确的呢?是的。没错,只有一个写者会得到它,其他的写者不会。理解这一点的方式是因为代码开始时的那个锁。所有进入系统的线程都会被锁串行化,所以你每次只能查看一个条件。
好的,所以下来一个一个地处理,即使有一千个写者,也是一个一个地处理,第一个将获得作为活跃写者的能力,其他的会在稍后等待。好的,然后最后,如果我们只使用一个条件变量,会怎么样呢?
所以不要将读取和写入操作分开,仅仅说“好吧,继续进行就可以了”。好的。现在事实证明,我说的关于循环的内容是正确的,所以我们不会得到不正确的行为,但是如果我们不使用广播而使用信号,可能会发生什么呢?
是的。没错,因为如果读者和写者都在同一个队列中,我们只唤醒一个,那么我们可能无法得到我们想要的服务员类型。
好的,那么例如如果我们将“okay to write”变成“okay to read”,再变成“okay to continue”,这样就变成了不同的代码,现在看到红色了。然后我们有这样的场景,假设我们的一个到达了,接着w一个要到达。嗯,我们的一个仍在工作。然后当那个读者去检查时,如果我们只发送信号给一个。服务员,那么会发生什么呢?我们实际上会发送信号给错误的人,对吧?我们会给w一个发送信号。
但是w一个醒来后会说:“哦,看,系统中有读者,可能这样不起作用。”所以我们必须确保正确发送信号。
好的。所以我们在这里进行广播,只是为了确保有足够的人能唤醒所有的东西。好了,这对于写者来说尤其具有挑战性。嗯,它在双方都有挑战性,所以基本上你只想广播。好了,现在我们的监视器与信号量根本不同。嗯,你可能会怀疑答案是否定的。
因为我们设法以某种方式做出了一个非常复杂的提示。你知道的,对于可乐机来说,信号量是非常强大的。所以如果它们真的是等价的,似乎如果我们有了信号量,我们应该能够实现监视器。不是吗?从同步的角度来看,这不是某种完整性吗?
但是技巧是我们得小心。首先,锁是简单的,对吧,监视器有锁,信号量你初始化为1,锁很简单。好了,保证从那里开始会变得更难,对吧?那么如果我们尝试像这样实现条件变量,等待会获取一个信号量,并执行一个信号量P,信号则执行一个信号量V呢?
这能工作吗?有多少人认为我们可以通过这种方式实现监视器条件变量?有多少人认为我们做不到?如果你举手了,你得告诉我为什么。来吧,为什么?对,但让我们假设我们可以在这里放入任何一个信号量的值。是的。嗯。问题在于我们获取了信号量锁并执行了等待。
在一个条件变量信号量上,你将带着锁去睡觉。哦,一切都坏了。好的,所以这一实现一开始就不行,因为你不能在这个特定的信号量上睡觉。所以这样不行。好吧,这样行吗?所以等待释放锁,然后在条件变量上执行信号量P。
然后在退出等待之前重新获取锁。信号只是执行信号量V。好了,这看起来稍微好一点,因为它不会因为持有锁而进入死锁状态,因为你看,当你执行等待时,你先释放了锁。
好的,但这有效吗?是的。太好了。那么,这仍然是错误的。因为记住,信号与某些操作不相同,因为如果你先发出信号然后再等待,使用这个实现,它们永远不会等待。而条件变量是,你去说等待时,你总是会等待。好吧。所以这是错误的。大家明白了吗?信号量是对称的,而条件变量信号则不是。
好的。那么我们能做什么呢?嗯,你知道,你可以在这里说,假设一个线程发出信号,而没有人等待,知道吗?对吧。那么,如果一个线程稍后等待,线程就会等待。好吧。那么如果一个线程在一个森林中发出信号,而没有人听,没人听,知道吗?而如果你做一个V操作,而没有人在等待,你会增加信号量,如果线程稍后执行P操作,你就会减少信号量并继续。
所以,这将没有这样的特性:每次你调用等待时,你都会休眠。好吧。大家看到这个问题了吗?这看起来是错误的。也许我们做不到。好吧。之前尝试的一个问题是,P和V是可交换的,不管它们按什么顺序执行,结果是一样的,而条件变量则不是。所以,如果你看看,你可以说,嗯,这个修复解决了问题,等待时释放了锁。
是否一个信号量P并获取锁,而信号量说如果信号量队列不为空,那么执行一个信号量V。这样行吗?它有效,但它是非法的。你不允许读取信号量的值。好吧。所以,这里有一个竞争条件,信号量操作可能会在错误的时机执行。但实际上,事实证明这是可能的。我会给你们一个提示。
如果我们保持一个条件变量整数,所有我们用来在释放锁之前增加的那个变量,那么我们可以跟踪有多少个线程在休眠。信号量操作可以查看它,因为这是合法的。因为他们有锁。好吧。所以这里是一个有趣的例子,拥有锁在信号量操作周围实际上对于这个特定实现的工作至关重要。
好的,因为如果你没有锁,试图检查条件变量或检查那个整数变量,而你没有锁,那么有人可能会在你不知情的情况下改变它,这就是问题所在。好吧。我们不再详细讲这个问题了。那么,重点是,你可以用信号量做出监视器,而你可以想象,用监视器做出信号量更容易。这是一个你们可以做的练习。嗯。
你就坐在那里。那么,好吧,我将关闭这个话题。但是,随着我们前进,假设你们会越来越擅长于查看同步问题并解决它们。你知道,这就是其中之一,我可以给你们举例,但最终还是要靠你们自己去重新训练思维,去思考这些问题。好吧。
但你知道,我认为,如果最糟糕的情况发生,使用监视器应该是最容易想到的事情。我在我做的一个研究项目中曾建议过某人使用条件变量,因为他们需要让线程休眠。
然后稍后唤醒它,结果证明这绝对是最正确的做法。非常简单。顺便说一下,这些在很多情况下都可以使用,包括像 P 线程这样的线程包。但在我们完全关闭同步之前,我想说一点关于语言同步支持的内容。
所以如果你去查看线程,你会看到我应该在这里加入一张关于这个的幻灯片。这里有 P 线程新的 Texas,还有 P 线程条件变量。它们已经被实现了。所以如果你只需要做一下 man 查阅 P 线程包,你会看到它们。只要你在使用 P 线程,你肯定能够与监视器和信号量同步。
这些是可以使用的。好吧,C 是一种有点疯狂的语言。好吧,有多少人认为 C 是一种疯狂的语言?好的。C 最糟糕的地方就是它允许你做各种坏事。你永远不应该做的事情。最糟糕的是你可能会有悬空的内存指针,或者忘记了释放某个东西,或者释放了两次,这里有很多坏事。
但这不是我们今天要讨论的内容。如果你的代码获取了锁,并且在代码内部发生了异常,你需要检查每一个异常路径,并在从该过程返回之前释放锁。因为如果你不这么做,我们会把它留在那里。如果你不这么做。
然后你可以很容易地通过抛出异常返回调用函数,锁依然保持着,没有人会释放它。看到了吗?因为正常的代码是你获取锁,做一些操作,然后释放锁。但是如果发生了异常,你需要确保释放锁。好的。这使得 C 在锁定方面真的很复杂。你必须做这些事。
更加复杂的是 set jump 和 long jump 的概念。你们中有多少人遇到过 set jump 和 long jump?没有遇到过的真幸运。让我告诉你们,set jump 和 long jump 是异常的一种简陋版本。它们是这样的:有一个堆栈,你从程序 A 开始,然后到程序 B,接着调用一个叫做 set jump 的东西。
这会给你一个句柄,让你可以丢弃一大堆堆栈并返回到你调用 set jump 的地方。所以它就像一个异常。这里,我们进入 C,获取锁,进入 D。在 E 中出现错误或某些情况时调用 long jump。它会丢弃所有的堆栈并返回上面,而不需要重新获取锁。
或者没有释放锁。好了,坏消息。好的。现在,幸运的是,set jump 和 long jump 是你今天经常使用的东西。但我只是警告你,在 C 语言中,这只是乱七八糟。好的。然后也许你可以使用 goto。多少人曾被告诫永远不要使用 goto?好的。
以失去你的大儿子或某些东西被海怪带走为代价。我是说,这就是,你知道的。所以这并不好。当你接触到比 C 语言更强大的东西时,结果发现它是好的。例如,在 C 语言中,你仍然需要确保如果食物有异常,它会被抛出。而且你可以捕获它,这意味着你至少需要做类似这样的事情:你说我要尝试做,如果有异常,我会释放很多。
注意看,这看起来像是 C 代码,但它要干净得多,因为所有异常都会通过那一条路径。好的。现在,如果你是 C++ 背景的观众,你可能会说,但等等。这不是做这件事的好方法。还有更好的方法。好的,我鼓励你们谷歌一下锁守卫。好的,这意味着这里我们有一个过程。
无论有多少人尝试执行,它都会干净地增加这个全局变量。这里发生的事情是,当你分配锁时。其实这是一个局部变量。在这里,你分配了一个锁守卫,它锁定了全局互斥量。关键在于,无论安全增量退出的条件如何,这个锁都会在任何情况下释放。
好的,所以如果你在做锁定操作时,C++ 或它的一些变种是最干净的。好的,因为这种特定类型的锁守卫。基本上,每当它最终超出作用域时,它就会自动释放锁。好的。Python。嗯。你可以分配一个锁,然后你可以说用锁来停止,这意味着无论那个 with 语句如何退出。
锁会被释放。这几乎与我刚才展示的 C++ 完全相同。Java。你最喜欢的,对吧?他们,我不知道,他们现在还在用 Java 和 61 好吗?我已经有一段时间没教这个了。实际上,我从没教过它。我不确定你是不是不了解它,但如果你看看这里。
你可以说 public synchronized。意思是每个对象都有一个内建的隐式监视器,包括一个锁和一个条件变量。因此,结果是,你可以说这个特定的方法 get balanced。你还记得我们讨论过银行账户是同步的吗?而这个 get balance 只有在获取对象的锁之后才能被线程运行,然后它会进入并让你继续。
好的,仅仅通过说出“同步”这个词。我们就可以自动使用这个法则。比起我们几节课前介绍的方式,这样是不是简化了很多?好的。此外,正如我之前所说,Java 只有一个条件变量。你可以为每个对象编写代码,所以你可以进行等待操作。
你也可以进行带有超时的等待操作。而且你可以通知并通知所有等待的线程,或者进行广播、信号和广播。这就是你在 Java 中如何做的。所以 Java 实际上将监视器内建到了语言中。
这真的挺酷的。好吧。好了,我想我们暂时可以放下同步这个话题,除非有人有问题。继续吧。让我看看。确保没有人遗漏。好的,还有其他问题吗?哦。
后面那位?不,那只是个伸展动作。好的。所以,今天的琐事比较轻松。不像上周那样,你们既有考试,又有设计审查,还得在喝咖啡时站着解决停机问题。这一周相对轻松一些。所以我觉得没有什么重大期限。
当然,你们得继续推进各项工作。我瞥了一眼时间表。关于期中考试的部分。我被告知说,大约完成了 80% 到 85%。当然,我还要提醒你们,75%的统计数据都是随便编出来的,你们可以自行决定如何看待这一点。
我不确定今天大家是否有任何管理类的琐事。既然如此,如果大家没问题,我想我们可以开始一个新话题。我们可以把期中考试的事情放一放,答案已经发布了。关于答案的提问也有讨论区,虽然我还没能及时回答所有问题。
不过我会确保问题得到解答。我们继续往下走吧。往前走。好了,接下来我想说的是,这里有一张图。我记得这张图第一次课上就展示过,它是关于操作系统的一种循环。图中提到,如果有就绪线程,系统就会从中选择一个执行。
否则,你就会运行一个空闲线程,并定期执行循环。好吧,我想我说得对,这就是整个操作系统的工作。我们可以结束今天的内容,准备做我们的最终总结。今天我们想要做的,是开始讨论调度,实际上就是“选择什么”的问题。我曾经给你们展示过另一个关于调度的图。
这个图显示了一个就绪队列,它是所有准备好由 CPU 执行的线程集合。真正的问题是,操作系统如何决定就绪队列中的哪个线程将会是下一个执行的线程?好的,所以调度其实就是决定哪些线程在每一时刻可以访问资源。顺便提一下,在接下来的几节课中,我们将讨论如何分配 CPU 时间。
但是调度远不止这些。好吧,我们可以讨论带宽调度。你可以谈论I/O资源调度,可以谈论内存调度。你可以调度很多东西,但目前我们将只调度CPU。因为我们实际上是在试图弄清楚发生了什么,介于就绪队列和CPU之间。
好的,这是接下来几节课的目标。顺便说一下,我很高兴地承认,Anthony实际上回来了,所以他也许会参与几节课的讲解,我们也可以看到其他人的风格,但今天除了我之外,只有我一个人。
调度完全是关于提示信号的。好吧,我敢肯定,提示信号是你最喜欢的东西。好吧。这显然是从之前的时代,因为他们并没有戴口罩,而且他们之间也没有保持六英尺的距离。是的,可能很快会有这样的规定。关于调度的一些假设,首先,一些假设实际上来自70年代,调度在70年代成为了一个重要的议题。
这是一个重要的研究领域。请注意,我们只有三节课讲它,但它确实是一个重要的研究领域。很多关于CPU调度的隐性假设实际上都来源于那个时期。而这些假设类似于:每个用户只有一个程序,每个程序只有一个线程,程序是独立的。好吧,这些是一些基本假设。
你知道,这是一个非常老派的思维方式,也算是一种当多个用户共享同一台机器时的情况,所以它是一种大型机的视角。好吧,但70年代做的许多工作至今仍然被愉快地使用。
显然,这些具体的想法是不现实的,但它们确实简化了问题,以一种可以解决的方式来处理。所以在第一次讲座中,我们将从这种背景开始:每个用户一个程序,每个程序线程独立。
然后,我们将逐步为我们的过程增加更多的复杂性,以便使调度变得更加有趣。好吧。高层次的目标,提醒一下,这就是我们最喜欢的图表,再次来自第一天的打印机逆色:品红色、青色和黄色。这些代表着不同的线程,我们将决定如何在一个CPU上调度它们。
请注意,现在它们的CPU时间各不相同,因为程序执行的时间长短不同。调度程序如何决定呢?好吧,到目前为止,问题还很简单。是的,嗯,什么是用户?用户是一个实体或一个拥有资源分配的人,最简单的想法就是登录,你登录了,就成了用户。
CPU通过用户ID来处理你。但随着学期的推进,实际上可以更一般化。比如说,如果你有一个公私钥对,在安全的意义上,它也可以是一个“主体”,但是现在就当作登录ID来理解吧。
好的,这里还有一个问题,进程调度是不是更多的是调度器的隐式操作呢?比如调度器切换线程,而这些线程可能属于不同的进程。所以暂时我们不会讨论,或者限制自己去担心哪个线程属于哪个进程。好的,我们有很多线程,它们都需要CPU时间。那么对于这个问题的回答是……
比如你可以想象,可能会处理完一个进程中的所有线程后,再切换到另一个进程。你可以想象,几乎可以想象任何场景,对吧?例如,大规模的拉丁语程序之类的,你几乎可以想象任何事情。但有趣的是,既然已经提到这个问题,如果有一个进程有100个线程,另一个进程有一个线程。
什么是公平呢?将相同的时间分配给进程A中的100个线程和进程B中的一个线程,是否公平?也就是说,它们总共有101个线程,每个线程得到1/101的时间。或者,是否更合理地将一半的时间分配给进程A,另一半分配给进程B,然后进程A将其所有的时间分配给它的100个线程呢?
好的,那么什么是公平呢?这将是我们必须解决的问题。好的。那么,很多早期调度论文中的一个假设是非常好的,基本上就是这个“CPU突发”概念。如果我们看一个线程运行的时间。哦,是的,请继续。程序和进程是一样的吗?嗯,我现在先说是吧。想象一下,你知道程序是可以有多个进程的,在这种情况下,程序和进程不完全是同一回事,但我们今天只能处理这么多复杂性,所以暂时就按每个程序对应一个进程来理解吧。
但如果你注意到,当一个线程在CPU和各种等待队列之间循环时,你可以问它在CPU上运行多久才会被休眠。所以,如果你有一个定时器,而线程永远计算下去,那么你会看到线程可能会在CPU和等待队列之间循环。
它们都有相同的计算时间,直到进入休眠状态。所以,如果我们把I/O加到画面中,你可以想象一些线程是等待的,只有在键盘上按下一个键时才会被唤醒,它们运行一段短暂的时间后,然后再次进入休眠,等待与键盘相关的事件。
好吧,有很多原因导致线程进入休眠。所以,如果我们测量这个叫做CPU突发时间,就是线程运行多久后再回去休眠。所以,类似这样一个峰值,在低端有很多非常短的突发,且尾部有一个非常长的尾巴。
好吧。于是程序或者不管它们是什么,线程我们暂时不会深入探讨。然后,线程会运行一段时间,称为一个突发,然后它们回去休息。如果我们测量这个,可以把它看作是线程突发时间的概率分布,你会看到低端有一个峰值。有人能给我一个想法,低端的那个峰值可能来自哪里吗?好吧。
定时器中断的持续时间。嗯,让我解释一下为什么我认为这不是你所寻找的东西。所以,如果每个人都被定时器中断所限制。那么会有一个类似于脉冲函数的时刻,因为你知道你会运行10毫秒,然后定时器响起,下一个人会运行10毫秒。但我们有这种分布,不是那样的,它在低端有一些峰值,且有一个非常长的尾巴。
由于有长尾的东西,你必须怀疑,如果定时器介入了,它是在打断那些很长时间运行的人。对吧?因为他们运行了很长时间,然后才被中断。那么,为什么我们认为这里有一个突发?是的,启动进程。好吧,可能是。是的,继续。好吧,我欠你一些解释。嗯,是的,差不多了。接近了。那我欠你什么呢?
是的。好吧,也许是磁盘访问时间,虽然实际上那会在这里屏幕上看不见的地方,因为记住是百万次周期。那么,什么类型的I/O呢?好吧。所以,是的,你必须加载程序。所以我给你一个猜测,有人说是主动计算然后等待I/O,是的。
第一点。为什么是短暂的交互式处理?因为你在等待并且接收到一个按键输入,然后你处理字母a。接着你又回去休息,然后他们输入字母B,你处理字母B,然后又回去休息。所以这里低端的事情通常是交互式的。明白吗?
有一些情况,程序线程是代表用户运行的,通过快速处理来自用户的某些事件,让用户感到满意,然后再回去休息。好吧,现在如果我们开始考虑其他类型的I/O。
我们看一下这个更长的范围,但我认为这里重要的部分是,如果你看一下外面的一组线程。会发现有很多非常短的突发,这是因为即使在那时,这些机器也是在处理用户的交互。
我明白了。好的。所以每一个调度决策,其实都是关于在你有一堆线程的情况下,如何选择下一个要执行的线程。这就展示了如果你有很多线程并且运行它们,它们会怎样表现,然而调度决策是在我们执行之前做出的。我们如何决定哪些线程应该被执行呢?好的,怎么做呢?
在时间片轮转和定时器的配合下,你可能会被迫放弃CPU资源,假设你在计算圆周率之类的东西。但为什么有人能告诉我,仅仅按先入先出(FIFO)顺序处理线程,可能不是最符合用户需求的方式呢?对,用户有他们希望得到交互的需求。
如果你正忙着计算圆周率的下一个百万位数字,或许你并不在意。对吧?所以可能优先处理短时间的任务比长时间的任务更合理,这对于交互性来说非常重要。所以调度在这里开始发挥作用,如何确保调度程序能让用户满意,这对我们来说非常重要。那么,我们可能会对调度策略有什么样的目标呢?
其中一个目标可能是我们希望最小化响应时间。也就是说,最小化执行某个操作的经过时间。比如,在编辑器中回显按键的时间,或编译程序的时间,或者是实时任务,这将是下次讲座的更多话题。
我们可能想要满足外部世界的目标。对,挺有趣的。我注意到,最近特斯拉的Model Y车好像随处可见,几乎每条路上都有。你们看到过吗?这是一种由计算机驱动的汽车。你可以想象,在某些情况下,确实必须快速做出反应,比如刹车。好的。
所以如果我们回到这个问题,谈论的是,知道优先级应该放在哪里,在实时任务的场景下,我们可能会更倾向于优先停止任务,而不是错过你不应该观看的视频。对吧?所以实时任务调度开始变得非常有趣。好的,我们可能会有专门的讲座来讨论实时任务调度。
另外一个目标是最大化吞吐量。这些年,硬件变得越来越便宜,但仍然重要的是最大化CPU的使用效率。因此,最大化吞吐量可能很重要,而最大化吞吐量和最小化响应时间并不一定是相互兼容的。好的,因为最小化响应时间可能意味着一旦用户的响应线程需要运行,就会立刻执行。
继续执行并中断当前任务然后运行。这可以减少响应时间,最小化响应时间。但最大化吞吐量可能意味着永不中断计算线程,以保证它的缓存状态以及其他所有东西不被干扰,从而使得任务能够以最大速度运行。
对,所以最小化响应时间和最大化吞吐量并不完全是同一回事。好的,然后当然还有公平性的问题,你应该在不同用户之间,或者系统的不同部分之间共享 CPU。所以在这种情况下,比如在特斯拉中,用户可能会影响视频播放的优先级,或者其他任何事情,你可以把这些都进行分配。
这些都是不同的用户,我们需要在它们之间设定不同的优先级。好的。所以,之前我给你提到的那个关于在一个拥有 100 个线程的进程中,如何公平地分配线程的问题其实有些棘手,因为你可以按线程公平、按进程公平,或者按其他一些标准来分配公平。
好的,明白了。我不想过多强调这一点,但到目前为止有其他问题吗?所以现在你可以开始理解为什么调度不仅仅是“对,几个幻灯片就完成了”,对吧?因为你知道,你必须决定你想要的策略是什么,然后你还要决定如何构建一个好的调度器以匹配你的策略。所以。
第一种调度方式很简单,让我们先谈谈先到先服务(FIFO)。基本的思路是一个线程进入队列,直到执行完毕,然后再取下一个,依此类推。对吧?所以,这就是 FCFS 或 FIFO 队列。在早期的系统中。
这基本上意味着一个程序运行直到完成,包括 I/O 操作。所以你会有这些庞大的机器,它们占满了一个房间。你会提交你的任务,让它运行一整晚。然后它们会一个一个地执行这些任务。先运行第一个任务,然后运行第二个任务,再运行第三个任务。它们这样做是为了最大化硬件的使用率,尽可能高效地运作。
好的,现在,我们可以考虑一种现代化的更新方式,就是先到先服务,直到你第一个运行的任务结束。然后你排到队伍的末尾。好的。所以这里没有定时器中断,这只是一直运行,直到你做一些需要 I/O 或者你真的完成的事情。好的,我们可以用一个甘特图来表示,假设进程按顺序到达,进程 1。
假设进程一的执行时间为 24,进程二的执行时间为 3,进程三的执行时间为 3。如果我按先到先服务的顺序处理这些进程,这意味着我会先处理进程一,然后是进程二,再然后是进程三。
所以,如果你看看这个情况,这意味着如果我有一个 CPU,进程一会运行 24 个时间单位,然后进程二会运行接下来的 3 个时间单位,接着进程三会运行接下来的 3 个时间单位。我们可以开始计算一些指标,比如进程一的等待时间是零,因为它不需要等待。
进程 2 的等待时间是多少?是 24,因为它必须等进程 1 完成。好的,大家跟得上吗?然后我们可以算出平均等待时间。所以 0 加 24 加 27 除以 3,平均等待时间是 17。然后是平均完成时间,即等待时间直到开始,完成时间是直到完成。
所以我们可以计算这两个指标。好的,现在这并不是特别深奥,或者希望也不太难,除非你不会加法和除法,当然了,那总是个问题。你可以想象,平均等待时间和平均完成时间反映了调度器的优劣。
特别是如果你有一堆交互线程在等待运行,你可能希望尽量降低等待时间,以保持用户的满意度。对吧?所以这是一个可能需要优化的指标。事实上,如果你看这个图,会发现不幸的是,这只是因为进程 1 先到,而进程 2 和 3 在后面等待了这么久。
所以这个特定的调度器对任务到达的顺序非常敏感,你知道吗?而且,这,可能并不是一个好事。对吧?因此,我们实际上这里有一个叫做“车队效应”的问题。也就是说,短小的进程被长时间的进程卡住了。对吧?
这也被称为“少于五项”问题。安全检查,你觉得你在走“少于五项”的队伍,但那个只有一项的人的购物车里却有 12000 项东西,对吧?所以它永远都不会像应该那样工作。好的,如果你看这个车队效应,我们可以将其绘制成图。所以,我们有一个调度任务。
在这里,绿色到达,深绿色到达,蓝色已经准备好并在运行。现在绿色开始运行,你看到了吗?这就是实际调度的任务,这些是队列。然后,基本上我们先让蓝色运行,绿色和深绿色接着到达并运行直至完成,然后红色到达,依此类推。我们可以开始追踪这个过程,但是假设那个到达的红色进程需要一些时间。
很长时间。注意,我们把红色从队列中取出并开始运行,所以浅蓝色是队列的头,绿色到达,紫色到达,还有其他绿色,森林绿色到达,然后是蓝色。注意到这些线程都进入了我们的队列,而红色正在运行。
好的,这就是车队效应,因为它们都被卡在了红色进程后面。再说一遍,希望这只是一个图示,展示为什么可能“先来先服务”并不适合我们。好的。所以这里有一个,另一件需要注意的事情是,如果它们恰好以不同的顺序到达,比如 P2、P3 然后是 P1。那么我们可以计算一下,注意到我们的平均等待时间现在是 3,而不是 17,平均完成时间是 13,而不是 27。
所以通过重新排序,我们现在有了非常高兴的用户,他们变得更加满意了。好的,那么,这种“先到先得”的利弊是什么呢?优点是简单。缺点是短任务可能被长任务卡住。这是安全示例。我猜优点是你在排队时能了解一些关于外星人的知识。
当你在等待结账时,我猜应该有其他的东西吧。好的。那么,问题在于,“Burst”定义为一段时间内有大量计算活动的时间,不是“Burst”的时间是从你开始运行到停止运行的时间,然后我们之前做过那个图。好的。那么,现在我们做些不一样的事情。好的,继续提问吧。啊。
所以现在我们假设调度器是无限快的。它不需要运行很长时间,发生的事情就是当P2完成时,在那一小段时间里我们问调度器“谁在接下来?”调度器说“P3”。如果你想一想FIFO队列,在FIFO队列里不会涉及太多的计算周期。对吧?
现在,我的意思是你只是把任务从队列的头部取出来。现在,随着我们深入,结构会变得更加有趣,你们得记住你们的61 B(课程)。所以我们可以有堆结构或者类似的东西,在那里我们试图把预测的最短执行时间的任务从队列中取出来,但我们还没有到达那个阶段。
所以现在我们假设调度器在我们请求它取回任务的那个无限小的时间段内运行。好的,是的,问题。现在,记住所有关于将线程放入休眠和唤醒它们的机制,所有这些都在内核中。
而当你让一个线程进入休眠时,你会请求调度器(仍然在内核中)给我下一个线程,所以所有的队列操作这些东西现在都在内核中。好的,其他时候会更有趣,但现在一切都还在内核里。好的。
所以这个,这个看起来不太好。好的,首先“先到先得”这种方式在交互式系统中基本上是不可行的。好的,那我们还能做什么呢?我们可以采用一种叫做“轮询法”(Round Robin)的方法。那么,先到先得方案的问题在于,它可能对短任务不友好。它严重依赖于提交顺序。好的,如果你是排队拿牛奶的超市里第一个,你不在乎你后面是谁,但另一方面,如果你在某人后面,你会关心你在谁前面,对吧?
或者你在乎谁在你前面,所以轮询法方案就是我们所说的“抢占”(Preemption)。抱歉,我刚刚提到的“Robin”是因为当时我玩得太开心,做了这个插图,所以这个 Robin 我……我并没有让你们被这个 Robin 转来转去,我们没事了。好了,轮询法方案,通常称为抢占,非常简单。
我们从第一天开始就一直在讨论这个,但你们没有意识到。这是因为你运行一个线程,一个定时器响了,你将线程从CPU上取下,然后把它放到队列末尾,再取出下一个线程并运行。好了,这叫做轮转调度,相信与否,这是因为所有线程都在就绪队列和CPU之间轮流执行。好吧,当时间片到期时,不管它是多长,我这里说的是10到100毫秒,这是现代Linux内核的标准时间,通常是10毫秒或100毫秒。
当时间片到期时,进程会被抢占,我们将当前进程放到队列末尾,并取出队列头部的进程。例如,如果它们在进程中并且时间片为Q,那么每个进程都会占用一次CPU时间,因为它获得了一个时间片,并且以队列时间单位的块形式分配。所以,如果你仔细想想,没有任何进程需要等待超过n-1次队列时间。
为什么呢?因为你必须等前面的进程完成,然后才能运行。所以,如果我们想的话,可以将Q调小一些,以提高响应性,确保如果某个线程是交互式的,它可以更快地运行。
好的,如果你注意到,我把时间片量设定为10到100毫秒之间的范围,这个范围很标准。我想我在之前的讲座中提到过,看看你是否还记得,100毫秒对人类来说有什么特别的意义。希望你的反应时间通常比这个更快。还有什么?你在正确的轨道上。那就是当你开始感到烦躁时,当事情感觉有延迟时。
好的,延迟感在100毫秒时肯定比较明显,已经有很多研究做过这个。你可以想象,如果你是一个高度交互式的用户,可能希望将时间片缩短到10毫秒左右。好的。但你可以想象在性能方面,这里有很多调优的空间。如果Q非常长,那么定时器几乎永远不会响,你基本上就是让第一个进程被优先服务。
对,因为你几乎从不中断任何进程。如果时间片真的很短,你就会进行交替调度,因为你运行几毫秒后,接着是下一个进程运行,再接着是下一个进程。实际上,如果时间片非常小,比如1毫秒,可能就像超线程技术一样,但你永远不会这么做,因为软件会进行调度,这样会完全变成开销而不是计算。
好的,Q必须相对于上下文切换时间较大。否则,开销太高,但你也希望它足够小,以便交互式任务不会让用户感到烦躁。所以你可以看到这里有一个权衡。好了,有问题吗?事实上,你不可能让这个时间显示得太长,有什么问题吗?现在,如果你看一下……
这是一个时间量子等于 20 的轮转法示例。假设进程 P 一的首次执行时间为 53,进程 P 二的首次执行时间为 8,进程 P 三的首次执行时间为 8,进程 P 四的首次执行时间为 24。所以,假设量子是 20,这意味着我们等待 20 个时间单位,直到计时器响起。注意,如果 P 一开始,它将运行 20 个单位时间,然后会被中断,重新放回队列末尾,因为首次执行时间超过了 20,对吧,所以我们至少会运行 20 个单位时间。
而且现在,P 二只运行了 8 个单位时间。为什么?因为首次执行时间只有 8。它将在 8 时结束,然后 P 三会运行 20 个单位时间,因为它的首次执行时间至少是 8。接下来,随着我们继续执行,注意到 P 二已经退出了系统,等等。我们可以逐个线程地分析,但这可能会变得非常繁琐痛苦,所以我们就不继续了。
但是我们可以计算不同线程的等待时间。P 一的等待时间,注意,它一开始没有等待时间。但随后它需要等待 68 减去 20 个单位时间。然后它还需要等待 112 减去 88 个单位时间。所以,我们可以看到,P 一的总等待时间是 72。同样地,我们可以计算其他线程的等待时间。
然后,平均等待时间是 66 又四分之一,平均完成时间是 104 又二分之一。所以,这些,嗯,这些只是数字,但你可以看到,我们可以利用这些指标开始进行一些实验。例如,当我们改变量子时,是否能获得更好的平均等待时间?好的。所以轮转法的优点是,它对于短作业比先到先得好得多,但对于长作业来说,上下文切换的时间会累计。
好的,在这里,我们假设由于课堂演示的关系,没有上下文切换时间,但我们都知道其实并非如此。对吧?如果进行大量上下文切换,显然会增加许多时间,用来保存和恢复寄存器等操作。
好的。例如,在这里,如果我们有一个量子为 10,并且有 T 一和 T 二,我们可以看到,如果我们有更短的量子,平均响应时间就会变长。注意到我们的平均响应时间下降了。为什么?嗯,因为我们先处理了 T 一,这样 T 二就可以运行了。明白了吗?
这里有两个线程,量子为 10。嗯,量子为 10 对这两个线程没有影响,因为它们的首次执行时间都是 1。所以平均响应时间是 1.5。量子为 1 时也是一样,因此量子的改变对这里的结果没有影响。好的,现在你开始意识到,这很大程度上取决于线程。没错。
不幸的是,确实是这样。对吧?如果我们的时间片从1秒降到0.5秒,那么平均响应时间会增加。为什么呢?因为我们已经到了一个切换过多的地步,这反而使得平均响应时间变得更糟。好的,现在,你们显然可以从这里看到期中考试的相关问题,但希望你们不要因此困扰。
尝试从这里获取一些洞见。那么,如何在内核中实现轮询调度呢?我马上会在另一张幻灯片中向你们展示这种权衡。但5.0版本的队列,与先到先得是一样的,不过你会在计时器一响时就抢占任务,将其从CPU中移除,放到队列的末尾,然后继续下一个任务。好的。
时间片或中断。自从我们开始上课以来,我就一直在讲轮询调度。好的,第一课讲的是轮询调度。在这个就绪队列中,就是我们现在讲的这个队列。希望这不太神秘。项目二,还没有到。你们得做调度的作业。这是一个轻松的一周,专注于项目一。但当我们进入项目二时……
你将要开始做调度的作业了。好的,现在,如何选择时间片呢?希望你能明白,如果时间片太大,响应时间会变差。如果时间片是无限大的,你就会变成先到先得。如果时间片太小,你的吞吐量就会下降,并且交换太频繁。所以实际的选择,我再说一遍。
我们的时间在10毫秒到100毫秒之间。你知道,在最初的Unix系统中,他们实际上设置了1秒的时间片。我必须说,我曾经写过多篇论文并使用多个电子邮件账户,与同一个主机交互。当用户太多时,打一个长句子,“To be or not to be, that is the question”(生存还是毁灭,这是个问题),然后过了一秒钟才会响应。
你会通过思考“to be or not to be”(生存还是毁灭)来进行选择。即使是稍微不互动的情况也会如此。好的,试着想象一下像那样编辑一篇论文。那可真是不有趣。所以,你需要在短任务性能和长任务吞吐量之间找到平衡。典型的时间片是10毫秒到100毫秒之间。
通常情况下,切换开销在0.1毫秒到1毫秒之间。所以大致上可以说,切换的开销大约是1%。这并不算太糟,我们不会浪费超过大约1%的周期来进行切换。好,问题。好,嗯,时间片总是固定的吗,还是会变化?是的,确实会。
大多数情况下,时间片是固定的,不会变。但在某些情况下,你可以调整它。所以,这取决于你的调度程序和系统。你知道,关于时间片的一个问题,我接下来会给你们展示另外一张幻灯片。它是一个比较粗略的设定,调优它是一个挺好的选择。
它不一定会按你想要的方式提供快速响应等。它还是挺好的,算是一种大多数情况下有效的启发式方法。所以,尝试微调队列可能并不值得麻烦。但这是一个有趣的思考问题。从原则上讲,你可以。我打算展示一下这个。
我之所以要展示这个,是因为。让我们再放几张幻灯片。举个简单的例子,你有 10 个任务,每个任务需要 100 秒的 CPU 时间。轮转法调度器的量子时间是 1 秒。所有任务同时开始。所以你可以看看 FIFO 的完成时间。在这种情况下,你将在 1000 个周期内完成,假设没有切换的开销,在轮转法中,每 1 秒就进行一次切换。
你知道。任务一、任务二、任务三、任务四,你逐一执行它们。那也会在一千个周期后结束,因为它们两个都需要,嗯。你知道,100 乘以 10,总共需要的 CPU 周期。所以它们同时结束。但在轮转法下,平均响应时间要差得多。
简单的直觉是,在 FIFO 策略下,有些任务会非常早结束,而在轮转法中,它们都会非常晚结束。所以,平均响应时间和最终完成时间非常差。而在这个轮转法的例子中,你的缓存状态会不断被交换出去,所以……
如果你的 CPU 做得很好,尽量把任务加载到缓存中,这反而会影响吞吐量。好的。
所以这是另一张幻灯片。你知道,青色和紫红色是很棒的颜色,对吧?那么最好的是假设我们这里有线程,P 一是 53,P 二是 8,P 三是 68,P 四是 24。我们之前给你展示过这个。最好的先来先服务方式是最短的先执行,然后依次执行下一个最长、下一个最长、下一个最长。所以你可以看到,从等待时间的角度来看,最好的先来先服务。
然后,对于 31 和四分之一的平均值,最差的情况给你带来 83 和三分之一的平均等待时间。而从完成时间来看,我们得到 69 和三分之一,以及 121 和三分之四的完成时间。如果量子值为 8,我们可以看到平均等待时间降至 57 和四分之一,平均完成时间为 95 和三分之一。所以请注意,这个值介于最好和最差之间。
当然,我们也可以把这些其他情况放在中间。你可以看到,只要你处于中间,大部分情况下,它都能很好地工作。微调上下波动实际上并不会给你带来什么一致性的结果。好的。
另外请注意,P2。P2是什么?P2是8,它是最短的一个。请注意,P2在调度时的可能变动最差。所以注意。如果P2是短任务,因为它是一个用户的交互任务,注意它的等待时间可以是零,也可能会有非常长的等待时间,达到145。好的。
所以这个用户要么非常开心,要么非常生气。幸运的是,如果你能合理地选择一个时间片,那么两者的情况都会还行。所以最短的任务通过调整调度会受到最大影响。另一个需要注意的是,最长的任务,比如我们计算圆周率的任务,变化最小,因为注意。
除非你选择最差的先来先服务方式,让它最先运行,否则不管你的时间片是多少,它基本上是相同的。那么,这是什么?这个幻灯片的重点是什么?幻灯片的重点是,你不必过于担心长任务,因为短任务会受到调度的巨大影响。
好的,短任务是你的用户任务。所以,这意味着恼怒的用户比计算派的长时间任务要容易得多,对吧?好。
现在,一切看起来都很好,除了问题在于几乎所有的任务都被当作相同的来处理,对吧?因为我们基本上说我们设置了一个计时器。
如果计时器响了,而你还在运行,我们就把你放到队列的末尾,然后稍后再运行你,这就是这里发生的唯一事情,所有任务都是平等的。但是我们可以想象,实际上我们想要优先处理某些任务,这时我们就有了一个优先级调度器。
当你开始做项目的第二部分时,你会学到更多关于这一点的内容。所以,如果你看这里,我们可以有优先级三、优先级二、优先级一的任务队列。现在我们有一个执行计划:始终执行最高优先级的可运行任务直到完成,可能还会采用轮询算法,然后再执行下一个优先级的任务,依此类推。
所以请注意,现在不仅我们有了轮询机制,确保在给定的优先级层级中,所有任务都能有进展。而且我们现在还给它们设置了优先级。这样我们就可以决定哪些任务比其他任务更重要。好的,你们都读过《动物农场》,多少人记得《动物农场》对吧?一些动物比其他动物更重要,好吧,优先级调度就是我们从中得到的东西。
好的。当然,问题在于选择谁的优先级,正确地设置优先级,以便获得你想要的效果,这将是很棘手的。但是,像饿死现象这样的明显问题,也许会变得非常可能。
如果你有一个任务被卡在最低优先级,并且只有更高优先级的任务不断进入,而低优先级的任务永远无法运行,好的。然后,你甚至可能会遇到更糟糕的情况——优先级倒置。这实际上曾经导致了火星车的故障。也许下次我会确保我们讨论这个问题,但当时发生的事情是,如果一个低优先级的任务获取了锁,然后一个高优先级的任务在等待这个锁。那么,发生了什么呢?
一个中等优先级的任务正在运行计算饼图。现在你现在死锁了。为什么会这样呢?因为高优先级的任务正在尝试进入休眠状态。它们试图获取锁,但正在休眠,而中等优先级的任务正在运行,低优先级的任务则永远无法运行并释放锁。好了,这就是一个永远无法自行解决的情况。好的。
结果证明,火星车的确出现了一个类似的问题。高优先级任务是总线,低优先级任务是一个特定的测量方案,而中等优先级任务则在进行设备的维护。唯一让他们最终能够让火星车执行正确操作的原因是,他们能够从地球远程重启和调试它。
这真的很巧合。那么,如何解决优先级调度的问题呢?这将是一个有趣的话题。因为我们必须开始动态地调整优先级。所以我们从一组给定的优先级开始,但随后根据动态启发式算法将任务的优先级向上或向下调整。好的,这就是我们将要处理的方式。好的。
所以希望你们开始看到调度在这一刻变得更有趣了,或者可能更复杂了。我不知道。我们得讨论一下这个问题。所以我们可以问自己,公平性怎么样呢?严格的固定优先级调度在队列之间是非常不公平的。
好的,这不公平,因为只要有优先级三的任务,低优先级的任务就永远无法运行。但是如果优先级三的任务正在尝试实施制动措施,也许这样就可以了。现在,我刚刚在聊天中收到的问题是,我所说的优先级重排是否意味着,最低优先级的任务可能会突然变成最高优先级的任务?嗯。
事实上,是的,存在一个叫做优先级捐赠的机制。如果一个高优先级的线程试图获取一个锁,而一个低优先级的线程已经持有该锁。那么,高优先级的线程将能够将它的优先级捐赠给低优先级的线程,以便它能够运行足够长的时间来释放这个锁。好了,这只是我们将要提出的众多动态优先级机制中的一个。
所以,队列之间的严格优先级调度是不公平的。曾经有一个完全不真实的都市传说,但我还是要重复它,因为它是都市传说。那就是在多任务操作系统中,他们关掉机器,发现一个10年的作业一直在队列中等着,从未运行过。
好的,这唯一的作用是指出都市传说是荒谬的。并且很明确地指出低优先级的任务可能无法运行。好的。所以你必须为长时间运行的作业分配一定比例的 CPU。你使用严格的优先级调度。也许这样你就不会让一个作业卡在队列里待10年。
但是你可能会有一个作业,它想要取得进展,但始终没有进展。
好的,这在最纯粹的形式下可能不是一个好计划。好的,但它开始给我们提供了一些可以调整的思路,以便尝试将 CPU 分配给当前重要的任务。
好的。所以权衡是通过牺牲平均响应时间来换取公平性。我们做的就是为长时间运行的作业分配一些 CPU,然后让非常短的作业得到更少的 CPU,这可能是可以接受的。那么,如何实现公平性呢?你可以给每个队列分配一定比例的 CPU。
如果你有一个长时间运行的作业和100个短时间运行的作业,你该怎么办?你可以增加那些没有得到服务的作业的优先级。是的,继续吧。啊,所以,是否有办法来决定优先级?这个问题的难点在于,你以为自己想出了一个很好的方案,但往往它并不是一个好方案。所以。
人们有很多方法来选择优先级。好的,因为我喜欢回答问题。我给你这个问题。
假设一下有140个优先级。底部的或者最重要的前100个优先级,基于事件发生的频率来选择,底部40个则基于我们正在讨论的内容来选择。这正是 Linux 调度器所采用的方式,它有100个实时优先级。选择优先级的方式是,频率较高的事件会得到更高的优先级。
因此,可以根据周期来分配,你可以从100个优先级中选择一个,这个是一个数学选择。然后剩下的40个优先级,再结合我们刚才讨论的问题,你必须做出选择。但至少这是分配优先级的一种方式,这些优先级是重要的。
我将在谈论实时时提到这个问题。所以,你们可以分别为每个队列分配一部分 CPU。然后,我们可以增加那些没有得到服务的作业的优先级。所以,如果一个作业运行了很长时间却没有得到任何服务。
然后你可以逐渐提高它的优先级,以便为它提供更多的服务。好吧,这算是动态的。但是我想以这个问题来结束:如果我们知道未来会怎样呢?我们能不能想出最佳的先来先服务策略,因为请记住,先来先服务只是某些时候不好,尤其是当它真的很糟糕的时候。
但有时候它真的很好。对吧,一个非常好的先来先服务策略就是我们能确保最短的任务优先完成。太棒了。对吧。但是有谁能告诉我为什么这么做很难吗?没有对吧?你必须做出很好的预测。所以相反,在课堂的最后几分钟,我想探讨一下,如果你知道未来会怎么样。
所以我现在不会告诉你们怎么知道未来。我们下次会讲,大家可以准备好股票交易账户什么的。不过,假设我们真的知道。好吧,那么我们就可以得到一个算法,叫做最短剩余时间优先,这就是……
当你有一个队列,必须选择下一个任务时,你只需要选择剩余部分最短的那个。你选择剩余时间最短的任务来运行。最短剩余时间优先,实际上还有一个非抢占式的最短任务优先。但我们先讨论最短剩余时间优先。
我们可以将它应用到整个程序,或者应用到出生等地方,但如果我们知道未来。那么我们就可以做最短剩余时间优先,事实证明,最短剩余时间优先是,嗯,我们查看队列,选择剩余运行时间最短的任务运行,它有着绝对最佳的最小平均响应时间。所以如果我们能以某种方式预测未来,我们就可以做到。好吧,这个是可以证明最优的。
好吧,在可以证明最优策略涉及预测未来的情况下。如果你将最短剩余时间优先与先来先服务进行比较,例如,假设所有任务的时长相同。那么最短剩余时间优先就变成了和先来先服务一样,为什么呢?如果10个任务时长相同,大家都是一样的,你挑一个,它就开始运行。
它就永远不会被抢占,因为它现在是剩余时间最短的任务,所以它接下来就运行。好吧,另一方面,如果任务有不同的时长,最短剩余时间优先就能确保短任务总是优先执行。好吧。这里有一个好处。如果你有三个任务,假设a和b非常长,它们需要一周才能完成。
看,任务必须运行非常短的时间,因为它正在做一些像从磁盘上复制数据的事情。它计算出下一个要从磁盘获取的数据,然后执行磁盘操作,然后计算下一个任务。所以,为了获得最大效益,我想做的是将这个任务以极高的优先级运行在短时间内,然后把东西发送到磁盘,接着可以运行其他任务。因此,这个任务只有在我们始终以最高优先级运行短小的计算任务时,才能最大化磁盘带宽的使用。
好的,这些后台的任务无论如何都要计算一周,所以它们不在乎我们是否中断它们。好的,按照先到先得的原则,如果a或b可以运行,c就完蛋了。对吧?它根本行不通,因为你得等一周。好的,另外,你不能从磁盘上复制东西。那么轮转调度呢?如果使用轮转调度。
你这样理解就对了,先运行c很短的时间,然后它进行磁盘访问。然后它可以运行100毫秒,b也运行100毫秒,然后c再运行很短的一段时间。好的,如果你看一下之前的幻灯片,我们希望c实际上运行一毫秒和九毫秒的时间片。
所以只有在一毫秒和九毫秒的时间片内,我们才能实现100%的I/O利用率。所以在这里,我们的磁盘利用率现在是201个单位中的9个,仅为4.5%。好的,这样很糟糕。如果我们用轮转调度,那就浪费大量的时间。我们可以通过SRTF调度将磁盘利用率恢复到接近90%。
我们可以将其调整到90%,这是我们能做的最好的情况。所以我就先停在这里,我们下次再继续。不过,我们会用预测未来的方式来解决这个问题。但是轮转调度是给每个线程一个小的CPU时间片,执行时在所有线程之间循环,采用最短作业优先的策略。
它将运行计算量最少的任务,并通过预测未来以某种方式执行。我们将把这一切转化为一个多级反馈调度器,从而在某种程度上预测未来。好的,祝你们周二剩下的时间愉快,我们可能周四再见。再见。
[BLANK_AUDIO]。
P11:第11讲:调度2 案例研究、实时和前进进度 - RubatoTheEmber - BV1L541117gr
好的,我不确定我们是否能接入演讲者。所以只要你们还能听到我,能听到吗?有人回答一下吗?好的,欢迎回来,大家,我们遇到了一点技术问题。所以。那些看到我在这里的同学可以看到我。哇哦。也许我们会从课程录制中获取视频。
让我放大一下。更紧凑的镜头角度,我想从上次我们讨论调度的地方继续。如果你还记得上次,我们差不多在讨论调度时,决定从一组排队的任务中选择哪个。
下一步。好的,特别是,就像我们经常讨论的那样,就绪队列是我们经常讨论的一个问题。所以在那个实例中,嗯,哎,这里是哪里呢?这个队列就是。就是这个队列。好的,它是喂给CPU的队列。所以现在,调度基本上是做出决策。今天和下次我们会更多地讨论可能的调度策略。
好的,如果你还记得上次我们谈到的一些简单的策略,比如最小化响应时间。通过最小化完成某件事所需的时间。或者。响应时间通常是用户看到的内容,对吧?所以当你打字时。如果需要调度一个线程来处理你的按键并将其显示在屏幕上。
这可以是一个调度问题。我们讨论的另一个问题是最大化吞吐量。好的。这是一个。也许是最大化每秒的操作次数,比如你在做云计算。如果你还记得我们上次讨论的其中一件事是,响应时间和吞吐量有时是相互冲突的,对吧?因为响应时间意味着。快速抢占并继续执行。
线程就是这里,我们不能这样做,咱们继续吧。因为我需要这个。这个正在把麦克风传送给正在Zoom上的人。所以。如果你想想看,最小化时间消耗,就是要抢占那个线程来完成这项任务。最大化吞吐量其实就是。不抢占,允许它继续运行,这样你就能得到良好的缓存行为。
好的,然后公平性是另一个我们讨论的稍微有点。
这里有点复杂,我们稍后再解释。抱歉,大家稍等。你还记得我们讨论过的轮转法(Round Robin)吗?时间片是我们讨论过的一个问题。你知道,FIFO(先进先出)是最早的策略,但那不太有趣。
所以,圆形罗宾算法的思路是,你有一组进程。每个进程都有一个执行时间,也就是它运行直到完成并开始做 I/O 或类似操作的时间。举个例子,在这里我们有 P1、P2、P3、P4 及其执行时间,量子时间为 20。所以实际上,你知道,这不是火箭科学,你可以通过 P1 来处理这个问题。
运行 20 个单位时间后,它会被切换出去,因为量子时间超过了。P2 接管。它最终只会运行 8 个单位时间。为什么?因为 P2 只有 8 的执行时间。接着 P3 运行 20,P4 运行 20,然后我们重新开始。这里涉及到一个文件队列。当我们从 CPU 中取出一个进程时,我们将它放到队列的末尾,并继续这么做。
好的。所以我们接着讨论了像平均等待时间和平均完成时间这样的指标,这些是非常重要的,特别是对于交互式用户。好的,平均等待时间基本上就是将所有进程在队列中等待的时间加起来,然后除以进程数。而平均完成时间其实就是看结束的时间。
然后我们将这些时间加起来并进行除法计算。好了,我想在这里暂停,因为这是最基本的调度方法,除了 FIFO(先进先出)之外,没人真正使用它。我们讨论的就是这个。所以,大家对这个有问题吗?因为我假设大家应该是明白的,没问题吧?
很好的问题,那么我们的首次执行时间是预先计算的吗?不,首次执行时间是我们事后才知道的。所以,当我们尝试基于执行时间做一个最优策略时,这会有一点挑战,因为我们必须做一些预测。
好的,所以我在这张幻灯片上做的事情就是假设我们提前知道了这些执行时间。好的,很好的问题。还有其他问题吗?好的,那么,如果我们知道未来,是否可以始终模拟最好的先到先服务策略呢?如果你还记得关于先到先服务的内容,那就是如果你按照最短作业优先,接着是次短作业,依此类推,你将得到最佳的平均等待时间和响应时间。
然后我们提出了最短作业优先或最短剩余时间优先的思想。好的,这两者非常接近。最短剩余时间优先的真正含义就是:队列中的所有可能任务,如果我们知道了首次执行时间,因为我们拥有水晶球。
我们会选择执行时间最短的那个进程,先执行它,然后选择下一个,再选择下一个。好的,这大概是我们在讲座结束时的状态,我们稍微有些匆忙,所以我想确保大家都理解了这一点。
SJF(最短作业优先)和SRTF(最短剩余时间优先)之间的区别实际上就是抢占性。如果你使用SRTF,当一个新的短任务到来时,它会抢占当前正在运行的任务,或者如果当前在处理器上的任务需要更长的执行时间。好的。你知道,你可以将这个应用到整个程序或者当前的CPU任务上。
等等。但这里的关键是尽可能快速地将最短的任务从系统中清除出去,从而加快平均响应时间。好的,思考这个问题的方法,我再说一遍。如果我们知道未来并且一堆任务进来了,我们会重新排列这些任务并执行FIFO策略,但我们会以一种总是让最短的任务先运行的方式进行。
有什么问题吗?所以我想回到之前的问题。这里的问题显然取决于我们是否有水晶球。好的,我们必须知道每个任务的执行时间,这样才能选出剩余时间最短的任务。所以下次我们做的最后一件事是,我们正在查看。
然后我们展示了SRTF的例子,并解释了如果我们有它,为什么它会有用。这个例子是两个长任务和一个交互任务或者实时任务的情况。假设长任务A和B会运行一周。好的,线程C有一些非常特殊的属性,它需要运行一毫秒。
然后执行一毫秒的磁盘操作。然后再运行。好的,所以如果我们处在这样的情境下,你可以看到,能够确保每次线程准备好时就启动它变得非常重要。这里的想法是每一毫秒。
然后它开始一个磁盘操作,持续九毫秒。然后在这九毫秒完成后,它会再次运行。这将是我们能从磁盘获得的最大吞吐量,磁盘的忙碌度会达到90%。但前提是我们能在合适的时间安排这个小线程。只要在合适的时机启动,红色线程就能运行。好的。那么使用先来先服务的策略就不好了,对吧,如果A或B先进入,可能会运行一周。
如果磁盘空闲,那么磁盘的利用率将为零。那么使用轮询(round robin)或者最短剩余时间优先(shortest remaining time first)呢?好的,大家能猜到轮询会发生什么吗?好的,会有很多上下文切换,那么这是什么意思呢?是的,轮询是一种相对粗暴的调度方式。
我们必须选择合适的时机,以便每一毫秒进行一次切换,这样我们就能确保线程C总是能被调度。但即便如此,也不一定能成功,因为你必须在磁盘操作完成的瞬间准备好进行调度。
所以如果你看这里,这是基础。例如,那是我告诉过你们的标准时间。你运行一毫秒,磁盘运行九毫秒。但到那时,A已经开始,它运行了100毫秒,B运行了100毫秒。然后C开始运行。那么我们得到的是什么?我们的磁盘利用率是201个时间单位中的9个时间单位,它得到了运行,因此我们的磁盘使用率降到了4。
5%的带宽。所以这非常糟糕。对吧,但我们所做的就是每100毫秒切换一次。现在,如果我们加快速度,比如每毫秒切换一次。也许我们能达到大约90%的效果,就像我们想要的那样,只是我们会有大量的唤醒,因为我们不断地切换。对吧。所以,在我们讨论其他方案之前,问问你们的问题。
所以我们讨论的这些有意义吗?嗯。嗯。就是说这个磁盘会忙碌九毫秒。然后它会忙碌九毫秒,然后这是在201个周期中的一部分。好的。实际上我刚刚想到,我可能可以利用我笔记本电脑上的摄像头,至少给你们提供一些我自己的画面。
哦,等一下,那样会拔掉麦克风。所以,不,我做不到。好的,没关系。哦。让我们看看另一个选项。好的,SRTF是一个更好的场景。为什么?因为C的突发时间很短,毫秒级。所以每当C准备好调度时,它就立即启动,并且说,哦,运行C。
但除此之外,A或B得以运行。这个有趣的地方在于,当……
A开始运行时,A和B都是一周长,但一旦它们开始运行,A现在比B短,所以A总是会优先于B运行。好的。好问题。那么如果我们可以……是的,继续。你怎么知道是什么?
你怎么知道任务完成的时间,你有水晶球吗?好的。所以,如果你能预测未来,SRTF是很棒的。好的,显然你不能真正预测未来。所以,至少现在还不能。我们需要把它看作是一种保证不会超过的好策略,如果我们能逼近它的话。
所以这就是我们接下来要讨论的内容。好的。我只是想确保大家都理解了。好的。那么在我们继续之前,大家还有什么问题吗?是的,确实,我们得预测未来。这就是为什么我告诉你们今天带上你们的股票投资组合的原因。现在似乎欧洲有些激进的情况,可能正在搞乱股票期货,不过无论如何。
好的,我可以继续吗?好的,顺便说一下,磁盘的利用率是90%。好的,所以。希望我们能看到的显而易见的一点是,饿死现象是一个问题。所以,如果你有大量的小任务,它们会完全压倒任何大任务。因此,实际上在这种情况下,我们看到最下面B根本没有机会运行。
至少不是一周。好吧,所以这是一个问题。现在我们必须预测未来,这也是一个问题,所以一些系统可能会询问用户:“你认为你的任务大概需要多长时间?”现在在磁盘问题的情况下,也许他们可以说他们知道。因为他们单独运行了很多次,可能测量过,且它们的规律性足够强,能够做出一定的预测。
然而,大多数此类预测都很难做到准确。而且基本上是错误的。好吧。所以你可以保证,最有好意的程序员也不知道运行某个任务需要多长时间。而恶意程序员当然会告诉你它只运行了一个微秒。为什么他们会这么说呢?因为这样他们就能让它一直运行。好吧。
所以也许询问用户并不是一个好主意。那么这里的结论是,你实际上无法知道一个任务需要多长时间。但是我们将使用SRTF作为衡量其他策略的标准,并且它是最优的。所以你无法做得更好。那么它为什么是最优的呢?再次强调,它通过给出最小的平均等待时间和平均完成时间来实现最优。好吧,从这个意义上讲它是最优的。好吧。
所以它的优缺点是:最优是优点。需要预测未来是缺点。好吧,不公平。嗯,也许这个世界本来就不公平,所以我们不去深究,但我们还是把它当作一个缺点。好吧。所以,无论你对公平的理解是什么,SRTF显然都不是公平的。那么,我们怎么做预测呢?嗯,我们可以做的一件事是,看给定线程的历史运行情况,并基于此来做预测。
所以,提出一种使用过去行为的自适应策略。这在计算机系统中效果不错,原因是大多数计算机系统的行为具有很高的可预测性。好吧,所以让用户告诉你确切的任务执行时间并不是一个太好的选择,但通过测量并构建模型可能是一个不错的选择。好吧。
而且这是伯克利大学的,就像你知道的,机器学习中心。所以你可以想象,某种形式的机器学习可能会是一个好选择。但我们不会这么深入,因此我们可以使用一个估算函数,它简单得多,比如一个科尔曼滤波器,你可能在16系列课程中遇到过。
所以,想法是,如果你知道一系列过去的历史记录,你可以将它们输入到一个函数中,从而估算出下一个任务的时间。好吧。而一个简单的方法是使用指数加权平均,其中有一个α参数,用来调节最近过去的影响。
和过去的总体数据。好吧,因此基本上,你做的是α乘以上次时间,再加上1减去α乘以总体平均时间。这样可以得到新的平均时间。我意识到这里有些下标在转移过程中丢失了。
但这其实很简单。这是一个平均窗口。好的,这个方法你应该已经遇到过很多次。如果我们做了这个平均窗口,它基本上就是在说,这个任务的行为有点像它过去行为的平均值。你可以通过两种方式来做到这一点,要么保留一个无限版本的历史数据,要么只使用几个过去的值并将其平均在一起,这两种方式都可以。
在我们继续讨论彩票调度之前,先来看看这个问题。所以,我想法是,我会使用这个过滤器的预测作为每个线程的爆发时间。我会收集历史数据,然后随着精度越来越高,我会用它来安排线程调度。所以,线程准备好时,就可以运行了。
我会将其与模型关联起来,并用它来决定哪个线程剩余时间最短,并将其提前运行。是的,有问题吗?所以,最初可能会从一个很短的时间开始,10毫秒左右,这些过滤器。
尤其是当它们只有一个或两个参数时,它们适应得非常快。对于平均值来说,问题是,如果你有一个非常多参数的模型,适应起来就需要更长时间。所以,它会有一个不错的中等时间,并且会被实际值迅速覆盖。好问题。还有其他问题吗?好的,这就是预测未来的一种方式。
我再给你们看点别的。所以,这是一个问题。抢先来先服务和最短作业优先有什么区别?所以,最短作业优先就像是优化版的先来先服务,它会重新排序队列,把最短的任务放到前面,然后是下一个,接下来是下一个。好的,希望这回答了这个问题。
好的,那么我们现在来讨论一些更有趣的调度机制。彩票调度是一个非常有趣的概念,我们的目标是分配CPU时间给进程。好的,所以我们会给每个作业分配一定数量的彩票,然后我们会进行一个连续的抽奖,按照彩票的数量来分配时间片。
好的,如果总共有100张票,一个人有5张,那么他将平均占用5%的CPU。好的,因为每当定时器触发时,你就会进行一次新的抽奖,看看是谁的票,你就往前走。好的,那我们如何分配这些票呢?其实是通过模型来近似SRTF。我们有一些方法可以利用这些模型,将短任务排到前面。
首先通过给他们更多的票,而长时间运行的任务则分配较少的票。好的。关于彩票调度,和我们接下来讲的其他几种调度方式,比较有趣的是,我们可以通过确保系统中的任何任务至少获得一张票,避免整个任务被饿死。
好的,在有100个未处理票据的情况下,确保每个作业至少能获得一张票,这意味着无论发生什么,每个作业至少能占用1%的CPU。好的,我将这一点与我们在讨论优先级调度时所谈论的做个对比,我们稍后会再回来讨论。在优先级调度中,如果有足够多的高优先级作业,低优先级作业就永远无法运行。
而使用彩票调度,你可以安排低优先级的作业至少能运行一点。因此,相较于严格优先级调度,在这个实例中,它的优势在于它能随着负载变化优雅地调整行为。并且当添加或删除作业时,它会相应地让所有的进度变慢。所以你可以说,当我添加一个新作业时,我实际上是在系统中添加了额外的某些彩票票据,这会优雅地减慢所有作业的进程。
好的,所以这就是我们可以通过添加或删除作业来做的事情,基本上它影响了每个人的比例。因此,玩转彩票调度有很多方法。这里有一个例子,我们将短作业分配10个票,长作业分配1个票。然后在一个有一个短作业和一个长作业的实例中。
短作业占用91%的CPU,长作业占用9%的CPU。在没有短作业,只有两个长作业的情况下,那么两个长作业就会获得相同的CPU份额。如此类推,你也可以查看其他的情况,比如如果有10个短作业和1个长作业。
然后每个短作业会得到9.9%的CPU,而长作业得到0.99%的CPU。所以你可以在这些参数上做很多调整。但有谁能提醒我,在这个特定的例子中,我为什么要给短作业更多的票数?我的目标是什么?对,没错,为什么我要尽可能快地将短作业从系统中移除?是什么引导我们走上这条路径的?是的,最低的平均等待时间,是的,这会导致更好的响应性,是的。
所以,如果我们对交互式和计算型作业的混合感兴趣,我们想要找出一种方法,给短作业一些优先权。假设你已经以某种方式对它们进行了建模,那么彩票调度为你提供了一种简洁的方式来实现这一点,而不必完全给短作业100%的优先权。
长作业仍然可以继续执行。所以不会出现永久饥饿的情况。当然,如果短作业过多,导致响应时间不合理,那就真的很难取得进展了,对吧。所以我之前给出的这个例子,实际上,我不认为,等等,我在哪里给出了这个例子,举个例子来说。
如果我们有10个短作业和1个长作业,你知道,短作业占用的CPU开始减少。如果我有100个短作业,它会越来越少,最终这些作业都无法向前推进。好吧,所以这些调度机制中没有一个能够解决这个问题。
固定极端的过度消耗,极度过度承诺资源。你知道,你应该记住这一点。某个时刻,可能你只是需要一台更快的计算机。好的,调度是你用来处理的事情。你会逐渐变得更加熟练,做得更好,来处理那些多个进程争夺的资源。但它无法解决极端缺乏足够资源的情况。好的,所以记住这一点。
这是很重要的要知道。是的,问题。好,重复一遍。是的。所以,如果你有很多短作业,让我确保我理解你的问题。但你有一个非常快的CPU或有很多核心。那应该会很有趣。是的。因为在这种情况下,你有足够的资源,调度程序可以帮助分配它们。
所以调度程序无法弥补资源完全不足的情况。但是它可以很好地分配现有的资源。是的。所以,所有这些都有一个关联的时间片,这是你的问题。那么,彩票调度的工作原理是,你有一大堆票。好的。
你们都看过宾果游戏吧,你知道的,他们会摇动球。你知道,然后他们会说哪个标记赢了,但在这个情况下他们只是把球扔回去,所以你会不断地随机选择。是的。是的。所以现在记住。在这个例子中,问题是:长作业是否会一直运行直到完成?说到长作业。
我说的是有大爆发时间的东西。好的。这实际上意味着它必须运行完所有指令,然后它会进入I/O队列休眠,之后它会再次醒来并变得又是一个长作业。所以,我们现在讨论的其实是长作业和短作业代表它们在CPU上需要保持的时间长度,而时间片在这里较短。所以当你抽取一张票时。
可能它让你运行五毫秒。然后你醒来,另一个人运行五毫秒。长作业可能会运行很多这样的五毫秒时间片,而短作业则可能只运行其中的一部分。好问题。还有其他问题吗?是的。是的。所以在这种情况下,我们只讨论那些已经在就绪队列中并且可以运行的作业。
所以我们现在讨论的唯一资源是那些正在争夺一个CPU的东西。那些在I/O队列中处于睡眠状态的东西,但是当I/O事件发生时,它们会被从I/O等待队列中取出,重新放回就绪队列,然后它们就变成了一个新的作业。它们也在争夺资源。好问题。还有其他问题吗?现在。
彩票调度已经存在一段时间了。有趣的是,假设你知道如何区分长作业和短作业,这其实是有挑战的。这是一种很好的方式,可以平滑地将CPU时间分配给不同的作业,并且不会导致饥饿现象。
好吧,会有其他的内容,我们一会儿会提到。好吧。那么,你如何评估一个调度算法是否更好呢?好吧。你可以做的一件事是构建一个数学模型,并为它提出一个确定性的建模方法。这个问题是,你不仅需要数学地定义调度工作负载或调度器。
你可能会想象,彩票调度有一个数学描述。但是你还需要描述所有的工作负载,所有将要运行的应用程序的数学模型,这样你才能以某种方式将它们数学地结合起来。好吧,这是一个伟大的目标,但很少能顺利实现,因为它实在是太难了。好吧。
另一种方法是,有时你可以通过排队模型做得比完全确定性的建模更好。我们将在本学期稍后讨论一些排队理论,但现在我们不会讨论它。人们通常会做的是构建一个包含调度器的模拟器。某种你尝试测试的调度器的近似模型。
你已经收集了一堆应用程序,并测量了它们的行为轨迹。所有的启动时间是什么。好吧,你将这些留下,然后将这些轨迹输入到调度模拟器中,看看调度器的表现。之所以这样做,而不是直接运行它,是因为构建一个完整的模拟器并将其放入Linux系统中需要大量工作,因为你必须调试它,确保它做正确的事情。而如果你有一个新的调度算法,实际上你应该开始考虑有很多算法,如果我给你这样的印象。
还会有更多内容。但是你可以构建一个特定调度算法的模型,你可以向其中输入跟踪数据,而不必在一个真实的操作系统中完全实现它。也不需要花时间真正运行所有应用程序。好吧,这就是。
你会看到过去的调度论文,他们做了模拟。好吧。因为这通常是系统人员可以做得相当好的事情。好吧。而且它通常能像其他一些更数学化的模型一样准确,因为数学模型需要近似很多东西,所以它们并不总是那么准确。好吧。问题来了。那么,如何在不构建实际系统的情况下进行模拟呢?问题出现在聊天中,答案是:想象一下。
由于我们是基于突发时间进行调度的,所以你需要有输入文件,表示应用程序1、应用程序2和应用程序3的所有突发时间。读取它们,将其存储到就绪队列的模拟中,你有一个调度器的模拟。
它会把下一个任务取出,但你会说,嗯,这是一个12秒的突发时间,我就假装它运行了12秒,我将时间提前到12秒。你一直这样做,然后实际上你不需要运行真正的任务。好的,我们可以下次再详细讨论这个问题。那么现在让我们回到这些讲座的目标,如何处理同时混合运行的不同类型的应用。即使是暗示,如果你有交互式和计算性任务同时存在。
你希望为每个人做出最好的选择。然后你想通过启发式方法来说,嗯,如果你只是确保短时间的任务总是运行,而长时间的任务持续进展,你可能就没问题。但实际上,难点在于,你可能有计算型应用,它会经历一些阶段,做大量的短IO操作;你也可能有交互式应用,它会经历一些阶段,进行大量计算。
仅仅通过突发大小来判断任务是否具有交互性,实际上并不总是容易的。好的。然后,另一个有趣的事情是,如果你想想你所使用的所有计算机。即便是现在,在你从这里回到家的路上,你有你的笔记本电脑,你有你的手机,还有云中的数据,存在很多不同类型的计算机。
一个很好的问题是,它们都应该使用相同的调度器吗?你怎么看?是的,可能不行。为什么不行?因为它们在计算能力上构造得非常不同。是的,应用程序的构造也非常不同。是的,请继续。你想说什么?抱歉,我抢了你的话。好的,你知道的。
比如,我们曾经说过,可能第一次的时间是我们可以观察并建立模型的,短时间的突发意味着高交互性,高优先级。这些都是问题。而且有趣的是,如果你回顾所有过去在不同操作系统中运行过的调度器。
它们在底层确实有一些假设,这些假设是调度器设计者为尝试判断某个任务是否具有交互性而加入的。以便给它更多的优先级或不给它更多优先级。接下来,我会给你展示一个,叫做Linux从原版到2.0版本的01调度器,据说它非常棒,因为它是O(1),所以计算开销不大。
但是它有很多启发式方法来尝试识别应用类型。好的。所以,你知道,再说一次,关于那些长时间处于休眠状态但随后长时间进行计算的应用,或者是那些在任何情况下都周期性运行的应用,等等,可能会是个问题。所以,想想,识别应用是很难的。那么,关于这个问题呢?这就是在几个操作系统中实现的所谓多级反馈调度器。
所以你应该看到这里有一堆队列,顶部的队列会输入到第二个队列,第二个队列输入到第三个队列,依此类推。而且每个队列可能都有不同的调度器。好的,我这里的设置是,比如说,顶部的队列是轮转调度,时间片为 8,接下来的队列是轮转调度,时间片为 16,而这个队列是先到先得。
这是一种利用过去行为的方式,这在 CTS 系统中非常早就出现了。基本上每个队列都有不同的优先级和不同的调度机制。所以,你做的是从顶部开始放置一个线程,如果它的突发时间很短,结果是它没有超过顶部的时间片。
然后你会一直把它放到顶部。但是,如果它超过了时间片 8,那么你就把它放到时间片为 16 的队列中,它会以较低的优先级运行。如果它超过了那个时间片,那么你就把它放到最底部的队列,它会以最低的优先级运行。所以,每个队列都可以有自己的调度算法,我这里展示的是两个轮转调度和一个先到先得调度。
这有很多版本。我们可以调整每个任务的优先级,正如我所说,你从顶部开始,然后慢慢往下走。为什么我们要做这么复杂的事情?我们到底想要达到什么目的呢?好吧,接下来。
你走在正确的道路上,你说可以有一个交互式队列和一个非交互式队列,现在只有这个结构。这个怎么帮助我们区分交互式和非交互式任务呢?是的。交互式任务会保持在顶部,因为它们的首次运行时间非常短。而计算密集型任务则会待在底部。
如果你有一些任务,它们有短时间的高峰,然后是一些较长时间的任务。它们会慢慢下降,但永远不会到达底部,因为它们很快会回到顶部。好吧。当你运行的时间没有超过时间片时,你就会回到顶部。所以,你可以把这个看作是近似我们的 TF 的一种方式。
所以这就像是通过查看历史来尝试预测未来。这里的历史指的是你进入了哪个队列。顺便说一下,这在许多操作系统中非常常见,采用多优先级、多队列的方式,并通过不同的机制将队列互相传递。好的,长时间运行的计算任务会不断被降到底部。
所以结果有点像我们的 TF,因为 CPU 绑定的任务像石头一样掉到底部,短时间运行的待处理任务则保持在上面。现在我们需要弄清楚如何在队列之间进行调度,因为我刚刚引入了优先级。高优先级、低优先级和中间优先级。那么,我该如何处理这个问题呢?一种想法是固定优先级调度,你总是先做顶部的任务,然后是下一个队列,再然后是下一个队列。
或者我们可以进行时间片分配。这样每个队列都会得到一定的 CPU 时间,第一个队列得到 70%,下一个得到 20%,下一个得到 10%。那么,能有人告诉我,这些方法中,哪一种可能更偏向哪种情况?为什么?请说。哪一个?你想要修复优先级还是时间片?好的。好的。如果你注意到,这里固定优先级的方案是直接优先级,所以短任务总是先运行。
所以你应该思考的问题是,为什么我可能会选择时间片而不是固定优先级,或许这就是我该问的问题。是的,请说。是的,时间片的版本避免了那些不幸被排到队列底部的任务饿死。
因为我们持续地给它们至少 10%。所以这有点像优先级。但带有一个保障,确保这些任务总是能够运行一点。好的,现在注意,我们正在讨论的启发式方法基本上是将任务启动时设置为高优先级,然后逐渐降级。如果它们运行得太久。你可以想象有很多有趣且复杂的启发式方法来试图分配任务。
然后根据它的行为来调整它的位置,这不就是我们所做的吗?如果它表现出一连串的短时间爆发,它会接近顶部;如果它表现出很多长时间的爆发,它会接近底部。然后我们基于这个进行调度。好的,你几乎可以想象这是一个非常简单的想法,但你也可以有一个更复杂的想法。每一个进入的任务都通过一些启发式方法进行分类,放到一个队列里,然后根据该队列进行调度。
现在,哎呀,我们的好摄像头没了,接下来问题是,如何构建这些启发式方法。为什么不直接说,嗯,doom?好吧,我喜欢 doom,我们就叫它交互式的,我也喜欢另外一个东西。好的,我们就一直通过一个庞大的表格来分类应用程序。好的。
你可以想象像这样的启发式方法。而且你知道,调度和操作系统的历史上,曾经有很多人尝试做 exactly 那样的事情。好的,所以,这里有一件有趣的事情要告诉你,一旦你开始使用启发式方法,不论它是否复杂。
你可以开始把这当作是用户的行为。如果他们知道调度策略,现在就可以通过规避策略来避免。于是他们可以提出反制措施,比如,如果一个用户想要大量的 CPU,他们就做一堆打印操作,因为这些操作也会产生短时间的爆发,它们会一直排在队列的最前面,并且会获得大量的 CPU 时间。好的,事实上。
在早期的计算机游戏中,程序之间必须互相配合。所以,实际上有一个例子,有人想明白了这个问题,其中一个程序通过计算出足够的 I/O 操作来确保自己保持在调度队列的顶部,从而获得了更多的 CPU 时间。
好的,启发式方法是可以获得的。我们来谈谈 Linux 01 调度器。好的,这个调度器是 Linux 2.0 版本中的。它有 140 个优先级。好的,在这个版本中,优先级是越低,优先级越高。底部的 100 个优先级,从 0 到 99。所以,我们把它称为实时优先级。好的,最上面的 40 个优先级是用于普通用户程序的。
好的。所以有 40 个优先级用于用户任务,这个优先级由 nice 值设置,大家有多少人了解 nice?好的,nice 是一个你在 shell 中运行的程序,它可以让你调整程序的优先级,要么提高优先级,要么降低优先级。不幸的是,对于许多人来说,在共享机器上运行时,只有超级用户才能改变整个优先级。好的,你总是可以降低你的优先级。所以 01 调度器的优点是它提供了很多选择。
然后,实时任务将运行,我们稍后会讨论这一点,而低优先级的任务则会运行。高优先级的任务中有很多启发式方法,用来判断某个任务是否为交互任务,并且在这 40 个优先级中进行任务的调度。好的,并且有两个独立的优先级队列,一个是活跃队列,一个是过期队列。
所以,它避免饿死的方式是,它会接收所有的任务,然后先执行最高优先级的任务,再执行下一个,依此类推。与此同时,新的任务会进入到另一个不活跃的队列中。当你清空了所有活跃队列中的任务后,就会交换它们。
然后你执行下一个任务,交换它们,再执行下一个。结果就是,你确保了每个优先级都能运行一点。好的,它根据优先级调整时间片大小。因此,随着优先级的下降,CPU 的分配也越来越少。好的,所以,时间片的分配是线性地映射到时间片的范围内的。
然后你会有一个多级队列,每个优先级对应一个队列,你可以把它想象成有 140 个队列。好的,执行被分割成时间片粒度的块。它按优先级轮转执行。所以,这就像是,我们把上节课和这一节课之间所有的内容都学了。
然后它把所有的内容整合成 140 个优先级,你可以决定同时运行实时任务和交互任务。你可能会得出这个结果。好的,我能想象,这可能是你在喝咖啡时可能会想到的事情。
好的。
好的,这只是展示了两个用户如何交换任务。而关于 01 调度器的一个特点是,它有大量的临时启发式方法。所以,如果你看看那 40 个优先级,它的做法是,通常会取一个中间的 nice 值,因为那是默认值。然后,基于它的假设进行调整。
它是基于观察和一些启发式规则,甚至会根据是否认为某些任务是交互式的来调整优先级,上调或下调五个优先级单位。完全是基于它是否认为某任务是交互式的,且完全依赖启发式规则。好的,启发式规则会在优先级上加减五个单位,它们会计算任务休眠的频率,并利用这一点来决定是上调还是下调。这样,你就得到了所谓的交互式积分。任务在休眠时会赚取积分。
你在运行很长时间时会花费这些时间。你会得到一些滞后效应,这样你就不会在优先级之间频繁波动。无论你怎么做,交互式任务都会有更多的执行机会。不是很好吗?因为它解决了所有的调度问题。好吧,接下来,实时任务不会进行优先级调整,它们执行严格的优先级调度。
因为如果你已经决定让它们以某个特定的优先级运行,那么你就得这么做。多少人觉得这听起来不错?我看看举手的几位。是的,有个问题,来吧。滞后效应基本上是指,当你有某个值在来回波动时,滞后效应是一种时间平均的概念,用来防止值变化太快。所以,当……
你必须等待压力一段时间才能将其提高,然后再提高它。而滞后效应则是防止优先级频繁波动。好吧,在未来你会遇到很多不同类型的滞后效应。所以,这听起来很灵活。我要告诉你,Linus讨厌旧版调度器,它变得非常复杂,接下来让我告诉你为什么,你加入了一些启发式规则。
你想在调度器上做得更好,但又不想惹恼现有的应用程序持有者,因为他们已经知道调度器应该如何运行。所以,如果你做任何改变,必须保留旧的启发式规则,可能还要加入一些新的,结果这些启发式规则变得越来越复杂。
还有一个隐含的假设是,你的用户总是会得到他们之前使用的相同启发式规则。是的,很乱。好了,顺便提一下,这个问题被暂时搁置了,稍后我们会在讲座中讨论。好吧,现在谈谈考试。这个……哦,我猜这是这里的类型,所以我们的平均分大约在53分左右。
如果你有任何重新评定请求,必须在周日之前提交。好吧,解决方案已经发布。我已经看到Piazza上有一个讨论串,大家在问关于解决方案的问题。所以,请尽管提问。我想为答题卡框的大小道个歉,那个设计真不太好。这是一个实验,看看是否能让大家的生活更轻松,但我觉得它肯定让你们的生活更难了。
所以,下次我们会做得更好。这就是我刚刚意识到这些数字现在其实不对。不知怎么的,我输入了正确的答案。对,我输入了正确的答案。然后在我展示给你们的版本中,它似乎消失了,所以这个是不对的。
但我记得的数字大概是,均值和中位数差不多,都在53左右。这个数字差不多是对的,而且通常相当于B左右。好了,这是一个很好的问题。项目一的最终报告下周提交。另外,下周我们还会进行同行评价。所以我想简单说一下为什么我们这么做,因为我们非常关心你们小组的表现。
好的,所以同行评价的核心思想是给我们提供一个关于每个人贡献多少的整体印象。原则上,如果有人完全没有做出任何贡献,我们会采取零和策略,把那些没有贡献的人的部分分数转移给那些有贡献的人。
好的,但这是基于很多因素,特别是你的助教理解实际情况的能力。我们想了解情况的部分数据就是通过同行评价来进行的,这也是每个人都需要做的。这个过程的思路很简单。如果你有四个伙伴,或者你在一个四人小组中,你的其他伙伴有多少?三个,对吧?他们是其他的,不是你。好的。
四个人。其他的三个人是那几个在外面的人。你们小组中的每个人都会得到20分。你把这些加起来总共是60分,你可以随意将这些分数分配给你们其他的成员。好的,你不能给自己打分。你们通常是很难客观评价自己的能力的。我们不想知道你认为自己有多好,我们想知道你认为其他成员是怎么做的,并且希望你能根据实际情况给出一些理由。
你知道,对吧?好。如果你们是一个三人小组,其他的成员有多少?两个人。其他成员不是你吧,所以三减一等于二。你们总共能得到多少分?120分。你可以把60分分配到任何你想给的成员身上。好的,明白了吗?不对,是40分。你可以把40分随意分配。好的,明白了。
如果你们在一个二人小组中,没关系。好了,我们不再讨论这个了。好,任何问题吗?顺便提一下,这门课的第一次期中考试通常会很难,因为它涉及到同步问题,而同步是这门课中最难的主题之一。
好,继续。是的。嗯,它们会被保密的。我们知道这些。现在我们不会告诉你们,也不会告诉你们小组的其他成员,除非你们愿意让我们告诉他们你在评价中写了什么。这只是我们用来了解你们表现的许多工具之一。好的。
你的助教是你需要知道你进展如何的主要人物。好的,所以确保他们知道你的进展,并且在设计评审时能看到你,能看到你在课堂上的表现。好的,有问题吗?好吧,我们继续。那么,操作系统调度的是进程还是线程呢?嗯。
许多教科书使用的是传统的每个进程一个线程的模型。所以在很多情况下,如果他们没有明确说明,他们指的是“每个进程一个线程”,并且可能会说“调度进程”,但他们的意思是“调度线程”。线程和进程的切换有不同的开销。我们已经讨论过了。所以,切换进程有更大的开销,因为你需要切换翻译表,刷新TLB等。
好的。如果你记得的话,然而,同时多线程是一个很好的例子,其中硬件为你进行切换,而且非常快。现在,多核是我们在这门课上还没有讨论过的内容。不过,从算法的角度来看,它和单核调度的差异并不大,唯一的区别是可以使用更多的核心。好的,但它非常有趣的一点是,你可以为每个核心创建一个调度数据结构,以确保缓存一致性。
等等。但你可以使用所谓的亲和力调度。亲和力调度的意思是,如果我有一堆线程,且线程A曾经在核心1上运行过,那么调度器可能会尝试保持线程A和核心1的亲和力,并让该线程在同一个核心上重新运行。为什么呢?
嗯,因为那样会有更好的缓存状态,更好的TLB状态,等等。好的。所以,在多核中,你可以利用的一个与单核不同的特性是亲和力调度。让我给你展示另一个例子。好的,我们谈到了这样的自旋锁,其中要求的是测试和设置,等待,然后释放时将值设置为零。
现在,这样做是好事吗?嗯,我们说不是。为什么呢?因为是忙等待,对吧?我们在浪费周期。是的。然而,什么时候这样做可能更合适呢?嗯,当你有多核并且能确保同步的线程在不同的核心上并行运行时,实际上在一个核心上使用自旋锁可能会更好,因为它会持续运行,释放你并继续执行。
好的。所以,唯一的方法,顺便说一下,这就是我提到的那个测试和设置真正发挥作用的时候。好的,因为那样你就不会浪费大量的内存带宽。基本上,情况是这样的,你基本上是说,当锁被占用时,只需保持一个非常紧凑的循环,然后一旦锁释放,立即跳出循环。
然后你进行测试和设置。这个测试和设置实际上避免了值的来回切换问题。但你只有在确定可能释放锁的线程也在另一个核心上运行时,才想要使用自旋锁。
明白了吗?好的。要做到这一点,唯一的方法是使用全群调度。我们在几张幻灯片之前谈过亲和调度,全群调度是一个简单的概念:如果你知道某些线程是一起工作的,就将它们一起调度,使得它们总是同时在一组核心上运行。所以你可以使用许多多处理器全群调度器。
我们不会再多谈论它们了。所以有一些替代方法,其中操作系统会告诉并行程序到底有多少处理器在实际运行。我们现在不打算讨论这个话题。但我确实想谈谈其他的东西。
那就是实时调度。我们之前提到的特斯拉车在你踩刹车时应该能停下来,或者当它决定你需要刹车时。因此,实时调度的目标与我们之前讨论的调度目标是非常不同的。
对的,我们上次和这次讲座开始时提到的目标:最小化响应时间、最大化吞吐量或公平性。在实时调度的情况下,可预测性比这些目标更为重要。知道在你踩刹车时系统会如何响应,远比其他任何事情都重要。
刹车会在不到某个数字(比如9毫秒,具体是多少)内启动,所以你可以设计硬件来实现这一点。好的,这就是一个实时要求的截止日期。我们不在乎它是在两毫秒、七毫秒还是九毫秒时发生。只要在九毫秒内发生就可以。好的,如果我们能利用调度来保证这些截止日期,而不需要关注最快的速度,而是最可预测的速度,这就是我们进入实时调度的领域。
好的,大家看到区别了吗?你可以把之前的目标看作是优化某些性能指标,而实时调度则是在优化可预测性指标。好的,是完全不同的问题。因此,硬实时通常用于时间关键型的安全导向系统。基本上,你必须满足所有的截止日期。因为如果不满足某个截止日期,可能就会有严重后果。
所以这是一个典型的情况,比如你在处理汽车的刹车,或者在医院中有一个心脏监护仪,里面有一些截止日期,或者是其他类似的情况。这些都是必须要满足的硬性截止日期。我将给你们展示一些标准的调度算法,比如最早截止日期优先调度、最小松弛度优先调度和速率单调调度等。软件实时调度就像硬实时调度,但更加灵活。不要想太久。
它基本上是说错过几个截止日期是可以的,因为没有人会因此死亡。一个很好的例子就是视频,对吧?你偶尔错过一个帧也不会太严重,对吧?事实上,你通常可以很快恢复。所以,好的视频编码器会通过丢帧和其他方法来适应网络问题。为什么它还是实时的呢?因为你需要在每个明确的时间段内有一帧画面。
但我们称之为软实时(soft real time),因为我们可以允许有一点松动。好吧,稍微有些松动,虽然不能太多的松动,但一点点松动可能使得实现更容易。好的,这里有一些例子,如果你跟我一起上 262 课程,我们会讨论,比如常量带宽服务器,它通过使用最早截止时间优先(earliest deadline first)的一种巧妙方法来实现软实时。
好的,问题来了。所以,实时任务最重要的思考点是,它与我们之前讨论的有所不同。例如,我们将讨论实时任务的典型特征是什么。所以,与任务具有突发时间不同,在这种情况下,它们有截止日期和已知的计算时间。例如,这里有三条线程的集合,请注意,线程 4 的到达时间,我们从左到右看时间。
所以线程 4 在这个大矩形中到达的位置表示该线程需要执行的计算。而在这里,我很抱歉没有展示给你。好吧,所以,这里有一个指针。所以,线程 4 到达这个点时,最大的计算量已经到了,但最后期限也在这里。所以从这个图中你可以看出,如果我们在到达和截止日期之间的任何时间安排这个计算,都是可以的。
好的,所以 C,也就是那个灰色矩形,必须代表最坏情况下的计算时间。因此,许多实时编译器等需要在所有情况下非常小心地分析代码,以便你知道最坏情况下的计算时间是多少。
好的,现在看这三个,这四个线程,告诉我如果只有一个 CPU,这四个线程的调度是否有效。为什么会有冲突?为什么会这样?嗯,简单的答案比你想象的要简单。请注意,我们有计算重叠,这在只有一个 CPU 的情况下是不可能的。
所以,我们这里要在时间上切片,你看我们有两个任务同时计算。然后在聊天室里,有人说,“不合法,应该优先考虑截止时间较早的任务”。好主意。所以这就是为什么最早截止时间优先(earliest deadline first)可能对我们来说是一个好的策略。接下来是为什么轮询(round robin)策略不好。假设我做的是,我拿出我之前展示给你的一组任务。
如果我尝试用轮转调度来安排它们,你会看到轮转调度实际上与截止时间无关。 所以我们只是根据时间片来切换对吧? 所以如果你看,系统中只有线程四,它获得了一个时间片。 然后当线程三和线程二出现时。
现在我们在四、三和二之间交替运行,然后线程一出现,现在我们就在它们之间交替运行。 然后,出乎意料的是,我们错过了一个截止时间。 为什么这不奇怪? 因为轮转调度与截止时间没有任何关系。 好的,为什么它应该有效? 问题是,最早截止时间优先假设了几个条件,首先任务是周期性到达的。 所以我们看的是周期,而不是截止时间。 这只是意味着每当任务到达时。
一个周期后的截止时间。 一个周期后的截止时间。 所以我们通常看周期性任务。 它是一个抢占式优先级动态调度,所以每个任务最终都会获得当前的优先级。 它会根据任务到达的时间、计算其截止时间来确定优先级。 截止时间最接近当前时间的任务就是优先运行的任务。
我们将进行抢占并执行它。 这是一个例子,其中线程一的周期为四,需要一个计算。 线程二的周期为五,需要计算。 线程三的周期为七,需要计算。 它们都在零时到达。 好的,哪个线程的截止时间最早? 线程一,为什么? 因为它的周期最短,这意味着它的截止时间最早。 好的。
所以在这里,我只是计算截止时间。 请注意,线程一的截止时间在这里,线程二的截止时间在这里,线程三的截止时间在这里。 哪个线程的截止时间最早? 哦,线程一。所以它将首先运行。但现在在这个时候,我们必须开始运行线程二,因为它有下一个最早的截止时间,依此类推。如果你按照这个顺序进行,你会发现它有效,但有一些限制。
这是否适用于任何一组线程? 好的,有人摇头,为什么不行? 是的,为什么截止时间可能不可达? 是的。如果我把所有蓝色的部分加起来,结果占用了超过100%的时间,那么就不可能调度 CPU。 好的。我们解决这个问题的方法是看这里,线程一实际上需要 25% 的 CPU 时间。
为什么我说它每四个单位需要一个 CPU 单元? 好的。所以你可以想象,在最理想的情况下,或许如果我将所有的利用率加起来,并且它小于某个值,假设小于100%或1,它可能会有效。 事实上,即使是EDF调度,也无法在任务太多的情况下工作。 就像我们之前说的那样。
尽管 P 在这里表示截止日期,实际上,如果所有的利用率都小于 1,那么你就有一个可行的方案。好的。因此,EDF 的优点就是你可以进行一个非常简单的可行性测试,这已经足够了。好的,这是必须的,才能确保它是可以调度的,你可以运行这个简单的方程式,看看你是否能够调度它。好的,有问题吗?是的。啊,是的,非常好。我很高兴你提到了这个问题。你说,嗯。
调度器必须相信用户所说的。库比教授,难道你不是在一开始就说过用户永远不知道吗?那么这不是不可能吗,对吧?
我没有说过那句话,但我是这么说的,对吧?所以答案是:在实时调度的情况下,唯一能调度的方式是,如果你有非常精确的工具来评估最大计算时间是多少,然后你把这些信息传递给内核。所以,实时调度是一个很好的例子,你必须信任线程传入的数据。
好的,但你知道,在这一切背后其实有一些相当复杂的编译器分析等等,以确保你知道这些。好的,好的问题。还有其他问题吗?是的。它是怎么做到的?嗯,实际上很难做到完全正确。好的。事实上,你需要做的是,情况比你刚才提到的计算代码行数更复杂。
你必须把它转化成汇编代码,然后你需要计算缓存缺失,并找出最坏情况下的缓存缺失,然后把所有这些加起来。在所有可能的情况下,最糟糕的会是多久。好的。这不是一个简单的事情。有很多论文我们可以推荐给你,研究这些的人员。一个有趣的替代方案是 Corey 那边的一些人。
甚至我们还没谈到没有缓存的处理器,是否有其他不可预测的因素,正因为如此,所以计算更容易一些。好的,你可以想象,当你做大量实时调度时,你可能会真的去设计一个定制处理器来实现这一目标。
好的。现在,当然,目标是使用常规处理器,这样你就可以同时做一些实时的工作和常规工作,比如看视频。然后,你知道,接下来必须发生的事情就是,你必须大大高估计算能力。好问题。那么,接下来我们再聊一个话题。我要和你们讲一讲实时性。那么你想从实时性中得到什么?
你需要对计算进行非常准确的估算,尤其是当截止日期非常关键时,特别是对于硬实时系统,因为你知道,某些情况下可能会导致人员死亡。所以这是非常重要的。像 EDF 这样的调度器可以让你在前期计算出是否可行。让我告诉你为什么 EDF 并不总是被使用。问题在于,为了运行,你。
你会注意到我们实际上在进行分割。如果你跟踪下去,你会发现我们进行了预处理。一些线程会运行一段时间,然后被抢占,再回来继续运行。好的。我注意到在这个例子中没有出现这个情况,但它出现在EDF(Earliest Deadline First)中。所以,你知道,你可能不希望进行抢占。因此,也有一些更简单的方式,不需要抢占,就不会让你实现100%的利用率。
所以有很多不同的调度器我们会讨论,如果你来参加262课程,我们将详细讲解其中的几个。所以,让我们回到这个问题。那么,下一个问题是,饥饿是什么?饥饿是指线程在不确定的时间内无法取得进展。在本讲中,饥饿不是死锁。死锁是下一讲的内容。
因为饥饿原则上是可以解决的。饥饿是一个你无法取得进展的情况。但如果出现合适的条件,你最终会取得进展。那么,饥饿的原因是什么呢?调度策略。例如,如果你有一个优先级调度器,并且有一系列高优先级任务,那么低优先级的任务可能永远不会执行。
这就是饥饿,它不是死锁,因为高优先级队列可以完成任务,然后低优先级任务会运行。好的。那么,死锁将通过下次讲解来区分,事实上,死锁是指存在一个实际的循环依赖关系,即使从原则上讲,也无法解决。
所以,有一个“稻草人”理论所谓的非工作保护调度器。工作保护调度器是指当有工作需要处理时,绝不会让CPU处于空闲状态;而非工作保护调度器则可能简单地决定不运行任务。这样,你就会遇到饥饿现象。好的。所以我们假设不会使用非工作保护调度器,因为这是一个糟糕的想法。那么,最后到达服务呢?这就是一个栈。
所以,如果你使用这样的调度结构,后来到达的任务会得到非常快速的服务。而那些早期到达的任务可能会被堆积在栈的深处,永远不会运行。因此,这应该是一个非常简单的线索,说明为什么会出现饥饿现象。好的。当然,生活的另一面,哦,它是bifo。
这里的问题是,如果到达的任务太多,而计算时间不够,那么新的任务就永远不会执行。因此,先到先服务(First Come First Serve)容易导致饥饿现象。那么,如果是一个任务呢?上次我们展示了一个关于车队的例子,其中红色任务运行了很长时间,所有这些任务都堆积起来了。然而,这种情况。
不是饥饿的原因是它最终会让任务运行。什么才算饥饿呢?就是当它陷入无限循环时。好的。这就是为什么先到先服务不是一种特别好的通用操作系统策略。也是为什么我们希望在系统中加入抢占机制和定时器。好的。
但我告诉你,一些早期的个人操作系统其实会受到这种情况的影响。轮询调度是否容易导致饿死?嗯,每个进程都会被分配一个固定的CPU时间片。总是如此。如果有量子队列,那么你就知道你总会有机会运行。并且在N-1个时间片的毫秒数内,或者任何其他时间单位内,都不会有饿死的情况。对吧。
因为你总是可以运行。现在,当然,轮询调度方式在等待时间上是公平的,但在吞吐量方面未必公平,因为你可以自愿放弃你的时间片,之后就无法再得到这段时间。
那么优先级调度呢?容易导致饿死吗?我的意思是,有些人认为优先级调度容易导致饿死。好的,不是很多人这么认为。是的。我们在整场讲座中一直在说它容易导致饿死。对吧?所以是的,为什么呢?因为你可能会有一堆高优先级的任务,而永远不给低优先级的任务运行机会。好的。
但还有一个更严重的问题,我今天想结束时提到。比如优先级反转。好的,这种情况甚至高优先级的线程也可能会被饿死。让我给你展示一个例子。这是一个优先级调度器,作业一以优先级一运行。没有获取锁。好的,到这里。
调度程序在作业一运行时选择了哪个作业?但现在队列中有三个作业,接下来哪个会运行?嗯,假设此时优先级最高的是优先级三。这并不总是很清晰,你必须自己判断,但在这种情况下,作业三会运行。
那么如果作业三运行并尝试获取锁,获取锁是否会成功呢?因为低优先级的作业一已经占用了锁,对吧?所以在这种情况下,作业三会在获取锁时被阻塞,作业一持有锁。因此,作业三直到作业一完成之前都无法运行,除非作业二被卡在中间运行。好的。这种情况叫做优先级反转,因为低优先级的任务阻止了高优先级任务的执行。
所以无论你以为通过设置优先级来做什么,结果都是失败的。好的,因为你尝试对优先级进行分类没有成功。大家都明白为什么这是优先级反转吗?是的。那么为什么我不让作业二运行呢?但是作业二可能会一直运行很久。看,只要作业二还有时间去运行。
它不会让作业一运行。那么重点是,如果我们让它运行一段时间,而作业二有很多事情要做,我们就卡住了。好的,作业三也因为一个低优先级的任务而停滞不前,这就是优先级反转。因此,我们必须说它无法解决,但现在我们被困在优先级反转中。是的。对。
太棒了。他问,任务三是否应该以某种方式改变任务一的优先级来让这种情况发生。是的。我们马上就会讨论这个。好的,好的问题。我付钱让他说这个问题的。所以,高优先级任务被阻塞,等待低优先级任务。而低优先级任务必须先执行,高优先级任务才能继续。
而且,一个中等优先级的任务可能会饿死高优先级任务。你可能觉得这太复杂了。但其实并不复杂。我马上就会展示给你看。还有其他可能导致这种饿死现象的情况吗?好吧,这里还有一种情况,高优先级线程尝试获取自旋锁,但低优先级线程已经持有了锁。好的,现在高优先级线程将一直在循环中自旋,而低优先级线程将永远不会执行,我们永远也无法解决这个问题。
好的。那么,这是捐赠的想法。非常好。对了,任务三尝试获取。它进入睡眠状态,但将优先级分配给任务一。任务一运行一段时间直到释放。然后优先级被恢复,任务三获得了获取权限,没有优先级反转。好的。太棒了。大家都明白了吗?你们想看一下吗?来吧。任务三被阻塞了。好的,稍等。
让我们回到这里。好的,任务三尝试获取任务三被阻塞。但它把优先级给了任务一,任务一运行并释放了锁。任务三开始执行,每个人都很高兴,没有优先级反转。欢迎来到项目二。但让我们暂时放下更有趣的项目二,换个话题吧。
1977年7月4日,"先驱者"成功着陆火星。着陆过程非常惊人,使用气球将其从轨道上弹出并带到地面,气球爆裂,真是太棒了。它完美地运行了。自1967年"维京号"以来,美国的第一次火星着陆。我们使用了气球作为交付机制,它们将探测车从轨道上弹到地面,然后气球泄气,探测车就开始活动了。太美妙了。这是技术完美应用的最佳例子。
然后几天后,任务开始执行。发生了多次系统重启,重启,重启,重启。那是老式的情形,你做了所有这些伟大的工作,交付了探测车,但它不起作用。好的,系统在随机重启。抱歉,有多少人遇到过手机重启这种情况?我实际上曾遇到过一次。问题是什么?优先级反转。
不管你信不信,一个低优先级任务,负责收集数据,锁定了总线上的一个锁。然后,高优先级任务需要获取这个锁来实际传输数据。与此同时,一个中等优先级的任务正在运行。这实际上发生了。好的,那么为什么会发生重启?幸运的是,他们有一个看门狗,注意到系统进展没有发生,并决定重启一切。
这样它就能继续运行了。为什么这么幸运呢?嗯,这意味着他们可以从地球上调试并弄清楚发生了什么。好吧,他们最终搞明白了,信不信由你。解决方案是:出于让一切都高效运行的目的,有人决定关闭操作系统中的自动优先级或捐赠机制,因为那样会浪费时间去追踪锁定,所以我们不想要它。
结果证明,如果他们没有这么做,这件事根本不会发生。但通过调试模式,通过地球上的远程调试,他们能够进入系统,重新打开优先级和捐赠机制,瞧,问题解决了。好了,如果你认为优先级捐赠不是必要的,你会感到惊讶。好了,我们来结束这个话题。
所以我们讨论了一些基于传统性能的调度目标,比如最小化响应时间、最大化吞吐量和公平性。但随后我们引入了可预测性。我要确保你们知道,实时调度器可能会有一组非常不同的目标。
我们稍微讨论了一下轮询调度(round robin scheduling)。它的优点是更适合短作业。我们讨论了它在保证不超过最优最短作业优先(shortest job first)或最短剩余时间优先(shortest remaining time first)方面的优势。我们还讨论了多级反馈调度器作为最短作业的近似方法,最短剩余时间优先调度。我们还讨论了实时调度器,例如我们提到的彩票调度(lottery scheduling)。
我们没有完全讨论完的内容,下次我们会讨论的是Linux CFS调度器,这正是Linus在对现有调度器感到非常恼火后决定做的事情。好了,下次Anthony会来讲课,他回来了。所以我下次见,Mignana,但祝你今天剩下的时间愉快,周末愉快。谢谢。 [静默]
P12:第12讲:调度3 饿死(已完成),死锁 - RubatoTheEmber - BV1L541117gr
好的,大家能听到我说话吗?
好的,开始吧。这是我们关于调度的第三讲。我们将讨论一些其他的调度算法。然后我们会讨论饿死问题,接着谈谈死锁,如何检测死锁以及如何避免或防止死锁。好的。
所以请记住,在之前的实时调度讲座中,我们讲到的目标是性能的可预测性。所以我们希望能够以高度的信心预测出给定系统的最坏响应时间。现在,在实时系统中,性能保证可能是任务级别的,也可能是类别级别的。
我们希望提前保证这些任务的可预测性。所以,当我们对比常规系统时,我们看到的是,常规系统的性能可能是系统导向的,或者可能是吞吐量导向的。然后我们回头看,看看我们的吞吐量、性能在上一次怎么样。
比如一分钟或者十分钟。对于实时系统,关键是强制执行可预测性,对吗?
这不一定等同于快速计算。所以当我们考虑硬实时系统时,这就像你车上的防抱死制动系统,或者类似的东西,它们是时间关键的安全系统。很多时候,这也可能是工厂自动化,你有机器人之类的。
我们希望在可能的情况下满足所有的截止日期。我们希望设计我们的系统、配置我们的资源,确保我们始终能够按时完成任务。因此,我们需要根据系统中的任务数量以及我们拥有的资源来提前确定如何为这些任务提供服务。
我们看了很多不同的调度算法,以便能够做到这一点。现在,相比之下,还有我们为多媒体使用的软实时系统。对于在家中的你们来说,之间有很多系统,它们都在使用软实时来尽力保证我的视频和音频能够顺利传输。
这是没有中断的。但是没有保证,这意味着偶尔你可能会看到帧跳跃或者听到音频跳跃。好的,现在如果我们看看像“最短剩余时间优先”或者“多级反馈队列”这样的算法,它们容易发生饿死吗?如果你仔细想想,在像“最短剩余时间优先”这种情况下,我们可能会遇到…
这样做就会让那些长时间运行的任务因为短时间运行的任务而饿死,对吧?因为这是最短剩余时间优先算法的特点。我们在优先级调度中也遇到了相同的根本问题——我们把所有时间都给了高优先级的任务,而不给低优先级任务分配时间。现在,如果我们看看像多级反馈队列这样的算法,它只是一个近似方法。
对于我们来说,实施 SRTF(最短剩余时间优先)时,我们更多是回顾过去的性能,而不是预测未来应用程序的 CPU 使用情况。所以它会遇到同样的问题,对吧?我们将从长时间运行的任务开始,这些任务会在高优先级队列中启动,短时间量的队列。
它们将不断地到达时间片的末尾,但仍然希望继续运行。所以它们最终会被降级到最低优先级队列。如果我们看一下 CPU 分配的情况,如果高优先级队列中有任务,它们会先被运行,这就意味着其他长时间运行的任务将只能等待。
任务将会被启动。好,现在想想看,对吧?
到目前为止,我们研究的调度策略都围绕着优先级。我们希望将 CPU 分配给那些优先级更高的任务,这意味着那些优先级低或者没有优先级的任务可能永远无法运行。它们会被启动,但我们没有用优先级来表示“我们希望系统做什么”。
这主要是为了给系统提供不同类别的任务处理方式。我们希望能够让系统明确表示,嘿,这个任务比那个任务更重要,而不是只运行这个任务,完全忽略其他任务。所以,如果你这样想,我们的最终目标是:我们希望系统能够处理大量不同类型的任务。
有些任务是 CPU 密集型的,有些是 I/O 密集型的,有些是交互式计算的,有些是多媒体任务。我们希望系统能够运行所有任务,而不仅仅偏向某一类任务,只运行其中一类。所以我们需要考虑这一点,对吧?我们的 I/O 密集型任务,当它们完成一个 I/O 操作后,它们只需要足够的 CPU 时间来继续执行。
可以调度 I/O 设备进行下一个 I/O 操作。同样地,如果我们有交互式任务,我们希望性能良好。当你输入一个按键时,我们不希望按键的响应需要几秒钟的时间才能显示出来。而对于我们那些 CPU 密集型的任务,我们希望它们高效运行。我们希望能够快速执行它们。
尽可能快速地将任务处理完。像上下文切换这样的操作,只会让 CPU 密集型任务运行得更慢,因为上下文切换会带来额外的开销。
同时,也要考虑我们所处的环境。当我们回顾计算机发展的初期,回到 50 年代和 60 年代,那个时候的计算主要依赖大型计算机,对吧?每台计算机背后都需要很多人共同使用。刚开始时,世界上只有少数几台大型计算机,它们服务着成千上万的用户。
如果你想想这些主机的成本,它们的价格从数百万到数千万美元不等。所以在这些机器上,计算周期是非常昂贵的。但是现在看看我们所处的位置,物联网时代。如果你坐在一辆现代汽车里,你会发现自己被数十台计算机所包围。
你周围可能有40台不同的计算机、微控制器、微处理器。只要环顾四周,你会发现计算机无处不在。所以,比例发生了彻底的变化。每人拥有多台计算机。现在计算周期便宜了。但是,当我们最初开始考虑调度时,是在那种背景下——成千上万的用户共享计算资源。
我们需要在成千上万的用户之间共享如此宝贵的计算周期,这些用户使用我们的主机。因此,调度程序的设计确实是围绕优先级来进行的,以便为CPU密集型任务、I/O密集型任务等分配资源。在80年代,我们看到了个人计算的崛起。因此,计算机的使用比例从成千上万的人共享一台计算机,变为一人一台计算机。
在那个时候,计算周期的价值大大降低。即使我们看工作站或网络服务器,像是部门文件服务器,它可能只服务100个人。所以,现在的不同机器开始有了不同的用途。我有一个网络文件服务器,它是专门用来提供文件服务的计算机。
现在不进行任何计算。没有互动,只是提供内容。现在的转变是公平性和避免极端情况。我们不希望这些环境中出现资源饥饿。回想一下90年代,我们看到了云计算数据中心的兴起。于是,50,000台计算机被放置在一个仓库里。
现在我考虑在谷歌的Docker上运行Google幻灯片,制作我的课程幻灯片,而不是在我的笔记本电脑上运行它。它是在一个数据中心运行的,我和这个区域内的数百万其他用户共享这个资源。几乎回到了主机时代,只不过不再是单一的计算机,而是成千上万台计算机协同工作,以尽可能高效的方式运行。
为我提供一种交互体验,几乎和我在本地运行PowerPoint时一模一样。所以当你开始思考时,现在你要考虑的是可预测性,比如95百分位的性能保证。你不得不做大量的系统工程,这真是令人惊叹。
目标是让这50,000台计算机表现得和我本地运行的应用程序一样。但我也得到了服务器整合的好处。管理一个填满了50,000台计算机的数据中心,比管理一堆桌面PC更高效。同时,你还需要处理一些突发流量。想象一下,如果你是CNN。
比如说你是《纽约时报》的一员,上周你观察到你的网络和系统流量,在几分钟到几小时的时间内,从原来的基准值增加了100倍。尽管如此,最终用户仍然看到了相同的第95百分位性能,因为你在这些数据中心里做了大量的扩展。
如果只有一台机器,而我突然需要处理比平常高 100 倍的流量,那就难得多了。好的。那么我们真正要问的问题是,如果我们要优先处理一些工作任务,是否意味着那些没有被优先处理的任务将会被饿死?嗯,按照我们所看过的方法,的确会发生这种情况。对吧?
那些优先级较低的工作,如果有足够的高优先级工作,这些低优先级工作将永远无法执行。我们的所有 CPU 都将分配给这些高优先级工作。因此,我们可以考虑一些方法,确保始终为这些低优先级工作分配一定的 CPU 份额。对吧?所以,采用基于优先级的调度。
我们总是会倾向于将 CPU 优先分配给优先级更高的任务。如果就绪队列中有优先级更高的任务,它就会得到执行。这意味着低优先级的任务可能会被饿死。那么相反的做法是,我们可以考虑按比例分配 CPU。
因为最终,这就是我们想要的。对吧?你知道,我们有一组多样化的应用程序在运行,我们希望每个应用程序都能得到一定份额的 CPU。优先级更高的应用程序,我们希望分配更多的 CPU 份额。优先级较低的应用程序。
我们希望获得更少的 CPU 份额,但仍能获得一些 CPU。如果我们不希望它们获得任何 CPU,我们就根本不会将它们接入系统。对吧?好的。所以目标是,如果每个任务根据其优先级分配一定的 CPU 份额,那么低优先级任务的运行频率会更低,但它们仍然会运行,不会饿死,直到它们完成。
能够取得进展。对吧。记住彩票调度法。这里的基本思想是,在给定一组工作任务,或者说工作任务的混合时,我们将为每个任务分配一定份额的票数。对吧?
所以在这种情况下,我们将把 50% 的 CPU 分配给任务 A,30% 分配给任务 B,剩下的 20% 分配给任务 C。因此,我们将根据任务的优先级分配票数。我们可以看到,例如,任务 A(红色)获得了大量票数。它获得了 50% 的票数。
任务 C 获得 20% 的票数,而任务 B(蓝色)获得 30% 的票数。现在,在每个时间片,每次时钟滴答声中,我们都需要做一个调度决策。我们将随机抽取一张票,安排相应的任务或线程运行。所以我们可以使用的简单机制是将所有票数加总起来。
非常棒,选一个飞镖,投掷那个飞镖,然后任务记录它们分配到的票据数量。它们按票据数量排序,然后我们就选择前J个票据,使得这些票据的总和大于飞镖落的位置。好的,这种方法的问题是,它在长期内表现良好,但在短期内却不一定有效。
在短期内,我们可能会遇到大量的不公平现象。所以我们有两个任务,任务A和任务B,它们具有相同的优先级和相同的运行时间。我们为它们分别分配50%的票据,按理说,任务A的运行时间相对于任务B的运行时间应该是相同的。若我们计算它们的比率,那比率应该是1。
你会发现,对于较短的运行时间任务,它可能非常不公平。那么,为什么呢?
为什么它不会正好是50%呢?它有一半的票据。有什么想法吗?
我是如何选择哪个任务运行的?我使用一个伪,哦,我是怎么选择的?
我使用了一个伪随机数生成器,其中有“伪”这个关键词。它不是一个真正的随机生成器,它会在生成的值中存在一些偏差。顺便说一下,很多重新调度的工作花费了几个月的时间来设计一个既快速又高效,同时在调度决策中非常均匀的伪随机数生成器。
它生成的值的分布。事实证明,实际上,即使你尝试这么做,如果你看较小的抽样集,因我的调度,调度决策的次数很少,所做的调度决策次数不多,我会看到那些偏差。如果我从我的伪随机数生成器中生成数百万个伪随机数,它会。
看起来很均匀。但是在一个小子集中,它可能看起来非常、非常有偏差。而这是生成随机数时遇到的问题。这个问题出现在各个地方,特别是在安全上下文中,我正在生成一个密钥的随机位。如果我知道使用了什么伪随机数生成器,并且了解其中的偏差,我就能知道它生成的。
初始条件和种子,我通常可以通过高度的概率预测出你的秘密密钥是什么。很多设计加密应用中随机数生成器的工作,目的就是避免别人通过猜测随机数的选择来猜出密钥。好的,这里面临的问题是,随机性并非真正的随机,这破坏了。
所以作为一种替代方法,人们提出了步幅调度(stride scheduling)。在这里,思想是我们希望在给定每个任务的情况下,实现相对CPU的比例调度,而不需要使用随机性。因此,我们将避免使用类似大数法则的逆法则,也就是小数法则。
我们看到的偏差问题。所以任务的步长是一个大数除以基本上你分配到的份额。所以如果你考虑一下,你拥有的票证份额越大,你最终的步长就越小。所以在这个例子中,如果我们将 W 设置为 10,000,并且给 a 100 个票证,那么我们会。
给 a 分配 50 个票证,b 分配 250 个票证,那么 a 的步长是 100,b 的步长是 200,c 的步长是 40。现在每个任务都会有一个通过计数器,调度器将会选择通过计数器最小的任务来运行。
在那个任务运行后,我们会获取步长并将其添加到通过计数器中。在运行一个量子后,添加通过计数器。因此,如果你考虑一下,这意味着拥有大量票证的任务,步长较小的任务,其通过计数器的增加速度会比其他任务慢。
拥有更少的票证和更大的步长,因此它们的通过计数器会更快地增加。由于我们选择的是具有最小通过计数器的任务,因此我们会更频繁地运行那些拥有更多票证的任务。但最终,它们的通过计数器将超过那些票证较少任务的计数器,然后票证较少的任务将会运行。
你可以看到,我们在预先计算我们希望的分数,并且我们已经从方程中去除了随机性。是的,问题是,如果我们有一个运行时间非常长但票证数很少的任务,我们需要处理像计数器溢出这样的问题。是的,所以有很多复杂的问题我们需要担心,因为我们希望。
要能够处理这样一个事实,即使我们有很多短时间运行的任务,随着时间的推移,我们的通过计数器也会溢出,另外,你有新的任务加入系统,我们应该将它们的默认通过计数器设置为多少,以避免它们总是运行直到赶上系统中的其他任务。
所以总是有像这样的细节问题我们需要担心。但是关键是,即使是那个只有少量票证的运行时间非常长的任务,它仍然能够运行,这就解决了我们在基于优先级的系统中遇到的问题。还有其他问题或评论吗?好,好的,那么我们来看一下另一个调度器,Linux 完全公平调度器。
这个调度器的目标是,我们希望给每个进程分配相等的 CPU 时间。哦,问题是 W 是否只是一个任意选择的大数?是的,我们希望选择一个非常大的 W,这样我们就可以有一个良好的范围来设置我们的步长。好的,所以这里的目标是每个进程都能获得相等的 CPU 时间,我们有 n 个线程。
所以我们需要一些东西来保持同步,以便最终能够实现这样的情况:当我们查看时,我们可以看到所有线程在CPU上几乎同时执行。所以可以这样理解:你有n个线程同时在一个CPU的周期上执行。这就像我们之前讨论过的同时多线程的概念,我们可以通过利用额外的功能单元,来同时运行多个线程。
如果这是一个完美的环境,完美的世界,我们如果在任何时刻查看,我们会看到CPU时间的分配是均等的。也就是说,线程1、线程2和线程3每个都有一个周期。通常来说我们无法做到这一点,因为真实的硬件并不是这样工作的。
为了让整个CPU工作,或者通过SMT技术,我们可能会调度少量的线程,比如2个线程同时执行。因此,对于一个特定的量度,现在我们暂时假设一个线程将获得CPU的一个周期。
我们看到它们都得到了相等的CPU时间。好了,这里是我们要做的基本框架。我们将跟踪每个线程使用的CPU时间,然后调度线程,确保我们能够达到每个线程一个周期的目标。所以每个人都应该有一个周期的时间。
也就是说,平均执行速率基本相同。那么我们该如何实现呢?
这完全是关于调度决策的问题。我们该选择哪个线程来运行?
当我们需要做出这个决定时,我们会查看所有线程,并尝试修复执行速度最慢的线程。所以这将修复这个完全公平的错觉。这里有一个例子,线程一已经领先,它的运行时间已经超过了一个周期。
线程二运行时间少于一个周期,而线程三则已经完成了一个周期。所以,如果我们要修复这个错觉,我们会选择哪个线程呢?
是的,我们会选择蓝色的线程二。因为线程二落后了。所以它没有那个“公平”错觉,因为它的公平份额应该是一个周期。而且这与稍后我们在讲解网络时会讨论到的一个叫做公平排队的概念非常相似。好,我们将使用一个堆调度队列来实现这一点,它可以为我们提供不错的等效的平均执行率。
它的行为将是O(log n)的复杂度,其中n是线程的数量,用来添加或移除线程,所以我们可以高效地做出调度决策。好,现在想想看,如果一个线程处于休眠状态,它的CPU时间就不会前进。那么当你敲击键盘时,等待中的线程会发生什么呢?
当IO事件被唤醒时,它会滞后,所以它会立即运行。这对于交互性能和响应性非常有利。所以,除了保证公平性,我们还希望系统有一个低响应时间,这使它适合交互式计算,并且我们希望确保它不会受到任何。
所谓的饥饿现象。但是每个进程至少应该能运行一点。因此,我们必须解决几个约束条件。一个是我们想要有目标延迟,所以我们需要设定一个线程得到响应的时间上限。
所以这将是我们的量子,接下来我们必须设置我们的调度量子。量子将是我们目标延迟与系统中线程数的函数。因此,如果我们的目标延迟是20毫秒,而我们有四个进程,那么我们的量子将只是简单地设置为5毫秒的时间片。
好的,这很有道理,但是如果我们有很多线程,很多进程,比如200个呢?
那么这就意味着我们应该使用0.1毫秒的量子。这是一个问题。回想一下轮询调度算法,轮询调度的一个大问题就是选择合适的量子大小,以免我们最终把所有时间都花在保存和加载寄存器上。所以这就是为什么我们需要对我们的量子设置最小值的原因。
即使这意味着我们无法达到目标延迟,权衡的结果是,我们可以尝试使用0.1毫秒的量子,但最终我们将会花费大量时间在上下文切换上。因此,我们无论如何都无法达到目标延迟。好的,Linux CFS的另一个目标是吞吐量,这意味着我们必须避免过多的开销,这也意味着我们不能有过多的上下文切换。
所以这实际上会给我们设定一个最低的粒度,量子也应该遵循这一规则,这样我们就不会把所有时间都花在上下文切换上。因此,我们将为特定架构选择一个合理的量子下限,从而得出合理的开销上限。
上下文切换。好的,现在我们的目标延迟可能是20毫秒,最小粒度为1毫秒,假设有200个进程,那么我们将给每个进程分配1毫秒的时间片。好吧,稍微偏题一点,因为我们需要讨论一下优先级是如何设置的,用户在Unix系统中如何与优先级交互。
所以,当我们回顾人们在60年代和70年代使用的工业操作系统时,优先级只是一个整数,你指定它并强制执行你所设定的目标调度策略。当他们在伯克利开发Unix时,他们的思考是,与其将其视为简单的优先级,不如让用户彼此友好。
在 Unix 中,实际上有一个系统调用叫做 nice 系统调用,用于设置进程的优先级。优先级的范围从负 20 到正 19。负值意味着进程不友好,正值表示友好。所以,如果你想对与你共享系统的朋友好一点,你就将你的进程的 nice 值调高。
这将导致系统给你分配更少的 CPU 时间,你的进程将花更多时间在就绪队列中。你的朋友的进程将获得更多的 CPU 资源。相反,如果你不想对你的朋友好,你只需将所有进程的 nice 值设为 -20,它们就会运行,你知道这样做并不好。
保持朋友们在外面。好的,那么调度器将会把那些优先级较高的进程保留在就绪队列中。它们不会那么频繁地运行,而优先级较低的进程将能更频繁地运行。现在,对于一个一阶调度器,这将直接转化为优先级。但在 Linux CFS 中,我们将以不同的方式来看待这个问题。
好的,那么在 Linux CFS 中,我们将改为根据进程的 nice 值或优先级来调整它们获得的 CPU 周期速率。那么我们该如何做到这一点呢?
好吧,想一想,如果我们想给一些进程更多的 CPU 资源,而给其他进程更少的 CPU 资源。那么我们想要不同的比例分配,但要保证每个进程都能得到一些资源。我们的模型是,我们希望不同的线程在一段时间内能够获得不同数量的 CPU 周期。
那些我们希望给予较低优先级的进程,在一段时间内将获得较少的 CPU 周期。所以,最简单的方法就是通过权重来实现这一点。我们将为每个进程分配一个权重 W_i,这样我们就能计算出切换时间片的大小,也就是我们允许进程运行的时长。
如果每个进程都获得相等的份额,我们只需简单地将每个进程的权重设为 1。然后,我们就可以应用基本的计算方式:这就是 1 除以总权重的和,乘以目标延迟。那就是 n。
它将是 1 除以 n 乘以我们的目标延迟,这就是我们基本的平均分配。现在,我们有灵活性,如果我们想增加或减少某个进程的优先级,只需更改它的权重,这样就能给它不同的资源份额。让我们看一下实际应用。我们将用 nice 值作为优先级的代表,因为这是用户设置优先级的方式。
Unix会告诉系统这个任务的优先级高或低,较低的值表示较低的优先级,负值表示“不友好”,较高的值则表示优先级较高。所以可以理解为一个反向的关系,如果你这样思考的话。我们将使用“nice值”来调整我们的权重,通过一个指数函数来实现。所以它会是1024除以1。
nice值的5次方的25。假设我们有两个CPU任务,它们的nice值差为5。这意味着优先级较低或nice值较低的任务将最终获得三倍于高优先级任务的权重。所以1.25的5次方大约等于3。是的。所以问题是,如果它是三倍速率,它是否就能运行三倍的时间。
是的,它将运行三倍的时间。它将获得更长的量子时间,并且会按照调度的方式来执行。接下来,我们还需要处理如何分配CPU时间以及如何选择下一个要运行的任务。好了,这就引出了我们下一个要讨论的点,那就是我们将不再使用实际的CPU时间。
所以,我们将使用虚拟运行时间的概念,而不是壁钟时间。我稍后会解释这是什么意思。好了,给你一个例子,我们的目标延迟是20毫秒,最小粒度是1毫秒,我们有两个CPU绑定的线程,它们可以在我们允许的情况下持续运行。假设线程A的权重是1,线程B的权重是4。
那么线程A的时间片是多少,另外我们的总权重是5。好了,A的权重是1,回到之前的例子,它将是1除以总权重5,再乘以目标延迟20,得到的结果是4毫秒。这就是A在被调度时的运行时间。
对于B,它的时间片将是4,这就是它的权重4除以总权重5,再乘以目标延迟20,得到的结果是60,因此它将运行16毫秒。这样,我们就能达到目标延迟,或者我们将按照20%的比例分配给A,80%分配给B。如果我们查看物理CPU时间或壁钟时间,可能会是这样的。
我们会看到这种不对称性,B会在这16毫秒的时间段内运行,而A会在更小的4毫秒时间段内运行。但我们将跟踪线程的虚拟时间,简单地理解就是,如果你有更高的权重,你的虚拟。
运行时间将增长得较慢,如果你的权重较低,你的虚拟运行时间将增长得更快。因此,在这个虚拟世界中,我们可以保持A和B获得相等的份额,但当它们实际运行时,B将运行得更长,并且会更频繁地被调度。好了,我们的所有调度决策都基于虚拟时间,因此我们只需继续跟踪我们的。
红黑树包含了所有可执行的进程,我们会寻找最左边的元素,也就是最小的虚拟时间元素,因为我们再次尝试修复这个问题。每个人都得到一个时间片的幻觉,但实际上是虚拟时间中的时间片,而不是 CPU 时间。我们通过O(1)时间来决定运行哪个进程,我们甚至可以将其缓存,以确保始终如此。
在内存中,当我们需要添加或移除一个线程时,它将会是O(log n)时间复杂度。所以当我们准备进行调度时,我们只需抓取最左边的节点,这就是我们要调度的进程。
好的,我们已经向你展示了许多不同的调度算法,最终,当你设计一个系统时,你会根据应用场景来选择一种调度算法。具体选择哪种算法将取决于应用场景和用例。在某些情况下,你可能需要一些适用于通用计算的算法,而在其他情况下……
在某些情况下,你可能需要像机器人操作系统(ROS)或网络文件存储操作系统那样的调度系统,因此你会选择一个符合这些要求的调度器。如果你关心 CPU 吞吐量,那么你可以使用先到先得(FCFS)调度算法,这在高性能计算环境中是可能使用的算法。
如果你关心的是平均响应时间,那么也许你会使用类似最短剩余时间优先(SRTF)这种近似算法。如果你关心的是 IO 吞吐量,类似的 SRTF 算法也适用。如果你关心的是 CPU 时间的公平性,可以使用类似 Linux 的 CFS(完全公平调度器)算法。如果你关心的是 CPU 等待时间的公平性,那可能需要使用……
如果你关心按时完成任务,那么类似轮转调度的算法可以应用于实时系统。你可以使用 EDF(最早截止时间优先)或其他实时调度算法。如果你更关心优先处理最重要的任务,可能会使用严格优先级调度,但你必须意识到,可能会发生饿死现象。
总的来说,在很多情况下,我们处理的系统是多用途的,工作负载各异,因此你需要选择一个能够满足大多数工作负载需求的调度算法,但这意味着它不能满足所有工作负载的需求。
关于调度的最后一言。当调度策略、调度的公平性和相关细节真正重要时,何时才是关键呢?
答案是,我们没有足够的资源来满足需求。我们没有足够的资源。另一种思考方式是,比如我有一群工程师,什么时候该为工程师购置一台更快的计算机?或者如果我是一个城市规划师,什么时候需要在高速公路或桥梁上增加一条车道,或为我的公司增加一条更快的网络连接?
所以一种方法是在它能够自我回收成本并提高响应时间时购买。你可以这样想,那个工程师,那个开发人员,你每年支付他们几万美元。如果他们坐在那里等待计算机做某事,那就是浪费生产力,你支付给他们的钱只是让他们坐在那里盯着屏幕或者。
也许在窗口里玩wordle之类的游戏。所以购买更快的电脑将在提高生产力方面迅速收回成本。如果你正在为一群网站用户提供服务,购买更快的电脑意味着他们可以更快地完成订单结算。因为他们不太可能转向某个有更快结账流程或更高便利性的竞争对手。所以有很多原因让你要确保你已经合理配置了资源。
现在你可能会想,好吧,当我达到百分之百的利用率时,那就是我去买下一台电脑的时候了。但是这里有一个响应时间与利用率的图表。你可以看到,随着利用率接近百分之百,响应时间趋向无穷大。你最终会花费所有时间进行上下文切换,或者进出队列。
处理所有的开销。开销开始占主导地位。所以实际上,所有调度算法起作用的时间,尤其是它们有效的时间,就是在这条线性部分的曲线中。而且正是在你接近这个拐点时,才是你购买新电脑的时机,或者更快的链接,那就是你做出增加资源决策的时候。
在此之前,你知道你可以选择算法,算法将帮助你管理可用资源,因为你不会让这些资源过载。只有当你开始接近过载时,才会变得无关紧要。好吧,有关时间表的任何问题吗?是的。在哪两个之间?LLC。
是的,问题是Linux完全公平调度器和轮转调度之间有什么区别。轮转调度只是简单地给每个人一个平等的机会使用CPU,给他们相同大小的时间片。而CFS则根据优先级动态调整这个时间片的大小。是的,问题是,如果我们有相等的优先级,比如说。
在所有线程之间,那么在Linux完全公平调度器和轮转调度之间,它们基本上会是一样的吗?是的,它们基本上都会按轮转调度进行,因为你总是会用CFS挑选在虚拟时间中落后的线程。它们都会以相同的速度推进虚拟时间,因为它们都有。
这就是相同的公平性,因此你将简单地轮流进行。队列的顺序将是一样的,因为它们的优先级相同。是的,所以它们会非常相似。是的,问题是,如果一个线程进入休眠,然后你把它唤醒,是否会补偿它在内容上落后的部分。是的,正是如此。
所以这就是好处,如果你总是关注哪些线程进度最慢,这些线程会被进一步推迟,你会努力让它们赶上来。关键在于细节,如何让它们赶上,而不是简单地把所有的CPU时间分配给它们,这样你仍然需要运行其他任务。
另外,再次提到,你会有新的任务进入系统,你需要弄清楚它们在虚拟时间中的位置。虽然在稳态下思考这些问题更容易,但在实现过程中,你必须担心所有的边界情况。好了,我们有一些行政事务要处理。
我的办公时间从下周开始,周二从一点到两点,周四从十二点到一点。我还在找房间,但应该会在Soda大楼的四楼某个地方。今天你们有一个截止日期,项目一、代码、报告、最终报告和小组评估都要提交。作业二截止日期是周四,而期中考试二,好像我们刚考完期中。
但是期中考试二的冲突请求截止日期是星期五。学期进展得非常快。好了,接下来我们休息四分钟。好了,现在让我们换个话题,讨论一下“饿死”的致命版本。人们在讨论死锁和饥饿时常常感到非常困惑。我将尽力讲解清楚,如果你有任何问题,随时举手提问。
请举手,我们再讲一遍。关于饥饿,饥饿意味着线程会无限期地等待。像我们讨论的低优先级线程可能最终会饿死,因为系统中会不断有高优先级线程进入。死锁则不同。
死锁意味着我们有一些循环等待资源的情况。这里有线程A,它拥有资源一。它在使用资源一的同时,等待资源二。资源二由线程B拥有并使用。线程B在等待资源一,而我们刚才提到,资源一由线程A持有。
所以这是一个循环等待资源的情况。这里的关键区别在于,线程A在无限期等待,线程B也在无限期等待。所以这就是饥饿的现象。因此,死锁意味着你会有饥饿的情况。线程无法前进。死锁和饥饿的区别是,饥饿是可以结束的。
如果我们停止将高优先级的线程引入系统,低优先级的线程就会开始运行。发生死锁时,除非有外部干预,否则它不会结束。在这里没有办法改变这种情况,使得线程A或线程B能够运行。这些线程无法做任何事情来改变这种情况。
这两辆车都被困住了。让我们看一个例子。我们从一个现实世界的例子开始。这是一座横跨峡谷的桥,位于加利福尼亚140号公路上,通往优胜美地国家公园,或者如果你是从优胜美地国家公园回来。为了这个例子,我们假设汽车必须占有公路上的段落。
我们将资源看作是高速公路的段落。每辆车必须占有自己下方的段落,如果它想前进,必须获得前方的段落。它持有下方的段落并试图获得前方的段落。
对于这座桥,我们将桥分为两半。为什么分成两半呢?
因为这样更容易理解。通过这座桥,你必须获得一个段落才能上桥。你还需要获得另一半桥的段落并进入,然后你需要离开桥。你可以认为,一次只能有一个方向的交通流动。
我们有这两段路段,橙色和红色的车占有它们的桥的一半。我们试图获取绿色车所占有的段落,而绿色车则持有该段落并试图向另一个方向行驶。这里我们有死地。橙色和红色的车在等待东半部分,同时占有西半部分,而绿色车占有东半部分。
东半部分的车等着西半部分的车。我们有了死地。我们该如何解决这种情况呢?
好吧,绿色车可以倒车,释放那个段落,让橙色和红色的车占有它。但事情并不简单,因为绿色车后面还有蓝色车。蓝色车必须倒车,但它不能倒车,因为后面还有紫色车。紫色车也必须倒车,然后蓝色车倒车,绿色车也倒车。
现在,橙色和红色的车可以通过了。所以饥饿但不是死锁的情况是这样的:我们有一队从西向东行驶的车队。在这种情况下,每辆车会在前方的段落释放时获取该段落。
由同一方向行驶的汽车组成。这些车正在使想要向西行驶的交通饥饿。这种情况最终会得到解决。一旦向东行驶的车停止,向西行驶的车就可以通过。这与我们现在所处的情况不同,后者没有解决办法。在这里,那些车无法前进。
它们都被困住了,等待着其他人持有的资源。
好的,接下来我们来看一下锁的情况。这里有一个例子,使用两个线程,线程A和线程B,其中线程A会尝试获取X锁和Y锁,做一些工作后释放Y锁,再释放X锁。线程B会获取Y锁,然后获取X锁,做一些工作后释放X锁,再释放Y锁。这是最糟糕的死锁情况,因为它是非确定性的。
有时候不会发生这种情况。而其他时候,比如项目截止前的凌晨两点,它就会一直发生。你一看它就不发生了,一提交给自动评分系统它就又发生了。非确定性是一件非常糟糕的事情,因为它让调试变得非常困难。应用程序调试改变了时序并使得bug消失了,但这对于部署来说也是不好的。
如果你为你的公司部署这个程序,而且它会随机死锁,那就是个问题。好的,接下来我们来看一下不幸的情况。在不幸的情况下,线程A获取了锁X。然后由于某些原因,发生了一个上下文切换,线程B被调度,它获取了锁Y。现在我们再切换回A,A尝试获取Y,并被放入等待队列。
所以Y被B占用了。现在我们选择另一个线程来运行,最终B可以运行,并且它去获取X,而X被线程A占用了。所以它被放入等待队列。它也被阻塞了,剩下的代码无法访问。并且这种情况不会自行解决,因为A无法继续执行,B也无法继续执行。所以我们在这里没有其他选择。
所以我们遇到的情况是,线程A持有锁X,同时等待锁Y,而锁Y被线程B持有,而线程B又在等待锁X。所以你可以看到我们又有了一个循环依赖图。好吧。于是没有线程可以运行,这是死锁。现在,在幸运的情况下,有很多幸运的情况,一个幸运的例子是:
A线程获取了X锁,获取了Y锁,开始做一些工作。我们发生了上下文切换。B线程去获取Y锁。它被阻塞了。我们切换回A,完成A线程正在做的工作,释放Y锁,或者说释放X锁,最终我们切换回B线程。B获取了X锁。这是它想要做的。然后释放X锁,释放Y锁。
还有很多其他类似的幸运交错情况。只有几个不幸的交错,但幸运的交错有很多。因此大多数时候这个程序会正常工作。然后偶尔火箭爆炸。好的,接下来是一个来自网络的例子。这里我们有一组火车,所有的火车都有这些交叉点。
这里。火车可以向东向西行驶。它们可以向南向北行驶,或者在这个例子中是南北行驶。然后它们尝试转弯。所以每列火车都在试图向右转。对,结果我们就得到了一个循环依赖,它想向右转,但那条轨道被另一列火车占用了,而那列火车也在试图向右转到已经被占用的轨道上。
由另一列火车和四列火车之间完整的循环,类似的问题也出现在多处理器网络中,我们需要在不同的微处理器之间路由消息包。在我们的多处理器系统中,消息就像一条小虫子。这就是为什么这种路由方法叫做虫洞路由。解决方案是。
即使我们的网格扩展到四个方向,我们也设置了规则。我们规定了频道的顺序。路由的方式是,你总是先朝东西方向走,然后再朝南北方向走。这样就强制要求我们不允许北南方向的火车试图右转,或者南方的火车。
或者北南方向的火车也试图向右转。因为它们必须先走东西方向,而不是第二个方向。这被称为维度排序,我们先处理x维度,再处理y维度和z维度,依此类推。稍后我们会看到为什么这种排序方式也能解决这个问题。
我们刚才看到的线程A和线程B的情况。好吧,我们可以发生许多其他类型的死锁。选一个资源,如果线程必须等待这个资源,那么就可能会发生死锁。资源可以是锁,也可以是终端、打印机、CD驱动器、DVD驱动器、内存,甚至是其他线程,线程会在管道或套接字上阻塞等待。
来自其他线程。任何资源,只要符合某些标准,我们就会阻塞在上面。我们可能会发生死锁。例如,我们可能会在空间上发生死锁。那么,假设有一个程序线程A,它将分配或等待分配1MB内存,接着它会再分配或等待分配另一个1MB内存,然后执行一些操作,再释放。
那么,分配1MB内存后再释放另一个1MB内存。线程B会尝试做同样的事情。假设我们有一台只有2MB内存的机器,这时我们就可能会遇到同样的死锁问题。如果我们先运行线程A,它占用1MB内存,然后运行线程B,它也占用1MB内存,那么现在两个线程都无法再占用更多的内存,只能静静地等待。
这是计算机科学中著名的问题——哲学家就餐问题。我们通常把它叫做律师就餐问题。问题的基本参数如下:这些律师去一家餐馆,这是一家便宜的餐馆,他们坐在一张圆形桌子旁,桌子上有五个律师。由于餐馆非常便宜,所以他们。
他们每人只在桌子上放五根筷子。桌子中间有一大碗米饭。我们都知道,如果我们想吃米饭,不止一根,而是需要两根筷子。那么我们说,好,吃饭时间到了,律师们会怎么做呢?每个律师都会去拿一根筷子。如果每个律师同时都拿了一根筷子,会发生什么呢?
死锁。对吧?每个人都拿着一根筷子,大家互相对视着,中间有一碗米饭,结果大家都饿死。真的饿死。那么,如何解决这种死锁呢?我们可以对一个律师说,做个好人,把你的筷子让给别人。祝好运。我觉得这不太可能发生,对吧?
但是,如果他们这么做,如果有一个无私的律师,如果我们找到了独角兽,他们会放弃筷子,然后另一个律师就能得到两根筷子。然后他们可以吃饭,吃完后把筷子放回池子,其他律师就可以吃饭,依此类推,直到所有律师都吃上饭。明白吗?
这可能给我们一些启示,了解如何解决这些死锁情况。但这里的警告是,这将需要说服其中一个人放弃他们持有的资源。好吧,我们如何预防死锁呢?我们可以制定桌面规则,规定如果拿最后一根筷子会导致桌上的某人没有两根筷子,那就不能拿。明白吗?
因为只要剩下一个筷子,而且至少有一个律师手里有一个筷子,他们可以拿到这个筷子,然后就有两根筷子可以吃饭,吃完后再把筷子放回池子里,其他律师就可以吃了。明白吗?
所以这就是我们可以通过类似规则来避免死锁的方式。你可以考虑如何将其正式化,对吧?我们该如何正式化这一点?
假如你知道,桌子上坐着一群章鱼,会发生什么呢?
那我们如何将这种情况转化为一个规则,确保所有的章鱼——它们非常聪明——能够使用筷子吃饭,做所有那些事情呢?好吧,让我们先从正式化死锁的定义开始。死锁发生需要四个条件。第一个是必须有互斥。
所以,每次只有一个线程可以使用特定的资源或资源的某个实例。第二个是必须有占用等待。所以,持有至少一个资源的线程正在等待获取其他线程持有的资源。接下来是不可抢占。所以,如果一个线程持有资源,我们不能在它还没有用完资源之前从它手中抢走资源。
就像在律师用餐的案例中,意味着如果一个律师拿着筷子,我们不能直接过去抢走筷子,直到他们吃完。我们不能插手。最后,我们必须有循环等待。即存在一组线程,其中T1正在等待某些资源。
被T2持有。T2正在等待T3持有的资源。T3在等待T4持有的资源,依此类推,一直到Tn在等待由bread one持有的资源。现在,关于死锁有一点需要注意。为了使系统发生死锁,必须满足四个条件。
移除这些需求中的任何一个。我们就不会有死锁。我们可能会有饥饿现象,但我们不会有死锁。如果系统处于死锁状态,且我们可以移除其中任何一个要求,那么我们就能将其从死锁状态中解救出来。好了,有什么问题吗?好的。那么,首先我们需要做的是,弄清楚如何判断我们的系统是否处于死锁状态。
死锁。我们将使用我们所谓的资源分配图。所以这是模型。我们有线程。在这里我们有线程T1和T2。我们有一个小框,这里是这些线程的结束点,然后我们有资源。有不同类型的资源。比如CPU,或者是内存。
CPU实际上不是一个非常好的例子,因为我们可以抢占CPU。我们可以从某个线程那里拿走CPU并将其分配给另一个线程,但它仍然是一个资源。我们可以有磁盘驱动器、打印机、锁等,任何东西都可以是资源,然后我们还有一些资源有多个实例,我们将通过标记有多个实例来表示。
通过这些额外的点。额外的点意味着像在这种情况下,资源二有三个实例。这意味着有三个线程持有资源二的一个实例,或者一个线程可以持有所有三个实例,或者任何其他组合。好,现在,线程通过请求资源来使用资源。比如获取该资源并在使用完毕后将资源释放回池中。
我们生成资源分配图的方式如下。我们的图V被分为两种类型。我们有一组,包含线程和资源,然后我们有指向这些资源的有向边。指向这两组之间的资源,线程和资源之间的边。现在,请求边是指从线程指向资源的边,就像这样,从T1指向R1。
这个R1的实例。分配,所以持有资源的对象是指向相反方向的有向边。也就是说,从资源实例指向特定的线程。所以说,从这个第一个点到线程二,这就意味着该资源的一个实例由线程二持有。
好的。那么这是一个资源分配图的例子。这里有资源一,它的一个实例被线程二持有。资源二,一个实例被线程三持有。资源四,一个实例被线程三持有,然后资源三这里有两个实例,一个由线程一持有,另一个由线程二持有。那么,第一个问题是。
这个系统有死锁吗?没有。它没有死锁。因为例如,线程二在等待资源二,而资源二被线程三持有。线程三不在等待任何东西。它持有资源二的一个实例,它持有资源四的一个实例。所以它可以完成任务并退出系统。
这样就会释放资源二。那么线程二可以运行,对吧?
因为它将拥有资源一的一个实例、资源二的一个实例以及资源三的一个实例。它将释放资源一,然后线程一可以获得该实例并执行直到完成。所以这不是一个死锁系统。这里是死锁的一个例子。我们这里有一个循环图,对吧?线程一持有资源三的一个实例,并在等待资源一的实例。
资源一由线程二持有,线程二正在等待资源二的实例,而资源二由线程三持有,线程三正在等待资源三的实例,资源三由线程二和线程一持有。我们有一个循环等待。没有任何一个线程能够继续执行。没有任何东西会改变这一点。
没有线程能够释放资源并让其他线程继续运行。仅仅因为你有一个循环,并不意味着你有死锁。再次强调,必须满足所有这些条件才算死锁。因为在这里,线程三正在等待资源二,同时持有资源一的一个实例。
线程一正在等待资源一,同时持有资源二的一个实例。所以你可能会想,哦,我们这里有一个循环,可能是死锁。但我们还有两个其他线程,对吧?
这里有线程二,它持有资源一的一个实例。不需要任何资源,所以它可以运行并退出系统,对吧?
这将释放一个 R1 实例。那么 T1 就可以获取它。然后 T1 将拥有运行所需的所有资源,或者线程 T4 拥有所有它需要的资源。它拥有 R2 的一个实例,可以运行直到完成,释放该实例。然后线程 T3 可以获取它,拥有所有它需要的资源并完成任务。
只要有线程可以运行并产生解决方案计划,那么我们就知道我们的系统没有死锁。但在这个中间情况中,我们无法取得任何前进进展。
好的。如果你仔细想想,这为我们提供了一个死锁检测算法。我们可以说我们的向量 X 代表一个非负整数的 M 元向量,这些整数是给定资源的数量。所以我们为每种资源都定义了空闲资源,对吧?
所以我们向量的每个元素将代表一个给定的资源,资源一、二、三,依此类推。每个资源有多少实例是可用的?我们有一个请求向量,它表示每个线程对于这些资源的当前请求。然后我们有一个分配向量,它是每个线程的资源分配情况。
这些资源已经分配给该线程。所以现在我们可以查看任务是否最终能自行完成。那么这是我们将要使用的算法。我们将初始化可用资源向量,使其为我们拥有的空闲资源。我们将把所有节点加入未完成的集合中。接着,我们将执行一个 while 循环,并遍历所有节点。
在未完成集合中的节点。对于每个节点,我们将进行检查,查看该节点的请求向量是否小于或等于可用资源。这意味着什么?
这意味着没有进程能够获取它所需要的所有资源并运行到完成。所以我们将假设它做了。它将运行到完成,然后它会怎么做?
它将释放所有已分配的资源。所以我们将把它分配的资源返回到可用资源池中。现在我们将查看下一个节点。也许那个节点没有它所需要的资源。也许它的请求大于可用资源。所以它会保持在未完成状态。
我们将查看下一个节点。我们会继续遍历,反复检查所有未完成的节点,只要情况在变化,直到我们达到一个点,要么什么都不变,我们的完成变量保持为真,要么未完成集合中没有节点。
那么如果我们退出这个过程,而未完成集合中还有节点,那意味着什么?或者说,如果我们完成了这个过程并退出,而未完成集合中什么都没有,那意味着什么?没有任何节点?
是的。是的。所以如果未完成集合中没有任何内容,那么就没有死锁。如果未完成集合中还有节点,而我们发生了死锁,那么说明即使所有其他节点退出系统后,仍然有节点无法获得所需的资源。没有找到可以让节点完成的路径。
让所有的进程完成吧。所以这就是我们的死锁检测方法。这将是我们判断系统是否当前处于死锁状态的方式。好的。现在问题变成了,如果我们发生了死锁,我们该怎么办?有几种选择或四种不同的选择。
其中一种方法是我们可以简单地避免死锁。我们编写代码时确保不会发生死锁。想一下频道的逻辑顺序。你先在X维度上路由,再在Y维度上路由,然后是Z维度,依此类推。你对资源获取顺序进行强制排序,字典顺序。你必须总是在获取Y之前先获取X。
你在接下来的过程中不能获得Y。这些类型的方法从一开始就避免了死锁的发生。第二种你可以用来处理死锁的方法是恢复。系统,你知道,死锁检测会说,“哦,系统发生了死锁。你该怎么办?”
你得找出如何从中恢复。也许你终止一些进程或者回滚操作。”另一种方法是使用像死锁检测算法这样的东西来预测,如果我允许某个系统请求,是否会导致死锁,从而避免批准该请求,防止系统发生死锁。
另一种方法就是直接忽略死锁。死锁根本不存在。继续前进。有时会使用所有这些不同的方法。最后这种方法是最简单的,因为你只是——死锁不存在。所以,如果它偶尔发生,我就不打算去检查它了。好吧。现代操作系统会尽量确保操作系统不会。
遇到死锁。这通常是对的。确实有一些bug偶尔会导致系统死锁,但基本上,你知道,设计良好的代码是不会死锁的。应用程序可以随便死锁。操作系统只是会忽略它。所以我们称这为鸵鸟方法。对应用程序来说,死锁根本不存在。好吧。
那么我们可以使用哪些技术来尝试避免进入死锁情况呢?
所以一种方法是,如果我们有无限的资源,对吧?记得那些资源的实例吗?好吧。如果我们有无限数量的实例,那么线程永远不会进入那种必须等待特定资源的情况。现在,它不一定要是无限的,只要足够大,我们就不会用完它。
对吧?我们可以通过使用虚拟内存,制造我们有无限内存的幻觉。这样我们就不会遇到两个线程请求两兆内存,而我们没有足够的内存的问题。我们可以把两兆内存扩展为两千兆内存。对吧?如果你把它扩展到两万兆,那可能就有问题了,对吧?
所以我说幻觉并不完美,但我们可以让这个幻觉足够大,足以让人觉得我们有无限的资源,对吧?比如一座有12,000条车道的大桥,你永远不需要等待。实现起来当然并不实际。就像无限磁盘空间一样。如果我们没有任何共享的话。
但是,如果我们有完全独立的线程,没有共享资源。那么我们就不会有死锁。我们也不会有任何非常有用的东西。你知道的。如果你从不与真实世界互动,那么做任何事都会非常困难,你永远不会使用终端,永远不会使用打印机,永远不会进行任何IO操作。是的。
这实际上不可行。我们禁止等待。这其实是电话网络的工作方式。如果你说,“哦,打电话给妈妈和托莱多”,对吧?你的电话会通过电话公司网络进行路由。如果它遇到了一条满的干线或者网络交换机已经满了,你就。
变得快速繁忙。稍后再试。这有点像,你知道的。想象一下,你想去旧金山,你开车。如果你遇到红灯或交通堵塞,你就会被瞬间传送回伯克利。进入旧金山可能需要一段时间。这非常低效,对吧?
因为你每次都必须继续重试,直到你听到忙音才能通过。好了,回到虚拟资源的概念,如果我们有类似虚拟内存的东西,就能避免这个问题,假设系统只有两兆内存,而两个线程都请求两兆物理内存的话。
如果我们将虚拟地址空间设置为四个吉比字节,我们就不会遇到问题。即使有数百个线程,它们都可以请求它们的两兆内存,我们也不会遇到占用等待的情况。好了,最后一张幻灯片,我想讨论一些防止死锁的技术。你可以让线程在一开始就请求所有它们需要的资源。
问题是,如何预测在一开始时我所有需要的东西呢?
这真的很难。那么,作为开发者你该怎么办?如果你被告知程序错了就会被终止,而且你必须从头开始,那你只会高估自己需要的资源。所以,这会非常低效。好了,但如果你能够算出只需要两根筷子,你就可以请求这两根。
同时使用两根筷子。如果你不知道自己是需要两根还是五根,而它又有点像是死刑,如果你弄错了,那你就会请求五根筷子,即使你只需要两根。你可以强制所有线程以特定的顺序请求资源。因此,这又涉及到像是x,y,z的路由顺序,或者字典顺序排列。
这时候就会起作用。所以,我们说每一个锁你要获取时,必须按照字典顺序获取,像是x,y,z,或者先请求磁盘,再请求内存等等,你就能避免那种循环等待的链条。因为每个人都会按同样的顺序请求。因此。
这是我们可以采用的另一种方法,但你必须通过编程或者其他实践和工具来强制执行,确保这一点能发生。好的,有问题吗?
好的,我周四见。好的。
(深呼吸)。
P13:第13讲:内存1 地址转换与虚拟内存 - RubatoTheEmber - BV1L541117gr
好的,我们开始吧。
我们将结束死锁讨论,特别是看一下死锁避免和预防算法。然后我们将切换话题,开始讨论内存管理。这是我们将进行的几次关于地址转换和虚拟内存的讲座中的第一讲。好了,记住,死锁基本上是一种饥饿的形式。
与饥饿情况不同,我们有线程无限期地等待,比如低优先级线程等待高优先级线程。而死锁和饥饿的关键区别在于,死锁中我们有资源的循环等待。这里线程A等待资源2,而资源2被线程B持有,线程B又在等待资源1。
这是由线程A持有的。死锁和饥饿之间的第二个关键区别在于,尽管饥饿可以结束,对吧,阻塞低优先级线程的那些高优先级线程可以离开。而死锁则必须依赖外部干预才能停止。否则,它会永远停留在那个状态中。好了,现在记住。
死锁有四个必要条件。首先是互斥条件,其次是保持等待,其三是无抢占,第四是循环等待。去除其中任何一个条件,就不会发生死锁。好了,给你们一个提示。你们可能会在期中考试中看到一个关于这个的问题,假设给你一个情境。
如果有抢占,死锁会发生吗?不会,对吧。同样地,如果没有循环等待,就不会发生死锁。因此,另一种思考方式是,如果我们能确保系统始终具备抢占能力,那么我们可以确保系统不会发生死锁。
系统将是无死锁的。如果我们能保证没有循环等待资源,我们就可以确保不会发生死锁。好了。那么一些可以用来预防死锁的技术是让线程在一开始就请求它们所需的所有资源。对吧。但那不太实际。对吧。
如果你告诉程序员,要求他们请求他们可能需要的所有资源,而且如果你搞错了,运行三小时后我就杀掉你的进程。他们会高估所需的资源。这样会导致资源利用率低,效率差。另一种替代方案是强制所有线程按照某种顺序请求资源。对吧。
如果我们能保证每个人按某种顺序请求资源,那么我们就不会有循环等待。对吧?我们不会有资源的循环请求。我们已经去掉了那四个条件中的一个,所以我们不会有死锁。我们可能会因为其他原因导致系统饥饿,但那可以自我解决。一个例子是我们对锁的获取施加字典顺序(lexicographic ordering)。
所以我们说你先执行 X 点 acquire,再执行 Y 点 acquire,再执行 Z 点 acquire。这样就避免了这种情况:一个线程持有 X 并且想要 Y,而另一个线程持有 Y 并且想要 X。因为那样就不符合字典顺序。我们可以跨资源进行这种安排,要求你首先请求所有内存资源。
你需要的所有资源,先请求所有的磁盘资源,再请求所有打印机资源,然后是所有网络资源,等等。通过保证顺序。你不能回头重新请求之前的资源。这样我们就不会有资源的循环等待请求。因此,我们就不会有死锁。
有什么问题吗?好的。那么另一种方法是说,我一次请求所有资源。所以这里我做了修改,原本是线程 A 执行 X 点 acquire,Y 点 acquire,线程 B 执行 Y 点 acquire,X 点 acquire。我们改为调用这个原语 acquire both。这样,A 将同时获取 X 和 Y,而 B 将获取 Y 和 Z。
并且我们将保证这些操作是原子性的。也就是说,要么同时获取 X 和 Y,要么都不获取。对吧?
那么,如果我想再添加一个,比如说,嗯,我想添加 W,怎么办?
或者我可能需要获取三重锁(acquire triple)。然后我想添加,嗯,我不知道,U。现在我需要获取四重锁(acquire quad)。对吧?嗯,另一种思考方式是,为什么我们不在获取锁的过程中加一个保护机制呢?所以我们可以设定,Z 作为我们的保护锁。然后你先获取 Z,再获取你需要的所有锁,最后释放 Z。对吧?
现在在这个临界区内部,顺序就不再重要了,因为一次只能有一个线程获取锁。对吧?那么,另一种方式是,如我所说,采用一致的顺序。我们为锁的获取分配某种字典顺序(lexic),在这个例子中,就是从 A 到 Z。
这样就禁止了 B 执行这个操作。B 不能先获取 Y 再获取 X。所以,B 必须先获取 X,再获取 Y。那么释放的顺序呢?
我们释放 X 在先,释放 Y 在后,重要吗?是吗?不重要?好,我们来做个投票。认为释放顺序不重要的人请举手。好。认为释放顺序重要的人请举手。好,猜猜看?
你们两个都是对的。每个人都是对的。所以从语义角度来看,顺序并不重要。无论是先释放一个锁再释放另一个锁,还是其他顺序,都是一样的。但如果我们在线程 A 等待 Y 或者等待 X 的时候释放 X 会发生什么呢?然后再等待 Y?对吧?这时线程 A 会被唤醒,抓取 X,因为 X 现在是空闲的。
然后去抓取 Y,结果发现,哦,为什么被阻塞了?对吧?
然后它就会再次进入休眠状态,我们释放 Y 后再返回上下文,重新唤醒它。如果我们反过来,先释放 Y 再释放 X,那么当线程 A 唤醒时,它可以访问 X,就会抓取 X,然后继续获取 Y。所以从语义角度来看,顺序并不重要。
从性能和效率的角度来看,顺序确实很重要。因此,我们希望避免我们拥有的上下文数量,以及等待和排队的问题。有问题吗?
好的。明白了。所以,再次提醒,我们可以做排序的另一个例子是我们的火车网络,在那里,我们有这些试图右转的火车。我们可以简单地设置一个维度顺序规则,先往东西方向行驶,然后才能往南北方向行驶。这样就不允许那个从南北方向行驶,然后试图转向东西方向的火车。
这个北南方向行驶的火车,然后试图转向东西方向。这样就避免了死锁的发生。更棒的是,实际为此开发的多处理器网络是多维的,可能像超立方体一样。
所以,你有 XYZ 方向,然后你按顺序排列。所以你首先在 X 方向行驶,然后在 Y 方向行驶,再然后在 Y 方向行驶,最后在 Z 方向行驶。这样就避免了在这些网络中出现死锁。而且你可以无限扩展它们,它们会相互环绕等等。好的。
那么我们来看一些从死锁中恢复的技术。首先,我们可以终止线程,强迫它放弃资源。如果我们回到单车桥的例子,你会看到两辆车停在那里,僵持不下,没有车能前进,因为另一辆车挡住了路段。
它必须获得锁。而简单的解决方法是,哥斯拉从海洋中出来,抓起一辆车,扔进海里,现在其他车就能继续走了。死锁解决了。好吧,这对那些现在在海里的车里的乘客来说并不好,但你知道,我们解决了问题。其实并没有。更大的问题是,通常情况下,问题并没有得到根本解决。
我们需要互斥锁,因为我们要修改某些状态。而且我们希望以一种原子性的方式进行修改,因为我们可能正在进行某些操作,这会产生不一致性。例如,我们正在把钱从一个银行账户转到另一个账户,并且还需要更新分行的银行账户余额。
所以,如果我们只是拿掉锁并让其他人运行,我们将面临不一致的数据操作。因此,一般来说,这并不是一个好的方法。因此,我们可以采取的另一种方法是预先抢占资源,而不实际终止线程。我们之前已经见过这种情况,对吧?你知道,我们有一个CPU,我们抢占这个CPU。
从正在运行的线程中拿走资源,保存所有与线程相关的状态到内存中。然后加载新线程的状态,然后我们就能运行它。所以也许我们可以用这些其他资源来做类似的事情。对于像锁这样的资源可能行不通,但它可能对内存这样的资源有效。
我们稍后再回到这个问题,我们将在接下来的讲座中花费很多时间讨论它。但这并不总是与计算语义相符。没有真正的概念可以“抢占锁”并从某人那里拿走锁。所以另一种方法是回滚死锁线程的操作。
所以,这就像按下Tivo上的倒带按钮,顺便提一下,Tivo是原始的DVR,后来被所有有线电视公司和其他公司复制。然后就像什么都没发生过一样。然后我们让事情继续进行。现在一个问题是,如果我们将每个人都回滚到最初的起始条件,然后再将它们推进,我们可能会再次进入死锁状态。所以也许我们选择其中一个线程。
所以我们后退一辆车,并回滚它后面的所有车。现在朝着另一个方向行驶的车可以继续前进。所以你只需要弄清楚哪个方案最合理。这是一种在数据库中处理事务时常用的方法。
如果事务导致系统进入死锁状态,你只需要中止一部分事务,然后重新尝试。当我们看操作系统时,还有很多其他方法可以用来避免死锁,或者说在这种情况下,我们讨论的是如何从死锁中恢复。在大多数情况下,它们通常简单地忽略死锁可能发生的情况,并尽量进行设计。
这样它们就不会导致死锁。好了,回到关于抢占资源的评论,记住我们之前的例子。线程A和B正在尝试在我们机器上分配两个兆字节,而我们的机器只有两个兆字节的物理内存。所以如果一个线程得到一个兆字节,另一个线程得到另一个兆字节。
然后它们无法获取所需的另一个兆字节来继续运行,两者都无法继续。现在,如果我们不再使用物理内存,而是说这些线程将操作虚拟内存,那么我们可以提供一种“无限内存”的假象。现在我们并不需要真正做到无限。我们可以仅仅说,你知道的。
让人产生有两GB内存的幻觉。这对这两个线程来说足够了,每个线程都能获得两兆字节。事实上,我们可能会有几十个,甚至上百个线程都在做同样的事情。而对每一个线程来说,它都会觉得有无限的内存。
只需要让这个幻觉看起来足够大,这样你就不会遇到问题。好的。另一种思考方式是,如果我们没有虚拟内存,当我们想要批准一个请求时,我们可以预empt(抢占),比如,先将它的一兆字节区域的内容复制到磁盘,清空它们,然后让B使用该区域。当B完成后。
然后我们会将A唤醒,将它的内存内容复制回物理内存,允许它继续执行。因此从它的角度来看,它只是需要等待很长时间才能获得它的第二个一兆字节的分配,但除此之外,它并没有看到其他副作用。这不会影响语义。
仅仅是计算的时机。问题?好的。好的。那么让我们看看一种避免死锁的技术。这里有一个,我会,我会。我要提出这个作为一个想法。当一个线程请求资源时,操作系统会检查如果批准这个请求,会不会导致死锁?
如果答案是否定的,那么它会继续批准资源请求,立即执行。如果答案是肯定的,那么它会让请求等待其他线程释放资源。那么这看起来似乎是合理的,但实际上它并不起作用。让我们看看为什么。我们来看一下我们的示例,我们正在运行的示例,线程A和线程B。
线程A运行时,执行x.acquire。于是我们检查。好的。如果我们批准x.acquire,那会导致死锁吗?不会。好的。因为有可能我们先执行x.acquire,然后执行y.acquire,然后A离开,过一段时间后B会运行。好的。那么现在我们回到线程B。
授予B请求,为什么x.acquire会导致系统死锁?不会。所以我们批准了这个请求。现在我们回到A,A尝试执行x.acquire,然后它被阻塞。现在我们回到B,B尝试执行,但它必须等待。所以我们不能批准y.acquire。它实际上会将系统置于死锁状态。等等,系统已经处于死锁中了。
所以问题在于,我们只是看到了请求本身。我们没有看到它对未来可能产生的影响。因此,我们必须看一下系统未来的状态。实际上,我们有三种不同的系统状态。
有一种安全状态,即系统可以延迟资源请求,从而避免死锁。但是我们当前不处于死锁状态,也没有死锁的风险。死锁发生了,现在已经太晚了,无法挽回。然后在这中间有一种奇怪的状态,就是我们还没有发生死锁,但线程的请求很可能会导致死锁的高风险。
所以我们希望保持系统在安全状态。因为一旦我们进入了不安全状态,就存在风险,事情会迅速变得糟糕,到那时就游戏结束了,无法挽回。好了,请注意,处于死锁状态时,系统也被视为不安全的。你已经死锁了,死锁已经发生了。好吧。
所以死锁避免的关键在于防止我们进入一个不安全的状态,从而避免死锁。好了,现在我们要做的是,代替我们原来的想法,做如下处理。请求到达时,操作系统将检查这个请求,看如果我批准这个请求,它是否会使系统进入不安全状态?答案是否定的。我们可以立即批准。
如果答案是肯定的,我们就让它等待。好了,让我们看一个例子。这里我们执行 X.dot acquire。这会把系统置于死锁的潜在风险中吗?
有前进的路径吗?当然,有前进的路径,对吧?
如果 Y 现在可用,且如果批准了 Y 锁,A 就可以退出系统。那么,当我们查看 B 并执行 Y.dot acquire 时,会发生什么呢?嗯,如果我们批准了它,是否还有前进的路径?没有,对吧?因为一旦我们将 Y 锁批准给 B,就将它置于一个无法完成 X 锁的状态。
如果我们展望 B 未来要做的事情,B 会想要获取 X 锁,而 X 锁正在被 A 持有。因此,如果我们批准 Y 锁,A 或 B 都无法继续执行。所以我们必须等待。然后,存在一种前进的路径,现在 A 可以获取 Y,进行工作并退出系统。A 退出系统并释放 X 后,B 就可以获取 Y 锁了。
获取 X 并退出系统。好了,现在,这就像是做了一些空泛的手势,来说明我们要做什么。那么现在我们将这个过程转化为一个更正式的算法。好了,这使我们朝着目标更进一步。我们希望提前声明我们将需要的最大资源。然后,如果我们查看可用的资源,我们将允许线程继续执行。
减去它请求的资源。如果这个值大于或等于任何线程可能需要的最大剩余资源。那么,因为如果是这样的话,在我们批准这个请求后,仍然会有足够的资源供某个线程继续执行,我们知道该线程能够完成工作。如果该线程完成,它将释放其资源。
然后我们可以检查其他线程是否也符合这种情况。我们将继续进行检查,直到我们检查完所有线程。好吧。所以在银行家算法中,这种方法要更加灵活。我们将允许你动态分配资源。
所以,我们不再要求你一开始就指定所有资源。我们只是说你需要先指定资源。然后我们将动态评估,这样我们可以少一些保守。我们将评估每个请求。如果没有死锁的排序,我们可以批准它。
这个请求。好吧。所以我们使用的技术是,我们将假装已经批准了这个请求,然后运行我们的死锁检测算法。但是做了一点调整。调整是,我们将替代,查看一个节点的请求是否小于或等于可用资源,而是查看。
该节点可以请求的最大资源请求,减去已分配给该节点的资源。如果小于或等于可用资源。好吧,那么这到底会是什么样子?再提醒一下,上次我们用的是银行家算法。我们从所有资源开始,并将其放入我们的空闲资源中。
将其资源放入我们的可用向量中。然后,我们将添加所有未完成的节点,并开始迭代。在每次迭代中,我们将提取一个节点,检查该节点的最大分配请求,减去已分配的资源。
已分配给该节点的资源小于或等于可用的资源。对的。那意味着,如果剩余的请求小于可用资源,我们就知道该节点可以完成。这样就有了一条路径。因此,我们可以让它完成,从未完成的节点集合中移除它,将它的资源。
将已分配的资源放入我们的可用资源池中,然后我们对所有节点进行迭代。如果仍然有未完成的节点,我们再次迭代。如果没有任何变化,那我们就知道完成了。好吧,到最后,当我们到达结尾时,如果没有未完成的节点,。
这意味着所有的节点在成功退出系统之后,几乎是按照这个请求被批准的。一定有某种路径、某种顺序让它们完成。如果还有节点未完成,则意味着批准这个请求可能会导致死锁情况。所以我们不能批准它。是的。那么问题是:
这怎么与我们上一个例子中获取锁的情况相关联?
所以在锁的情况下,这是一个只有一个资源实例的情况。你这里的向量资源将是x,y,z,等等。所以你需要查看,比如说,每个线程将会想要一个x实例和一个y实例。所以如果我们遇到这种情况,一个线程得到了一个x实例,另一个线程得到了一个y实例。
而且我们知道它们的最大需求是,它们会需要另一个资源,并且每个资源的需求现在为零。那我们就知道,我们会进入死锁情况。谢谢。还有其他问题吗?是的。所以问题是,当我说“可用”时,我是在说所有系统资源的可用情况,还是只针对那个进程的资源?所以有两个方面。
一个是当我说“可用”时,是指未分配的资源。比如说没有被占用的锁,或者没有被预留的磁盘通道,等等。然后你可以根据你做死锁检测的范围来看这个问题。如果你是在系统级别做检测,那就是所有系统资源。
如果你只是在你的程序中做,比如说你在实现一个网络文件服务器,你想确保它的执行是无死锁的,那么就是只在这个范围内。但由于它依赖于外部资源,你必须确保你对这些外部资源的所有使用都不会导致死锁情况。明白吗?
[听不清],是的,所以问题是,知道剩余资源如何帮助你,因为你可能有很多不同种类的资源,而且你可能会请求不同的资源,这些资源是剩余资源中的一部分。所以再说一遍,记住这些是向量。所以可用的空闲资源,这些都是向量,其中每个元素代表。
一个不同的资源。类似地,当我查看一个节点的最大需求时,那是该节点特定的资源向量,表示该节点想要的资源。所以我只是,知道了。我会为每个我可能需要的资源做一个条目。我们可能有多个实例的资源。如果你还记得资源图的话。
我们可以有一个盒子,假设里面有三个实例。那么初始条件就是三个实例,对吧?而对于一个锁,我们会从只有一个锁实例开始。其他问题?
这是一个很好的问题,因为这个问题可能让人觉得,贝尔克尔算法确实很难理解。我建议你尝试几个简单的例子并进行实验,这样你就可以再次理解它了。这种问题有时会出现在期中考试上。还有其他问题吗?好。所以,再次强调,如果我们能够进入一个没有未完成节点的状态。
它告诉我们的是系统处于安全状态。如果任何节点仍然未完成,意味着从授予这个请求到所有线程能够退出系统之间没有清晰的路径。这是一个不安全的状态。它并不保证我们会发生死锁,但它意味着我们有可能会发生死锁。
所以这是保守的。好吧,我们不知道未来会发生什么,实际请求的顺序是什么。因此我们在授予任何请求时都会保持保守,以防进入不安全的情况。好吧。我们可以应用班克算法的示例,我们将用我们的餐桌律师来做这个。安全的,我不会造成死锁的情况是,当你试图拿起一根筷子时,它要么是。
不是最后一根筷子。至少还剩下一根筷子。或者它是最后一根筷子,但有人已经有两根。想一想这一点。如果还剩下一根筷子,意味着其他已经拿了一根筷子的人可以拿那根筷子并完成。如果没有筷子了,但有人已经有两根,他们也能吃饭,但他们的筷子。
回到池子里。然后其他人就能完成。所以我们保证了会有一条前进的路径。现在,酷的是我们可以将这个扩展到多个要求上,例如吃饭需要多少个筷子。所以如果我们有一群章鱼臂的律师,
我们可以将其扩展到K个手臂。对于一个K手的律师,我们授予请求的规则是,如果授予该请求会导致这是最后一个,而没有人有K个手臂,或者是倒数第二个而没有人有K-1个手臂,或者是倒数第三个而没有人有K-2个手臂,依此类推,我们不允许授予这个请求。对吧?所以这非常棒,因为如果我们改变数量,算法不需要做出改变。
线程数量或实例数量或资源数量都无关紧要。它独立于这些因素。好吧。那么总结一下死锁。四个条件,再次记住互斥、保持等待、无抢占和循环等待。我们看了几种不同的技术,能够用来避免死锁或解决死锁。我们可以通过编写代码来防止死锁发生,确保死锁不会。
发生时。我们可以对资源请求进行字典顺序排序。我们可以进行恢复。所以死锁发生时,我们可以检测系统处于死锁状态,然后我们有一些机制,无论是抢占、终止还是回滚,都能让我们继续前进。我们可以在首先避免死锁,继续做像班克算法这样的事情。
我们通过动态延迟请求服务来保持安全状态。或者我们可以简单地说死锁不存在。那么系统发生死锁,你知道,哦,好吧。再次强调,大多数操作系统倾向于采取最后一种方法,尤其是在处理应用程序时。它们只是认为死锁从未发生过。那些写完美代码的人,如果你没有,
这是你的错,不是操作系统开发者的错。所以有个问题是,这其实有点像优先级,除了最高优先级是“谁最接近获得它们所需的资源”。这不完全像优先级,更像是确保有人能够获得他们所需的资源。
并不是说一个线程比另一个更重要。关键是要弄清楚是否存在从当前状态到最终状态的路径,在这个路径上,你可以满足所有线程的请求,确保它们能够完成并获得所需的资源。所以你实际上只是看是否存在这样的路径,这就是为什么你知道,排序并不重要。
你正在使用的节点排序实际上并不重要。你可以在处理未释放的节点时使用随机排序。排序顺序并不重要。你只需要检查是否存在一种排序方式,能让所有节点都完成。是的。
[听不清],是的,在锁的情况下,你知道,锁没有语义上的概念来允许抢占。如果我已经授予你锁,那么我不能把它拿走,因为你在关键区段操作时可能已经将世界留在了一个不一致的状态。所以在锁的情况下,你不能进行抢占。但正如我们接下来看到的,在内存的情况下,你可以。
我们刚刚在一系列讲座中看到,你也可以对处理器做相同的操作。我可以把处理器从一个线程移走,给另一个线程使用。好的,接下来我们将转换话题,讨论更多的虚拟化内容。我们之前讨论了虚拟化CPU和调度。
现在我们要看看如何虚拟化内存。在未来的讲座中,我们将讨论如何虚拟化I/O设备,比如磁盘、网络等等。所有这些都围绕着这样一个概念:我们想要提供“独占访问”的错觉。我有我自己的专用机器。
在物理现实中,我们有成百上千的线程,试图调度到一组有限的物理资源上。那么问题是,为什么最大分配银行算法比请求算法更不保守?
所以实际上情况正好相反。最大分配是最大值减去已分配的资源。这种方式不那么保守,因为我们不是在事先查看用户所需的所有资源,而是只关注如果我们现在授权这个请求,当前的情况如何。这有点不同。好的,那么我们为什么要进行内存共享呢?很简单。
如果我们查看一个程序的状态,它完全由CPU中的工作状态和内存中的内容定义。所以如果我们能共享内存,那么我们就可以同时运行多个程序。问题是我们不能共享内存,对吧?
我们不能让两个程序同时占用相同的物理内存。因此,我们需要某种方式来多路复用它们对内存的使用。第二个原因是保护问题。我不希望其他程序能够访问我的程序数据。所以我们都希望能够进行受控共享或不共享,即保护机制。
我们希望能够将这些程序多路复用到有限的物理RAM中。好的,记住一些基本概念。我们有一个线程。那是我们的执行上下文,是程序的活跃部分,完全描述了程序的状态。我们有我们的地址空间。那是程序的被动部分,可能有或没有地址转换。
所以我们可能在处理物理地址,或者可能在处理虚拟地址。这是一个程序可以读取、写入和执行的内存地址集合。当我们思考地址空间时,它实际上可能在程序对内存的视图和内存中的物理实例化之间是分开的。
所以程序可能正在查看虚拟化的世界视图,那么我们需要将其转换为物理视图。在一个进程中,它只是一个正在运行的程序实例,包含一个受保护的地址空间和一个或多个执行线程。
我们需要的最后一个组件是双重模式操作或保护。只有系统才能访问和操作某些资源。我们将其与地址转换结合起来,来隔离一个程序与另一个程序、进程以及操作系统。我们也可以通过这种方式来进行控制,对吧?
如果让进程来控制地址转换,那么它就可以简单地设置转换,让它看到任何它想看到的内容,包括操作系统。因此,这就是双重模式操作的作用,它确保只有在操作系统中,你才能操作地址转换的内容。好,接下来讲一下地址和地址空间的基本概念。
所以一个地址是k位长。如果是k位长,我们可以引用一个包含2的k次方元素的地址空间。现在在几乎所有你会遇到的机器上,这些机器都是字节可寻址的。所以这些2的k次方的元素是2的k次方字节。因此,地址空间。
当有人说有k位时,意味着你可以引用一个包含2的k次方字节的地址空间。那么,2的10次方字节是多少呢?
你得记住这些东西。你会用得非常多,到学期结束时你一定能记住它们。没错,就是一千字节,或者说1024字节。好的,注意,这不是1000字节。顺便提一句,如果你买了一个硬盘,标签上写着是五太字节,仔细看小字部分,你会发现它实际是1000乘以1000乘以1000字节。
那不是五太字节。那是市场宣传用语,和太字节没有任何关系。太字节应该是1024乘以1024乘以1024字节。为什么他们这么做呢?
因为他们可以卖给你一个较小的磁盘,并说它是一个较大的磁盘。这有点烦人。但我们以前也曾遇到过同样的问题,尤其是在显示器上。实际上有一条法律规定了如何测量显示器的大小,因为制造商们曾把面板中你看不见的部分也算作显示器的大小。
因为这样他们就可以告诉你显示器比他们实际卖给你的要大。好吧。那么假设我们有一个页面,里面有四千字节。我们需要多少位来寻址页面上的每个字节?
难道不是每个人都想着2的幂吗?是的,是的,2的12次方是4,096。好吧。那么如果我们有一个20位、32位或64位的地址空间,我们可以引用多少个不同的字节?
答案就是2的K次方。这里有个小提示,考试时如果我们问你某个问题,而你记不住计算公式,可以保持符号形式。你会惊讶于有多少学生认为,2的2次方是8或其他类似的错误,因为在考试压力下,很容易犯错。
这样的问题。所以最坏的情况是,始终保持符号形式。好吧。那么当我们思考一个地址空间时,它是可访问地址的集合,和与这些地址相关联的数据。所以如果我们有一台32位机器,那意味着我们的地址是32位大小,我们可以。
参考大约4亿字节的机器。那么,多少个32位数字可以适应这个地址空间?答案其实很简单。每个字是4字节大小。就是2的32次方除以2的2次方。所以大约是十亿个。当我们尝试访问时。
当处理器尝试读取和写入这些地址中的一个时,在我们的地址空间中会发生什么?答案是:这取决于情况。也许你可以像普通内存一样读取和写入它。也许写入这个地址会导致在磁盘或网络上发生某些IO操作,或者发生其他事情。或者,也许如果你像这样尝试写入此处的区域。
这会导致分段错误。你的程序崩溃。操作系统会终止它。或者,也许这是我们与其他程序之间的通信方式。所以,也许两个程序实际上可以查看相同的物理内存位置。你可以用它们来相互传递信号,或者使用共享数据结构。
关键是,通过转换我们可以做任何事情。在硬件实现转换的范围内,我们将看到我们可以做一些非常强大的事情。你不必仅仅把内存看作一个只做读写操作和执行指令的块。好吧。记住,程序的典型结构通常是我们有一个代码段。
所以那就是所有指令所在的地方。我们可能会有一些静态数据,可能会有一些未初始化的数据。我们可能有各种各样的数据段和数据部分。然后是堆。这是我们动态分配内存的地方,当我们进行像 malloc 这样的操作时。堆在这个情况下可能正在增长,这是倒过来的。
所以这就是向内存顶部增长的过程。然后是一个堆栈段,我们将其放在内存的最上方,并且在进行递归时它是向下增长的。好的。接下来我们有指向这一切不同部分的处理器寄存器,像程序计数器指向代码段中的某个位置,栈指针指向堆栈段中的某个位置。
现在,如果你知道,假设我们尝试引用一个超出堆内存的内存位置,因为我们想要分配一个非常大的内存块。我们将引用一个未翻译、未映射的区域。那么这将触发一个系统调用,你知道的,会陷入内核,然后由内核。
实际上会意识到,哦,你需要更多的内存,它会给我们更多的内存。Ubring 系统依赖,某些操作系统会直接终止你,其他一些操作系统则要求你明确指定你需要多少堆内存,等等。但在许多情况下,仅仅是超出堆栈或堆的边界就会导致操作系统的。
系统为你分配更多的空间。好的。所以记住,我们有单线程和多线程的进程,线程是进程地址的活跃组成部分。地址空间是被动组成部分,它为我们提供保护。它能防止其他程序影响我们,也能防止我们的进程影响其他进程,甚至是影响。
操作系统本身。这就是我们的保护。现在,为什么每个地址空间会有多个线程呢?效率,通信,你知道,上下文切换的成本非常低。好的。现在让我们来看一下内存多路复用的一些方面。第一个也是最重要的方面是保护。
我们想要保护我们的进程不被其他进程影响,并且保护其他进程不被我们影响。对吧?因此,我们可能在进程内部设置不同的保护级别。例如,我们可能会将某些页面标记为“不允许修改”,如果我们尝试写入它们,会触发操作系统的异常。我们可能会有一些仅允许执行的区域。
执行专用。对吧?这是现代的安全技术之一,保证你无法向进程注入代码。因为它是执行专用和只读的。你只能从标记为执行专用的区域执行,而不能修改它们,因为它们是只读的。
你还可以有一些区域,正如我们稍后会看到的,我们将这些区域标记为对用户程序不可见。这听起来有点奇怪,为什么我会在程序的地址空间中存在一个程序实际上无法查看的区域?相信我,我很快就会解释清楚。内核数据,我们希望保护它不被用户程序和其他程序的数据访问。
我们希望保护这些程序免受影响。在某些情况下,我们希望通过将区域标记为只读等方式,保护程序免受自身影响。第二个重要方面是转换。我们希望能够将程序所理解的虚拟地址空间转换为我们如何将其物理存放到内存中的方式。通过控制转换。
它为我们提供了很多灵活性,决定程序实际存放在物理内存中的位置。事实上,它赋予了我们将程序从物理内存中取出、将其抢占并存放到磁盘上的能力。程序在语义上并不会意识到这一点。我们将在未来的讲座中看到,这种做法带来了很多好处。
另一个好处是我们可以用它来避免两个进程之间在物理内存中的重叠。即使它们有相同的虚拟地址,我们也只需简单地将它们映射到不同的物理地址。其反面是我们可以进行受控重叠,让它们实际上能够相互看到。
如果我们愿意的话。我们可以在两个程序之间设置窗口,允许它们共享数据。好的,如果你仔细想一下,当你进行IO时,操作系统会对每次IO操作进行插入。这样做是为了进行访问控制。它这样做是为了在你思考文件中的字节和实际物理文件之间进行转换。
磁盘上文件的物理块。我们想进行插入的原因有很多。我们进行缓冲和其他操作。操作系统也会对进程使用CPU进行插入。你可以设置优先级等各种方式,将CPU资源共享给多个进程。所以这里我们也想做同样的事情。我们希望操作系统能够插入到内存访问中。
所以我们可以控制程序看到的与物理内存中的实际情况之间的映射。当然,我们不希望操作系统在每次指令获取、每次内存读取或写入时都触发进入操作系统。操作系统需要做大量的工作来处理这些情况,这样会非常慢。
所以我们通常使用硬件来加速这个转换过程。而在非常罕见的情况下,当出现奇怪的事情时,我们才会发生页面错误或陷入操作系统,然后操作系统决定如何处理这种情况。但常见的情况必须通过硬件来处理,因为硬件处理速度非常快。
很快。确实,极少数情况下可能会是慢速的,因为我得通过操作系统。好了,记得加载是怎么回事,对吧?
我们从我们的程序开始。程序存储在存储介质上。操作系统使用I/O控制器和其他组件将程序加载到内存中。这就是加载过程。现在,当我们从磁盘中取出程序并将其放入内存时,程序的绑定就发生了。
该程序的指令和数据会映射到物理内存中。好的,所以我们从这里左侧的代码开始,左侧是进程对内存的视图,包含一些数据、代码、子程序、循环等内容。然后我们有与该进程相关联的物理地址。
现在你可能会想,为什么这里的数据1的物理地址是300十六进制,而这个加载操作,加载的字是加载C零十六进制呢?这是因为它是按字寻址的,我们加载的是一个字,因此假设一个字是四个字节,所以C0乘以4就是300十六进制。好的。
所以现在这些物理地址就存在我们的物理内存中。因此,左边,我们有进程对内存的视图,右边是如果我们查看字节,内存实际的样子。好了,我们加载了程序。假设,我不知道,可能是你最喜欢的ID或者EMAC,或者其他什么程序。
现在,系统中的其他人也想运行相同的ID或EMACs。所以我们将加载第二个副本。如果我们使用完全相同的物理地址,那么它们的副本将直接覆盖在我们已有的副本上,位于物理内存中。因此,在进行加载过程时,我们需要将其放到内存中的其他位置。
所以这是我们的第一个应用实例,我们的第二个应用实例必须放到其他地方。因此,我们需要地址转换。无论是在加载时进行,还是在动态时进行,我们都需要某种方式使得同一个程序的两个副本,甚至多个副本,能够在同一时间驻留并运行。
这种地址转换允许我们将不同的物理地址集与相同的程序关联。所以我在运行EMACs,你也在运行EMACs。它们在内存中的两个不同位置运行。这只是众多可能的地址转换之一。在接下来的讲座中,我们将继续探讨更多的转换方式。
这个绑定过程可能发生在我们编译时,编译时会给它分配物理地址。它也可能发生在加载时,加载时只需要将地址指定到我们希望它所在的位置,或者它可能发生在每一个内存访问时。一次性动态地进行。所以我们来思考一下。当我们创建一个进程,最终得到的是一个进程,我们从程序开始。
所以你写代码,首先你做的是编译它,然后将它链接到静态库。接着在执行时我们会链接它,加载它,然后再链接你使用的任何动态库。因此,地址可能会绑定到最终值,绑定到这条路径上任何地方的物理地址。如果我们看一下计算机的历史。
越早回顾历史,绑定发生得越早。所以在早期的计算机中,当你编译程序时,就会生成物理地址。因为你一次只运行一个程序,所以不会有多个副本。而现在,当我们看到现代计算机时,绑定发生在你实际执行时。
在执行时引用地址。稍微提一下动态库。所以当你编写程序并使用像 libc 函数等内容时,libc 并不会被链接到你的程序中。相反,存根会被链接进来,然后我们在实际执行时加载它到内存中。
有一个加载链接器,它将其与实际的动态库链接。这就是它为什么叫做动态库。而这些动态库,你会看到,它们可以在所有运行并使用 libc 的人之间共享。我不需要在内存中有 100 个 libc 的副本。它也很方便,因为如果你有一个更新的操作系统版本,它更新了一些内容。
对于 libc 函数,你不必回去重新链接你的程序。因为你的程序只是与一个存根链接,当你实际运行时,你将使用最新版本的 libc。我已经提到过这一点,但在统一编程环境中,你不需要保护。这里只有一个程序在运行。
那个应用程序可以始终位于内存中的同一物理位置,并且可以访问任何物理地址。这很简单。但它的优势在于,我们通过为你提供一个专用的机器,给出了专用机器的假象。缺点是,这个应用程序可以随意涂抹操作系统。但这是共同的命运。它是唯一运行的程序,如果它崩溃了,操作系统也会崩溃。
应用程序将不再运行。它是一个电源周期机器。所以我们不需要翻译,也不需要保护,因为我们唯一要做的就是运行那个应用程序。而且你仍然会看到,例如在物联网设备中,你会看到这是默认模型,对吗?你不需要翻译,因为只有一个程序在运行。所以,你知道。
例如,墙上有一个小的数字温控器,它有一个微控制器或微处理器。它只运行一个程序,所以不需要做任何翻译。如果它崩溃了,那么,嗯,我不知道,可能会变得非常热。好吧。
快进到早期的 PC 时代,我们有了非常原始的多任务处理,因为我们没有翻译,也没有保护机制。所以无论如何,你需要某种方法来加载 Word,然后加载 PowerPoint,确保它们不会互相冲突。因此,他们的解决方案是,当加载程序时,使用加载器链接器调整地址。
进入内存。所以每个程序在编译时都会有一个重新定位表。它会告诉加载器链接器,程序中每个位置的地址,哪些位置有跳转、数据加载或数据存储。然后,当你加载时,你将使用这个重新定位表来修补所有这些位置。
将其设置为实际物理地址。好了。所以,无论我说的是哪个目标,我说它是零。我只是根据这个零的偏移量调整重新定位表中的所有内容。这里是 20,000 十六进制,我只是将一切设置为偏移 20,000 十六进制的地址。好了。
所以这是 Windows 3.1、Windows 95、Windows 98 等操作系统中非常常见的用法。然而,存在一个问题。没有保护机制。没有任何东西可以阻止程序获取一个地址并在其上加上一百万,然后随意修改内存或读取其中的内容。因此,程序之间没有保护。如果 Word 崩溃了,
它可以摧毁 PowerPoint 崩溃的内容。它可以摧毁操作系统。这就是为什么我说这是非常原始的原因,因为任何崩溃都会造成问题。任何恶意行为也会造成问题,因为没有任何东西能阻止恶意版本的 Word 访问你的浏览器,查看密码等内容。
像这样的或其他任何秘密。但它让你可以运行多个程序。所以我们如何在没有翻译的情况下实现保护呢?
好吧,一种方法叫做基址和界址。这个方法是 Cray1 超级计算机使用的方式。所以这是第一个建造出来的超级计算机。它是这样工作的:我们将有两个寄存器,一个是界址寄存器,另一个是基址寄存器。好了。
所以我们将使用基址寄存器来指定程序的起始位置。我们将使用界址寄存器来指定程序的结束位置。现在,题外话,这是 Cray1 计算机的图片。需要注意几件事。首先,它的形状像一个 C 形状。这是故意的,因为这样它就让
所有组件之间的距离是均匀的。与其拥有一个线性的背板,它们基本上将背板折叠了起来。它还很酷,因为它是由公司创始人 Seymour Cray 创建的,因此从上面看,计算机看起来也像他的名字的首字母一样。但是,大家知道它周围的这个黑色物体是什么吗?
那就是所有冷却设备所在的地方。但实际上它看起来不像是金属。事实上它不是。它实际上是被类似皮革的材料覆盖的。你可以看到,Cray曾经有一个愿景,那就是超级计算机不是你会锁在数据中心里的东西。而是你可以想象它摆在你的客厅里。那么,你不想坐在超级计算机旁边吗?不用多说。
如果你去参观一个数据中心,看到这个有着漂亮皮革外表的东西,周围还放着座位,你坐上去的话,估计会被保安带出数据中心,而且也不会被邀请回来。另一个小小的趣事。你知道,工作在第一台超级计算机上真是件了不起的事情。
就像是去Google X,参与开发一些疯狂的飞行器一样。所以,我有一个兄弟会的朋友,他有机会在Cray做暑期实习。他兴奋极了。他说,哦,我简直不敢相信,我要在这里工作,做下一代计算机之类的。
还记得我说过他们有一个折叠的背板吗?
他们为了让机器的设计和构建变得更加容易,提出了一个创新的想法,那就是所有东西都以光速为单位来考虑。大约需要一个纳秒的时间,铜线可以传输12英寸的距离。虽然实际情况稍有不同,但大致是这样。
所以,这台机器里的每一部分都用铜做,而且这些铜的长度都是12英寸的倍数。他作为实习生的工作就是花整个夏天,把铜线剪成12英寸的倍数。真是一个激动人心的实习机会。不用说,第二个夏天他就没再去Cray了。好吧。
选择那些比坐在那里剪线更加有趣的实习机会。
整个夏天。好的,那么基址界限是如何工作的呢?我们拿到程序生成的地址。好的,左侧这个地址。然后我们用它来引用内存。然后我们将做两个检查。第一个是确保这个地址大于或等于基址。
我们要做的第二个检查是,确保它小于界限。所以我们拿到原始程序,并把它加载到内存中,在这种情况下,它的原始地址是从零开始的。我们会调整它们,让它们变成类似1000多的基地址。好的,我们将通过重定位表来转换这些所有的地址。
这些引用。但通过对界限和基址进行动态检查,就意味着,如果我有一个程序,它拿到一个指针,随便加上十亿,然后尝试引用内存,它就会失败,因为它会大于我们的界限。好的,注意我们没有对地址进行任何操作。所以这是非常快速的。
我们可以在进行内存访问的同时进行检查。这样会使得操作非常高效。所以这可以保护操作系统,并且将程序相互隔离。一个程序无法去引用另一个程序的内存,反之亦然。而且它们不能引用操作系统的内存。但这仍然需要我们在加载程序时进行地址重定位步骤。
它还需要编译器和链接器在加载时生成该重定位表。但地址路径上没有其他的变化。
好的,再次提醒,我们有这两种内存视图。在左边,我们有由CPU生成的虚拟地址。在右边,我们有实际的物理内存条,地址是物理的。位于中间的内存管理单元(MMU)的任务就是进行地址转换。
从虚拟内存视图到物理内存视图的转换。我们可以在这个黑盒子——内存管理单元(MMU)中实现我们想要的任何功能。好吧,基址和界限只是我们可以在MMU中实现的一个示例。接下来我们会看到更复杂的方案,带有地址转换。
它使得实现保护变得更加容易。对,因为我们可以使用MMU确保我们无法生成会引用其他人的地址。而且我看到聊天中有个问题,基址和界限的数值存储在哪里?答案是,它们存储在处理器寄存器中。
它们能被用户更改吗?这是第二个问题。答案是不能。它们只能由操作系统更改。对,如果我们回到这里,如果允许用户程序更改基址或界限。
如果更改了界限,它就可能将其设置为覆盖整个内存并访问内存中的任何内容。为了效率和速度,我们将它们保存在处理器中,因为它们很小。对,每个只需要两字节,因此在进行进程上下文切换时,我们需要保存基址。
对于一个进程,加载基址和界限,对于下一个进程也是如此。你将看到的是,根据我们使用的不同方法,我们已经需要保存和加载大量的内容,进行上下文切换时,需要保存所有的处理器寄存器,也许还包括浮点寄存器。现在,我们增加了额外的状态,必须在上下文切换时保存和加载。
切换。有些我们不需要做。如果我们在同一个地址空间内进行线程上下文切换,我们就不需要做;但如果我们在两个不同地址空间之间进行线程上下文切换,那么处理器中的所有地址转换内容都必须进行上下文切换。
我们必须为新的程序加载这些内容。好的。现在,翻译的妙处在于,借助翻译,我们可以让每个程序都保持零基地址,我们只需要动态地将这些地址翻译成实际的物理地址。
那么我们来看看如何做到这一点。我们要对基地址和边界做一个小的修改。我们要做的修改是,不是让基地址成为一个检查项,而是让它成为我们加到地址上的内容。现在我们可以拿到原始的零基程序,随便加载它,比如加载到1000位置,或者将基地址设置为1000。然后每个生成的地址都会基于这个新地址。
我们只是简单地添加基地址,然后我们还需要检查这个地址是否在边界内。所以这给了我们硬件的重定位。对吧。那么,这样程序能触碰到操作系统吗?
不,程序不会触碰其他程序。没有,我们已经将它们隔离开了。但这里的缺点是我们现在添加了一个加法,对吧。所以在之前,我们只需要拿到那个地址,开始引用内存,然后并行地检查它是否在基地址和边界内。现在,在我们引用内存之前,我们需要做些事情。
我们必须做一个加法。这些操作非常快,但确实需要一些时间。现在我们向路径中添加了一些纳秒的时间。我们会看到,在一些更复杂的方法中,我们会向实际读取和写入内存的时间中添加大量的纳秒。好吧。那么我们在这种方法中可能遇到的一些问题是什么呢?好吧。
假设我们有一堆进程在运行。我们有进程2,进程5,进程6,还有我们的操作系统。那么现在进程2完成了。好吧,现在我们有了一个空隙,一些未分配的内存,然后进程9来了。我们把它放在那个区域。现在进程9正在运行。那么在某个时刻,你知道。
后来的进程10出现了。我们开始运行它,进程5结束了。现在,内存看起来是这样的。然后进程11又来了。我们遇到一个问题。我们并没有用完物理内存。如果我们看看9和6之间的区域,再加上10和操作系统之间的区域,它们的大小比进程11还小。但我们没有一个连续的区域。
这是进程11的大小。这就是我们会遇到的问题。随着时间的推移,我们会发生碎片化,因为并不是每个进程的大小都相同。那么我们该怎么办?
那么一种方法是说,对不起,进程11,你不能运行了。试着稍后再运行。这会非常不愉快,也会非常痛苦。想象一下,你知道,现在是11:50,你正在尝试提交作业给自动评分系统,然后我们说,哦,抱歉,碎片化了,明天再回来。好吧,我不认为你会太高兴。那么我们该怎么办?
嗯,我们可以把进程9和进程10移到更低的位置,为自己腾出足够的空间。这需要大量的内存复制,成本非常高。但你知道,这就是我们不得不做的事情。这种方法的系统最终会不断进行复制来处理这种碎片化问题。
另一个问题是,地址空间不是大而连续的块。记得我之前给你展示过吗?我们的代码段、数据段、栈段、堆段,它们不是一个紧凑的块。它们散布在内存中,散布在我们的虚拟地址空间里。然而,我们却把它们当作一个整体块来处理。
所以这无法高效工作,因为在实际情况中我们的地址空间非常稀疏。因此这些块将会比实际需要的大得多。并且进行进程间共享也不容易或不可能实现。你必须做一些奇怪的重叠处理,嗯,这样做不会奏效。
对吧?我们真的希望能将虚拟地址空间中的某个特定区域与另一个共享。是虚拟地址空间中的特定区域,而不是像地址的顶部和底部这些区域。所以那样做真的行不通。另一个问题是,记得我说过吗?我们有10个人在运行EMACs。我们不希望有10个EMACs的副本在运行。
采用这种方法,正是我们必须做的事情。我们把宝贵的内存浪费在同一段代码的10个副本上。所以这肯定不高效。所以我们真正需要的是更灵活的分段,对吧?
如果我们看看我们地址空间中的内容,会发现有很多不同的段。比如栈、代码、未初始化数据、初始化数据等等。还有我们希望共享的段。所以我们真正需要的是一种方法,能够让我们拥有一个用户空间,也就是说,我有段一、段二、段三、段四。
在我的虚拟地址空间的某些地方,散布着这些段,然后我将它们映射到物理内存空间的不同区域。这些段不是一个连续的块。所以每个段可以看作是一个基地址和界限。每个段是连续的,但作为一组,段可以是非常不连续的。那么我们怎么做呢?这里是多段模型实现的一个例子。
段映射存在于处理器中,并不大。所以在这个例子中,我们有八个段。因此它相对较小,可能大小只有大约8个字。然后我们会将我们的虚拟地址分割成不同的字段。所以我们的高位将是我们的段号,我们将利用这些作为。
索引进入我们的段映射。然后我们将取偏移量,并将其加到基址上,用来生成物理地址。所以现在我们所拥有的只是一个基址和界限的集合。就这样。所以现在,不再是每个进程只有一个基址和界限,而是有八个。因此,我们将有八个不同的段。好的。
我们需要进行一些错误检查,因为我们必须确保不超出段的边界。每个段的限制是有的。而且,我们可以使用的物理内存块数等于条目的数量。在这个例子中,我们有八个。因此,我们将使用最多八个物理内存块。现在,在这个例子中,我给你一个使用段引用的示例。
来自地址的位。在 x86 架构中,指令中实际上有些位,允许你指定这是来自 ES 段、CS 段、SS 段等。但它们本质上是一样的。最终,你只是与这些基址和界限指针的表格进行交互。现在,我们还需要一些额外的位。在这个例子中。
我只有一个有效位和一个无效位。但是你也可能有只读、仅执行等其他标志。所以你可以有很多不同的位。你将会看到,等我们进入更复杂的分页和类似的内容时,我们需要一些元数据的管理位。
所以,快速看一下 x86 架构,它有一个段寄存器。然后我们将段寄存器的字段进行划分。所以,请求或特权级别是低位,而索引实际上是高位。存在多个不同的段寄存器。所以我们有 ES 段、SS 段、代码段。
数据段等等。你实际上无法关闭分段。在下一节课中,我们将研究分页,并探讨分页和分段的结合。当你进入类似的情形时,也许你根本不想使用分段,你想使用分页,因为它可能更合适。
在 x86 中,你实际上无法关闭它。所以人们采用的黑客方法是简单地将基址和界限设置为整个内存范围。然后它实际上被“关闭”了,因为它没有被使用。稍微有点细节。
好的,我们以后会回到这个问题。这里有一个包含四个段、16 位地址的示例。我们将使用最高两位作为段映射的索引,因为我们有四个段,二的二次方等于四。剩下的 14 位将作为偏移量。好的。
所以这里我们有代码段为零,数据段为一,共享段为二,堆栈段为三。那么这些在内存中的位置在哪里呢?如果我们看段零,它将位于4000到4800十六进制之间。如果我们看段一。
它将位于4800十六进制,并延伸到5500十六进制。如果我们看段二,它将从F00000十六进制开始。这个段是共享的,所以可能会与其他应用程序共享。其他的空间是未分配的,供其他应用程序使用。
好,我们将把这些段像玩俄罗斯方块一样放入内存中。我们可能遇到的问题是碎片化,需要移动段,以便满足连续的请求。但有一点会稍微帮助我们,那就是这些段比那些大而单一的进程大小的段要小。它们仍然会很大。
它们的大小依然不同,所以我们仍然可能会遇到碎片化问题。
fragmentation问题。好,让我们来看一个如何进行翻译的示例。所以我们将使用相同的段映射,现在让我们模拟一下这段代码会发生什么。我们这里有一些代码,它的主程序从240十六进制开始,有一个变量在4050十六进制地址处,里面存储了一些数据,然后有一个小的子程序,对吧?
这里是字符串长度等的地址。这些蓝色的地址是虚拟地址或物理地址。虚拟地址,对吧?所以我们需要将它们转换为物理内存中的位置。我们从程序计数器240十六进制开始。所以我们将执行指令提取,获取存储在物理内存中240十六进制处的数据。
我已经用绿色标出了我们的段零,它是4000。所以我们将4000加上偏移量240。这样就会生成物理地址4240。所以我们提取的指令是加载指针var x到a零中。
所以我们将这个位置加载到寄存器a零中。所以我必须进行翻译,对吧?是吗?不是?也许?程序在想什么?虚拟地址。所以答案是否定的。当我加载指针时,我是在加载虚拟地址中的指针。当我实际引用它时,才会将其转换为物理地址。
但是从程序的角度来看,它所看到的都是虚拟地址。这是一个非常重要的概念,因为这正是许多学生感到困惑的地方,那就是我在处理虚拟地址和物理地址时的区别。程序始终处理虚拟地址,然后通过翻译,我们才能得到物理地址。
物理地址。所以我们将把4050存储到零。增加我们的程序计数器四个单位,然后从244提取,转换为4244。我们将提取跳转到字符串长度,递归到字符串长度。这将导致我们将2048移动到我们的返回地址寄存器RA。
我们将设置程序计数器为字符串长度。那个过程在360处。再次强调,我们将程序计数器设置为虚拟地址。真正的地址我们将在执行时处理。所以现在我们将提取十六进制360处的指令。然后我们将进行一次地址转换。
它将是4360,然后我们将加载立即值0到V0。我们将零移动到V0,更新程序计数器。现在我们想提取364。再次提取4364,那就是加载由区域A,零指向的字节。由于A零是4050,我们现在必须进行地址转换,转换十六进制450到。
一个物理地址。所以这将是段一。所以它将是4800。然后它会是4800加上偏移量50。所以它将是4850。然后我们将从4850加载字节到我们的寄存器。好了,增加我们的程序计数器。大家跟上了吗?好的,我们将在这一张幻灯片上完成。
所以关于分段的一些观察。我们在每次内存访问时都进行地址转换。所以当我们提取指令时,当我们进行加载或存储时,那就是我们实际进行转换的时刻。这和我们加载时只做一次地址转换的做法有很大的不同。但是通过进行地址转换,我们能得到保护,因为我们可以强制执行可以访问的地址。
必须生成确保无效地址不能被生成。我们的虚拟地址空间有“空洞”。对吧,段与段之间是内存的空白区域,如果你试图访问它们,某些事情将会发生。对吧?有时是可以的。如果你超出了栈的末尾,操作系统会分配更多的内存。如果你超出了已初始化数据区域的末尾。
你会得到一个段错误。如果你超出了代码段的末尾。你会得到一个段错误并且程序将被终止。现在我们需要在段表中添加一个保护模式位。我没有详细讲解这一点,但是你知道。我们可能会将代码段标记为只读并且只能执行。
我们可能会标记为读/写,因为我们想允许存储。最后一个问题是,在上下文切换时我们需要保存和恢复什么?CPU中的所有内容。所以那个段映射,我们必须保存,并且我们必须加载新进程的段映射。我们还可以将一些物理内存中的段存储到磁盘。
当程序太大,无法全部装入物理内存时,我们通常会这样做。我们可能会交换程序的部分内容,或者在从一个进程切换到另一个进程时,交换整个程序。好了,有问题吗?好的,下周见。好的,下周见。再见。(人群喧哗声)
(嗡嗡声),[BLANK_AUDIO]。
P14:第14讲:内存2虚拟内存(续),缓存和TLB - RubatoTheEmber - BV1L541117gr
好的,我们开始吧。
所以我们将继续讨论虚拟内存,然后深入探讨缓存和翻译后备缓冲区(TLB)。我们会涉及到TLB。好的,记住,对于一般的地址翻译,CPU、处理器以及进程看到的是虚拟地址。然后,内存管理单元(MMU)的任务就是将这些虚拟地址翻译成物理地址。
地址,即我们实际访问内存的方式。现在,我们也可以进行未翻译的读写操作。那么,内存有两种视图,对吧?
处理器有一视图的内存,而从内存的角度来看,实际是物理地址。这个翻译盒就是将两者之间进行转换的工具。所以如果你考虑翻译,它可以使我们更容易实现保护。对吧?
如果一个进程不能看到其他进程的内存,不能看到操作系统的内存,那么我们隐式地保护了这些进程不被那个进程访问,同时也保护了操作系统不被那个进程访问。好,现在还有另一个好处,那就是如果我们将一个进程的内存视图与其他进程的内存视图解耦。
那么我们就可以让每个进程都有相同的内存视图。所以内存从虚拟地址空间的零开始,直到2的31次方减一。好的,记得上次我们讲过的多段模型,其中有一个段映射表存储在处理器中,这个段映射表包含了一组基地址和限制地址对。
在这里,我们有八个字段,我们将虚拟地址分成两个字段。一个字段是段号,我们用它来索引到那个表格。它给我们一个基地址。我们加上偏移量,就得到了我们要使用的物理地址。我们需要进行一些检查,确保没有越界。
我们还必须确保区域的大小大于偏移量。现在我也可以在虚拟地址中编码段号,它也可以被编码到指令中。例如,在x86中,我们有一个移动指令,将使用ES或额外段。
地址是BX寄存器。我们将把该位置内存的内容移到AX寄存器。我们还需要一些元数据。接下来的几讲,我们将深入探讨这些元数据是什么。现在,在这个例子中,元数据只是一个有效和无效位。但它也可以是权限位,例如读取、写入、读取或读写,或者仅可执行。
或者其他什么内容。好的,问题是,翻译是否能帮助我们避免复制100份相同的程序?
所以在一定程度上,是的。我们可以为所有运行IDE的用户共享一个代码段。它将标记为只读执行,并可以映射到相同的基址零寄存器。所以相同的零段就能出现在每个人的虚拟地址空间中的相同位置。好的。那么,如果我们遇到的情况是并非所有段都能装入内存,我们该怎么办?
是针对单个程序还是一组程序?
好吧,我们可以做一种极端形式的上下文切换。好的。如果你仔细想想,在这种情况下,当我们进行上下文切换时,我们需要做什么?
保存这个段映射并加载新的段映射。这将改变处理器对内存的视图,使其变为新进程的视图。对于新进程,同样我们必须加载旧进程的所有CPU寄存器,加载新进程的所有CPU寄存器。嗯,这的极端形式是,如果一切都不能装入内存。
然后我们只需要将段从内存交换到磁盘。因此,当我们要运行进程P1时,我们将它的段加载到内存中,并将进程P0从内存卸载到磁盘。现在这看起来非常慢,有点不合理,但这实际上是许多早期计算机上采用的方法,因为我们没有足够的物理内存,而且我们。
想做多道程序。于是我们付出了非常昂贵的上下文切换代价。好的。那么,什么是更好的替代方案呢?嗯,如果你仔细想想,程序大部分时间都在其代码中的一个相对小的部分上。对于大多数程序,虽然不是所有程序,但对于许多程序,如果你看看什么是热点。
程序的活动区域要小得多。现在在分段模型下,我们要么必须将整个段装入内存,要么将整个段放到磁盘上。我们不能同时拥有这两者。对吧,无法说我只想让段的这一部分在内存中,而另一部分可以放到磁盘上。所以我们需要更细粒度的控制。
我们只保留段中实际使用的部分在内存中,其他部分可以放在磁盘上,只有在实际访问时才会付出非常高的代价。好的,如果我们看看分段,你会发现我们遇到的一些问题,比如这些我们必须装入内存的变大小块。我们在内存中玩俄罗斯方块。
随着程序的启动和结束,我们最终会根据它们在内存中段的大小,得到不同大小的空洞。现在,当新进程到来,我们试图将它们的段装入内存时,可能需要做大量的复制和移动操作。因此,这会非常昂贵。如果我们想扩展一个段,可能需要重新排列一些东西。
所以需要大量的移动。此外,我们在交换到磁盘时也面临着有限的选择,因为我们只能按整个段的粒度进行交换。而且段通常很大。这意味着有大量的数据需要复制出去,而且如果其中包含一些活跃的区域…
然后我们将把它复制回内存。所以我们有两种不同类型的碎片化问题。一个是外部碎片化。那些在段之间创建的空隙。我们的解决方案是我们必须移动一些东西。
另一个问题是,段可能会随着时间的推移而增长,但它们不会收缩。因此,我们最终会遇到内部碎片化问题,比如说我们分配了一个堆栈段,大小为64KB,但实际上我们只用了其中的16KB。那么剩下的48KB就成了浪费空间。每个人都可以使用它。所以我们需要一种更好的方式。
如果我们考虑我们想做什么,我们想做的是我们对它的视图是这样的。
从进程的虚拟地址空间获取内存,这里有程序一和程序二。我们使用翻译映射将其转换为物理地址,以及内存的物理布局。在这里,我做了一个段式模型。虚拟地址空间中有一个代码区域,它映射到一个连续的代码区域。
物理内存。但这只是一个映射函数。因此我们可以将任何虚拟地址映射到任何其他物理地址。所以不需要这些地址是连续的区域。这将给我们带来更多的灵活性。那么真正的问题是,合适的粒度是多少?是到单个字节的翻译映射吗?
是否是到地址空间的单个字节来给它们分配位置?
还是按段的粒度?我们看到段存在一些问题。所以我们知道我们需要更小的粒度。我们可能不希望它小到字节粒度,因为那样会让这些翻译映射的复杂性变得非常高。我们需要的书面记录信息可能会比实际使用的内存还要多。
这就引出了分页的问题,我们将考虑如何将物理内存组织成固定大小的均匀块。所以我们不再按这些可变大小的段来分配内存,而是按页面分配内存。如果你仔细想一想,如果我按页面分配内存,如何确定给定的内存块是空闲的还是已分配的呢?
好吧,如果它们是固定大小的,我可以直接使用位向量。在那个位向量中,如果有一个零,意味着该页是空闲的。如果是一个一,意味着它已经被分配给某个进程。所以现在,当我需要找到一个空闲页面并将它分配给一个进程,因为它需要一些内存时,我只需快速扫描那个位向量,一旦遇到零,我就分配它。
现在一个重要的考虑因素是,每个内存页面都是等价的。读取或写入任何页面所需的时间是完全相同的。无论我连续分配两个页面,还是把它们分配在内存的两端,访问时间都是一样的。所以现在我们真的可以考虑这个转换映射。
它只是将任何虚拟页面映射到任何物理页面。好的。那么,我们应该把这些页面做多大呢?
就像之前提到的那样。我们不希望把它做得太小,以至于管理开销超过我们页面的大小。但我们也不想把它做得像段那样大,因为如果我们把它做得非常大,我们最终会出现大量的内部碎片。如果我们有四兆字节大小的页面,而堆栈是64千字节大小,那么这个页面。
分配给堆栈的空间大部分将会被浪费。而且,记住,在这种情况下,物理内存是珍贵的。所以不要太大,但也不能太小。因此,随着时间的推移,很多人已经演变出今天的典型大小,大约是1千字节到4千字节,甚至16千字节大小。一些架构支持更大的页面大小,我们知道我们有物理。
目标是将对象做得非常大。稍后我们会讨论这个问题。所以现在,如果我们考虑一下,要表示我们之前存储的相同信息,可能会需要很多页。它不像之前只有一个段,而是我们有了八个段,所以我们。
我们只是有八个页面来表示我们的程序。它可能有更多的页面,远远超过我们之前的段数,可能是几百页,甚至更多。好的,那么我们如何实现简单分页呢?我们将从最简单的方式开始,即在处理器中,我们会有一个页表指针。
这个页表指针将指向物理内存中一个包含表格的位置。这个表格由物理页号和权限位的组合构成。好吧,现在我们将取出我们的虚拟地址,然后再次将其分为两个字段。所以会有一个偏移量字段,它是数据所在页面的偏移量。
我们尝试检索的数据。现在,既然它已经进入了页的层面,且我们在按页粒度进行操作,我们可以直接将那个偏移量复制到物理地址中。所以我们不需要像段那样进行加法运算。因为我们不知道一个段在物理内存中的起始位置,所以我们必须进行增量。
起始点,即基地址与我们的偏移量结合。这里我们知道页面的边界是统一的。所以它只是页面起始位置的偏移量。好的,举个例子。如果我们有一个10位的偏移量,那就意味着每个页面的大小是1,024字节。如果我们有一个12位的偏移量,我们的页面大小会是多少呢?对,是4,096字节。好的。
如果你还没有学过二的幂,你将要学习它。好的,现在我们将使用地址的其余部分,即虚拟页号。这只是我们表中的一个索引。所以我们根据这个索引查找表,得到物理页号。然后我们将其与偏移量结合,得到物理地址。当然。
我们总是需要做很多检查。我再说一遍。不要忘记做检查。这可能会出现在期中考试或者类似的地方。你要确保被引用的条目是有效的。它既是有效条目,同时也要符合处理器使用的访问模式。好的。
比如说,这个页面是只读页面。所以我们要确保访问操作是像指令获取或加载,而不是存储操作。好的,我们检查权限位。如果它们不匹配,比如你试图写入这个页面,那将会引发一个故障。
或者如果页面无效,或者如果你超出了页表的末尾。所以我们还需要查看虚拟页号与页表大小的关系。所以我们将在处理器中存储两个寄存器:页表指针和页表大小。好的,地址的其余位。
我们的虚拟地址将是我们的虚拟页号。所以如果我们有一个32位地址,页号会占用10位,剩余的22位将用于我们的虚拟页号。2的22次方大约是400万。所以我们在页表中最多可以有大约400万条条目。好的,有问题吗?是的。
所以问题是,给定一个物理地址,我们如何知道该物理地址表示的是内存中的数据还是可能在磁盘上的数据?这时元数据和账本记录将发挥作用。举个例子,如果它在磁盘上,我们可能会将该条目标记为无效。那么如果我试图引用它,会发生什么呢?页面错误,对吧?
因为我尝试访问一个被标记为无效的内容。现在操作系统检查它的记录并意识到,哦,那个页面实际上在磁盘上。所以它将必须从磁盘中获取该页面,并将其放置到某个地方,更新页表条目,记录实际的物理页面号,并将该位标记为有效。
然后它将重新启动指令。
好的,让我们来看一个非常简单的例子。所以我们将使用四字节页面,这里是我们的虚拟内存。我们的虚拟内存中有三页。我们有一个包含三个条目的页表,这里是我们的物理内存。好的,四字节页面。我们需要多少位来表示偏移量?两个。两个,两个。
二是四。好的, 那么我们其余的地址将是我们的虚拟页号。所以如果我们看地址零, 对吧, 那将是虚拟页号零。然后, 如果我们在页表中查找,我们会看到它在物理页四。我们将一切写出为二进制, 那是一个零零零,然后它就在这里。
十十六进制在物理内存中。类似地,如果我们看下一个,那么四十六进制位于哪里呢?
这是零, 一, 零, 零。所以偏移量是零, 页是一个,对应到三, 也就是一。 然后再来看,查理十六进制。最后,如果我们看八十六进制,那将是一个。零, 零, 零, 零, 二, 三的三次方,然后那将是第二页, 那是一个。所以,这将是上下, 这里, 还是上面, 在四十六进制的位置,所以一, 零, 零, 对吧?好的。
这是简单的。现在让我们来看一些偏移量。那如果我给你地址六呢?
那么它映射到哪里呢?所以我们所做的只是将它写成二进制。所以如果我们把它写成二进制,我们会看到它是零, 一, 一, 零。所以我们的偏移量是一个。零,所以我们把它复制到物理地址,它在页。一个,页一是三或一, 一。所以我们最终得到的是一, 一, 一, 零,对吧?
在十六进制中是零, e十六进制,对吧?再做一个,我们做九。那么九十六进制位于哪里呢?所以我们再次将九十六进制转换为二进制。所以它将是一个。零,零,一。所以偏移量是一个,页是零,或二。所以页二,那是一个。所以它将是一个,零,一,或五。五十六进制。好的。所以我们再次做分页。
复制偏移量,查找虚拟页号,检查权限位。合并物理页号,得到目标地址。好的。现在在聊天中有一个关于共享的问题。那我们如何进行页面共享呢?嗯,其实很简单。所以这里是一个进程, 进程A的,页表。
并且它与页表指针相关。如果我们想共享一个特定的页面。假设我们想共享第二页,我们只需简单地将该条目添加到第二个进程的页表中。好的。现在这里有一个警告。所以它们都, 相同的物理页将在两个进程中出现。
但它是否出现在相同的位置呢?现在,右边是虚拟页二,另一个是虚拟页四。那可能会有什么影响呢?潜在的问题?对, 正确。是的。所以我们将有不同的虚拟地址指向相同的物理地址。这意味着如果我们有对象,比如进程A创建的对象,里面有指针之类的。
从进程B的角度来看,这些指针的虚拟地址将不起作用。所以我们可以共享基本的值和类似的东西,但如果我们想共享对象,我们需要确保将它们都映射到,例如,页面二,或者我们将它们都映射到虚拟页面四,这样虚拟地址就会相同。
相同的映射到相同的物理地址。这样,如果我有一个树结构或类似的东西,指针都会起作用,否则它们就不起作用了。好的。所以如果你要将同一个页面映射到不同的地址空间,这就是你必须担心的小细节。接下来,聊天中的问题是,
这是否意味着两个进程可以修改相同的地址?
它们可以使用不同的虚拟地址修改相同的物理地址,也可以使用不同的虚拟地址读取相同的物理地址。问题是,当我们进行换页时,我们是仅交换页面还是交换该页面所在的整个段?
所以在这里,我们可以以单个页面为粒度进行换页。因此,单个页面可以保存在物理内存中,也可以存储在磁盘上。而对于段,整个段要么保存在内存中,要么整个段存储在磁盘上。所以现在你可以大致想象我们如何利用这一点来为自己带来好处。
如果我们地址空间中的大多数页面实际上并未被积极使用,它们就不需要在内存中。它们可以存储在磁盘上。这可以释放出宝贵的物理内存供其他进程使用。所以我们现在可以将更多的进程活跃部分装载到内存中。
好的。那么我们在哪里使用页面共享呢?我们可以将一个进程的内核区域映射为所有进程共享相同的操作系统内核代码和数据结构。这非常好,因为这意味着当我们进入内核模式时,操作系统可以访问它所有的代码和数据,并且可以访问进程的代码和数据。所以它不需要进行任何转换。转换已经为内核完成了。当然,
我们必须确保当我们处于用户模式时,不能访问页面表的那部分。好的。你知道的,今天我们第一个问题就是。我们还可以有不同的进程,它们使用相同的二进制文件。我们只需将代码的页面映射到物理内存中的代码页中,映射到所有的。
运行该二进制文件的进程,只能将其标记为只读执行,这样就没有人可以修改它。好的。我们还可以用它来处理用户级库和系统库。因此只需要加载libc的一份副本,而不是多个副本。同样,我们将其标记为只读执行,这样就不必担心有人修改它。
然后正如我们之前讨论的那样,我们也可以在进程之间共享内存。两个进程可以看到相同的一组数据,如果我们将它们放在虚拟地址空间中的同一位置,它们可以共享对象。所以如果你仔细想想,这和线程之间的共享非常相似。
它不需要任何上下文,也就是说,不需要进入内核来在两个进程之间进行通信。我只需读写内存,而其他共享相同内存的进程可以看到我的读取和写入操作。所以这是一个好处。同时,它也是一个坏处,因为显然我们需要诸如同步等机制,以及所有这些确保每个人都能看到一致数据视图的事情。
我们修改的结构体。好的。那么记住我们之前讲的内存布局了吗?
我们让内核映射到每个地址空间中。这样做的好处是,内核可以从用户缓冲区复制到内核缓冲区,所有翻译工作都由硬件完成。我们有堆栈,有内存,有堆,有代码,有初始化数据和未初始化数据段。现在。
我们所做的一些安全处理,可能会有点难以阅读,因为投影的幻灯片上显示的可能不太清楚,但我们栈的起始位置从2的31次方减1开始的某个随机偏移。同样,我们的堆从某个随机偏移开始,代码可以从0开始的某个随机偏移开始。
我们通过地址空间随机化来做到这一点。为了让攻击者更难猜测某个特定数据结构在内存中的位置,或者返回值、返回地址在栈上的位置,或者局部变量在栈帧中的位置,我们进行了这种随机化处理。
这样,如果有人进行缓冲区溢出或代码注入攻击,他们就无法直接知道某个特定例程加载的位置,或者某个特定数据值的位置。这里的警告是,如果你在一个32位机器上操作,那只有4GB的地址空间,所能做的随机化是有限的。
如果我有足够的探测能力,通常可以猜测出一些东西的位置。在64位机器上,这不是一个问题,因为很难猜测某个东西可能会随机地被放置在64位地址空间中的某个位置。好吧,另一个问题是由于Meltdown漏洞,我们意识到用户程序可能会推测出。
关于内核数据结构的内容,尤其是那些对安全敏感的结构。所以现在我们做的是,不将整个内核映射到所有地址空间中。那些敏感的东西会映射到一个单独的内核地址空间中。我们只映射一些简单的内容,比如用于内核和用户空间之间复制的缓冲区和简单的代码例程。
进入进程的地址空间。这就保护了内核免受这些观察攻击的影响。但是这也带来了成本。现在,当内核需要访问这些代码或数据时,它必须切换地址空间的上下文。这是昂贵的。我们稍后会看到为什么,特别是当我们讨论缓存和TLB时。
好的,来总结一下分页所带来的好处。我们现在可以通过页面表将虚拟地址空间映射到任何我们想要的物理内存视图,并且是按页面粒度来映射的。现在这里我使用的模型与段式管理类似。虚拟地址空间中的连续区域恰好映射到物理地址空间中的连续区域。
在我们的物理地址空间中。但是这并不是一个必须的条件。如果我们的栈在这里向下增长,如果我们看看物理内存,这里没有足够的空间来容纳我们需要的两个额外的页面框架。但这没关系。因为每一个内存页面都是,物理内存是等价的。
所以我们可以将这两个栈框架放在任何我们想要的位置。我可能会选择,例如,把它们放在这里。或者我可以把一个放在这里,另一个放在这里。或者放在任何其他地方。没关系。让它们是连续的并不会带来任何好处。正因为如此,你知道,我们在如何放置内存中的内容时获得了很大的灵活性。
现在我们不必担心外部碎片。我们不需要为了给另一个进程腾出空间而移动东西。我们唯一可能做的是,我们可能会将一个或多个页面复制到磁盘,以释放内存,使我们可以为另一个进程分配内存。
但是我们不会在内存中随意地移动东西。好的。但是有一些挑战。看一下中间的这个表。它将我们所有的虚拟地址或虚拟页面映射到物理页面。你注意到什么了吗?它有很多空位。而且你还注意到,这个表的大小与页面数量成正比。
在我们的虚拟地址空间中。所以我们有一个表,它大部分是空的,但非常大。那么,当然,你可能会问,嗯,事情到底能有多大?
如果我们有一个32位地址空间,对吧,那它允许我们拥有4GB的内存。我们使用典型的页面大小4KB。那么我们需要多少位来表示偏移量呢?12位。12位对应的是4096。那么,最终我们为虚拟页面号分配多少位?
最终我们得到了20位,对吧?所以这20位转化成了一个页面表,每个条目大约是一个字。因此,我们将需要4兆字节的页面表。也就是4兆字节的物理内存,而如果我们回头看看,这部分大部分是空的。
所以这并不是很高效,对吧?
那64位机器呢?嗯,如果你想一下,64位的话,2的64次方除以2的12次方,我们将会有多少虚拟页?是2的52次方虚拟页?那是多少?4.5 exa条条目,每个条目的大小是8字节。所以我们需要36乘以10的15次方字节的物理内存。
只是为了存储页表。所以显然,这种方法行不通。我们会有更多的内存被用来做记录,而不是被程序实际使用。所以这肯定行不通。好的,我们这里的问题是我们的地址空间是稀疏的。然而,使用单一的页表,我们需要跟踪每一个条目。
那如果我们尝试以某种方式仅跟踪那些可能正在使用的条目呢?我们该怎么做呢?我们马上就会讲到这个问题。哦。这里有个问题,为什么我们需要将某些内核空间映射到用户空间?
因为用户永远无法访问内核空间中的内容。是的,没错。所以用户永远不能访问内核空间。我们将内核空间映射回进程的原因是,当你从用户模式切换到内核模式时,假设你想写点东西。
到一个文件中。你是否给内核提供了一个缓冲区,这个缓冲区可能会跨多个不同的页,取决于你的虚拟地址空间如何将这些页映射到物理页。通过使用进程的进程映射表,也就是页表,内核可以。直接说,哦,只需复制虚拟地址空间中的这个缓冲区,大小为这个数。
将字节复制到内核空间中的缓冲区。由于内核空间被映射到进程的页表中,所以这使得操作变得非常方便。否则,如果你在内核空间中,就需要手动遍历页表并翻译所有的地址,以便进行复制。这样就使得操作变得更简单。
我们就用硬件来做这件事。并且使用保护措施来确保用户进程无法访问内核的数据结构或代码。好的,接下来是讨论。那么在上下文切换时,我们需要切换什么?
嗯,我们只需要切换页表指针和限制。这是我们需要做的唯一事情。对,问题?[听不清],是的。那么问题是,如果我们在内核模式下,并且想要访问另一个进程的内核数据或内存,通常你会怎么做?
从源进程复制到内核缓冲区,然后改变页表指向新的进程。现在你将拥有它的翻译,然后从同一个缓冲区中复制。再次说明,这就是为什么要将内核映射到所有进程的缓冲区中。那就是进程间通信的一个例子。
所以,我做一个类Unix管道之类的操作,它将会从一个进程的地址空间复制到另一个进程的地址空间。但是这个过程是由内核调解的,所以我必须做一个系统调用才能执行。正因为如此,能够将一页内存映射到多个进程中是非常好的。因为这样我只需要直接读写那一页。
内核并没有参与数据的复制。事实上,根本没有复制。你只是将数据复制到那个页面。可以说没有额外的复制。好吧。那么,什么为我们提供了保护呢?嗯,我们通过翻译来获得保护。页表条目限制了你实际可以访问的内容。
如果没有虚拟到物理的映射,允许你访问其他进程的内存或内核的内存,那么你就无法访问它们。你只能生成与你的表中的映射相对应的物理地址。我们使用双模式操作来防止用户修改该表。
只有内核可以修改该表。好吧。那么,这样做有什么优点呢?
嗯,我们得到的是非常简单的内存分配。对吧。我们只需要使用一个位向量来告诉我们,某个页面正在使用,某个页面没有使用。所以我们可以通过扫描很快找到一个空闲页面。这非常容易实现共享。对吧。我们只需要将同一个页面映射到多个地址空间中。
那么,这样做的缺点是什么呢?嗯,我们的地址空间往往是稀疏的。所以我们最终会得到一些非常大的表,这些表可能比我们实际要存储的数据要大,比如我们要编码的数据、堆、栈等等。所以这是一个问题。另一个问题是并不是所有页面在任何给定时刻都在使用。
我们真的要考虑的事情是,只保留那些正在使用的页面在内存中,剩余的页面则存放在磁盘上。我们需要在页表的上下文中实现某种机制来做到这一点。好吧。因此,这种简单的页表就太大了。所以,除非我们准备使用非常大的存储,否则我们无法使用这种方法。
页面的话,我们就会遇到巨大的内部碎片化问题。那么,如果我们有一种多级方案呢?对吧。我们可以有多个级别的页表,就像一个页表树或包含段和页的树。让我们看看这可能是什么样的。
所以,如果我们首先考虑页表为我们提供的功能,它只是一个映射。它将某个虚拟页面号映射到我们为该页面分配的一个物理页面号。如果我们拿到一个虚拟地址,交给页表,页表返回相关的物理地址。所以它其实就是一个查找表,一个非常大的查找表。
但是我们可以实现查找表的方式有很多种。所以我们可以利用这些方式,尝试做一些比仅仅拥有一个巨大的线性表更高效的事情,后者会占用大量连续的物理内存。好的。那么我们可以在这里使用哪些其他的数据结构呢?我们刚刚提到了其中的一种。
也许是树形结构。我们还可以使用类似哈希表的结构。我们需要的只是一个映射函数,给它一个虚拟页面号,它就能返回物理页面号。就是这样。好的。那么让我们来看一下一个潜在的解决方案,那个就是二级页表。好的。我们将有一个页表树,我们将把虚拟地址分割成一种神奇的方式。
我们将取12位作为我们的页面大小。12位就是4096字节。好的。所以这就从我们的32位地址中去除了12位,剩下了20位。我们将均匀地分割这20位,取10位作为顶级页表的索引,另外10位作为下级页表的索引。
下一层页表的位数。每个页表条目的大小是四个字节。好的。我们的页面有多大?四千字节。我们的页表条目有多大?四个字节。每个页面有多少个条目?1024个。我们需要多少位来索引1024个条目?
由于这10位或者说10位的缘故,这就是我们所谓的“魔术10”位的由来。如果你仔细想一想,这一页包含了这一层的整个页表。因此,一层页表的大小是4096字节,除以每条四个字节,就是1024个条目,用10位来索引。这就是为什么这是一种“魔法”分解32位数字的方式。好的。
所以我们有一个页表指针,它存储在处理器中。我们不需要页表大小指针。为什么?因为它是固定大小的,对吧?它的大小由页面的大小限制。而且该页面的大小包含了2的10次方个条目。所以我们只需要一个指针。我们将使用我们的高10位来索引页表的根。
这将为我们提供下一级页表的物理页面号。对吧?所以这就是这里的物理页面。然后我们会……哦,我应该指出,当我们进行上下文切换时,唯一需要保存的是这个页表指针,在x86架构下它是CR3寄存器。好的。
所以我们将取接下来的10位,并将其用作这个页表的索引。这将为我们提供内存中页面的物理页面号。然后我们将这个物理页面号与偏移量结合起来,就得到了该物理页面上的实际偏移。好的。现在我们还需要在所有这些条目上有有效位,因为现在我们没有……
长度。所以,你知道,可能我们只使用了顶级页表中的前500个条目。所以我们需要有效位来告诉我们某些条目是有效的还是无效的。我们不需要每一个第二级页表。对。如果顶级表中的条目无效,那么就不会有相应的第二级表。
第二级表。所以现在我们只需要映射那些正在使用的虚拟地址空间区域。如果没有被使用,我们就不需要为其关联第二级表。对于稀疏地址空间来说,这将非常有用。因为我们现在大大减少了需要的这些第二级表的数量。
另外一点是,虽然我们把顶级表保存在内存中,这些第二级表其实可以存放在磁盘上,如果它们不被使用。而且,我们只需要在顶级表中做一些记录,告诉我们,“哦,嘿,我把这个第二级表存放到磁盘上了。”实际上它并不是无效的。
它只是存在于磁盘上。好的。所以我们获得了更多的灵活性。现在我们可以只保留实际被使用的页表在内存中。那些真正活跃的页表保持在内存中,而不那么活跃的则转存到磁盘。好的。这里是x86上32位地址转换的一个示例。
他们的术语中,顶级目录叫做页目录,它包含页目录项(PDE),而不是页表项(PTE)。这个基址寄存器CR3提供了那个页目录的物理地址。我们取出顶级位,将其用作索引来获取我们的PDE。这给我们提供了页表的物理页号。
我们使用接下来的10位来索引到那个表。这给我们提供了物理4K页。然后我们使用偏移量来获得物理地址。好。所以当我们进行上下文切换时,我们只需要保存旧进程中的CR3,加载新进程中的CR3,这样我们就得到了不同的页目录。是的,问题。好的。
所以他就是我让他在观众席上坐着的那个人,这样他就可以提出那个问题,为我切换到下一个幻灯片。所以我们为什么需要在页表中使用很多位呢?因为我们需要什么?
我们需要知道下一级页表的指针是什么,或者说指向物理页的指针是什么?这需要的位数不多,对吧?那么我们还在做什么?
我们有权限位,对吧?所以我们需要追踪的是它是有效的还是无效的,是可读写的,还是只读的,是否仅可执行,是否存放在磁盘上?上次访问是什么时候?是干净的,还是脏的?你知道,我们会在页表项中存储大量的信息。所以这就是为什么它是四个字节的原因。你可以在这里看到,例如。
这里有一些空闲位。这些可以分配给操作系统。操作系统可以使用这些位来进行记录。所以它可以利用这些位来表示,哦,这个页面实际上是在磁盘上。那么可以利用页面表项中的其他位来确定它在磁盘上的位置。比如,可能是存放页面表的磁盘块的逻辑块号。明白吗?
但是还有很多其他的事情。所以,你知道,是否有效、可写。它最近有被访问过吗?
当我们进入分页时,这是我们用来表示一个页面的方式。它最近被访问过,所以是活跃的,相对于那些没有被最近访问过的页面。你知道的,它其实不需要一直待在内存中。也许它可以存放在磁盘上。还有其他的位,比如,它是脏的?是否已经被修改过?是否最近被写入过?
这很重要,因为如果我们想要说,从内存中驱逐这个页面,就意味着我们实际上必须将它复制到磁盘上。好的。还有一些位,你知道的,是架构相关的。好的。那么我们如何使用页面表项(PTE)呢?对吧?有效的页面表项可能意味着多种情况。它可能意味着,是的。
其实这就是无效的,你的程序已经访问了这个位置。这是一个雷区,我们就会发生段错误并终止你的程序。对吧?
或者这可能意味着,页面或目录实际上在别的地方。它不在内存里。它在磁盘上。所以我们检查有效性,然后利用其他的位来判断,嘿,它在哪里?如果不在内存里。那么需求分页(demand paging)就是我们只会将活跃的页面,像我之前说的,保留在内存中。
我们将使用那个活跃位来告诉我们哪些页面是活跃的。如果页面不活跃,我们就可以放心地将它发送到磁盘上。当然,我们会将页面表项标记为无效,并用记录位来告诉我们这个页面存储在磁盘的哪个位置。另一个例子是记得 fork 吗?对吧?当我做 fork 操作时,它非常快。对吧?因为我并没有真正复制父进程的所有地址空间并为它创建一份副本。
对于子进程,我直接复制页面表。所以我复制所有的页面表,这样会小得多。我会将所有被读写的条目标记为只读。接下来会发生什么呢?程序,无论是子进程还是父进程,继续执行,它试图写入某些内容时,会生成一个异常,因为它试图写入一个标记为只读的页面。
现在我进入操作系统的陷阱。操作系统查看页面表条目,然后说,哦,我进行了写时复制(copy on write)。所以接下来它会做的事情是,复制这个页面,生成两个副本,一个在父进程,一个在子进程,并将两个页面标记为可写,重新启动指令。所以,这就是写时复制的来源。
因为我们只会在子进程或父进程真正尝试对页面进行写操作时才会复制这些页面。大家都明白吗?这就是我们让 fork 操作非常快速的方法。好,另一个例子是分配内存。所以当我给一个进程分配内存时,我调用 malloc 或 espray 来请求更多内存,我向操作系统请求。
给我一些物理页面。操作系统返回一组物理页面。现在,如果这些页面曾被另一个应用程序使用,操作系统必须将这些页面清零。为什么?嗯,假如那是一个敏感的服务器进程,或者是内核在使用这些物理内存呢?
它可能包含机密信息、SSH 密钥或其他敏感数据。所以内核总是会给你一个已经清零的内存页。但实际上去清零 4,096 字节并且多次执行这一操作是非常昂贵的。因此,操作系统通常会分配页表项。
然后在后台有一个进程,它正在清理空的内存页,确保它们被清零。当你实际上尝试访问该页面时,它会将一个已清零的页面交换进来。这样,你就不需要等待清零页面的创建,它们基本上是按需分配的。或者我应该说是分配。
它们是在你发出请求时被分配的,实际上是分配的。
按需分配。好的,我们如何实现共享呢?共享其实很简单,对吧?
所以在这里我们有多级页表。我们可以在整个页表的粒度上共享,或者我们可以在单个页面的粒度上共享,或者两者都共享。好的,这样我们就获得了更多的灵活性。我们可以将页表与代码关联,这些页表会在所有运行相同二进制文件的程序、进程之间共享。
或者分配给 libc 的页面,这些页面会被映射到每个地址空间中。所以这使得在进程之间进行共享变得非常高效。那么有个问题,物理页号是 20 位。是的,实际的物理页号,在这种情况下是 20 位,它会依赖于机器架构。
20 位并不是很多内存。所以,你知道,随着机器变得越来越大,现在你可以拥有带有一 terabyte 内存的机器,你需要更多的物理位。
更多的物理页号位。好的,关于二级分页的总结是:之前我们只有一个中间表,它将左边的任意页面映射到右边的任意页面。现在我们通过一个顶级页表,然后经过一个二级页表。所以你可以看到这里我将地址分成了三个字段。
所以下三位是偏移量。然后,我们绿色的两位是第二级表,我们的三个位是顶级页表。所以顶级页表有八个条目。然后我们的中间表这里有四个。每个页面有八个字节。是的。那么问题是。
顶级页表的结构和下一级页表的结构一样吗?是的,通常是一样的。每一级的页表项都是一样的。这是因为在硬件中做这样统一的结构最容易。如果你有两种不同类型的页表结构,它会让事情变得更复杂。
硬件。是的。[听不清],问题是,如果操作系统想修改页表,它是在虚拟空间中做的还是物理空间中做的?是在物理地址空间中。所以页表存储在物理地址内存中。但你也可以把页表存储在虚拟地址内存中。
但这取决于机器架构。[听不清],是的。嗯,实际上内核并没有做地址翻译。它是在设置表格。然后硬件中的内存管理单元会遍历这些表格来实际为你做翻译。所以操作系统设置好一切,并说,这是规则。
这就是这些表格在从虚拟地址到物理地址映射时的样子。然后硬件实际上通过这些表格来实现快速遍历。[听不清],是的。所以操作系统既可以生成翻译后的地址,也可以记住,我现在不知道它在哪里。但我们有两种视图,对吧?
好了,我们开始。第二张幻灯片。
所以,CPU会生成虚拟地址,但内核可以生成未翻译的地址。内核实际上可以说,我想写入这个物理内存位置。它必须能够这样做,因为如果它需要从磁盘中加载一个页面并写入到某个位置,它也必须能够访问未翻译的地址。明白了吗?问题?
[听不清]。
所以问题是,当我做malloc时,我得到的页面,如果我读它,它会是全零吗?还是可能有随机数据?不幸的是,答案是肯定的。如果页面是内核新分配的,你可能会得到一个全零的页面,且已经被清零。但如果你做了很多malloc,很多free,再做很多malloc。
这取决于malloc的实现,以及你是否调用像zalloc这样的函数,它会将页面清零。当你调用free时,它只是把页面或者内存放回堆中。它不一定会为你清零。这就是为什么有zalloc,我认为它叫做zalloc,一个C库函数,它会给你一个清零的页面。
它保证该页面将是零。好的,所以在这里,如果我们想扩展堆,添加一个新的页到堆中,我们只需在顶级表中查找,看看这是红色的100号条目。然后下一层是10,我们在这里查找,这会给我们这个10。
二进制中的000映射到这里,十六进制是80。
所以我们只需要遍历这些表,这样就能告诉我们物理地址是什么。复制偏移量。所以在最好的情况下,对吧,我们将使用的页表的总大小将与程序的虚拟地址空间中使用的页数成正比。对吧?这比之前要小得多,因为之前是与虚拟地址的最大大小成正比。
地址空间。所以假设程序相对较小,这意味着我们的页表会相对较小,对于这些二级表,它们可以存储在磁盘上,如果我们不使用它。现在我们付出的代价只是进行一次内存读写或指令获取。现在我们要做的是额外的两次内存读取。
所以我们实际上使得内存变得三倍慢了。而访问内存已经是很昂贵的了。在芯片上访问某些东西只需要纳秒级的时间,而访问内存可能需要几百个纳秒。现在我们让它变得三倍更加昂贵。如果一些二级表存储在磁盘上。
这将使得代价更加昂贵。我们需要一个解决方案,我们稍后会回到这个问题。
好的,我们来看另一种方法。那是一个表的树结构。相反,我们可以尝试将段的好处和页的好处结合起来。所以最低级别将是一个页表,位于任何树形结构中。为什么?
因为它是一个非常简单的分配,对吧?我们不必担心外部碎片问题。我们将页表放在顶部,段放在底部。我们必须担心将这些段在物理内存中移动,找到足够大的连续内存块,以便能够分配这些段。
所以在最低级别使用页表要好得多。好的。然后我们就用位图。我们的层级可以是段。我们可以有任意数量的层级。但我们将把顶层设置为段。所以现在我们的虚拟地址看起来像这样。最上面是一个段号。
一个虚拟段号。然后是我们的虚拟页号。接下来是偏移量。因为我们最低级别是页表,所以我们可以直接复制偏移量。固定大小的页。我们的顶层使用的是存储在处理器中的段映射。所以在这里我们有八个条目,基本地址和限制,每个都指向一个页表。
然后我们使用地址的第二级,即虚拟页表中的虚拟页号。实际上,是通过索引查找物理页号。当然,像往常一样,不要忘记我们需要检查确保没有超出表的范围。我们还需要检查所有的权限位,以确保没有任何问题。
访问模式。好的。现在,当我们进行上下文切换时,以前当我们有一棵表格树时,我们只需要保存那个页表基指针,即CR3寄存器。现在就像使用段一样,我们必须保存段映射。好的。因此,我们在上下文切换时需要保存的数据量相对较小。
两个不同的地址空间,位于两个进程之间。
那么我们如何进行共享呢?嗯,现在我们可以在整个段的级别进行共享。所以我们只需简单地指向一个表示共享段的页表,比如我们的共享代码。现在,如果我们有另一个进程B,它也只需指向同一个共享段,它将有一个指向相同共享段页表的段。
现在,我们有了在两个程序中都能看到的相同数据。再说一次,如果我们处理的是虚拟地址和引用之类的东西,我们的指针,我们需要确保将它们放入相同的段中,这样虚拟地址在两个地址空间中都是有效的。如果只是整数表或其他类型的字符串。
那么我们不需要将它们放在同一个位置。如果我们需要有指针、代码或类似的东西,它们必须放入同一个虚拟段中。
好的。那么,让我们回顾一下使用多级转换技术学到的内容。优势是什么?嗯,我们只会为我们的应用程序使用的页表项分配必要的数量。所以,如果我们有一个非常稀疏的地址空间,所有这些稀疏性都没关系。我们不需要为此追踪稀疏性而额外创建数据结构。
这一切都会在我们数据结构的顶层得到捕捉。现在,如果我们在空间中的大间隙内均匀分配一个页面,是的,那样的话,我们最终得到的将不是一个非常稀疏的数据结构集合。但这通常不是我们的操作方式。我们通常会有代码、数据、栈和堆。
因此,这些区域可以非常远离彼此,总体上我们为管理所占用的空间就会相应减少。我们获得了便捷的内存分配。只要我们始终使用最低级别的页表,我们就可以使用位图来进行分配。我们还可以轻松实现共享。我们可以共享单个页面集,或者在页表级别进行共享。
我们的整个段。所以有很多灵活性。但一切都有代价。所以一些缺点是我们仍然需要每个页面都有一个指针。所以,对于每个我们想分配的4KB或16KB内存,我们都需要一个页表项。所以这可能会很昂贵。哦,之前有人问过,使用段和页与其他方式相比有什么优势?
只是使用多级页表?这只是一个设计选择。所以,无论你选择使用多级页表,还是选择使用多个段,实际上只是一个架构设计的选择。当架构师在考虑这个处理器或架构的目标市场时,他们会做出这样的决策。
那么,人们将如何使用它呢?他们会尝试设计一个能与应用程序和操作系统都能良好配合的内存系统。所以,段和页表,或者多个级别的页表,只是人们采取的两种方法的例子。稍后我们将看另一个例子。
但它们都在试图完成相同的基本目标。好的,我们面临的另一个问题是,我们的页表本身,如果它们占用多个页面,必须存储在连续的内存中。所以,这确实可能引发一些潜在的外部碎片问题。
如果我有一个长达20页的页表,我需要找到20个连续的零段,以便将这个页表存储在内存中。所以我可能需要做一些数据搬移才能使其正常工作。但如果我使用32位机器的10、10、12的划分,我的页表就是一页,我就永远不会遇到这种问题。
另一个缺点是,我们刚才看到的,我需要进行多次查找才能找到每个引用。所以,我必须遍历多个页表,进行多个内存引用,才能完成一次内存引用:读取、写入或指令获取。所以这将是非常昂贵的。这告诉我们,我们不能…
我们不可能对每个内存引用都做完整的地址转换。我们必须找到一种方法,将这些成本在多个内存引用中摊销开。好的,记住双模式操作,对吧?我们是否允许进程访问它的翻译表?
不对,正确吗?因为否则它可以访问任何内存。它可以访问与其他进程相关的内存,甚至可以访问操作系统的代码和数据。硬件通常提供两种操作模式:用户模式和内核模式,或受保护模式、超级用户模式,或者你想怎么称呼它都行。
但是它是一种特殊模式,具有完全访问权限。用户模式是非常受限的。内核模式下你可以做任何事情。现在一些处理器,比如英特尔处理器,包含多个级别。所以实际上你有四个不同的环级,允许你拥有不同级别的访问权限。所以你有最高访问权限的主管模式和保护模式。
你有较低的级别,一直到用户模式。在 x86 中,最高权限级别下方的级别通常被虚拟机监控器使用。有时也被设备驱动程序使用,这样你就可以限制它们的操作以及它们对操作系统造成损害或问题的能力。现在某些操作。
再次提到双模式操作,我们将限制为处于内核模式。所以修改 CR3 寄存器,修改段映射,你只能在内核模式下执行这些操作。通过这种方式,我们可以保证用户无法更改他们的翻译映射。并且,正如之前提到的,关于读取和写入页表项的问题。
当你处于内核模式时,必须完成这些操作。你不能在用户模式下修改或读取它们。好了,让我们让一切变得真实。这里是一个 x86 的示例,它结合了顶层的段和分页,一种多级分页方案。所以它将一切结合在一起。
所以你获得的是段。这里我们有一个逻辑地址或虚拟地址,或者他们所说的远指针。它可以包含段,也可以由指令本身提供段信息。这里,段来自指令,它是 GS 段,这是段选择符。段选择符选择一个段描述符。
我们在全局描述符表中查找它。对吧?这是全局的,适用于所有进程。还有一个本地描述符表(LDT),它特定于某个单独的进程。你实际上可以控制查找在哪个表中进行。好的,这将为我们提供一个基地址,用于在我们的线性地址空间内定义一个段。
这是我们如何从所谓的逻辑地址转换到实际地址的过程。在我们的线性地址空间中。这个线性地址现在是我们将在多级页表中查找的内容。所以我们将把这个线性地址分为一个目录、一个表和一个偏移量,然后按照顺序遍历这些表,最终获得我们想要的物理地址。
对吧?所以在大多数情况下,x86 会将这个线性地址空间映射到内存的所有部分,将每个段从零到内存的顶部,因此他们实际上并没有使用段。它们只是忽略了段。你生成的地址将仅仅是这些线性地址。好了。这里是一个 x86 32 位地址的示例。
所以在这里,段要么是指令中隐式包含,要么来自指令中的隐式部分,或者它们来自地址。所以有六个段寄存器:堆栈段、代码段、数据段、附加段,以及 F 和 G 段。段寄存器只是指向这个段描述符的指针。所以再次说明。
如果我们回顾一下,这实际上是一个指向这个表的索引,全球描述符表(GDT)。或者是指向局部描述符表(LDT)的索引。所以局部描述符表是一个每个进程的段映射,你可以把它想象成这样。而全球描述符表是全局的。例如,如果我有共享的二进制文件,我会把这些条目放到全局描述符表中。比如说,对于共享代码。
然后是当前请求者的权限级别。所以这就是实际的描述符长什么样。它包含很多信息。我不会一一讲解。它告诉你这是什么类型的段,是否是代码段、数据段,或者其他段。这很重要,因为代码段只能被执行。
还有什么是重要的?它告诉你访问它所需的权限级别。你必须确保请求者的权限级别小于实际段的权限级别。这样就能保证你是否能够访问它。所以如果是内核段,你就不能从用户模式访问它。然后,还有额外的位可以供操作系统使用。
A 中的位。
这大概就是所有重要的部分了。好吧。假设我们想要有一个 48 位的虚拟地址空间。比如它在一台 64 位机器上,我可能有非常大的指针,比如 64 位指针。或者更常见的是,你会有像 48 位这样的指针。就是这样。
我可以使用四级页表。所以我要用低 12 位作为我的偏移量。现在,因为我的页表条目必须更大,因为我的物理地址最终会更大,所以我必须使用 8 字节条目。那么如果我使用 8 字节条目并且每页是 4KB,我有多少个条目?4。
096 除以 8。小时是 2。5-12。1024的一半。好吧。我需要多少位?
这个索引指向一个有 512 个条目的表?9。它就在幻灯片上。好吧。所以我们的页表指针,也就是 CR3 寄存器,指向我们的基址或目录,或者你想称之为什么,每个页表条目占用 8 字节,共有 512 个条目。我只需要简单地遍历这些表,直到我得到实际的。
我想引用的页面的物理页号。将它与我的偏移量结合起来,我的 12 位偏移量,现在我就得到了我的物理地址。好吧,实际上你可以这么做。你会想这么做吗?大概不会吧。对吧,因为对于每次内存引用,我都需要做 1,2,3,4 才能得到地址,然后才能进行第五次引用。
这实际上是我的构造、加载或存储操作。好了,但效率不高。虽然可以做到,但不够高效。那么,x86架构支持的一件事,就是支持更大的页面大小。这有助于解决一些问题。对吧?所以,这是一个示例,展示了你有五级分页。
如果我改成2兆字节的页面大小,对吧,现在我需要的级别更少。但为什么——而且我还可以更大,对吧?我可以使用1千兆字节的页面。那么,为什么要这样做?你有什么想法吗?使用1千兆字节页面有什么好处?
那么,应用场景是什么呢?让我们发挥一下创造力。对,没错,数据库,对吧?
如果我有一个数据库,我想管理数据库中的对象。我可能希望在内存中保留一个大块数据。所以,使用1千兆字节页面意味着我可以将所有数据保留在内存中,并管理对它的访问。视频也是另一个例子。在视频编辑中,我可能会有非常大的视频片段,而我可以完全填充页面。
关键是要考虑那些应用场景,在这些场景中,无论是2兆字节大小的页面,还是1千兆字节大小的页面,我都能完全填满页面。如果我没有完全填满页面,比如我在1千兆字节的页面上放置一个64千字节的栈,那就会导致大量的内部碎片化,浪费了我昂贵的内存。
但如果我有一个应用程序,我将确保这些页面被完全填满,那么这就非常高效了,可以拥有一个大的虚拟地址空间,而不需要进行五次内存引用来执行一次读/写操作。好的,所以,这对内核非常有用,知道吗?内核可以全部放在一个页面中,而不是分散在多个页面中。
这对于大库等非常有用。好了,64位机器上的64位地址怎么办?
你知道,我可以只用一个6级页表。不行,那样会太慢,对吧?因为我们需要逐级遍历这个页表,然后是这个页表,再是这个页表,然后是这个页表,最后,我们才会在每次操作时进行加载和存储。所以。
这样会非常低效。好吧。那有一个替代方案,叫做倒置页表,虽然这个名字并不完全恰当。你可以创建某个数据结构,给它起个名字。但人们不总是选最好的名字,我们就这么说吧。那么,为什么叫做倒置页表呢?如果你回想一下我们到目前为止看到的内容。
所有这些我们都要遍历这个表。所以,这是我们走的一条前向路径,从我们的虚拟地址到物理页面号的映射。因此,页表的大小将与我们为该进程分配的虚拟内存量成正比。我们将使用这些多级方案之一,以避免数据结构中的稀疏性。
但是我们实际拥有的物理内存可能要小得多,对吧?如果你看一个64位的机器,带有64位指针,它的虚拟地址空间要比实际的物理空间大得多。你知道,我们的机器可能只有64GB的内存。所以,我们有一个巨大的数据结构来进行映射,而实际在内存中的页面要小得多。
我们的大部分代码和数据可能都存储在磁盘上。那么,我们将使用哈希表。我们会将虚拟页号交给这个哈希表,它会返回我们的物理页号。现在,这个哈希表叫做反向页表。这里的好处是,哈希表的大小与物理内存的大小成比例。
因为它只是跟踪实际在物理内存中的内容。它在哪里?
如果它不在哈希表中,我们就必须去查看我们的记账数据结构,来弄清楚,嘿,它在磁盘的哪里?所以,这样挺好的。现在它的大小不依赖于虚拟地址空间,而是与我们拥有的物理内存量成比例。因此。
这对于我们拥有64位地址空间的情况是一个非常好的解决方案,对吧?我们不想有六七层,或者更多层的页表。那么,这里的缺点是哈希很难找到好的通用哈希函数,能够产生短链。链是指你遇到哈希冲突的地方。
你去看桶里,它不是你想要找的项。所以,你必须重新哈希并查看另一个桶,再重新哈希并查看另一个桶。我们不想做太多这样的操作,因为那会非常慢且昂贵。你需要的是能生成非常短链的哈希函数。
通常你是在硬件中执行这一操作。因此,这使得很难确定什么是好的哈希函数。另一个问题是,如果我想遍历页表本身,那里没有局部性。因为一个虚拟页号会哈希到哈希表中的某个位置。
下一个虚拟页可能会哈希到一个很远的地方。下一个虚拟页也可能会哈希到很远的地方。所以,那里没有局部性。而我们之前看的方法,一个虚拟页表项会和下一个项紧挨着。它是紧挨着下一个项的。事实上。
它们都能装进一页里。所以,这样非常高效。好了。但这里的好处是,哈希表的大小是与物理内存中使用的页面数成比例的,这将比虚拟内存中使用的页面数小得多。所以,我就用下一张幻灯片来结束。
所以,我们研究了许多不同的转换方法。分段法,快速的上下文切换,我只需要保存段映射。缺点是,我们可能会遇到外部碎片问题。分页在单级分页时非常好,因为我们现在有了一种统一的、快速的内存分配方式,不会出现外部碎片。然而,我们最终得到的表大小是与虚拟地址空间成比例的,并且大部分是……
充满了空的条目。所以,它不适合稀疏地址空间。页分段或多级分页,这两种方法使得表的大小现在与虚拟内存中使用的页数成比例。通过在最低级别使用分页,我们能够获得快速分配的优势。
但是缺点是,进行一次读取或写入时,可能需要进行多次内存引用。所以,我们需要解决这个问题。这就是我们在下一讲中要解决的内容。倒排页表。现在我们有一个与物理内存中页数成比例的表大小。
但缺点是我们需要使用这些硬件哈希函数,这些函数可能非常复杂,而且我们没有页表条目的缓存局部性。对此,我们将在周四继续讨论。
5444

被折叠的 条评论
为什么被折叠?



