简介
计算机是由很多资源组成的,像我们常见的 CPU、内存、硬盘等。如果我们想要使用这些资源去完成某个计算任务,那么就需要有一个管理者来协调这些资源,操作系统就是这个管理者。
它将硬件和用户隔离开来,屏蔽了底层的复杂性,同时提供了统一的操作标准,简化了程序的编码运行。大伙会经常看到所谓的 内核态
和 用户态
运行模式。其实就是基于上面的理念来划分的,内核态拥有更大的权限和能力,比如可以操作硬件;而用户态所能操作的指令以及访问的内存空间是有限制的,相当于受管制的普通用户。
不过,上面的操作系统主要是考虑到通用性,会有这些限制,如果是其他类型的操作系统,那就得根据它的用途来调动资源了。操作系统的类型主要如下:
- 批处理系统:用户将一批作业提交给操作系统后就不再干预,由操作系统控制它们自动运行。批处理操作系统不具有交互性,它是为了提高 CPU 的利用率而提出的一种操作系统。
- 多道程序分时系统:系统将处理时间划分成一个个的时间片,根据时间片,轮流切换给各个程序使用。由于时间间隔很短,每个用户感觉就像独占计算机一样。
- 实时操作系统:在规定的时间之内快速的对作业做出响应,调度一切可利用的资源完成实时任务。提供及时响应和高可靠性是其主要特点。
现在常见的操作系统采用分时处理
的方式执行任务。常见的操作系统有 Unix、Linux、DOS、Windows、Mac 等。
在操作系统里,主要对以下四种对象进行了抽象管理:进程管理、内存管理、文件管理、I/O 管理。不同的组件彼此独立,即使某个组件故障了也不影响另一个组件的功能。
进程管理
什么是进程?
进程是属于操作系统的一个管理对象,它的内部结构包含了很多东西,不仅有我们自己编写的代码流程,还有当前跟 CPU、内存等硬件交互的状态信息。相当于我们将我们自己想要执行的任务定义好,然后扔给操作系统,操作系统会安排资源来协调完成这个任务。而这一整个过程就被抽象为了进程。
当操作系统将一个程序加载到内存后,会分成下面几个区域:
- 代码区:存放程序编译后生成的机器指令,也就是 CPU 将要执行的指令,它是只读的。
- 数据区:存放已初始化的全局变量、静态变量、常量数据,在 main 开始前就已经分配和初始化的了。
- BBS 区:存放的是未初始化的全局变量和静态变量。
- 栈区:由编译器自动分配释放,存放函数的参数值、返回值和局部变量,在程序运行过程中实时分配和释放,栈区由操作系统自动管理,无须程序员手动管理。
- 堆区:用于程序运行过程中,动态内存的分配;由程序员自己申请释放,较容易产生内存泄漏。
当进程开始运行时,通常会涉及到 5 个状态:
- 创建状态: 当程序启动时,需要到系统创建进程管理块(PCB:Process Control Block)完成资源分配,进入正在创建的状态。
- 就绪状态: 创建状态完成之后,进程已经准备好,此时还没被 CPU 调度到,需等待分配。
- 运行状态: 被 CPU 调度,分配到一定的时间片,开始进入运行状态。
- 阻塞状态: 正在等待某个事件完成(比如 IO 操作),当操作完成后则进入就绪状态,等待 CPU 调度进入运行状态。
- 终止状态: 进程结束或被终止,无法再被执行。
在进程运行期间主要是就绪、运行、阻塞状态这几种状态轮流转换。
上面提到过有一个进程管理块的创建过程,也就是 PCB 的生成。在 Linux 里它是一个 task_struct
的结构体,被称为 进程描述符
。PCB 维护了进程的很多信息,像上面提到过的进程状态这种控制信息,还有描述信息(例如进程的标识符)、资源管理信息(像管理内存数据结构的指针)、CPU 的执行信息等。相当于操作系统对进程的管理都在这个结构里体现。
进程的调度
当进程被创建出来后,之所以还不能被立即执行,是因为操作系统有属于自己的一套原则来决定调度,这个被称之为进程调度。一个好的调度决策是有以下特点的:
- 对于用户来说,响应时间应该是最短的。
- 每小时处理的作业数量应该是最大的,即优秀的调度算法应该提供最大的吞吐量。
- CPU 的利用率应该是 100% 。
- 每个进程能最大公平的获得 CPU 的执行。
进程调度的主要目标是让 CPU 始终处于忙碌状态,并为所有程序提供最短的响应时间。为了实现这一点,必须要适当的中断程序的运行以及唤起其他程序运行。当前的调度分为了两大类:
- 抢先调度:让程序按一定的时间去占有这些资源,时间到了就被迫让出现有资源,给其他的程序轮流使用。
- 非抢占式调度:让程序顺利的完成自己的任务,再把资源腾出来给其他程序使用。
当前的调度算法也有很多,总的可以分为下面几类:
- 先来先服务(FCFS):按先后顺序进行调度。
- 最短作业优先:按执行时间长度来调度。
- 优先级调度:给每个进程定义一个优先级,每次需要进程切换时,找优先级最高的进程进行调度。
- 循环调度(Round Robin):让程序再一个时间片内占有 CPU 资源,时间到了就换下一个,平等对待程序,没有特殊性。
- 多级队列调度:将进程存储到不同的队列中,每个队列都有自己的调度算法,比如按先来先服务,优先级调度等。
- 多级反馈队列:设置多个不同优先级的队列,动态调整进程所在的队列,如果进程使用过多的 CPU 时间,那么它会被移到更低的优先级队列。
上下文切换
当发生了程序调度的时候,势必涉及到当前进程状态的保存,以及另一个即将运行的进程的状态加载。这一过程被称之为上下文切换
。进程的上下文信息是保存在 PCB,也就是进程控制块里的,保存的信息包括了 CPU 寄存器的值、进程状态和内存管理信息。
上下文切换是纯粹的性能开销,因为在此过程中,操作系统不做任何有用的工作,仅仅只是为了切换而切换。所以这一块会很容易就变成瓶颈,导致我们会经常 new 一个新的进程来提高执行效率。
进程的创建
进程被创建出来后都有一个整数标识符,称为进程标识符或 PID。进程也可以通过 fork 创建出具有父子关系的新进程。在传统的 UNIX 系统上,进程调度程序被称为 sched,会被赋予 PID:0
。它在系统启动时会启动 init 进程,init 进程的 PID 为 1。后面 init 进程会负责启动所有系统守护进程和用户登录,成为其他进程的最终父进程。
进程的结束
通过系统调用 exit
,进程可以请求终止运行。当然,也有可能有其他原因导致进程结束,比如系统无法提供必要的系统资源或执行了 kill 命令。当一个进程结束时,它将释放所有的系统资源,包括打开的文件,占用的内存。
但如果是 fork 出来的子进程要结束,它在释放这些资源后,还会残留着一些状态信息在 PCB 里,也就是子进程的 PCB 信息还会留在系统里。只有等到父进程调用 wait 或 waitpid 函数来取走这些信息后才会真正的结束。
如果父进程比子进程先结束,那么此时子进程会交给 init 进程管理。当子进程结束时,即使没有原来的父进程去收走那些残留信息也没关系,因为 init 进程会接手管理。
但如果子进程先结束,此时父进程还没结束,又没有调用 wait/waitpid 来取走相关信息,导致子进程的进程描述符一直残留在系统里,那么就会一直占用着资源。当父进程也结束后,就会变为僵尸进程,此时的僵尸进程还是得交给 init 进程管理,由 init 进程统一清理。
进程间的通信
当前,如果想要实现进程间的数据传输,可以有如下几种方式:
- 管道(Pipe):在缓存中开辟处输出和输入文件流空间,只能适用于父子进程通信。
- 命名管道(FIFO):不同进程的管道通信,通过打开同一个 FIFO 文件进行数据传输。
- 消息队列(MessageQueue):存放在内核中的消息链表,简单高效。
- 共享内存(SharedMemory):允许两个不相关的进程访问同一个逻辑内存,通过映射同一段物理内存来实现,往往需要其他机制辅助,比如信号量。
- 信号量(Semaphore):类似于一个计数器,是一个特殊变量,常用于进程间的同步控制。
- 套接字(Socket):可用于同一台机子的不同进程通信,也可以不同机子的不同进程通信。
- 信号(sinal): 用于通知接收进程某个事件已经发生。
线程
前面提到过进程是操作系统将要执行的一个程序任务,这个任务将会在分配到资源后开始。其中,CPU 是最重要的一个资源,因为它将会执行对应的程序指令。但对于进程来讲,会有个限制,它不能并发的去执行内部的程序指令,一旦进程在执行过程中被阻塞(比如读取硬盘数据),那么只能挂起等待了。
所以,如果在进程里面再抽象出一层,让 CPU 的执行粒度再细化一层,那么进程就可以并发的执行指令了。这就是线程,线程依托于进程存在,操作系统在分配资源时仍以进程为单位,只不过到了执行单元,也就是调度粒度,则是以线程为单位。这样的话,如果一个进程里创建出了多个线程,那么这多个线程就可以并发执行了,特别是对于多核操作系统,将更能发挥出它的作用。
在同一个进程里的多个线程是共享资源的,比如数据段、代码段等。与进程相比,创建线程所需要的时间将更少,占用的资源、通信的成本也比较少,所以线程被称之为轻量级进程。多线程的存在将增强系统的吞吐量,增加单位时间内的作业处理量。
一般的,用户可以调用操作系统的 api 去创建线程,并且允许由用户自己去创建、管理、销毁,这种线程叫用户态线程。如果操作系统只支持进程
这一层次调度的话,那么这种多线程实际上是属于多对一模式,也就是看起来有多线程概念,实际上某个线程阻塞的话,那么其他线程也会阻塞。
如果操作系统支持到线程
这一层次的调度,那么此时操作系统需要在内核态也创建出属于内核管理的线程,然后将用户态线程和内核态线程绑定,当内核态线程获取到 cpu 的执行资源时,就可以执行对应绑定的用户态线程了。
这种绑定过程可以有一对一模型,也可以有多对多模型,但现在很多操作系统都是多对多模型,因为不可能让用户无限的创建线程来一对一绑定,像 Linux 操作系统就是多对多模型。
内存管理
现代计算机体系是一个为数据处理而设计的结构(冯·诺依曼体系结构),执行指令和数据会同时存放在存储器里。这里的存储器包括了靠近 CPU 的高速缓存(cache),也包括了我们熟悉的内存条这种主存储器(RAM);当然,还有像磁盘这种廉价但速度较慢的外存储器。通过这些存储器的分工合作,使得程序具备长期记忆、快速运算的特点。
在早期的操作系统里,物理内存都是裸奔在 CPU 面前的,也就是程序可以直接操作物理内存。而内存的分配方式采用的是连续分配管理,也就是用户程序将会得到一段连续的地址空间,主要有单一连续和分区式分配方式:
- 单一连续分配:分为系统区和用户区,系统区是操作系统使用,用户区给用户程序使用。用户程序加载到内存时会一次性分配所需内存,并一直占用,直到程序结束。
- 固定分区分配:将内存空间划分为若干个固定大小的连续区域,当程序需要分配内存时,会从一张分区说明表中查找未使用且大小合适的区域来分配。
- 动态分区分配:上面划分的区域大小将不再固定,是动态划分的,当程序释放后比较容易出现外部碎片,需要采用
紧缩技术
合并这些碎片。
虚拟地址
上面这种直接访问物理内存的方式会让程序变得很脆弱,很容易就出现访问冲突和内存碎片。为此,操作系统提供了一种机制,将程序要访问的地址和真实的物理地址进行了隔离,抽象出了面向程序的虚拟地址空间。
当程序运行起来后,每个程序都有属于自己的虚拟地址空间,这种虚拟地址空间的好处就在于每个程序所看到的内存地址都是对自己负责的,跟其他程序互不干扰,不用担心冲突的问题。而且一开始并不会分配真正的物理内存,只有当 CPU 需要操作这个虚拟地址的时候,才会通过 一个内存管理单元(MMU)来映射真正的物理地址。
上面这种抽象出虚拟地址的方式,使得程序看到的地址空间将不再需要和底层的物理内存地址空间一一对应,也就是说,一个程序的真实物理地址将可以是离散的,不需要一直连续的,而这更有利于物理内存的高效利用,只需要有一个类似映射关系机制即可。而这种设计主要有分段管理和分页管理。
分段管理
分段管理将程序的虚拟地址空间划分成多个段,这些段的划分依据是根据程序自身的逻辑关系来分配的,例如 main 函数的划分为一个段,库函数的划分一个段,数据划分为一个段。总之,这些段是有逻辑含义,可以由用户自己来指定段名。
为了能建立跟物理内存的映射关系,每当创建出一个段的时候,就会在一个段表里维护当前段的信息,段表里的段信息包括了当前段的索引号:段号;当前段的最大长度:段长以及当前段在物理内存里的起始地址:段基地址。
这样的话,只要虚拟地址是由段号和段内偏移量组成的话,那么就可以推算出物理内存地址了:即根据段号在段表里找到当前段基地址,段基地址加上段内偏移量就可以得到真实的物理地址了:
有上面可以看出,段地址其实是二维的,因为需要我们给出段名(段号)以及段内偏移量来标识一个虚拟地址。
分页管理
分段管理虽然建立起一套映射的机制,但是它包含了逻辑含义,需要用户去指定段名(段号)和段内偏移量。这种管理方式太过于灵活了,如果分配某一个段的段长很大,那么就很容易产生外部内存碎片了。
为此,操作系统提供了分页管理方式,并且对用户是不可见的。它将虚拟地址空间和物理地址空间切割成了一个个固定大小的页(例如在 Linux 里页的大小为 4k),并且它们的映射关系会交由一张页表来维护。
页表里包含了虚拟地址页号和物理地址页号的关联关系,只要我们的虚拟地址包含了页号和页偏移量,那么就可以通过页表找到物理地址页号,根据物理地址页号找到物理内存地址,再加上页偏移量,就得到物理地址了。
多级页表
页的大小是固定的,而虚拟地址空间大小也是固定的,比如在 32 位的 Linux 系统里,一个虚拟地址空间大小将会分配到 4G,如果按每页为 4k 计算,那么对于一个程序来讲,操作系统就要管理 100 多万个页了。如果有多个程序同时运行,那对于操作系统来讲,压力将会很大,效率也提不上去。
所以,操作系统进行了多级管理,例如,将这 100 多万个页先拆分到 1024 个页表里,每个页表管理着 1024 个页项。这样就缩小了管理页数,而且很多时候程序可能也就需要 100 多 M 的地址空间,并不会真正的用上所有的地址空间,所以后面的页表也并不需要去创建出具体的页项,可以等到使用的时候再创建。
段页式管理
分段管理让程序的内存分配有了逻辑含义,能更好的满足用户需求,而分页管理提高了内存的利用率,减少了外部内存碎片。因此将两者结合,也就是现在操作系统常用的段页式内存管理了。
段页式管理会先将程序划分为多个有逻辑意义的段,比如代码段、数据段等。然后在这些段里进行了按页管理的方式。段页式管理的虚拟地址是由段号、段内页号和页内位移组成。当要根据这些信息查找物理地址时,将会经历下面三个过程:
- 根据段号找到该段的页表起始地址
- 根据页表起始地址找到物理页号
- 根据物理页号找到物理内存的基地址,再结合页内位移就可以得到物理地址了。
内存交换
尽管操作系统为内存管理进行了很多优秀的设计,但对于物理内存来讲,它的上限就是固定的,比如内存条大小为 4G,那再怎么优化,也只能使用 4G 的上限。所以,一旦操作系统检测到没有足够的空闲内存分配时,此时就需要启动“交换”机制了。将那些近期不再使用或不会再用的内存交换到硬盘上,这样就能暂时的空闲出更多的物理内存来使用了。如果有些物理内存加载进来后一直没有被修改过,那么就会直接删除,等到下次触发缺页中断,重新加载。
因此,怎么合适的去交换这些空闲内存,也是需要用决策的,当前流行操作系统都是采用段页式管理内存的,所以主要有三种“页面置换”算法:
- 最佳置换算法:选择未来长时间不被访问的或者以后永不使用的页面进行淘汰,这种是一种理想化算法,需要去预估使用时间,所以很难分析得到。
- 先进先出页面置换算法:最先进入内存的页面最先被淘汰,性能较差,因为有些频繁使用的页面会被频繁交换出去,影响性能。
- 最近最少使用置换算法:选择最近且最少使用的页面进行淘汰,性能较好,符合局部性原理,Linux 系统采用的就是这种算法
如果页面交换频繁,那么操作系统势必要花更多的时间来执行这些动作,这在操作系统里称之为抖动颠簸,当产生这种现象的时候,CPU 的利用率将会很低,因为内存不能及时反馈获取。
总结
操作系统的进程管理、内存管理是我们程序运行的基础保障,其解决的问题也是经典的案例,很值得我们深入的学习!