本视频的内容和图来源于B站up主 常青藤中英字幕课程 的图解操作系统原理的视频,声明:本文内容尽量顺着视频讲解思路梳理,行文逻辑可能不够紧凑,如有表述不当或理解偏差,欢迎大家在评论区友好指正,我会及时修正。
并发
早期我们使用计算机时,计算机采用的是批处理系统,执行的工作单元称为作业,但是CPU只有一个,无法同时处理多个任务,于是后面就采用并发技术来处理多个任务,并发指的是我们的多个程序交替使用计算机的CPU去执行,由于我们的CPU速度很快,交替执行的速度也很快,所以看起来我们像是同时在执行任务,并发机制通过轮转分配计算机资源大大提高了我们计算机的运行效率
程序和进程
通俗来说,进程通常杯定义为正在执行的程序。程序是由指令序列及其所需数据组成的静态实体,用于指导CPU完成特定任务(这同样适用于可执行文件)
程序是存储设备中的静态实体,它静待用户点击执行指令,才会被计算机加载运行,要运行程序,首先需将其载入内存--因为这里是CPU获取指令和数据的唯一场所,当其载入内存时,可执行代码所在的区域称为代码段,并且还有一个名为数据段的空间用来存储我们的变量和其它数据,并且还需要额外的空间来存储运行时产生的数据,比如我们的输入,临时结果或变量
文本段和数据段大小不变但是数据段里面的内容是动态变化的,堆和栈是动态伸缩变化,这里展示的是进程的内存的布局而非进程本身
当我们打开多个文本文件时,操作系统会启动两个完全独立的文本编辑器实例,也就是俩个进程,这两个进程的文本段代码是相同的,但是它们的数据段却是不同的,从这里我们就可以理解进程和程序的区别,程序就是一段固定的代码,但是进程却是有很多属性,两个进程可以文本代码段一样但是数据段以及其它属性不一样,记住程序是被动的实体,而进程是主动的实体
再来讨论不同语言类型它们的区别,比如像c语言这种编译之后是可执行文件,也就是程序本身,只涉及一个被动实体,像python这种解释型语言涉及两个被动实体,作为程序的python解释器以及本质上属于文本文件的python代码文件(比如main.py,但是这本身并不是程序,这只是纯文本,计算机无法直接理解),在这种情况下,我们实际要求计算机运行的是解释器程序,当我们运行代码时,会生成一个进程,这个进程的代码段并不是我们的python源代码,而是解释器的可执行代码,我们的源代码可能会加载到堆区然后作为解释器读取和执行的数据对象
在系统内部,通过将进程置入队列来实现对CPU的轮转访问,关键在于,在任何时刻,都只有一个进程能使用CPU,其余进程需要等待,CPU内部包含通用寄存器,指令寄存器,地址寄存器(又称程序计数器),堆栈指针以及状态标志等组件,但是进程切换没有我们想的那么简单,这可能会带来两个问题,因为当我们切换进程时,cpu还残留着上个进程的信息和数据,所以就可能导致一些私密软件信息泄露,所以会导致安全性问题;就算我们假设所有进程是可信的(也就是不会去读取上一个进程残留的数据),当切换进程运行时,新的进程也需要使用CPU,也就是会覆盖上一个进程的信息,这就可能导致再切换回来时不知道进程运行到哪了,所以这就涉及的第二个关键问题是执行正确性。为了解决上述问题,我们会在进程切换时保存进程的状态。假设我们当前正在运行两个进程,先运行第一个进程,然后当切换时,操作系统并非直接覆盖地址寄存器来跳转第二个进程的可执行代码,而是先运行一个特殊例程来捕获CPU的当前状态,就是把当前CPU的状态(CPU状态的副本)保存到某个地方,等到恢复当前进程时再取出来继续执行,操作系统会为每一个进程保存一个副本,每个进程状态的副本一开始都是清空的
通过这种方式,每当进程重新获得CPU控制器时,都会发现寄存器状态与中断发生时完全一致,这一机制成功解决了前面的两个问题,这种捕获进程CPU状态,并恢复另一进程状态的操作叫做上下文切换
现在我们已经知道进程拥有地址空间,程序计数器及寄存器--这些可通俗理解为进程的CPU状态,此外进程还可能包括已打开的文件列表以及分配给它的输入输出设备,这便是我们理解进程的最直观的方式,如图所示,进程并非单一实体,而是包含完整运行环境并与其他进程相互隔离的执行单元
新的问题来了,进程是包含一系列高层实体的完整上下文,我们该如何将它们放入队列,因为进程并非像普通对象那样能直接作为数据结构中的元素使用,那么我们该如何处理这个问题呢,答案就是进程控制块(PCB),这是操作系统中用来追踪每个进程的特殊数据结构,每个进程都会分配一个进程标识符(分配一个编号表示一个唯一的进程)并且进程还具备状态属性(可能处于多种不同状态中的任意一种,上下文切换为每个进程保存的CPU状态就是保存在进程控制块里
在内存管理方面,操作系统还需追踪分配给每个进程的所有内存块,在进程运行时,操作系统必须明确这些边界,不然就可能跳到其他进程的地址空间然后出现问题,此外,当创建新进程时,操作系统需要掌握现有进程的地址空间信息,才能为新进程分配可用的内存区域,因此,进程控制块中必须包含内存管理信息,至少需要记录每个进程地址空间的内存边界,我们还可以在此记录分配给进程的其他资源,比如IO设备列表,需要再次强调:进程控制块并非进程本身,而是进程在系统中的具象化表征,它承载着启动或恢复进程所需的全部数据,同时记录着相关的统计信息,我们放入队列的正是这种具象化表征
特权模式与非特权模式
众所周知,每款计算机处理器都具备特定的操作指令集合,也就是我们常说的指令集,若要让某个软件获得比其他软件更高的系统控制权,那么我们可以设置一组特殊指令,限制部分进程使用这些指令的权限,那么我们如何知道当前进程能否使用这些特殊指令,就需要硬件支持不同的运行模式,我们常常用一个特殊寄存器数值来表示当前的运行模式,比如假设模式1情况下可以使用特殊指令,那么模式0情况下就不能使用,电路设计就是这样的
那么新的问题来了,那应该具体通过什么方式或者由谁来改变这个模式位的值呢,这时我们一般采用在中断中去改变模式位的值,那么我们先来讲中断
中断
中断的用处是什么,我理解的是当发生某件事情并且需要紧急处理的话,比如我们按下键盘或鼠标,就会触发中断,中断如同发送给CPU的信号,用于通知处理器有事件发生,然后让系统对此类事件作出响应,当处理器接收到信号时,会暂停当前进程,并且跳转到某个地方,这个地方就是中断处理程序所在的空间,然后执行这个程序,在这个程序中的代码我们可以拆解为四部分来执行,第一步就是保存当前进程的状态(一般通过压栈把数据压到栈中),第二步就是执行我们对应事件的的程序,第三步就是把压到栈中的数据然后弹出来,最后就是CPU执行名为中断返回的特殊指令,使其能够跳转回被中断的进程并恢复执行
现在让我们回到模式位的话题,中断不仅能改变程序计数器跳转位置,并且也能切换模式位使其处于特权模式,这就意味着位于该内存地址的代码将获得CPU控制权,但是要注意的是我们的一些应用软件也可以处于这段代码,这就可能导致这些应用软件对系统做出一些修改使得其他进程永远无法获得特权模式,现代操作系统的解决方案是确保系统内核常驻该模式,从而通过特权指令保持对计算机的完全掌控,支持运行模式划分的处理器正是为此而生,特权模式专为操作系统内核设计,通常被称为内核模式,受限制模式专为用户应用程序设计,防止其凌驾于操作系统上,因此,这个模式常被称为用户模式
内核模式 由于直接操控某些I/O设备需要特权指令,所以我们只能在内核模式下才能访问这些设备,比如在内核模式下我们才能允许操作名为内存管理单元的特殊硬件组件,并且内核模式允许重新定义中断处理机制,这是因为我们的中断处理程序并不总是固定在某一个固定地址,这就导致如果不适当的重新定义,那么就会导致我们跳转执行代码的地方可能是某个用户的进程,这就会带来明显都安全隐患,重新定义中断处理机制就意味着在每次中断进行时系统总能找到正确的地址并且执行中断处理程序。由于内存管理单元只能在内核模式下修改,这就避免了用户的进程去修改存储中断处理例程的内存区域从而保障了安全性
用户模式 在用户模式下,程序可以执行基础运算任务,包括数据移动与复制,以及一些if判断或者循环操作,但是在某些情况下,我们用户程序也需要使用硬件,比如将某些内容打印到屏幕上,那么这时应用程序是怎么做到的呢,操作系统通过库函数的形式(也就是API函数库)为用户程序访问硬件资源提供了一系列接口,这些函数被称为系统调用,是用户程序与操作系统交互的关键桥梁。但是这就似乎有一些矛盾,明明前面我们才说用户程序在用户模式下运行,无法操作硬件,但是现在又说可以操作硬件,不是矛盾了吗,其实这并不矛盾,因为这些库函数里面的实现也是触发中断,然后进入内核模式,使得能够执行特权指令操作硬件,由此可见,中断不仅可由硬件触发,也能通过软件方式发生。当必要的特权指令执行完毕后,需将控制权交还给被中断的进程,并且修改模式位,使程序按预期在用户模式下恢复执行,不同架构实现这一过程的方式各有差异
系统调用最重要的优势在于它为开发者提供了硬件抽象层,这就让我们无需理解底层硬件的电路结构是如何实现的,使得我们能够专注于代码逻辑;另一个显著优势在于安全性;第三个优势在于可移植性,只需要多个平台遵循特定规范,同一份源代码就能在不同系统中完成编译
但是系统调用也存在一定的缺陷,首先就是性能开销,系统调用所依赖的中断机制,在性能方面代价不菲,这些操作需要额外的步骤,比如上下文切换,而具备缓存等特性的现代架构,可能进一步加剧性能的损耗,此外,我们不能保证我们的调用会立即执行,因为这时操作系统可能执行某些优先级更高的程序,从某种角度来看,系统调用可被视为一种陷进机制,硬件抽象如同诱饵,诱使用户程序将控制权交还给操作系统;第二个弊端是平台依赖性,尽管许多平台致力于实现兼容性,但仍存在某些特殊变体
系统调用的成功,以至于某些架构通过提供专用指令简化了调用流程,无需手动配置中断,在许多架构中都能看到SYSCALL指令以及对应的特权指令SYSRET
当操作系统依赖用户程序的协作来重新获得控制权并执行管理任务时,它被归为非抢占式或协作式操作系统,这类通用操作系统现已不再使用,因为它们有重大缺陷,比如在我们的应用程序中执行一个没有系统调用的死循环那么就导致系统一直无法拿回控制权导致系统利用效率下降并且使得其他进程得不到资源,解决方案需要借助定时器来实现,当定时器超时时,会触发硬件中断,将定时器内置CPU中,并且只能由特权指令操控。在将CPU分配给用户进程之前,操作系统会预先设置定时器
当进程运行时,定时器开始倒计时,每次切换进程就会重置这个计数器,如果计数器减到0了还没有切换进程的话也就是意味着某个进程长长时间没有进行系统调用,那么这时候会进入关于定时器的中断来强行终止进程,将控制权交换给操作系统。这一机制使内核模式能够完全控制
每个进程可使用的CPU时间。当用户重新试图直接操控硬件,硬件会立即触发中断,并将控制权移交操作系统,由系统处理用户程序的异常行为,通常将直接终止该进程的执行
中断是CPU的核心功能,因此更准确的说法是:内核模式赋予了操作系统对CPU使用的完全控制权,当硬件架构支持这些特性且操作系统能够有效运用它们时,我们便实现了抢占式操作系统
许多人可能会疑惑:除了操作系统之外,其他软件是否也能在内核模式下运行,确实可以。比如当新款显卡发布时,操作系统可能无法直接控制它,并且因为操作系统的诞生早于硬件的发布,所以操作系统开发者不可能为所有硬件设备编写代码,这项工作就由硬件制造商来完成,解决方法就是驱动程序--这种专门用于控制特定硬件的软件,由于控制硬件需要执行特权指令,驱动程序必须在内核模式下运行这就需要,当用户程序需要访问硬件时,操作系统会调用相应的驱动程序。然而这种技术存在一个严重缺陷,任何在内核模式下运行的代码都能访问特权指令,这使得操作系统无法将其与其他进程--甚至与操作系统自身--完全隔离开来
因此,所有在内核模式下运行的代码共享一个地址空间,若驱动程序写入错误的内存地址,就会导致操作系统关键数据遭到破坏,那么操作系统就崩溃了,用户的应用程序就无法访问硬件,那么整个系统就会崩溃,比如就会蓝屏
但是电子游戏的反作弊系统和恶意软件检测工具等应用也必须在内核模式下运行,这就超出了设备驱动的范畴,为什么反作弊系统和恶意软件检测工具必须在内核模式下运行,因为当我们试图作弊时一般都会改变硬件或者改变进程的地址空间里的数据,所以这些系统必须在内核模式下进行检测才能检测出异常,但是这也可能带来严重的后果,比如杀毒工具一旦损坏了配置文件,那么就很有可能导致操作系统崩溃进而导致整个系统崩溃,再比如反作弊系统也有可能修改系统文件导致电脑蓝屏
进程之间的通信
如今我们模块化的应用程序就是依靠进程间通信机制才能构建出,我们将探讨默认相互隔离的进程如何通过信息交互实现与其他进程的交互,前面我们讲到进程不仅仅是程序本身,它是程序运行所需的完整环境,然而这种隔离性也带来了显著缺陷,实际应用中经常需要多个进程协同工作,若进程完全隔离,它们将无法实现协作
因此,我们可以根据进程是否与其他进程协作来进行分类,进程可分为独立进程与协作进程两类,两个共享数据的进程显然属于协作进程,但实现进程协作还存在其他考量因素,例如为了提升计算速度,在支持并行处理的系统中,可将复杂任务拆分为多个能同时执行的子任务,从而缩短完成整体任务所需的时间,或出于模块化设计的考量,采用模块化架构,可将系统功能拆分为多个独立进程,但注意无论哪种方式,进程间都需要通过特定机制协调各自行为才能确保系统正常运行。协作进程需要通过进程间通信机制实现数据交换,使彼此能够相互发送和接收信息
进程间通信(IPC)存在两种基本模型:共享内存与消息传递
共享内存 共享内存机制,顾名思义,该机制允许多个进程直接共享同一块内存空间,在之前我们提到过,在进程创建时,操作系统会为其分配独有的空间,一旦其他进程尝试访问别的的进程的空间,那么操作系统就会终止这个进程。因此,如果想要实现共享地址,操作系统要求它们必须共同解除这项限制,通常,共享地址空间通过系统调用创建,并驻留在创建它的进程地址空间,任何其他想要通过这块空间进行通信的进程同样需要通过系统调用将其映射到自身的地址空间中,这里有个细节是:**当操作系统在授予共享内存区域后,便不再参与管理这片空间。**这就需要两个通信的进程之间严格按照某种约定发送或接收数据,不然就可能导致数据解析错误或者导致单个或全部进程运行失败,并且各进程还需确保不同同时写入一块位置,不然可能会造成竞态问题 共享内存机制的一个例子就是浏览器,当我们打开一个网页时会同时创建几个进程,比如一个进程负责用户输入输出,另一个进程负责渲染,二者通过共享内存来接收需要的数据,这样做的好处是当某个进程崩溃时,别的进程不受影响
消息传递
共享内存并非进程通信的最佳方案,因为这种方式不仅容易出错,还可能给开发人员带来超出预期的编程负担。消息传递就是由操作系统提供通信机制,使进程能在不同地址空间下实现交互。使进程能在不同地址空间下实现交互与行为同步。
地址空间可保持隔离,进程通过消息传递而非内存写入来实现通信,常用的消息传递机制包括管道,套接字和远程过程调用
假设存在进程A与进程B,若进程A需向进程B发送消息,必须通过系统调用请求操作系统建立与B的通信链路,该链路构成消息传输的逻辑通道,实际上,操作系统内核会在自身地址空间内创建队列作为“邮箱”
该队列的行为会根据通信需求动态调整,例如当需要限制邮箱容量时,我们可能采用异步通信替代同步通信,或使用带缓冲的队列,为实现全双工通信,可通过双队列结构实现邮箱功能,使两个进程能同时互传信息而互不干扰
由于邮箱位于操作系统地址空间内,进程无法直接向邮箱发送数据或从邮箱接收数据,所以需要操作系统为其提供系统调用,至少应该提供两种系统调用:发送和接收。当A进程想要发送数据到B时,操作系统会通过系统调用通知操作系统执行传递操作,同样的,当需要接收信息时,进程也是通过对应的系统调用向操作系统发起查询请求
首先引入共享邮箱概念的操作系统是Mach,这个系统将进程视为任务,并为偶像赋予了一个特定名称--端口(port),需要注意的是,消息网速发送到端口而非直接传递给进程的。这种区分至关重要,因为两个进程之间可以建立多个通信链路,并非所有端口都严格关联两个进程,一个进程可以保持开放端口,以接收来自任意其他进程的消息,这类端口我们称之为监听端口(在计算机网络中,当想要进行TCP连接是,就需要有一方保持一个监听端口用来建立TCP连接)
可见,消息传递机制具有显著优势,由于无需共享地址空间,进程甚至可以在不同机器间实现通信,当然这需要更复杂的底层实现,例如通过网络功能建立计算机间的连接,这涉及对网络接口卡等组件进行驱动程序级别的编程,但如果在操作系统层面正确实现,对开发者而言整个过程应该是无缝的,不同机器间的进程消息传递其工作方式与同一台机器上的进程间通信完全相同
在网络通信时,IP地址相当于对应的机器,端口号则是用来接收或传输信息的邮箱
遗憾的是,消息传递系统确实存在一些缺点,因为每次进行传递都要进行系统调用,这就导致在性能方面会造成显著开销,而共享内存方案则不受此限制,使用共享内存时,仅需在创建共享区域及建立进程关联时执行系统调用,完成这项步骤后,进程即可像访问自身地址空间那样直接读写共享区域,无需再执行系统调用,这使得通信速度极快,本质上达到了直接内存访问的速率,共享内存的运行效率确实更高,但是99%的情况消息传递就足够
线程
我们将探索线程的原理以及如何通过并发机制优化计算机资源利用率
在单CPU环境中,若存在多个运行中的进程,那么操作系统就会通过轮转调度机制,让这项进程共享CPU资源,这种切换速度之快,足以让用户产生所有进程都在同步运行的错觉,这种技术称为并发处理,它与CPU共同调度造就了我们日常使用的流畅多任务操作体验,但多任务处理只是并发与CPU调度最广为人知的作用,另一个不太显见却同样关键的作用--最大化利用计算机资源
当前我们需要明确:虽然进程常被定义为“正在执行的程序”,但是这并不意味着进程总是在占用着CPU,当进程在等待IO操作时,这时CPU是空闲的,这时更合理的做法就是将CPU分配给其他已就绪,能够执行指令的进程,这正是并发机制至关重要的第二个原因,这不仅仅是同时运行多个进程那么简单,更重要的是,当一个进程无法使用CPU时,系统能立即将这些空闲资源分配给其他进程可运行的进程
在本系列中,我们始终将进程视为执行的基本单元,因此我们一直假设,在调度层面没有比进程更基础的执行单元,导致在单个进程内部实现多个执行实体的并发操作(翻译一下就是在传统进程模式下,同一进程内的任务,比如进程内要执行两个函数,无法实现异步执行),这是因为每个进程只有一个程序计数器,这就导致两个函数不可能并发的执行(如果要并发执行,需要两个程序计数器)
为什么我们在这里讨论进程内部的并发执行呢,因为单个进程的并发处理能发挥巨大作用。假设有个服务器在3000端口接收请求,并且这个服务器的唯一功能就是返回给用户图片,当单个用户请求时,服务器会接收请求并且通过从磁盘搜索并读取图片进行处理,待图片加载到内存后,将图片发送回客户端完成响应,那如果用户多了起来,响应时间会很长,并且CPU效率会很低(因为大部分时间都花费在从磁盘搜索并读取图片)
而原本这些时间可以处理其他客户端的请求,当一个任务因其他任务正在运行而无法执行时,即使这两个任务没有依赖,这种现象便称为阻塞效应
长久以来,解决方案如下:存在一个监听请求的主进程,我们称之为监听进程,每当客户端发送请求时,监听进程不会直接处理,而是创建一个全新的进程来专门服务该客户端,这样一来,当其他客户端发送请求时,无需等待前一个请求完全处理完毕,因为监听接收请求的进程与服务前一个客户端的进程是并发运行的
虽然这种方法很巧妙,但并非完美无缺。每个进程都像是独立运行的封闭环境,拥有包括地址空间在内的专属属性。为每个客户端创建完整进程的方案,在数百客户端或许可行,但当规模扩大至数千客户端时,内存使用效率将显著降低。此外,创建新进程本身也是一项开销可观的操作,这会消耗处理器时间。此外,若服务器需要维护全局状态,所有子进程都必须保持同步以追踪状态变化,这就需要通过进程间通信来实现,从而增加了实现的复杂度
既然进程这一概念非常实用,我们无法直接摒弃它,但可以稍作调整,正如前面所说的,主要限制在于单一程序计数器无法让进程控制块同时追踪多个任务,解决方案是取消程序计数器与进程的直接关联,转而将程序计数器分配给进程内每个需要并发执行的内部可执行体,这些内部可执行实体就是我们所说的进程
通过打破每个进程只能拥有单一程序计数器的限制,现在当同一进程内的代码需要并发执行时,我们就能创建新进程,这样既解决了阻塞问题,又无需创建新进程
虽然线程共享所属进程的完整地址空间,但无法共享CPU状态,由于线程会被中断以便将CPU分配给其他进程,我们需要记录每个线程的状态,以便在CPU重新分配时,能够恢复其执行状态。因此若每个线程都拥有独立的程序计数器,则必须同时配备专属的寄存器组,状态标志位,累加器等
并且需要注意的是,这其中还包括独立的栈指针,每个线程拥有独立栈空间,并不意味着它们无法互相读写对方的数据,当我们把地址空间视为进程的属性时,进程内的空间都是共享的,所有栈都位于共享地址空间内,除非有充分把握,否则最好避免直接访问其他线程的栈空间。若同一进程内的线程需要通过共享内存进行通信,堆内存是更合适的选择。但即便使用堆内存,仍需保持谨慎。当一个线程正在读取某块内存时,若另一个线程同时进行写入操作,可能会造成灾难性后果,因此硬件层面直接内置了相应的同步机制
此外,由于我们并未完全摈弃进程的概念,不同进程的地址空间仍然保持隔离,因此,虽然同源进程默认共享内存,但若不同进程的线程需要共享数据,则必须通过进程间通信实现
采用多线程方案替代传统的多进程方案时,需要重新设计进程控制块的实现,最直接的解决方案是引入第二组结构体来表示线程。虽然具体实现因平台而异,但最普遍的做法是采用linux内核的方案,用名为task的同一结构体来表示进程和线程
它巧妙的淡化了进程与线程的界限。因此,与其将并发描述为进程,线程或二者交替获取CPU资源,不如直接将其表述为任务间的交替执行。这种实现方式显然更符合直觉,也就是对线程和进程使用统一调度器
无论采用何种方式,每个进程都必须至少包含一个主线程。操作系统在创建新进程时,会自动生成该线程。因此,即便进程不依赖多线程机制,操作系统仍可通过调度其主线程来为其分配CPU资源
进程初始线程数量是否固定,以及能否动态创建线程,取决于操作系统的具体实现方式。当前主流操作系统普遍采用动态线程管理机制。进程初始均为单线程模式吗,运行时按需创建额外线程。这自然增加了操作系统内部实现的复杂度,因为系统必须提供动态创建线程的调用接口。但动态线程创建仍是首选方案,因为在多数场景下,编译阶段无法预知运行时实际需要的线程数量。并且我们还要注意到,若主线程执行结束,即使其他派生线程(也就是子线程)仍在运行,也会被全部强行中止
采用多线程方案后,每当新客户端接入时,我们只需创建一个对应线程,这样既能实现与多进程方案相当的效果,又能显著降低内存占用。此外使用线程还能带来性能提升,因为创建线程所需的系统调用比创建进程的速度快
线程的边界
线程并非函数,线程执行所需的代码都来源于进程内存布局的文本段中。线程本身并不包含代码,它通过程序计数器指向代码,这意味着多个线程可以指向完全相同的可执行代码。在服务器示例中,由于所有请求都需要以相同方式处理,所有处理线程并不包含独立函数,而是指向内存中同一个函数,那么多个线程同时访问该内存区域是否危险,我们其实不怎么需要担心,因为可执行代码和常量都存储在文本段与数据段,这两个区域永远不会被写入,因此多个线程访问同一段可执行代码是完全安全的
为何在C这类底层语言中,创建线程需要以函数指针作为参数,这个参数实际上传递的是线程将要指向的代码起始段内存地址
这里讨论的内容,即使对于单核处理器系统也同样适用
并行
前面我们说过并发处理能够看起来同时处理多个任务,并且也能在其他进程因为IO操作而没有使用CPU时将CPU分给其他能够执行的任务。但是当并发任务数量过多时,系统在某个临界点就可以会出现卡顿,因为任务多起来后CPU执行同一任务的间隔会越来越长,使得用户看起来就是卡顿 解决这种卡顿的方法有三种,第一种就是提高CPU的性能,前面我们说过卡顿的原因是CPU的执行速度不够快,如果能提升CPU的性能那么就不会那么卡顿,但是如果继续增多又可能出现卡顿,并且CPU的性能提升在近十年已经变得缓慢;第二种解决方案是采用更智能的CPU调度策略,这个后续再讲;第三种方法是增加CPU的数量,也就是增加能够处理任务的核心的数量,一旦数量增加,那么每个核心就可以分别不同的并发处理系统的任务,使得效率大大提升 并行系统的主要优势在于系统的流畅性不依赖于任务间的快速切换,并且同一进程的内的不同线程可以分给不同的核心去做使得效率更高,这也是为何几乎所有现代操作系统都将线程而非进程视为作执行的基本单元 我们需要记住两个核心点,第一核心的数量是固定的,因此创建上千个进程并不意味着能并行执行上千个任务。实际上若系统拥有n个核心,则最多能同时并行处理n个任务,因为线程会竞争资源。由于操作系统的主要目的之一是确保所有线程公平分配CPU资源,因此限制了我们的线程能够并行运行的数量 程序需要并行处理的另一个重要原因是性能,过去并发处理一个时刻只能运行一个任务,而并行处理一个时刻能够运行多个任务,这就导致性能大大提升。并行处理分为两种,一种是数据并行,一种是任务并行。数据并行的核心在于将同一数据集的不同子集分配给多个计算核心并在每个核心上执行相同的操作;任务并行是指将不同的计算任务或线程(而非数据)分配到多个计算核心上执行,换言之,每个线程都在执行独特的运算操作
为何应用程序是操作系统特定的
我们会发现,在某一个操作系统上能够运行的程序而到了另一个操作系统就运行不了,尽管可能底层采用同样的处理器架构,但还是运行不了 事实上,应用程序不仅于硬件架构相关,还与操作系统紧密绑定,这里的紧密绑定是因为我们的软件大量使用的了操作系统给我们提供的系统调用,而系统调用是操作系统定义封装的,这就可能出现当我们将代码复制到别的操作系统上时很可能这个操作系统没有我们需要的系统调用,就算可能系统调用名字和作用一样但是最底层的实现可能不同(比如在这个系统中会将数据存到栈,在另一个系统就可能存到堆),这就导致可能出现我们无法预知的错误 有一套约定规范统称为应用程序二进制接口(ABI),就像API一样,ABI是解决不同工具,不同模块生成的二进制代码,如何在机器上无缝协作的问题 应用程序在不同操作系统上无法运行的原因有很多,这里就不细讲了,感兴趣的自己可以去搜
硬件如何在多任务系统中辅助软件
我们前面讲过在进程切换时需要将当前进程的状态以及一些数据保存起来,这个过程就叫上下文切换,但是这一过程如何在不改变被中断进程状态的前提下实现呢(也就是说我们要执行保存指令,本身就要修改程序计数器,那么当前进程的程序计数器就丢失了,下次切回来不知道从哪运行了),这就需要依赖中断机制,接下来我们讨论基于中断的切换机制
据我们所知,进程可通过两种方式被中断,一种就是软件触发中断将控制权交给操作系统,另一种就是硬件触发中断(比如定时器中断)。当我们切换进程时,需要保存当前进程的状态也包括要保存程序计数器,但是在初始阶段,我们无法通过任何指令来保存程序计数器(因为只要执行指令就需要修改这个值)
所有支持中断的架构都必须提供一种机制,确保被中断进程的状态不会丢失,换言之,硬件至少需要提供基础支持确保软件能够保存被中断进程的程序计数器
处理中断主要有两种方案,请注意,每种架构都有其独特实现方式,这里介绍的只是两种通用方案。第一种方案最好理解,我们可以提供多组寄存器配置使得每个进程都有一个专属的寄存器组,这样每次切换进程只需要找到对应的寄存器组并执行即可,但是缺陷很明显,一旦进程数量很多超过可用寄存器组,那么就不知道怎么做了。实际上我们并不需要无限多组寄存器组,两组寄存器便已足够
若CPU支持操作模式,一组寄存器可由多个用户程序共享,而另一组则仅供操作系统在内核模式下访问
为便于区分,我们将其称为通用寄存器组与特权寄存器组。当中断发生时,会进入内核模式,并且停用通用寄存器组并启用特权寄存器组,这样一来就保证了当前进程的所有信息都被锁定不会被修改,这时中断就会覆盖特权寄存器组的程序计数器去执行一些指令,然后操作系统就可用通过特殊特权指令访问被禁用的寄存器,然后就可以通过一系列指令将通用寄存器里的值保存进被中断进程的进程控制块里,接着读取另一个进程的PCB,并执行逆向操作流程
最后若采用抢占式调度,需通过特权指令设置定时器,随后执行SysRet特权指令,在此类架构中,指令执行双重操作,既切换活动寄存器组,又使系统返回用户模式
但遗憾的是,这种方法并不常见
接下来我们讨论其他方案,通过硬件实现流程的部分自动化,由于不同架构的实现方法各异,但通常通过两种方式实现。第一种方式是硬件通过自动化特定步骤来避免寄存器组的重复配置,如同直接内置于CPU的硬连接指令般工作,一种实现方式是通过固化在CPU中的内存地址或专用寄存器确保操作系统栈指针始终可被CPU访问,中断发生时,程序计数器和栈指针等关键寄存器会在被修改前自动压入操作系统栈,后面的保存职责便交由软件承担
还有些架构的设计更为激进,它们几乎完全通过硬件来执行上下文切换,这通常通过一个指向操作系统管理内存区域的段寄存器来实现,该区域存放特定的数据结构,当中断发生时,CPU会自动将所有寄存器的完整内容复制到这个段中
CPU调度
FCFS(先来先服务)算法
下图是操作系统的调度准则,主要有这几个指标:CPU利用率,吞吐量,周转时间,等待时间,响应时间
在进行调度时,我们通常采用队列这种数据结构来进行调度,而队列中排队的元素并不是进程本身,而是进程控制块,在后续进行调度时我们还是统称为进程
最简单的策略就是先来先服务策略,这个策略就是先准备的进程先占用CPU,并且只有这个进程执行完毕后才能轮到其他进程,听起来很合理,但是我们忽略了很多东西,其中关键的就是进程并不总是占用CPU的,当进程执行IO操作或者等待IO操作时这时CPU是空闲的
我们可以看到图中的空隙就是处理器等待IO操作完成后才能继续执行后续指令的时间段,这些间隙并非例外情况,而是常态现象,至少在冯诺依曼架构的系统中(绝大部分计算机设备的底层架构)情况正是如此
在这里我们将CPU的执行称为CPU突发,等待IO称为IO突发,进程往往都是在CPU突发和IO突发之间来执行的,CPU突发的持续时间经过大量实测发现总体上遵循可预测的分布规律,其分布形态如图所示,这个图作为后面考量调度策略的重要参考
前面我们提到过,CPU调度的核心目标之一,就是进程间共享CPU的方式,在确保进程高速运行的同时,尽可能维持CPU处于忙碌状态。我们要注意进程的CPU突发从来不会在前一个突发结束后立即开始,中间始终存在一个微小的附加进程,这个进程在下图用灰色标了出来,其实这个微小进程是调度进程,这个进程主要就是保存当前被中断上下文然后进行调度
现在我们就明白了调度器不应该将CPU分配给进程直至完全终止,而应仅分配至当前CPU突发结束为止,当然实现这种机制会使调度器的设计稍显复杂
现在我们需要考虑进程可能处于不同状态的情况,如下图所示
这五种状态分别为新建态,准备态,运行态,等待态,终止态,尽管可能在不同的地方描述不同但是基本都是这几种状态,这几种状态,每一种状态都会有一个队列,如下图所示
当运行中的进程进入IO等待状态且CPU空闲时,调度器会介入将CPU分配给就绪队列首端的进程。尽管运行中的进程不存储在任何队列中,这使得调度器能够在中断发生时,将CPU状态保存至进程控制块中。保存的状态使得进程能够在后续恢复执行。随后,调度器将该进程控制块置入等待队列,随后在处理完被中断的进程后,调度器从就绪队列首部获取下一进程的进程控制块,将其标记为新的运行进程,恢复其原有的CPU状态,若处理器支持,调度器还需在将控制权移交进程前切换至用户模式。换言之,调度器通过特定调度策略管理就绪队列,决定下一个使用CPU的进程,而调度程序则负责执行调度器作出的决策
调度程序引发的CPU冲突(占用)不可避免,也就是我们前面图中的灰色区域,这种延迟被称为调度延迟,由于这种延迟具有隐含性,进程间的调度延迟通常不会被明确标示
先来先服务的调度策略在某些时候并不能很好的利用CPU,前面我们展示了一个CPU突发的时间图,尽管CPU突发时间长的进程很少,但是也是存在的
我们将IO冲突时间长的进程称为IO密集型,CPU冲突时间长的进程称为CPU密集型,接下来我们分析先来先服务的一种情况,假设存在好几个IO密集型进程和一个CPU密集型进程,在执行一段时间后我们会发现IO密集型进程会花很多时间等待那个CPU密集型的进程,这就出现了车队效应
所有进程都得等着那个大块头进程离开CPU,这种效应会导致CPU和设备利用率下降--如果允许较短进程优先执行,本可获得更高利用率,这就引出了我们下面的最短作业优先调度算法
最短优先算法
最短优先算法会将CPU分配给下一个CPU突发时间最短的进程,如果两个进程的突发时间相同,则采用先来先服务原则,需要说明的是,更准确的名称应为最短下次CPU突发优先调度,因为该算法优先考虑的是进程的下次CPU突发时长,而非进程总长度
该算法可通过优先级队列实现,其优先级由等待进程中下一个CPU突发的时长决定,这使得短CPU突发进程能够超越高CPU占用进程。该算法已被证明具有最优先,能为给定进程集提供最小的平均等待时间。但最短作业优先算法存在一个显著缺陷,就是我们无法预测进程的CPU占用时间,也就是无法预测未来该进程要占用多久CPU时间
为了使用这个算法,我们只能根据历史CPU突发记录进行估算,为进行估算,我们采用历史CPU突发时长的指数平均值进行计算
这里不做公式的讲解,使用这种估算的方法能够提供相当不错的近似效果
但这个调度程序还有一个关键决策我们尚未讨论,假设一个进程的CPU突发时间较长,并且正在运行,这时如果有一个突发时间较短的进程进入了队列,并且这个时间比正在运行进程的剩余运行时间还要短,那么这时是否切换进程,运行那个突发时间较短的进程呢。如果运行切换,我们面对的是抢占式调度算法,如果不允许,那就是非抢占调度算法,抢占和非抢占的区别并非无足轻重,因为进程的执行顺序会因最短作业优先算法采用抢占式或非抢占式模式而发生改变
既然我们已经理解了抢占机制,现在就来探讨为何它成为现代系统的默认选择。当进行非抢占模式时,我们需要等待当前进程主动释放CPU,那如果它一直不释放呢,无限循环在计算机科学中并不罕见,如果在无限循环内进程一直不执行系统调用交还CPU控制权,这会造成严重问题
请记住,并非是一种让CPU在进程间快速切换执行的技术,从而营造出所有进程的同时运行的假象。然而在非抢占式系统中,若某个CPU密集型进程无限期占用CPU,那么其它进程在我们看来就会卡顿,这时其它进程就会饥饿,饥饿是指其它进程大量占用CPU时间导致当前进程一直无法执行的情况
但是采用抢占式就不会造成饥饿了吗,也可能会。例如在最短作业优先调度中,预计需要长CPU突发的进程可能被无限期滞留在队列中,若持续有较短CPU突发的进程加入队列,该进程获得CPU时间的唯一机会,就是队列中没有等待的进程,但是实际情况中,这种情况几乎不可能发生,因为通常有数百个进程在竞争CPU时间。因此,饥饿现象不仅与进程执行时长相关,更是调度策略本身导致的必然结果。虽然最短作业优先在CPU利用率和等待时间方面效率很高,但通用操作系统还需要考虑其它调度标准
轮转算法
轮转算法就是抢占式调度的典型范例,轮转调度算法与先来先服务调度算法有相似之处,但通过引入抢占机制实现了进程间的切换。系统设定了一个称为时间片的小时间单位,比如为100ms,设置完后运行可能会出现下面两种情况之一:第一种是运行时间小于时间片,此时,进程主动释放CPU控制权,随后调度程序将继续处理就绪队列中的下一个进程。若当前运行进程的CPU执行时间超过单个时间片,计时器触发中断,操作系统随即执行上下文切换,并将该进程移至就绪队列末尾,随后从就绪队列中选取下一个待执行进程。需要重点说明的是,计数器是硬件设备,这意味着只有当硬件支持时才能实现抢占式调度 当进程被抢占时,它不会进入等待态,而是进入准备态 若时间片分配过大,多数进程将在被抢占前进入IO操作阶段,导致退化为先来先服务模式。如果设置过短,那么就会频繁进行上下文切换,系统开销会增大,这就可能导致CPU在进程间切换所耗费的时间将超过实际任务的时间 每个进程按轮转方式获得固定的时间片。当一个进程的时间片用尽后,系统会立即切换到下个一进程继续执行,若这种切换足够迅速,就会营造出所有进程同时运行的假象。唯一的问题在于,所有计算机处理器都存在速度上限,这就进程数量达到某个临界点还是会出现卡顿 针对这个问题,存在三种可能的解决方案:提升处理器速度,使上下文切换更快速,这样就能在采用更小时间片的同时避免堆周转时间产生负面影响。或者采用多处理器架构,使进程能够并行运行并分担工作负载。然而这两种方案都需要硬件升级,但硬件升级并非总是可行的方案。但第三种解决方案仅需通过更智能的CPU调度器就能实现,无需硬件改动 在多个进程中,有些进程是我们需要流畅的,比如游戏,音乐视频等,有些则不必要那么流程,比如邮件,如果我们为合适的进程分配更高优先级,就能缩短它们的响应时间,这肯定会导致一些进程分配的时间变少了,但是那些进程(比如邮箱)晚发送半秒钟影响真的很大吗
优先级调度
游戏机调度通过优先级队列实现,每个进程被分配一个代表其优先级的整数值
数值大小与优先级高低的关系同样取决于具体系统
混合使用多种调度策略也是常见做法,比如可将优先级调度与轮转调度结合,但是优先级调度有一个故有问题:饥饿现象,当大量的高优先级的进程涌入时,低优先级的进程一直得不到分配,就导致饥饿
针对低优先级进程可能被无限期阻塞的问题,“老化”机制提供了一种解决方案。老化机制通过逐步提升长时间等待进程的优先级来实现这一目标,比如每秒提高一个优先级
所有进程可被置于单一队列进行管理,这样每次搜索最高优先级的时间可能就是O(N)。实践中,为不同优先级分别设立独立队列通常更为便捷。这种方法被称为多级队列调度。此时需要在各队列之间进行调度决策,如下图所示,调度程序会直接选取最高优先级非空队列中的进程执行
为避免低优先级陷入饥饿,我们可以采用轮转方式调度就绪队列,但会根据队列优先级连续分配不同数量进程。在队列间进行时间片轮转
多级队列调度通常根据进程类型将其划分至多个独立队列,从而更有效低调度具有不同响应时间要求的进程。这种设计甚至允许为每个队列采用不同的调度算法,比如其中一个采用先来先服务,另一个采用轮转调度
还有一个关键问题需要解决,在典型的多级队列调度算法中,进程进入系统时会被永久分配到某个队列。这种设计优势在于开销较小,但缺乏灵活性,因为进程在其生命周期内的行为模式并非一成不变,就比如邮箱软件,在后台静默运行,但是一旦切换到前台就需要立即响应
多级反馈队列调度算法正是为适应这类行为动态变化而设计的,其工作机制如下:若进程在单个时间片内完成其CPU执行周期,则在IO操作后返回原队列,若进程所需CPU时间超过当前时间片限额,则将其移至较低优先级队列,较低优先级有更长的时间片时间。若进程能在比高优先级队列时间片更短的周期内完成CPU执行(也就是更接近密集型IO的进程),那么就被移回高优先级队列。随着时间的推移,具有较短CPU执行周期的进程将倾向于驻留在较高优先级队列中。相比之下,CPU密集型进程将逐渐下沉至较低优先级队列,系统正是通过这种方式,根据进程行为自动调整其优先级。需要注意的是,这仅是多重反馈队列调度的一种实现方式,它存在多种变体,实现方法不唯一
这里展示的IO队列是经过简化的版本,实际上,输入输出子系统的运行机制要复杂得多。进入IO队列的进程,其完成顺序可能与到达顺序不一致。比如需要使用显卡的进程不必等待正在使用磁盘的进程完成操作。不过这些具体细节都属于操作系统另一组件--IO调度器
竞态
我们在初学编程的时候,可能会以为我们编写的一行对应一条CPU指令,有些指令可能是这样,但是大部分指令都是对应多条语句,这正是我们理解竞态的关键概念,大多数操作并非原子操作 我们来看一个例子,假设我们的现在有两个线程,一个线程负责将一些字符串写到数组中(比如hello world),而另一个线程则负责从数组中读取字符串然后打印到显示屏上,我们假设我们的CPU是单核的,不能同一时刻执行多个线程。这时可能会出现这样的情况,我们写入的线程写了一部分内容,比如只写了hello到数组中,而这时切换到了读进程,那么读进程读出来的字符串就是不完整的,这就是一个典型的竞态问题 竞态条件是一种软件缺陷,当程序执行结果取决于多个线程或进程访问修改共享资源的不可预测顺序时,就会发生这种错误,竞态条件通常是抢占式操作系统的副作用,这正是此类缺陷难以预测的关键所在 但是现在操作系统给我们提供来一些工具能够很大程度上避免这个问题,这些工具和方法我们后续再讲
预防进程越界
通过前面的学习我们知道每个进程都会有自己独特的地址空间,并且各个进程之间不能互相访问对方的内存区域,不然就会造成很严重的后果。那么我们是如何预防进程越界访问呢,通过硬件
我们会在CPU增加两个新的特殊寄存器,通过这两个特殊寄存器来实现内存边界保护,这两个寄存器分别为基址寄存器和界限寄存器,基址寄存器指向进程可访问的最小物理内存地址,界限寄存器定义了该进程可寻址范围的大小,借助这两个寄存器,我们可以验证进程试图访问的地址是否在其自身地址空间范围内
下图展示了简化后的电路图,这个图能够帮助我们理解我们是如何通过硬件来帮助我们避免越界的
当硬件成功实现功能后,操作系统的唯一职责是在将CPU调度给用户进程牵,正确设置基址寄存器和界限寄存器的数值。这两个寄存器被我们设置为特权寄存器,仅允许操作系统进行修改
当CPU从一个进程切换至另一个进程时,操作系统不仅要保存被中断进程的状态并恢复新进程的状态,还需更新内存边界--即调整基址与界限寄存器,使其与新进程的地址空间相匹配。当所有设置准备就绪后,操作系统会将CPU切换至用户模式,从而允许用户进程开始运行。这就是操作系统在硬件支持下防止用户进程相互访问内存的基本原理
本文同步发布于 CSDN 与掘金,作者为同一人,原创内容,转载请注明出处。csdn账号名称为 .普通人