资料来源:《操作系统概念》
绪论部分
一些名词解释
硬件:计算机系统的基本计算资源。
应用程序:用户要使用计算资源解决问题,应用程序决定了使用资源的方式。
操作系统:控制硬件、协调各个应用程序、确保程序对硬件的真确使用且互不干扰。
内核:操作系统中最基本的部分(并不是I/O的全部),除此以外还有系统程序和用户程序。
引导程序:计算机开机时运行的第一个程序。
引导程序通常位于计算机的ROM或EEPROM中。初始化系统各个组件(CPU寄存器、设备控制器、内存……),加载并执行操作系统。
以上阶段完成后,系统就启动完成,等待事件发生。
中断:事件通过中断来通知。
硬件通过系统总线向CPU发送信号;
软件通过系统调用实现。
发生中断时,停止当前工作,控制转给中断处理程序。程序结束后再继续被中断的计算。
控制转移的实现:
通过一个通用程序检查中断信息,再由该程序调用中断处理程序。
改进:
为了加快中断响应,可通过一张指针表快速调用中断处理程序。
中断请求可以设备号作为索引,从而查到中断处理程序的地址。
内存
所有形式的内存都是字节数组的形式,每个字节都有地址,交互通过CPU对特定的地址执行一系列load、store指令实现。
I/O结构
通用计算机由CPU和多个设备控制器组成,它们通过总线连接。
每个设备控制器维护一定量的本地缓存和一组寄存器。并提供有设备驱动程序。
计算机系统的体系结构
单处理器系统
只有一个主CPU。
通常还有多种处理器分担任务。如针对某设备的特定处理器和功能泛用性较强的通用处理器(如负责传输I/O数据的处理器)。
这些辅助的处理器不执行用户进程。
有时由OS管理;有时自主完成任务,OS无法干涉。
如果系统只有一个通用CPU,那就是单处理器分配系统。
多处理器系统
也称为并行系统或多核系统。
有多个紧密通信的CPU,共享计算机总线,有时还共享时钟、内存、设备。
优点:
吞吐量高,高效。
便宜,多种硬件资源可以共享。
可靠性增加。
多个CPU之间的资源竞争和协调运行会需要额外的开销。使得实际工作效率低于理论值。
多处理器系统类型:
非对称处理:一个主处理器控制系统,向其他的处理器分配任务。
对称多处理(SMP):没有主从关系,每个处理器都能参与系统的所有任务。都有自己的寄存器、私有缓存。
SMP多处理器各个CPU相互独立,可能会导致一个CPU负载严重、而另一个CPU在摸鱼。可通过共享数据结构避免。
均匀内存访问(UMA):所有CPU对内存的访问时间都相同。
非均匀内存访问(NUMA):一个CPU对不同内存的访问有快有慢。可通过虚拟内存管理提升访问效率。
集群系统
另一种类型的多核处理器。多个处理器通常不在一台计算机上,而是通过网络连接的多台计算机处理器的集合。
OS的执行
现代OS是中断驱动的。如果没有进程要执行、没有I/O要服务、没有用户要响应,则OS就保持空闲等待事件发生。
事件总是由中断或陷阱(也称为异常)引起。
-
陷阱:陷阱是用户态使用系统调用时触发,使执行流程从用户态**“陷入”**内核态,从而调用只有在内核态才能执行的内核函数。
因为陷阱是由程序调用触发的,所以发生时间某种程度可预测。
-
中断:中断由外部时间导致,且发生时间不可预测。
外部时间主要指时钟中断、硬件中断等。
时钟中断:进程用完时间片 ,将CPU交给新的进程;
硬件中断:由硬件产生。
中断主要作用是完成进程间切换。
中断处理过程通常会屏蔽中断。
-
异常:由程序代码导致的异常行为,如除零、内存溢出等。
OS 得确保程序的中断只影响自己。
双重模式
将代码区分为 OS 代码和用户代码;系统拥有 2 种独立运行模式:用户模式和内核模式(或监视模式、或系统模式、或特权模式)。
计算机硬件通过模式位来切换模式。例如内核模式-0;用户模式-1。
计算机刚启动时处于内核模式,在用户模式下运行用户程序。在发生中断、系统调用(陷入)、异常,硬件就切换回用户模式。
(先切换模式,再交换控制权)
双重模式如何提供保护?
将可能引起机器错误的指令设为特权指令,只有在系统处于内核模式时才能执行。用户模式对特权指令的执行视为非法。
(基本可以把特权指令看做系统调用)
没有双重模式保护的例子:
MS-DOS(微软的软盘操作系统)中,多个程序可以同时写入一个设备。
对之后内容的一些概括
进程
执行的程序称为进程。
程序≠进程。程序是被动实体(passive entity);进程是主动实体(active entity)。
进程是系统的工作单元。
系统由多个进程组成,有操作系统进程(执行系统代码),其他的是用户进程(执行用户代码)。
内存
内存是管理现代计算机系统执行的中心。
内存是一个规模从数十万到数十亿大的字节数组,每个字节都有地址。
CPU所执行的指令、访问的数据,都在内存中,内存是CPU能直接寻址访问的唯一一个大容量存储器。
数据存储
文件
OS 对存储设备的物理属性进行抽象,文件就是其定义的逻辑存储单元。
文件是个极其宽泛的内容,不一定要有格式。
大容量存储器
内存容量较小且掉电会失去数据。所以需要大容量外存备份数据。
大多数程序、数据都保存在外存,需要时才调入内存。
高速缓存
设置在内存和外存之间,弥补二者的缺点。
(个人理解,介于两个容量、访问速度差距较大的存储设备之间的设备,似乎都能叫做缓存。)
-
数据同步
在层次存储结构中,同一数据(设为A)可能在多个层次中存在多个副本。例如:硬盘->内存->高速缓存->硬件寄存器。
在数据被CPU修改后,被修改的数据会重新写回硬盘。
对于多任务环境,CPU会在多个进程间切换,所以某进程对数据A做出修改后,应尽快让所有进程获得更新后的数据A。
在多处理器环境中,每个CPU都有各自的内存寄存器、高速缓存。
分布式环境中,一个数据的多个副本可能会出现在多个不同的计算机中。
保护和安全
保护
一种机制,控制进程或用户访问计算机系统的资源。确保系统正常运行。
安全
防止系统受外部或内部的攻击。防范恶意攻击。
内核数据结构
数组
最简单的数据结构,元素可以直接访问,内存就是一个数组。
列表
除了数组外,最重要的数据结构。需要按特定次序访问。最常用的实现是链表。
栈(栈堆)、队列、树、哈希函数与哈希表
(略,没什么好记的)
位图
为 n 个二进制位的串。
例如 0 表示资源可用,1 表示资源不可用。有如下位图:
001011101
第0、1、3、7资源可用,2、4、5、6、8资源不可用。
空间效率高,常用于表示大量资源的可用性。
磁盘驱动器就是这么工作的,每个磁盘块的可用性用位图表示。
第二章 操作系统的结构
与用户交互的界面
命令解释程序(shell)
2种实现方法:
- shell自带代码,接收命令后执行。程序大小与能执行的指令数量相关。
- 命令由系统程序实现,shell解释命令,通过命令确定文件并执行。
图形用户界面(GUI)
一直在用的就是。
shell更容易执行重复的任务。
系统调用
提供 OS 服务接口。
应用程序开发人员根据应用编程接口(API)间接使用系统调用。而API函数通过 OS 的库函数提供。
直接使用系统调用会涉及到更多的细节,且可移植性较差。
传递给系统调用的参数通过寄存器来传递。
如果参数过多,就将参数说在的内存地址通过寄存器传入。
系统调用类型
6类:
- 进程控制
- 文件管理
- 设备管理
- 信息维护
- 通信
- 保护
系统程序
也称为系统工具,为程序开发和执行提供环境。
(个人理解:介于 OS 和应用程序之间?)
一些对 OS 运行的理解不太有帮助的知识
系统引导
加载内核以启动计算机的过程。
大多数计算机系统都有一小块代码(引导程序),这段代码定位内核,加载到内存执行。
有的计算机系统(如PC)会家在一个更复杂的引导程序,再由后者加载内核。
引导程序都保存在ROM中,无法修改。
固件(EPROM,Erasable Programmable Read-Only-Memory,可擦可编程只读存储器)
对于大型OS,引导程序放在固件上,OS放在磁盘上。
只有当OS内核加载到内存中并开始执行后,才能说系统在运行(running)。
第三章 进程
现在的计算机都是将多个程序加载到内存并发执行。为了对运行的程序进行控制和划分,就有了进程的概念。
进程概念
可看做是执行的程序。除了代码外,还包括当前活动,如程序计数器的值、处理器寄存器的内容等。
在内存中:
文本段:进程的代码。
栈区:临时数据,如局部变量、函数参数、返回地址等。
数据段:全局变量……
堆区:进程运行时分配的内存。
进程与程序的区别
- 程序是被动实体;进程是活动实体,有程序计数器和一组相关资源。
- 2个进程可以与同一个程序相关联:进程的文本段相同,数据、栈、堆不同。
- 进程本身可以作为一个环境,用于执行其他代码。如虚拟机。
进程状态
新的(new):进程正在创建。
运行(running):进程正在执行。
等待(waiting):进程等待某个事件发生。
就绪(ready):进程等待分配处理器。
终止(terminated):进程已执行完毕。
以上随意的状态名随OS变化。
进程控制块(PCB)
包含与某个特定进程相关的信息。
- 进程状态:(上一节)
- 程序计数器:表示进程要执行的下一个指令地址。
- CPU寄存器:累加器、索引寄存器、栈堆指针、通用寄存器等,在发生中断时要与程序计数器一起保存。
- CPU调度信息(用于被CPU调度的信息):优先级、调度队列的指针、其他调度参数。
- 内存管理信息:包括基地址和界限寄存器的值、页表或段表。根据OS使用的内存系统而定。
- 记账信息(个人理解:状态监控信息):CPU时间、实际使用时间、时间期限、作业或进程数量等。
- I/O状态信息:记录分配给进程的I/O设备、打开文件列表等。
线程
多线程进程一次能执行多个任务。PCB也进行了扩展以保存线程信息。
进程调度
多道程序设计:在计算机内存中同时存放多个相互独立的程序,在管理程序控制下,交替运行。
多道程序设计的目的:无论何时都有进程运行,从而最大化CPU利用率。
调度队列
进程进入系统时,PCB加载到作业队列,这个队列包括系统内所有进程。
就绪队列(ready queue):保存驻留在内存中的、就绪的、等待运行的进程。这个队列通常由链表实现,头结点有2个指针分别指向队头和队尾。
设备队列(device queue):等待指定I/O设备的进程列表。每个设备都有自己的设备队列。
进程状态的转换:
新进程被加到就绪队列;在就绪队列中等待,直到被选中执行。当进程分配到CPU并执行时,可能会发生:
进程发出I/O请求,被放到I/O队列;
进程创建一个新的子进程,等待其终止;
由于中断被强制释放CPU,回到就绪队列。
前两种状态,进程最终会从等待状态切换到就绪状态,回到就绪队列。进程一直重复这循环直到终止。最后从所有队列中删除,PCB和资源被释放。
调度程序
进程在队列间的迁移通过**调度程序(scheduler)**来执行。
长期调度程序(或称为 作业调度程序)
从缓冲池(通常为磁盘等大容量存储设备)中选择程序,加载到内存执行。
短期调度程序(或称为 CPU调度程序)
从准备执行的进程中选择进程并分配CPU。
二者的区别
调度频率:
二者的区别主要在于执行频率。短期调度100ms左右间隔,长期调度几分钟间隔。
执行的任务:
短期调度主要用于切换 CPU 执行的进程。
长期调度控制多道程序程度,即内存中的进程数量。
#### 中期调度
分时系统中引入。用于交换操作,将进程换入、换出内存。减轻内存压力。
上下文切换
切换CPU到另一个进程需要保存当前进程状态和恢复另一个进程的状态,这个任务称为上下文切换(context switch)。
切换上下文涉及到的进程状态信息保存在PCB中,包括CPU寄存器的值、进程状态、内存管理信息等。
上下文切换的时间:
上下文切换的时间是纯粹的开销,系统没有做任何工作。
上下文切换速度与机器和硬件有关。不同机器决定了指令数量、涉及到的寄存器等;有的硬件支持则提供多个寄存器组,在切换时只需修改寄存器指针即可。
系统越复杂,上下文切换所要做的就越多。
进程运行
进程创建
大多数 OS 通过唯一的**进程标识符(pid)**识别进程,通常为一个整数。
Linux系统中所有的用户进程构成一棵树,树根进程为 init,pid 总是1。在系统启动后由 init 创建各种用户进程。
创建新进程后父进程的行为:
父进程与子进程并发执行。
父进程等待子进程执行完毕。
新进程的地址空间:
子进程是父进程的复制品(相同的程序和数据)。
子进程加载另一个新程序。
进程终止
子进程执行完后通过系统调用 exit() 请求 OS 删除自身。子进程可以返回状态值到父进程,相关的资源由 OS 释放。
父进程可通过子进程标识符终止自己的子进程。
级联终止:由 OS 启动,当一个进程终止时,得先终止它的所有子进程。
父进程调用wait()
当一个进程终止时,OS会释放资源,但它位于进程表中的条目还存在,直到它的父进程调用wait();这是因为进程表包含了进程的退出状态。
僵尸进程:进程已终止,但其父进程还未调用wait()。
所有进程终止时都会过渡到这种状态。在父进程调用了wait()后,僵尸进程的进程标识符和它在进程表中的条目都会被释放。
父进程没有调用wait()就终止,使得子进程称为孤儿进程(orphan process),Linux和UNIX的处理方法是: init进程定期调用wait()收集孤儿进程的退出状态,并释放最后的进程残留。
进程间通信
协作的进程需要与其他进程共享数据,需要**进程间通信(IPC)**机制。
IPC有两种基本模型:内存共享(shared memory)和消息传递(massage passing)。如同字面意义:开辟一个共享内存区域向该区域存取数据;进程间交换消息。
以上两种模型在大部分OS中都实现了。
多核处理系统不需要考虑高速缓存的一致性,使用消息传递更高效。
内存共享
快于消息传递,因为不需要经常使用系统调用,只在建立共享内存区域时才会用到,共享内存区建立好后就没有内核的事了,所有进程都向访问自己的内存一样访问共享进程。
具体实现:
一片共享内存区域驻留在创建共享内存段的进程的地址空间内,其他希望使用这个共享内存段进行通信的进程将其附加到自己的地址空间。
注意事项:
共享内存区的数据类型和位置取决于参与的进程,也由这些进程保证不向同一个位置同时写入数据。
总之,共享内存的实现需要靠应用程序的程序员明确编写出来。
协作进程的通用范例:生产者-消费者模型
生产者(producer)进程产生信息,以供消费者(consumer)进程消费。
解决这一问题的方法之一是采用共享内存,为了允许生产者进程和消费者进程并发执行,应有一个缓冲区,以被生产者填充和消费者清空。
消息传递系统
消息传递系统主要靠OS提供的机制实现。
消息传递工具至少要提供发送、接收两种操作。
两个进程在进行通信前,得先建立通信链路(communication link)。
直接通信
通信的每个进程都必须明确指定通信的接收者或发送者。
对应关系
这种方案属于寻址的对称性:每个链路只与两个进程相关;每对进程之间只有一个链路。
变体:寻址的非对称性
只有发送者需要指定接收者,接收者不需要指定发送者。
间接通信
通过邮箱或端口来发送和接收消息,邮箱是一个抽象对象,进程可以向其存取数据。每个邮箱都有一个唯一的标识符。
对应关系
只有当两个进程共享一个邮箱时才能通信。一个链路可以与多个进程相关联;一对进程间可以有多个不同链路,每个链路对应一个邮箱。
邮箱
邮箱可以为进程或OS所拥有,如果邮箱为进程拥有,则邮箱就是进程地址空间的一部分。进程终止,邮箱消失。
OS拥有的邮箱独立存在,此时OS必须提供机制,以允许进程进行:创建邮箱、使用邮箱、删除邮箱的操作。
第四章 线程
概述
CPU 使用的基本单元。
包括:线程id、程序计数器、寄存器组、栈堆。
与同一进程的其他线程共享代码段、数据段和其他 OS 资源。
动机
以 Web 服务器为例。
“进程的创建很耗时间和资源,如果新进程与原来的进程执行相同的任务,那么为什么要承担这些开销?”
使用了包含线程的进程更有效。可以用创建线程来处理请求。
优点
-
响应性:一个多线程的进程中部分线程阻塞,整个进程仍能继续运行,增加对用户的响应程度。这对用户界面设计尤其有用。
-
资源共享:进程只能通过共享内存或消息传递之类的技术共享资源。这些技术由程序员显式地安排。
线程默认共享所属进程的内存和资源。代码和数据共享的优点:它允许一个应用程序在同一地址空间内有多个不同活动进程。
-
**经济:**进程创建所需的内存和资源分配非常昂贵。因为线程的共享性,创建和切换线程更加经济。虽然测量二者的区别很困难。
-
可伸缩性:在多处理器体系结构中,线程可在多处理器上并行运行。
多核编程
将多个计算核放到单个芯片,只要是多核,无论是单个芯片还是多个芯片,都称之为**多核(multicore)或多核处理器(multiprocessor)**系统。
并行类型
数据并行
将数据分布到多个计算核,每个核执行相同任务。
任务并行
将任务分布到多个核,每个核执行不同任务。
实践中混合使用,不严格区分。
多线程模型
有两种方法来提供线程支持:用户层的用户级线程(user thread)或内核层的内核线程(kernel thread)。
用户线程在内核之上,它的管理无需内核支持;
内核线程由OS直接支持与管理。
二者的联系:多对一、一对一、多对多。
多对一编程
多个用户级线程映射到一个内核线程。
优点:线程管理由用户空间的线程库来完成,因此效率更高。
缺点:如果有一个线程执行阻塞系统调用,整个进程都会阻塞。且任一时间只有一个线程可以访问内核,所以多个线程不能运行在多核系统上。
现在几乎没有OS继续使用这个模型了。
一对一模型
每个用户级线程映射到一个内核线程。
优点:当一个线程执行阻塞系统调用时,允许其他的线程继续执行,提供了更好的并发功能;也允许多个线程并行运行在多核系统上。
缺点:每创建一个用户线程就得创建一个相应的内核线程。创建内核线程的开销会影响应用程序的性能,所以这种模型的大多数实现限制了系统支持的线程数量。
Linux和Windows家族,都实现了一对一模型。
多对多模型
多路复用多个用户级线程到同样数量或更少数量的内核线程。
内核线程的数量可能与特定应用或特定机器有关。
总结
- 多对一允许开发人员创建任意多的用户线程,但并没有增加并发性;
- 一对一提供了更大的并发性,但创建线程太多会影响性能;
- 多对多拥有前两者的优点且克服了两者的缺点。
线程库
**线程库(thread library)**为程序员提供创建和管理线程的API。实现方式有2种。
1、在用户空间中提供一个没有内核支持的库。库的所有代码和数据结构都位于用户空间。这使得调用库的一个函数并不是系统调用。
2、OS 直接支持的内核级的库。库的代码和数据结构都位于内核空间。调用库的一个API会导致对内核的系统调用。
创建多线程常用的策略
异步线程:父线程创建了一个子线程后,父线程就恢复自身的运行。父线程与子线程并发执行,每个线程的运行相互独立,无需知道对方何时终止,所以线程间很少有数据共享。
同步线程:父线程创建一个子线程后,在恢复执行之前等待所有子线程的终止。父线程创建的子线程并发执行,在完成工作后终止,与父线程连接。所有子线程都连接后,父线程才恢复执行。同步线程通常涉及大量数据的共享。
隐式多线程
将多线程的创建和管理交给编译器和运行时库来完成。
线程池
多线程有一些潜在问题:创建线程所需的时间?线程完成后会被丢弃;如果允许所有并发请求都能通过新线程来处理,无限制的线程可能耗尽系统资源,如CPU时间和内存。
解决这一问题的方法之一:线程池(thread pool)。
线程池的主要思想
在进程开始时创建一定数量的线程,加到池中等待工作。当服务器收到请求时,唤醒池内一个线程(如果有可用的),将需要服务的请求传递给它。一旦线程完成了服务,它会返回到池中在等待工作。如果线程池没有可用线程,服务器会等待,知道有空线程为止。
优点:
使用现有线程比创建一个线程更快;
线程池限制了线程数量;
将要执行任务从创建任务的机制中分离出来,允许我们采用不同策略运行任务。例如:安排任务延时或定期执行。
池内线程数量可根据一些因素来估算:系统CPU数量、物理内存大小、并发客户请求数量的期望值等。
多线程问题
信号处理
(自己理解:线程用于通知进程的机制)
UNIX信号用于通知进程某个事件已经发生。信号的接收可以是同步的或是异步的。
两种信号都遵循相同的模式:
信号是由特定事件触发的。
信号被传递给某个进程。
信号一收到就进行处理。
信号类型
同步信号(自己理解:进程内的线程触发的)
例子包括非法访问内存或被0所除。某个运行程序执行这类非法动作,就会产生信号。
同步信号发送到由于执行操作导致这个信号的同一进程。
异步信号
由进程以外的事件产生,进程异步接收这一信号。
通常异步信号发送到另一个进程。
信号处理程序
信号的处理可分为两种:默认的信号处理程序和用户自定义的信号处理程序。
每个信号都有一个默认信号处理程序(default signal handler),在处理信号时由内核来运行。默认动作可以通过**用户定义信号处理程序(user-defined signal handler)**修改。
有的信号可以忽略(如改变窗口大小),有的信号要通过终止程序来处理(如非法内存访问)。
线程撤销
在线程完成之前终止线程。例如多个线程并发搜索数据库时,若有一个线程得到了结果,其他线程就可以撤销。
要撤销的线程通常称为目标线程,有两种撤销情况:
异步撤销:一个线程立即终止目标线程;
通常OS回收撤销线程的系统资源,但不回收所有资源,异步撤销线程可能不会释放必要的系统资源。
延迟撤销:目标线程不断检查它是否应该终止,这允许目标线程有序终止自己。
延迟撤销仅当目标线程检测到标志确定它应撤销时,撤销才会发生。线程可以检查它是否处于安全的撤销点。
线程本地存储
某些情况下,每个线程可能需要拥有自己的数据,这种数据称为线程本地存储(Thread-Local Storage,TLS)。
TLS不同于局部变量,TLS类似于静态数据,但是每个线程独有的。
调度程序激活(LWP)
许多系统实现多对多和双层模型时,在用户和内核线程之间增加一个中间数据结构,称为轻量级进程(LightWeight Process,LWP)。
对于用户级线程,LWP表现为虚拟处理器。每一个LWP与一个内核线程连接,只有内核线程才能通过 OS 调度运行在物理处理器上。如果内核线程阻塞,与之连接的LWP阻塞,与LWP连接的所有用户级线程也都阻塞。
工作过程:
内核提供一组LWP给应用程序,用户程序可以调度用户线程到任何一个可用的LWP。内核将有关特定事件通知应用程序,这个步骤称为回调(upcall),它由线程库通过**回调处理程序(upcall handler)**来处理。
当一个用户线程要阻塞时,一个触发回调的事件发生,内核向应用程序发出一个回调,通知它有一个线程将会阻塞并标识该线程。
然后,内核分配一个新的LWP给应用程序,应用程序在这个新的LWP上运行回调处理程序,它保存阻塞线程的状态,释放阻塞线程运行的LWP。
接着,回调处理程序调度另一个适合在新的LWP上运行的线程。
(?被阻塞的线程仍占有LWP,新的线程是在原来用于运行回调处理程序的LWP上运行?)
当阻塞线程等待的事件发生时,内核向线程库发出另一个回调,通知它先前阻塞的线程可以运行了。
该事件的回调处理程序也需要一个LWP,内核可能分配一个新的,或抢占一个用户线程,并在其LWP上执行回调处理程序。
第五章 进程调度
CPU调度是多道程序设计的基础。
对于支持线程的OS,OS实际调度的是内核级线程而非进程。
基本概念
单处理器系统,同一时间只有一个进程运行,该进程等待时,CPU闲置。
多道程序使多个进程同时处于内存,一个运行的进程等待时,OS从该进程接管CPU控制,将CPU交给另一进程。
- 几乎所有计算机资源在使用前都要调度。CPU是最重要的资源之一。
CPU 调度程序
每当 CPU 空闲,OS 通过短期调度程序或 CPU调度程序选择一个合适的进程,分配 CPU ,执行。
抢占调度
需要CPU调度的4种情况:
运行→等待;(I/O请求,调用wait()等)
运行→就绪;(中断)
等待→就绪;(I/O完成)
终止。
1、4情况只进行调度,没有对进程做选择。(即,执行系统调用的进程被调度)
2、3可以选择具体是哪个进程被调度。(由调度程序决定)
非抢占或协作调度只能触发1、4情况;抢占调度能触发所有情况。
抢占调度可能会导致竞争情况,如一个进程正在更新数据时被抢占,而第二个进程刚好要读该数据,这就使得数据处于不一致的状态。(第六章详解)
如果正在修改的数据是重要的内核数据,则会导致严重后果。有的OS这么处理:在上下文切换前,等待系统调用完成或I/O阻塞发生。
调度程序
将CPU控制交给短期调度程序选择的进程,功能包括:
切换上下文。
切换到用户模式。
跳转到用户程序的合适位置,以便将其启动。
调度程序停止一个进程而启动另一个进程的时间称为调度延迟(dispatch latency)。
调度准则
CPU使用率:对于实际系统,应是40%~90%。
吞吐量(throughput):一个单元时间内进程完成的数量。
周转时间(turnaround time):从进程提交到完成的时间段。包括等待进入内存、在就
绪队列等待、在CPU上执行、I/O执行。
等待时间:在就绪队列所花时间之和。CPU调度算法所影响的时间。
相应时间:从提交请求到产生第一响应的时间。
调度算法
先到先服务(FCFS,First-Come First-Served)
字面意思,先请求CPU的进程先分配到,执行完后再交给下一个进程。可通过FIFO队列轻松实现。
缺点:平均等待时间很长。
护航效果(convoy effect):FCFS中的现象。像舰队航行一样,整个OS因为某个(或某些)进程而变慢。起因是一个执行时间较长的进程先获得CPU,导致大量晚到的进程在后面等待。
最短作业优先调度(SJF,Shortest-Job-First)
将每个进程与其下次CPU执行的长度关联,CPU空闲时,优先赋予最短CPU执行的进程。如果两个进程执行时间同样长,则用FCFS处理。
平均等待时间最小,是最优的调度算法。但如何知道下次CPU执行的长度很困难。
对于批处理系统的长期调度,可以将用户提交作业时指定的进程时限作为长度,这就需要用户精确估计进程时间。所以SJF常用于长期调度。
SJF在短期CPU调度上无法实现,只能使用近似的方法。
SJF可以是抢占的或非抢占的。抢占SJF称为**最短剩余时间优先(shortest-remaining-time-first)**调度。
优先级调度(priority-scheduling)
SJF算法是通用优先级调度算法的一个特例。
每个进程都有一个优先级与其关联,最高优先级的进程获得CPU,同优先级按FCFS调度。
优先级通常为固定区间的数字,0~x。对于0表示最高还是最低的优先级没有定论。
优先级的定义可以分为内部的和外部的。
内部优先级采用一些系统测量数据来计算。
如时限、内存要求、打开的文件数量、平均I/O执行时间、平均CPU执行时间等。
外部优先级是与计算机学科无关的外界因素。
如进程重要性、支付使用计算机的费用类型和数量、赞助部门、政治等。
进程可以是抢占的或非抢占的。非抢占的优先级调度算法只是将新的进程加入到就绪队列的头部。
优先级调度算法会导致饥饿(starvation):低优先级的进程一直处于就绪状态无法运行。要么在系统负荷减轻后能获得运行权,要么在系统关闭后仍未能运行。
解决低级进程无穷等待的方案之一:老化(aging)。逐渐增加在系统中等待很长时间的进程的优先级,最终会使得进程在系统内拥有最高优先级,得以运行。
轮转调度(RR,Round-Robin)
将一个较小时间单元定义为时间片(time slice)或时间量(time quantum),大小通常为10~100ms。
就绪队列为循环队列,CPU调度程序轮流为队列中的每个进程分配不超过一个时间片的CPU。
专门为分时系统设计。类似于FCFS调度,但增加了抢占。
RR算法的性能很大程度取决于时间片的大小。时间片太大会演变为FCFS;时间片太小会导致大量的上下文切换,拖慢进程执行时间。
多级队列调度(multilevel queue)
将就绪队列分成多个单独队列(根据进程属性,如内存大小、进程优先级、进程类型等),一个进程永久被分配到一个队列,每个队列有自己的调度算法。
优点开销低、缺点不灵活。
队列之间也有调度,通常用固定优先级的抢占调度。
每个队列与优先级更低的队列相比有绝对的优先级。即只有当优先级更高的队列都为空时,低优先级队列的进程才能有资格获得CPU。
也可以在队列之间划分时间片。
注意!:这里提到的“时间片”与RR调度的“时间片”概念不同,指的是将一个CPU的执行时间按比例分配给不同的队列,如队列A有80%的CPU时间,队列B有20%的CPU时间。
多级反馈队列调度(multilevel feedback queue)
多级反馈队列基础上,允许进程在队列之间迁移。
如果进程使用过多的CPU时间,就会被转移到优先级更低的队列;使得I/O密集型和交互进程在更高优先级的队列上。
在低优先级队列中等待过长的进程会被转移到更高优先级队列;阻止饥饿发生。
例子:
每个进程进入就绪队列后,就被添加到优先级最高的队列0内,队列内每个进程都能获得8ms的时间片;
如果进程没有在8ms时间片内完成,就被移到队列1尾部,队列1的时间片为16ms;
如果进程还不能完成,被抢占,添加到队列2,队列2内根据FCFS执行。
多级反馈队列调度要考虑到有:
队列数量。
每个队列的调度算法。
确定进程何时升级到更高优先级队列的方法;
确定进程何时降级到更低优先级队列的方法;
用以确定进程在需要服务时将会进入哪个队列的方法。
多级反馈队列调度是最通用的CPU调度算法。通过配置,能适应特定系统。但也是最复杂的算法。
线程调度
在支持线程的 OS 上,内核级线程才是 OS 所调度的(而不是进程)。
用户级线程由线程库管理,内核并不知道它们。用户级线程为了在CPU上运行,通过轻量级线程LWP映射到相关的内核级线程。
使用多对一和多对多模型的系统线程库会调度用户级线程,以便在LWP上运行。线程库的调度方案称为进程竞争范围(Process-Contention Scope,PCS),因为竞争CPU发生在同一进程的不同线程之间。
决定哪个内核级线程调度到一个处理器上,内核采用系统竞争范围(System-Contention Scope,SCS),SCS发生在系统内的所有线程之间。
采用一对一模型的系统,只采用SCS调度。
通常情况下,PCS采用优先级调度,允许抢占,用户级线程的优先级由程序员设置。
多处理器调度
以上都是单处理器系统的CPU调度问题。如果有多个CPU,就能实现负载分配(load sharing)。调度问题也会变得更复杂。
主要关注同构系统,这类系统的处理器从功能上来说形同。可用任何一个处理器来运行队列内的任何进程。但有时也会有一些调度限制:假如有一个系统,它有一个I/O设备与某个处理器通过私有总线相连,希望使用该设备的进程应调度到该处理器上运行。
多处理器调度的方法
非对称多处理(asymmetric multiprocessing)
让一个处理器处理所有调度决定、I/O处理及其他系统活动,其他的处理器只执行用户代码。
这种结构较简单,因为只有主处理器访问系统的数据结构,减少了共享的需要。
对称多处理(Symmetric MultiProcessing,SMP)
每个处理器自我调度。
所有进程可能处于一个共同的就绪队列中,或每个处理器都有它自己的私有就绪队列。
如果是前者,可能会这样调度:每个处理器的调度程序都检查共同的就绪队列,选择一个进程执行。每个处理器都得仔细编程,确保两个处理器不会选择同一个进程。
几乎所有现代OS都支持SMP。
处理器亲和性
进程在一个处理器上运行时会在缓存中存放内存信息,方便下次内存访问。如果这时将进程移到其他处理器上,缓存会这么处理:原处理器缓存的内容设为无效;新处理器的缓存重新填充。
由于缓存的无效或重新填充代价高,大多数SMP系统避免进程在处理器间的转移,尽可能让一个进程保持在一个处理器上运行。这称为处理器亲和性(processor affinity)。
软亲和性:OS尽量保持进程运行在统一处理器上,但该进程仍能迁移到其他处理器。
硬亲和性:允许某个进程运行在某个处理器子集上。(子集?)
系统的内存架构可以影响处理器的亲和性。下图为采用非统一内存访问(Non-Uniform Memory Access,NUMA)的一种架构,通常这类系统包括组合CPU和内存的板卡,每个板卡的CPU访问本版内存快于其他板的内存。
如果OS的CPU调度和内存分配算法一起工作,那么当进程分配到一个CPU上时,应分配到同一板上的内存中。
负载平衡
对于SMP系统,重要的是保持所有处理器的负载平衡,以便充分利用多处理器的优点。**负载平衡(load balance)**设法将负载平均分配到SMP系统的所有处理器。
对于具有公共队列的系统,负载平衡通常没有必要,因为一旦处理器空闲,它就立刻从公共队列中取走一个可执行的进程。
负载平衡的主要方法:推迁移(push migration)、拉迁移(pull migration)。
个人理解:
推迁移——由一个特定的任务周期性地检查每个处理器负载,将进程超载的处理器推到空闲或不太忙的处理器上。处理器被动接受。
拉迁移——空闲的处理器主动从忙的处理器上拉一个等待任务。
负载平衡处理会抵消处理器亲和性的好处。何时该转移进程,具体情况具体分析。
(剩下的对理解帮助不大,或理解不了)
第六章 同步
协作进程(cooperating process):与系统内其他执行进程互相影响。直接共享逻辑地址空间(代码和数据),或通过文件或消息来共享数据。
共享数据的并发访问会导致数据不一致,于是创造了多种机制,确保共享同一逻辑地址空间的协作进程的有序执行,从而维护数据一致性。
背景
一个进程在它的指令流上的任何一点都可能会被中断;中断后 CPU 可能会用于处理其它进程的指令。
并发执行如何导致错误?以生产者-消费者模型为例:
生产者进程代码:
while (true) { while (counter == BUFFER_SIZE)//空转等待 ; buffer[in] = next_produced; in = (in + 1) % BUFFER_SIZE; ++counter; }
消费者进程代码:
while (true) { while (counter == 0)//空转等待 ; next_consumed = buffer[in]; out = (out + 1) % BUFFER_SIZE; --counter; }
整形变量counter为共享数据。
以上二者代码各自正确,但并发执行时可能无法正确执行。语句“++counter”转换成机器语言后可能是如下形式:
register1 = counter
register1 = register1 + 1
counter = register1
register1为CPU本地寄存器 语句“–counter”同理
register2 = counter
register2 = register2 - 1
counter = register2
并发执行“++counter”和“–counter”相当于按任意顺序来交替执行以上六行低级语句(每条高级语句内的顺序不变)。
register1 = counter { register1 = 5 }
register1 = register1 + 1 { register1 = 6 }
register2 = counter { register2 = 5 }
register2 = register2 – 1 { register2 = 4 }
counter = register1 { counter = 6 }
counter = register2 { counter = 4 }
这就得到了不正确的状态“counter = 4”,而实际上counter=5。如果交换最后两条语句,又会得到“counter = 6”。
因为允许两个进程并发操作变量counter,所以得到不正确的状态。
竞争条件(race condition):多个进程并发访问和操作同一数据并且执行结果与特定访问顺序有关。
为了防止竞争条件,需要确保一次只有一个进程可以访问操作变量counter,这就带要求进程按一定的方式同步。
临界区问题
假设某系统有n个进程{P0、P1、P2、……、Pn},每个进程都有一段代码,称为临界区(critical section),进程在执行该区时可能修改公共变量、更新一个表、写一个文件等。进程同步的概念是:确保没有两个进程可以在它们的临界区内同时执行。
- 进入区(entry section):进入临界区前,每个进程应请求许可,实现这一请求的代码区。
- 退出区(exit section):执行释放锁等操作。
- 剩余区(remainder section):退出区后,不是同步问题关心的部分。
临界区问题的解决方案因满足一下三条要求
-
**互斥(mutual exclusion):**如果进程Pi在其临界区内执行,那么其它进程都不能在其临界区内执行。
-
进步(progress):(简单理解)没有进程在临界区内时,处于进入区的进程可以进入临界区,且选择不能无限推迟。
-
**有限等待(bounded waiting):**字面意思,一个请求进入临界区的进程不能无限等待。
非抢占式内核不允许处于内核模式的进程被抢占,直到该进程退出内核模式、阻塞、自愿放弃CPU,所以基本不会导致竞争条件。
抢占式内核需要认真设计以避免竞争条件出现。SMP体系结构的抢占式内核更难设计。
Peterson解决方案
经典的基于软件的临界区问题解决方案。适用于两个交错执行的进程。
设两个进程Pi和Pj,j==i-1。两个进程共享的两个数据项:
int turn;
bool flag[2];
变量turn 表示哪个进程可以进入临界区。如果turn==i,那么进程Pi被允许在临界区内执行;
数组flag 表示哪个进程准备进入临界区。如果flag[i]为true,那么Pi准备进入临界区。
进程Pi的代码结构如下:
do { //进入区 flag[i] = true; turn = j; while (flag[j] && turn == j) ; //临界区 //退出区 flag[i] = false; //剩余区 } while (true)
自己的理解(从 Pi 的角度):
Pi先设置flag[i]表示自己准备进入临界区。
flag[i] = true;
在进行进入区检查之前先“礼让”进程Pj(turn = j;),表示“您先请”。
turn = j;
如果对方(进程Pj)真要进入临界区,则自己用空while空转等待。
while (flag[j] && turn == j);
如果两个进程同时试图进入临界区,turn几乎会同时被设置成 i 和 j ,参考6.1的例子,最后只有一个赋值结果被保留(另一个被立即重写,参考6.1的counter)。变量turn的最终值决定了哪个进程被允许先进入临界区。(最后一个“您先请”决定了最终进入临界区的进程)
方法评估:
1、互斥成立
还是参考6.1的例子,Pi 和 Pj 的对变量 turn 的赋值结果只有一个能保留,即一定有一个进程的while不成立,从而得以进入临界区。
2、进步要求满足
以进程Pi为例,空转循环while (flag[j] && turn == j); 中的一个条件是自己设置的“turn == j”,所以 Pi 能否进入临界区取决于另一个进程 Pj 对 flag[j] 的设置:
1、若 Pj 不准备进入临界区(flag[j] == false),则 Pi 的while不成立,Pi进入临界区;(取决于 flag[] )
2、Pj 准备进入临界区(flag[j] == true),则哪个进程能进入临界区取决于共享变量 true ,Pi 和 Pj 之间必有一个进程进入临界区:(取决于 true )
若turn == i,Pi 进入临界区;
若turn == j,Pj 进入临界区。
无论哪个进程进入临界区,都会在退出区执行 flag[i/j] == false,使得另一个进程得以进入临界区。
综上所述,进步要求满足。
3、有限等待要求满足
只有两个进程参与,如果两个进程Pi、Pj同时要进入临界区,必有一个进程被允许进入,且该进程在退出区中会“赋予”另一个进程进入临界区的许可,有限等待满足。
硬件同步
基于软件的解决方案并不能保证在现代计算机体系结构上正确工作。以下的解决方法都是以**加锁(locking)**为前提的,即通过锁来保护临界区。
对于单处理器环境,临界区问题可通过在修改共享变量时禁止中断解决。这种方法往往被非抢占式内核采用。
在多处理器环境下,中断禁止会很耗时,因为得将消息传递到所有处理器。另外,如果系统时钟通过中断来更新,则也会受到影响。
因此许多现代操作系统提供特殊硬件指令,用于检测和修改锁的内容。
两个抽象的指令例子:test_and_set() 和 compare_and_swap() 用于说明常用的机器指令的主要概念。
原子操作(atomic operation):要么全做,要么全不做。不会被打断的操作。
test_and_set()
可按以下代码定义:
bool test_and_set(bool* traget)
{
bool rv = *traget;
*traget = true;
return rv;
}//将传入的bool变量修改为true,再返回该变量的原始值
这一指令是原子的。一台支持该指令的机器可以这样实现互斥:声明一个 bool 变量 lock ,初始化为 false 。该机器的进程Pi结构如下:
bool lock;
do
{
//进入区
while (test_and_set(&lock))
; //空转
/*临界区*/
//退出区
lock = false;
/*剩余区*/
} while (true);
自己的理解
lock为false时,相当于钥匙可用;
进程在进入区的while开始“争抢钥匙”,以
false
为条件调用test_and_set()
就相当于“抢到”了钥匙。
test_and_set()
无条件地将试图从当前进程“取走钥匙”(*traget = true;)
,若当前进程“抢到”钥匙,则允许进入临界区(return rv;),钥匙处于不可用状态;进程离开临界区,在剩余区释放钥匙(lock = false;)。
compare_and_swap ()
需要三个操作数,定义如下:
int compare_and_swap(int* value, int expected = 0, int new_value = 1)
{ //默认值是自己添加的
int temp = *value;
if (*value == expected)
*value = new_value;
return temp;
}
//将传入的value变量修改为new_value,再返回该变量的原始值
与test_and_set()效果基本相同,在修改“锁”时多了一个多余的 if 判断。
同样是原子操作。
使用方法:声明一个全局int类型变量lock,初始化为0。调用compare_and_swap ()的第一个进程将lock设为1,然后进入临界区。进程代码结构如下:
int lock = 0;
do
{
//进入区
while (compare_and_swap(&lock, 0, 1) != 0)
;//可能在别的语言中,while不像C++那样将0视为false
/*临界区*/
//退出区
lock = 0;
/*剩余区*/
} while (true);
使用方法也和 test_and_set() 基本相同,以
lock = 0
为条件调用 compare_and_swap() 的进程进入临界区。
改进(较麻烦)
这些算法满足互斥要求,但未满足有限等待要求。于是有另一种基于test_and_set()的算法,能满足所有临界区的要求。共用的数据结构如下:
bool waiting[n];
bool lock;
都初始化为false。代码如下:
do
{
//进入区
waiting[i] = true;
key = true;
while (waiting[i] && key)
key = test_and_set(&lock);
waiting[i] = false;
/*临界区*/
//退出区
j = (i + 1) % n;
while ((j != i) && !waiting[j])
j = (j + 1) % n;
if (j == i)
lock = false;
else
waiting[j] = false;
/*剩余区*/
} while (true);
个人理解:
进程会在第一个while中空转等待,跳出等待的条件有2个,waiting[i] 和 key
while (waiting[i] && key)
各变量意义:
当waiting[i] = true 时,进程 Pi 一定在 while 空转等待。
当waiting[i] = false 时,3种情况:
1、进程 Pi 未开始执行;
2、进程 Pi 获得上一个进程P(i-1)的授权,得到进入临界区的条件;
3、进程 Pi 已经执行完毕离开临界区。
**1、多个进程到达进入区时,根据各自的变量i的值,在waiting[n]中申请一个“等待区”(**waiting[i] = true;)。
2、当有多个进程并发执行时,只有一个进程能进入临界区。在还没有任一个进程进入临界区时,进程若想跳出while (waiting[i] && key)
只能通过变量key,即第一个“抢到”锁(以false为条件调用test_and_set()
)的进程先执行。
**3、**第一个进入临界区的进程执行完毕,到达退出区。然后从第一个进入临界区的进程的i开始,在循环队列waiting[n]中,以递增的顺序依次执行在进入区中等待的进程。在执行途中如果有新的进程加入,并在waiting[n]中申请了一个已经处理过的“等待区”,则不理会,让其等待下一轮处理。
补充:如果等待区没有被塞满,第二个while循环的条件
!waiting[j]
能跳过哪些还没有参与运行的进程。
**4、**当循环回到了第一个执行的进程位置 i 后,才释放锁if (j == i) lock = false;
,然后开始下一轮处理。
例子
假设n=5,waiting[5]的初始状态为
假设有5个进程同时要进入临界区,第一个抢到锁的进程 P2 中,变量 i=2:
进程P2执行完临界区代码,进入退出区:
退出区的循环while ((j != i) && !waiting[j])
中条件!waiting[j]
不成立,所以直接跳出第二个while,然后执行waiting[j] = false;
,允许下一个处于“等待区”的进程进入临界区。
之后的进程执行同理,当轮到“这一趟待处理的进程”中的最后一个进程P1时,进程P1的退出区while循环while ((j != i) && !waiting[j]) j = (j + 1) % n;
会一直执行,直到“j == i”跳出循环(因为“等待区”中其余所有进程都执行完了,while循环的!waiting[j]
始终成立。此时的 i = 1)。
最后P1在退出区中释放锁if (j == i)lock = false;
,此轮处理结束。
如果一开始“等待区”没有被塞满,在进程执行过程中有新的进程加入,分2种情况:
1、新的进程申请到的“等待区”还没被处理到,则该进程能在此轮处理中执行。
2、新进程申请的“等待区”已被处理过,则暂时忽略,等到下一轮处理。
互斥锁
基于硬件的临界区问题解决方案,方案复杂,且不能被程序员直接使用。因此OS设计人员设计了软件工具。
最简单的工具就是互斥锁(mutex lock)。一个进程在进入临界区时获取锁(调用acquire() );退出临界区时释放锁(调用release() )。函数定义如下。
acquire()
{
while (!available)//为了迎合语义,“可用”就是可以进入临界区
;/*busy wait*/
available = false;
}
release()
{
available = true;
}
二者的调用必须原子地执行,所以互斥锁通常采用6.4节所述的硬件机制来实现。
互斥锁的主要缺点:需要忙等待,当有一个进程处于临界区中时,其余进程都必须连续循环调用acquire(),浪费CPU时间。这种类型的互斥锁也被称为自旋锁(spinlock)。
自旋锁也有一个优点:进程在等待锁时,没有上下文切换(上下文切换可能需要相当长的时间)。因此当使用锁的时间较短时,自旋锁还是有用的。
自旋锁通常用于多处理器系统,一个线程在一个CPU上“空转”,其余线程在其他处理器上进入临界区执行。
信号量
类似于互斥锁,但能提供更高级的方法,方便进程同步。
一个**信号量(semaphore)**S是个整型变量,除了初始化外只能通过两个标准原子操作:wait()和signal()来访问。操作wait()最初称为P(荷兰语proberen,测试),signal()最初称为V(荷兰语verhogen,增加)。二者可按如下定义:
wait(S)
{
while (S <= 0)
;//busy wait
S--;
}
signal(S)
{
S++;
}
二者的信号量整数值的修改应不可分割地执行(S–;和S++;),S整数值的测试(S <= 0)也是不能被中断的。
信号量的使用
OS通常区分计数信号量与二进制信号量。
**计数信号量(counting semaphore)**的值不受限制,
**二进制信号量(binary semaphore)**的值只能为0或1。因此二进制信号量类似于互斥锁。所以一定程度上可替代互斥锁的功能。
计数信号量可以用于控制访问具有多个实例的某种资源。信号量初值为可用资源数量。
信号量解决同步问题的一个例子:
进程P1有语句S1,P2有语句S2。要求只有在S1执行之后才能执行S2,可以这样实现:让P1、P2共享信号量synch,初始化为0。
P1中:
S1; signal(synch);
P2中:
wait(synch); S2;
只有在P1中调用了signal()之后,P2中的进程才能继续。
信号量的实现
将忙等状态改为阻塞,将进程放到与信号量相关的等待队列中,并将该进程状态切换成等待状态,转移CPU控制权。
等待信号量S而阻塞的进程,在其它进程执行 signal() 后,被 wakeup() 唤醒,进程从等待状态改为就绪状态,在就绪队列中等待调度。
可按如下定义信号量:
typedef struct
{
int value;
struct process* list;
}semaphore;
每个信号量都有一个整数value和一个进程链表list。当一个进程必须等待信号量时,就被添加到进程链表。操作signal()从等待进程链表上取走一个进程,并加以唤醒。
现在信号量wait()和signal()可修改为:
wait(semaphore* S)
{
S->value--;
if (S->value < 0)
{
//add this process to S->list;
block();
}
}
signal(semaphore* S)
{
S->value++;
if (S->value <= 0)
{
//remove process P from S->list;
wakeup(P);
}
}
block()挂起调用它的进程,wakeup(P)重启阻塞进程P的执行。两个操作都由OS作为基本系统调用提供。
这样实现的信号量的值可以是负数,负数的绝对值就是等待的它的进程数。(在实现操作wait()时互换了递减和测试的顺序,先修改了信号量的值,再进入测试)
通过每个PCB的一个链接字段可以轻松实现等待进程的链表。链表可以使用任何排队策略,信号量的正确使用与信号量链表的排队策略无关。
应保证信号量操作的原子执行。
在单处理器环境中,可以通过禁止中断实现;
但在多处理器环境中,对每个处理器都禁止中断很困难,且会严重影响性能。因此SMP系统应提供其他加锁技术,如compare_and_swap()和自旋锁,确保wait()与signal()的原子执行。
注意:这里定义的操作wait()与signal()并没有完全取消忙等待,只是将忙等待从进入区转移到了wait()和signal()的临界区内。这些区的代码很短,所以临界区几乎不会被占用,忙等很少发生。
个人理解:所谓**“wait()与signal()的临界区”是指操作信号量S的代码吧,综合上一段内容,应该是在使用信号量之外再套一层compare_and_swap()或自旋锁**,使得 wait() 和 signal() 成为**“进入区中的临界区”**,得以实现二者的原子性。
而套在外层的机制的正确执行,也已经得到了保证。并且 wait() 和 signal() 自身的代码的确很短。
优先级反转
假设有三个进程 L、M、H,优先级大小为 L<M<H。假定进程H 需要资源R,而 R 正在被进程L 访问。
通常,进程 H 需要等待 L 用完资源 R 。如果这时进程M 进入可执行状态,并抢占进程 L ,间接地,具有较低优先级的进程M 影响了进程H 的等待时间。
这种情况称为优先级反转(priority inversion)。只出现在具有两个以上优先级的系统中。一个解决方案是,只有两个优先级。而这无法满足大多数系统。
通常的解决方案是:正在访问资源的进程获得需要访问它的更高优先级进程的优先级,直到它用完了有关资源为止。用完资源后,优先级恢复到原始值。
上面的事例中,进程L 临时继承进程H 的优先级,防止进程M 抢占执行。进程L 用完资源R 后,恢复优先级,进程H获得资源。
经典同步问题!
有界缓冲问题(生产者与消费者)
有界缓冲问题(bounded-buffer problem)参照6.1节。通常用于说明同步原语能力。
生产者,消费者共享如下数据结构:
int n;
semaphore mutex = 1;
semaphore empty = n;
semaphore full = 0;
信号量mutex 提供访问缓冲池的互斥要求;empty、full 表示空的和满的缓冲区数量。
代码如下:
生产者
do
{
/*========生产========*/
//进入区
wait(empty);
wait(mutex);
//临界区
/*========将产品放入缓冲区========*/
//退出区
signal(mutex);
signal(full);
} while (true);
消费者
do
{
//进入区
wait(full);
wait(mutex);
//临界区
/*========从缓冲区取走一个产品========*/
//退出区
signal(mutex);
signal(empty);
/*========消费========*/
} while (true);
(书中没提到)注意:wait()的调用顺序,必须先确认缓冲区的资源能让当前进程运行,才能申请锁“mutex”,否则会导致死锁。signal()的顺序无所谓。
读者-作者问题
假设一个数据可为多个并发进程所共享,有的进程只可能需要读数据库,有的进程需要更新数据库(读和写)。为了区分,将前者称为读者(reader),后者称为作者(writer)。
多个读者同时访问没有问题,但如果一个作者和其他任意一种进程同时访问数据库,就可能会导致混乱。
所以要求作者访问数据库时能独占访问权。这一同步问题称为读者-作者问题。读者-作者问题分为有多个变种,都与优先级有关。最简单的两种情况:
一、读者只会因作者等待;
二、在所有作者都用完共享资源之前,不允许任何读者访问。
两种情况都可能导致饥饿问题,第一种可能导致作者饥饿,第二种情况读者可能饥饿。
第一种情况的解答:
共享数据结构:
semaphore rw_mutex = 1;
semaphore mutex = 1;
int read_count = 0;
//rw_mutex为读者、作者进程共用;
//mutex确保在更新变量read_count时的互斥;
//read_count用于跟踪有多少进程正在读对象。
//rw_mutex在作者间作为互斥信号量,也为第一个进入临界区和最后一个离开临界区的读者使用。
代码如下:
作者进程
do
{
//进入区
wait(rw_mutex);
//临界区
/*写*/
//退出区
signal(rw_mutex);
} while (true);
读者进程
do
{
//进入区
wait(mutex);
read_count++;
if (read_count == 1)
wait(rw_mutex);
signal(mutex);
//临界区
/*写*/
//退出区
wait(mutex);
read_count--;
if (read_count == 0)
signal(rw_mutex);
signal(mutex);
} while (true);
//当所有读者都离开后才允许写。
//mutex上锁后,执行增/减read_count,并检查是否需要上锁/释放rw_mutex
有些系统将读者-作者问题及其解答进行了抽象,提供了读写锁(read-write lock),获取读写锁时,需要制定锁的模式:读或写。
哲学家就餐问题
假设5个哲学家,如图所示,一个哲学家一次只能拿起一根筷子,不能从其他哲学家手里抢,只有拿到两根筷子后才能吃,吃完后放下筷子。
哲学家就餐问题是一个经典的同步问题,是大量并发控制的一个例子。这个例子应满足:在多个进程间分配多个资源,且不会出现死锁和饥饿。
一种解决方法:
共享数据:
semaphore chopstick[5];
都初始化为1,代码:
do
{
wait(chopstick[i]);
wait(chopstick[(i + 1) % 5]);
/*吃*/
signal(chopstick[i]);
signal(chopstick[(i + 1) % 5]);
/*思考*/
} while (true);
这方案能保证两个邻居不能同时进食,但可能导致死锁,例如所有哲学家同时拿起左边的筷子。
补救措施:
最多允许4个哲学家同时坐在桌上。
只有一个哲学家的两根筷子都能用时,他才能拿起它们。
非对称解决方案,单号哲学家先拿左边筷子;双号哲学家先拿右边筷子。
没有死锁的解决方案不一定能消除饥饿,确保没有哲学家饿死也是就餐问题应解决的。
管程!
信号量提供了方便有效的进程同步机制,但如果程序员没有正确地使用它们,可能会导致难以检测到时序错误,而这些错误只有在特定的执行顺序时才会出现,而这些顺序并不总是出现。(具体例子P.188)
为了处理这些可能因程序员而导致的错误,研究人员开发了一些高级工具,**管程(monitor)**就是其中一种。
(网络补充)
信号量机制的三点不足:
• 临界区、进入区和退出区都由用户编写。
• 信号量操作原语分散在各程序代码中,由进程来执行,系统无法有效控制、管理
• wait 和 signal 操作的错误使用,编译程序和操作系统都无法发现、纠正,可能导致死锁。管程就是将所有进程对临界资源的同步操作集中起来统一管理。
正如之前所述:对于共享变量的修改可能因进程运行的顺序导致未知的错误,而提供的信号量也不能保证程序员能正确使用。所以将进入区、退出区的“对共享变量的互斥操作”都放入管程内,管程保证了一次只有一个成员函数能执行,从而保证了互斥性,也隐藏了具体的细节。
(网络来源)进程对管程的互斥使用由编译器实现,总之就是没有细说。
使用方法
**抽象数据类型(Abstract Data Type,ADT)**封装了数据及对其操作的一组函数,独立于任何特定的实现(抽象类?)。**管程类型(monitor type)**属于ADT类型,提供一组由程序员定义的、在管程内互斥的操作。
(结合书的内容和知乎回答https://www.zhihu.com/question/30641734的理解)
管程纳入了进入区和退出区的操作,将二者作为成员函数提供给管程的使用者使用。
管程类似于一个class,除了成员函数外,还有成员变量、“构造函数”、以及辅助“public成员函数”实现功能的“private成员函数”。管程内的成员变量只能由管程的成员函数访问,这些成员变量就是同步问题中的共享资源。
条件变量和wait(),signal()
当一个进程调用了管程,在执行过程中却被阻塞或挂起,在此期间它都不会释放管程(占着茅坑不拉屎),以至于其它管程都无法进入管程。
为了解决这个问题引入了条件变量。能让管程中的进程被阻塞或挂起时释放管程。
阻塞或挂起的原因有很多种,每种情况对应一个条件变量。
每个条件变量以链表的形式组织,记录了因该条件阻塞或挂起的所有进程。
对条件变量的操作仅有 wait () 和 signal()。
条件变量也可用于进程间的同步。
被挂起进程的唤醒
对条件变量执行wait()操作,例如x.wait(),会将该进程挂起,直到另一进程调用x.signal()。如果条件x中没有挂起的进程,则x.signal()不会执行任何操作。
这又有了一个问题,假设进程P调用 x.signal() 唤醒了x上挂起的进程Q,从概念上P和Q都可以继续执行,所以有2策略:
- 唤醒并等待:P等待至Q离开管程;
- 唤醒并继续:Q等待至P离开管程。
不同语言采用不同策略,且不限于以上2种。
用管程解决哲学家就餐问题
如图所示。一个哲学家进程i 应按如下顺序调用管程操作:
Process.pickup(i);
……
eat
……
Process.putdown(i);
可供哲学家调用的管程函数仅有pickup()和putdown(),test()相当于private成员函数。
条件变量self[5]共有5个。
信号量的管程实现
管程内的进程重启
决定某进程执行了x.signal()后,该唤醒哪个进程。
使用条件等待结构,具有如下形式x.wait©。c是优先值,进程在调用wait()时为c传入值,在x.signal()时,唤醒最小优先值的进程。
(本来有挺长一段内容,但理解不了,就不记了。P.192)
第七章 死锁
OS一般不提供死锁预防机制,程序员有责任设计无死锁的程序。
系统模型
一个系统拥有有限数量的资源,这些资源需要分配到若干竞争进程。
资源能分为多种类型,每种类型有一定数量的实例。如果一个进程申请一种类型的资源,那么这种类型资源的任何实例都可以满足申请。
常见的资源有:CPU周期、文件、I/O设备(打印机、DVD驱动器)等。第六章的锁和信号量等同步工具也能作为系统资源,它们是常见的死锁源。
物理资源:CPU周期、I/O设备、内存空间……
逻辑资源:信号量、互斥锁、文件……
死锁定义:当一组进程中的每个进程都在等待一个事件,而这个事件只能由这一组进程的另一个进程引起,那么这组进程就处于死锁状态。
死锁可能涉及不同资源类型。
死锁特征
发生死锁时,进程永远不能完成。
死锁的识别和检测是有难度的,因为它们只在特定的调度情况下才会发生。
必要条件
有了 不一定发生,没有 一定不发生
4个条件:
-
互斥(mutual exclusion):至少有一个资源处于非共享模式,即一次只有一个进程可使用该资源。
-
占有并等待(hold and wait):一个进程应占有至少一个资源,并等待另一个资源可用。
-
非抢占(no preemption):资源不能被抢占,只能由持有资源的进程完成任务后自愿释放。
-
循环等待(circular wait):有一组等待进程{P0,P1,……,Pn},Pi等待的资源被P(i+1)占有,i=0,1,2,……,n-1。在资源分配图中,就是一个环。
所有四个条件必须同时成立才可能会出现死锁。四个条件并不完全独立,例如占有并等待导致循环等待的情况出现。
资源分配图
有向图,顶点集合有2种结点:系统所有活动进程的集合P;系统所有资源类型的集合R。
有向边Pi→Rj表示进程Pi申请了资源类型Rj,并且正在等待这个资源。称为申请边。
Rj→Pi表示资源类型Rj的一个实例已经分配给了进程Pi。称为分配边。
当进程Pi提出的资源Rj的申请得到满足时,申请边转换成分配边。进程释放资源时,分配边删除。
由死锁的定义可知,如果分配图没有环,系统就没有进程死锁;如果分配图有环,就可能存在死锁。
如果图中的所有资源都只有一个实例,则有环是死锁的充要条件;
如果每个资源有多个实例,则有环是死锁的必要非充分条件。
死锁处理方法
通常有三种处理方法:
-
通过协议来预防或避免死锁,确保系统不会进入死锁状态。
-
允许系统进入死锁状态,然后检测并恢复它。
-
忽略问题,认为死锁不会发生。
第三种方法大多数OS采用,包括Linux和Windows。因此死锁由应用程序开发人员自己处理。
代价是一个重要的考虑因素。忽略死锁被大多数OS采用,因为与频繁且开销昂贵的死锁预防、避免、检测、恢复相比,这种方法更便宜。
死锁预防(deadlock prevention):确保至少一个必要条件不成立,通过限制资源申请的方法预防死锁。
死锁避免(deadlock avoidance):OS事先得到有关进程申请资源和使用资源的额外信息,根据这些信息确定是否允许提交申请的进程获得资源。
如果没有算法用于检测和恢复死锁,未发现、未处理的死锁会导致系统性能下降,因为资源被不能运行的进程占有,越来越多的进程会因申请资源进入死锁,最后导致整个系统停机。
死锁预防
使4个必要条件中至少一个不成立。
互斥
破坏方式:无。
互斥条件必须成立,有的资源本身就是非共享的,例如一台打印机、一个互斥锁等。
占有并等待
破坏方式:使每个进程申请一个资源时,不能占有其它资源。
2种方法:
一种方法: 进程在执行前申请并获得所有资源。可以通这么实现:把进程申请资源的调用放在所有其他系统调用之前进行。
另一种方法:进程仅在没有资源时才可申请资源,在申请更多资源之前应释放已分配的资源。
两种方法的缺点:
资源利用率较低。一些资源可能在任务快结束时才会使用一小会,被分配后长时间没被使用;
可能发生饥饿。一个进程可能需要多个常用资源,可能永远无法分配到所有资源。
无抢占
破坏方式:不能抢占已分配的资源。
实现方法:如果一个进程持有资源,并申请另一个不能立即分配的资源,那么它现在分配的资源都可被抢占(隐式释放)。被抢占的资源添加到进程等待的资源列表上,只有当进程获得其原有的资源且申请到新的资源时,才能重新执行。
抢占其他资源时,允许自己的所有资源可被抢占。
所以不是通过增加限制,彻底禁止抢占资源来解决;而是进一步开放,减少限制,让申请资源的进程自己彻底“开放资源”。
具体来说,一个进程申请资源时:
检查是否可用
如果可用,分配资源;
如果不可用,检查该资源是否已分配给其他等待资源的进程(因为申请的资源无法立即满足而等待);
如果是,从那些等待进程中抢占资源;
如果资源不可用且不被等待进程持有,则申请进程等待。
这个协议通常用于状态可以保存、恢复的资源,如CPU寄存器、内存。不适用于互斥锁、信号量。
循环等待
解决方法:对所有资源类型进行完全排序,且要求每个进程按递增顺序来申请资源。
为每个资源类型分配一个整数,以进行比较、确定先后顺序(为了方便描述,记为等级)。
一开始进程可以申请任何数量的 等级i 的资源,之后只能申请资源 等级>i 的资源。
如果进程已持有 等级i 的资源,要申请的资源等级为 j ,且 j < i ,则应先释放所有资源 等级 ≥ j 的资源。
如果同一资源类型有多个实例,应一起申请。
P.219有证明。
如果允许动态获取锁(P.219底部的两个函数调用),那么制定一个加锁顺序并不能保证死锁预防。
死锁避免
死锁预防的副作用:设备使用率低、系统吞吐率低。
死锁避免需要额外信息来做决策,了解进程如何申请资源。获知了每个进程的请求、释放顺序后,系统决定在每次请求时进程是否应该等待以避免未来可能的死锁。
不同算法需要的信息类型、数量不同。最简单且最有用的模型要求每个进程都应声明可能需要的每种类型资源的最大数量。
安全状态
如果系统存在一个分配序列(安全序列),按该序列顺序为每个进程分配资源,使所有进程完成工作并释放资源,那么系统的状态就是安全的。如果不存在这样的序列,系统就是非安全的。
非安全状态是死锁的必要非充分条件(就是非安全状态可能产生死锁)。
假设有12个资源实例,3个进程,进程的情况如下图所示:
安全序列的解释:还剩3个资源实例可用;P1运行完毕后释放,有5个资源实例可用;P0运行完毕后释放,有10个实例资源实例可用;P2可获得所有所需资源实例。
避免死锁算法应保证系统始终处于安全状态。当进程申请一个可用资源时,系统应确定:只有在分配后系统仍处于安全状态,才允许资源立即分配;否则等待。
在这种情况下,进程申请一个空闲的资源也可能导致进程等待,所以资源的使用率更低。
资源分配图算法
资源分配图中除了申请边和分配边外,再引入需求边,从进程指向资源。表示进程可能在将来申请资源,用虚线表示。
只有当一个进程P的所有需求边都在图中后,才允许加入进程P的申请边。
假设进程Pi申请资源Rj。只有在将申请边Pi→Rj变成分配边Rj→Pi后不会导致资源分配图出现环时,才允许申请。通过环检测算法检查安全性,n^2的数量级。
如图,假设P2申请资源R2,而资源R2不能分配给进程P2,因为可能出现右图所示的环。
(可以将需求边看做“模拟的申请边”,通过该边模拟分配资源,检查分配后的资源分配图是否有环,之后再决定实际分配或取消分配。
银行家算法(banker’s algorithm)
对于每种资源类型有多个实例的资源分配系统,资源分配图算法就不适用了。
银行家算法效率低于资源分配图方案。此算法可用于银行系统。
当一个新的进程进入系统时,应声明可能需要的每种资源实例的最大数量,数量不能超过系统资源的总和。
算法需要的数据结构:(设进程数为p,资源数为r)
-
Available:长度为r 的一维数组。表示每种资源可用实例数量。
Available[j] = k
,资源Rj有k个可用实例。 -
Max:二维数组p*r。各进程最大需求量。
Max[i][j] = k
, 进程Pi 最多可申请 资源Rj 的k个实例。 -
Allocation:p*r。已分配给各进程的资源数量。
Allocation[i][j] = k
, 进程Pi 已分配 资源Rj 的k个实例。 -
Need:p*r。各进程仍需的资源量。
Need[i][j] = k
,进程Pi 还可能申请 资源Rj 的k个实例。
Max[i][j] = Allocation[i][j] + Need[i][j]
。数据大小和值随时间改变。
用X和Y代表两个数据结构,X ≤ Y 当且仅当对所有 i=1,2,……,p,X[i] ≤ Y[i]。例如X=(0,3,2,1), Y=(1,7,3,2),那么X ≤ Y。
安全算法
检测系统是否处于安全状态。(新增数据结构Work[]
,Finish[]
)
1、有数据结构Work[r]
,Finish[p]
。对于i=0,1,2,……,n-1,初始化Work[i] = Available[i]
,Finish[i] = false
。
解释:
Work[i]
表示系统可提供的 资源Ri 的数目
Finish[i]
表示 进程Pi 是否已完成。
2、查找有这样的 i 满足:
a.
Finish[i] == false
b.
Need[i][j] ≤ Work[j]
如果不存在,跳到4。否则3
解释:找到这样的进程:进程Pi 未完成,但所需的各项资源
Need[i][all]
均少于系统可供给的数量Work[all]
,即系统能满足该进程的需求。
3、
Work[i] = Work[i] + Allocation[i]
Finish[i] == true
回到第2步。
解释:进程Pi 完成任务(
Finish[i] == true
)后释放占有的资源(Work[i] = Work[i] + Allocation[i]
)
4、如果对所有 进程Pi ,Finish[i] = true
,那么系统处于安全状态。
解释:所有任务都能完成
这个算法需要m*n^2数量级的操作。
资源请求算法
判断请求是否安全的算法。(新增数据结构Request[][]
,[all] 表示所有项)
设Request[i][all]
表示进程Pi 对所需资源的请求数量。Request[i][j] = k
表示 进程Pi 需要 资源类型Rj 的实例数量为k。当 进程Pj 做出这一资源请求时,采取如下动作:
1、如果Request[i][all] ≤ Need[i][all]
,转到第2步。否则,生成出错条件,因为进程Pi当前的需求超过了最大的需求量。
检查当前进程申请的资源数量是否合法(是否超过自己划定的需求量)。
2、如果Request[i] [all] ≤ Available[i][all]
,转到第3步。否则Pi等待,因为资源不够用。
检查当前系统的资源数量能否满足进程需求。
3、假定系统可以分配给进程Pi请求的资源,按如下方式修改状态:
Available[i][all] = Available[i][all] - Request[i][all]
Need[i][all] = Need[i][all] - Request[i][all]
Allocation[i][all] = Allocation[i][all] + Request[i][all]
系统可用资源数减少,进程仍需要的资源数减少,进程已分配的资源数增加。
如果新的资源分配状态是安全的,则进程Pi 获得申请的资源。如果新状态不安全,分配无效,进程Pi等待。
P.223有例子。
死锁检测
如果系统既不使用预防死锁算法也不使用避免死锁算法,则应提供:
1、检查系统状态是否出现死锁的算法;
2、从死锁中恢复的算法。
恢复并检测的方案有额外开销,包括维护信息、执行检测算法的运行开销,还有死锁恢复导致的损失。
每种资源只有单个实例
这种情况的检测算法使用资源分配图的一个变形,称为等待图。从资源分配图中删除所有资源类型节点,合并适当边,就能得到等待图。
等待图中:Pi→Pj 表示 进程Pi 等待 进程Pj 释放一个 进程Pi 所需的资源。
当且仅当等待图中有一个环时,系统死锁。
系统需要维护等待图,并周期调用检查图中知否存在环的算法。算法操作数量级为n^2。
每种资源有多个实例
同样,等待图不适用于每种资源类型有多个实例的系统。
这种系统使用的死锁检测算法类似于银行家算法。
-
Available:r。资源可用实例数量。
-
Allocation:p*r。每个进程当前分配的资源实例数量。
-
Request:p*r。每个进程请求资源的数量。
算法:
1、Work[r]
,Finish[p]
。
对于i=0,1,2,……,n-1,初始化
Work[i] = Available[i]
,如果
Allocation[i][all]
不为0,则Finish[i] = false
;否则Finish[i] = true
。(与银行家安全算法的初始化略有不用)
(以下2~4步骤与银行家安全算法几乎一致,仅第2步的比较略有不同)
2、查找有这样的 进程i 满足:
a.
Finish[i] == false
b.
Request[i][j] ≤ Work[j]
如果不存在,跳到4。否则3
3、
Work[i] = Work[i] + Allocation[i]
Finish[i] == true
回到第2步。
4、
如果存在i,使得
Finish[i] = false
,则系统死锁,且进程Pi死锁。
同样是m*n^2数量级。
何时调用检测算法
何时调用检测算法?取决于2个因素。
1、死锁可能发生的频率;
2、发生死锁时,有多少进程会受影响。
极端情况是每当进程的资源分配请求不能立即允许时,就调用死锁检测算法。
这种情况下能确定哪些进程死锁,还能确定哪些进程造成了死锁。
另一种不太昂贵的方法:隔一段时间,例如每小时一次;或当CPU使用率低于40%时。如果在任一时间点调用检测算法,则资源图可能有多个环,无法确定哪些进程导致了死锁。
死锁恢复
死锁恢复有多种可选方案,例如人工处理或系统自动恢复。
打破死锁有2个选择:1、简单地终止一或多个进程来打破循环等待;2、从一个或多个死锁进程那里抢占一个或多个资源。
进程终止
2种方法:
1、中止所有死锁进程。代价较大,被中止的进程可能已经计算了较长时间。计算的结果要放弃,且可能要重新计算。
2、一次中止一个进程,直到消除死锁循环为止。开销很大,每次中止一个进程,都要调用死锁检测算法,以确定是否仍有进程处于死锁。
仅中止一个进程也不简单,例如进程可能正在更新文件,或正在打印数据。在中止进程时应该将资源重置到正确的状态。
如果采用方法2,有应该确定哪个/哪些死锁进程应该被终止。这一决定类似于CPU调度策略,是策略决策,基本上是经济问题。应中止造成最小代价的进程,而最小代价定义宽泛,有:
进程优先级。
进程已经计算了多久?还要计算多久?
进程使用了什么资源?数量多少?
进程还需要多少资源?
需要终止多少进程?
进程是交互还是批处理?
资源抢占
不断地抢占一些进程的资源给其它进程使用,直到死锁循环打破。
相关的3个问题:
1、选择牺牲进程:
抢占哪些资源?哪些进程?应确定抢占的顺序,使得代价最小。
代价因素有:死锁进程拥有的资源数量、死锁进程已消耗的时间等。
2、回滚:
从一个进程中抢占了资源,对该进程做什么安排?
最简单的方法是将进程完全回滚,重新执行;更有效的方法是回滚进程直到足够打破死锁,但要求系统维护有关进程的更多状态信息。
3、饥饿:
如何确保不会发生饥饿?可能会导致一个进程总是被选为牺牲进程,使该进程永远无法完成任务。
解决方法有:限定进程的回滚次数。
第八章 内存管理
背景
内存由一个很大的字节数组组成,每个字节有各自的地址。CPU根据程序计数器的值从内存中提取指令。
内存单元只看到地址流,不在乎这些地址如何产生(指令计数器、索引、间接寻址等),不在乎它们是什么(指令或数据)。相应的,本章不关心程序如何产生内存地址,只对运行程序产生的内存地址序列感兴趣。
基本硬件
CPU能直接访问的通用存储只有内存和CPU寄存器。机器指令只能用内存地址作为参数,执行指令和指令使用的数据,被CPU转移到内存中后才能被CPU使用。
高速缓存(cache)的出现
CPU寄存器通常可在一个CPU时钟周期内完成访问;而对内存的读取则可能需要多个CPU时钟周期,在这期间,没有数据完成正在执行的指令,CPU暂停(stall)。
为了弥补这一缺陷,在CPU芯片上增加了更快的内存,就是高速缓存。
除了速度,还需确保操作的正确性,即各进程之间不会互相影响。出于性能考虑,OS通常不干预CPU对内存的访问。
一种实现保护的方法
为了分开内存空间,需要确定一个进程所能访问的合法地址范围,且确保该进程只能访问这些合法地址。
通过2个寄存器实现范围限定,通常保存有基地址和界限地址。
基地址寄存器(base register):最小的合法物理内存地址。
界限地址寄存器(limit register):指定范围大小。
内存空间保护的实现,通过用户模式下产生的地址与寄存器的地址进行比较来完成。
如果在用户模式下执行的程序试图访问非法地址,会陷入系统操作,OS执行后续处理。
内核模式下OS可以无限制访问所有内存地址。这使得OS可以提供多种服务,如调用用户程序到内存、转储错误的程序等。
地址绑定
通常,程序作为二进制可执行文件,存放在磁盘上,调入内存后才能执行。根据内存管理,进程在执行时可以在磁盘和内存之间移动。在磁盘上等待调入存储执行的进程组成输入队列。
单任务系统处理过程:从输入队列选取一个进程加载到内存;进程执行时CPU会访问内存的指令和数据;进程终止时,占用的内存空间释放。
大多数系统允许用户进程放在物理内存中的任意位置。
在程序执行前,要经过多个步骤,这些步骤中,地址可能有不同的表现形式:
源代码中的地址通常用符号表示,如
int value;
。编译器将这些符号地址绑定到可重定位的地址,如“从本模块开始的第14字节”。
链接程序或加载程序再将这些可重定位的地址绑定到绝对地址。
每次绑定都是不同地址空间的映射。
指令和数据绑定到存储器地址可在任何一步中进行:
1、编译时(compile time)
如果在编译时就知道进程将在内存中的驻留地址,那么就可以生成绝对代码(absolute code)。即:进程驻留的位置从内存地址R开始,那么生成的编译代码就可以从该位置开始向后延伸。
如果起始地址发生变化,则必须重新编译。例如MS-DOS的.COM格式程序。
2、加载时(load time)
编译时不知道进程驻留位置,编译器就生成可重定位代码(relocatable code)。绑定会延迟到加载时才进行。
如果起始地址发生变化,只需重新加载用户代码即可。
3、执行时(runtime time)
如果进程在执行时可以从一个内存段转移到另一个内存段,那么绑定应延迟到执行时进行。
这种方案需要特定硬件支持,大多数通用计算机OS采用此方法。
逻辑地址空间与物理地址空间
CPU生成的地址通常称为逻辑地址(虚拟地址);内存单元看到的地址(加载到内存地址寄存器的地址)通常称为物理地址。
编译时、加载时:生成的逻辑地址、物理地址相同;
执行时:生成的逻辑地址、物理地址不同。
程序生成的逻辑地址集合:逻辑地址空间;
逻辑地址对应的物理地址集合:物理地址空间。
内存管理单元(Memory-Management Unit,MMU):硬件设备。负责虚拟地址到物理地址的映射。
一个简单的MMU方案:
在这里基地址寄存器称为重定位寄存器(relocation register)。用户进程生成的地址在送交内存前,都要加上重定位寄存器的值。
用户不会看到真实地址,一个指针的使用、比较都是基于逻辑地址来执行的。只有当它作为内存地址时(执行*操作?)才会通过寄存器进行重定位。
动态加载
目前为止,都假设一个进程的所有程序和数据都保存在内存中。而实际上内存大小有限,要通过**动态加载(dynamic loading)**功能提高空间利用率。
动态加载中,一个程序只有在调用时才会加载。
所有程序都保存在磁盘上,主程序要运行时加载进内存,执行。当程序要调用另一个程序时,调用程序先检查该程序是否已加载。如果没有,则加载程序,更新程序地址,移交控制。
优点:如果一个程序的大部分代码是用于处理异常情况,整个程序可能很大,使用了动态加载后,实际运行时可能只需要很小的内存。
动态链接与共享库(代码的链接过程?不太理解)
动态链接库为系统库,可连接到用户程序运行。
静态库通过加载程序,被合并到二进制程序映像。
动态链接类似于动态加载,链接在运行时进行。
在二进制映像内,每个库程序的引用都有一个存根(stub)。存根是一小段代码,用于指出如何定位适当的内存驻留库程序,或当库程序不在内存时应如何加载库程序。
总之,存根会用程序地址替换自己,并开始执行程序。下次再执行该程序时,就能直接执行。
交换
进程可以通过交换功能暂时从内存转移到备份存储中,当再次执行时再调回内存。
标准交换
标准交换在内存与备份存储之间移动进程。备份存储通常是快速磁盘。
系统维护一个就绪队列,当CPU调度器决定执行一个进程时,调用分派器;分派器检查队列中的下一个进程是否存在内存中,如果不在,且没有空闲区域,则换出一个内存中的进程,再换入要执行的进程。然后,重新加载寄存器,移交控制权给新进程。
交换时间的主要部分是传输时间。为了提高效率,知道用户进程真正需要的内存空间很重要。所以具有动态内存需求的进程需要通过系统调用(例如request_memory()和release_memory() )来通知OS它的内存使用情况。
交换受到的其他约束
如果要换出一个进程,应确保它是完全处于空闲的。通常等待I/O的进程不能换出(例如进程P1正在调用I/O输出数据,而此时进程P1被进程P2交换,则I/O可能会试图使用已属于P2的内存)。
解决问题的方法:
1、不能换出等待处理I/O的进程;
2、I/O操作的执行只能使用OS的缓冲,而只有在进程换入时,OS缓冲才能与用户进程占用内存交换数据。
现代OS不使用标准交换,因为它的交换时间太多,提供的执行时间太少。通常都是用交换的变种。
一个例子:正常情况下,禁止交换;空闲内存低于某个阈值,启动交换;空闲内存增加了,再禁止交换。
另一个例子:交换进程的一部分。
这些交换的的变种通常与虚拟内存一起工作。
移动系统的交换
移动系统通常不支持任何形式的交换。移动设备通常采用闪存作为永久存储。导致的空间约束是移动设备OS设计者避免交换的原因之一。
以苹果iOS为例,空闲内存降到一定阈值以下时,要求应用程序自愿放弃已分配的内存。只读数据(如代码)可以从内存中删除,需要时再加装;已修改的数据(如栈堆)不会被删除。OS能终止任何未能释放足够内存的应用程序。
Android在没有足够空闲进程时可以终止进程。在终止前,将其应用程序状态写到闪存,方便快速重启。
连续内存分配
一种早期内存分配方法。所有内存内的进程都紧挨着。
分为两个区域:一个用于驻留OS;另一个用于用户进程。OS可以放在低内存或高内存,由中断向量位置决定,通常放在低内存。
内存保护
防止进程访问不属于它的内存。
有了重定位寄存器和界限寄存器就能实现。
每个逻辑地址应在界限寄存器规定的范围内,MMU动态地将逻辑地址加上重定位寄存器的值,来进行映射,映射后的地址再发往内存。
CPU产生的每个地址都要与这些寄存器核对。
内存分配
最简单的内存分配方法:将内存分为多个固定大小的分区,每个分区只能包含一个进程。最初为IBM OS/360所使用,现在不再用了。
可变分区主要用于批处理系统。OS有一个表,用于记录那些内存可用/已用。可用的内存被称为孔(hole)。开始时所有内存都可用。
随着进程进入系统,它们被加入输入队列。OS根据进程内存需求和现有内存可用情况,决定哪些进程能分配内存。
任何时候都有一个可用块大小的列表和一个输入队列。OS根据调度算法对输入队列排序。内存不断地分配给进程,直到无法满足下一个进程的需求为止。这时,OS可以等到有足够大的空间,或沿着队列扫描,确认是否有其他内存需求较小的进程可以被满足。
分配和回收
当新进程需要内存时,OS为该进程找到足够大的孔,如果孔太大,就分半:一半分配给进程,另一半回归孔集合。
进程终止,释放内存,内存还给孔的集合。如果新孔与其他孔相邻,则合并为大孔。这时OS可以检查:是否仍有进程在等待内存;新合并的孔是否能满足等待进程。
↑ 以上方法是动态存储分配问题的一个特例。这个问题有很多解法,选择孔的常用方法包括:首次适应、最优适应、最差适应。
首次适应(first-fit):分配首个足够大的孔。
查找可以从头开始;或从上次首次适应结束的位置开始。
最优适应(best-fit):分配最小的足够大的孔。
应查找整个列表,除非列表有序排列。
会产生最小剩余孔。
最差适应(worst-fit):
分配最大的孔。
查找整个列表,除非列表有序。
会产生最大剩余孔。
首次适应和最优适应在时间空间都优于最差适应。首次适应比最优适应较快,空间利用差不多。
碎片
外部碎片
随着进程加载、退出内存,内存空间被分成小的片段。当总的可用内存之和可以满足请求但内存不连续时,就出现了外部碎片问题。
首次适应和最优适应容易产生外部碎片。
首次适应方法的统计说明,无论怎么优化,属于外部碎片的块都占可用块的一半。这一特征称为50%规则。
内部碎片
例如有一个18464字节的孔,一个进程需要18462字节空间。将孔分配给该进程后,会留下一个2字节的孔,维护这一小孔的开销比孔本身大得多。因此,通常以固定大小的块为单位分配内存。采用这种方案,进程分配到的内存可能比实际需要的更大,这两个数字之差就称为内部碎片。这部分内存在分区内部,无法使用。
解决外部碎片的方法
紧缩
移动内存内容,将所有空闲空间合并成一整块。
如果重定位是静态的,且在汇编时或加载时进行,那么就不能紧缩。
只有在运行时进行的动态重定位才能紧缩。
另一解决方案:
允许进程的逻辑空间不连续。这样,只要物理内存可用,就能为进程分配内存。分段和分页可以实现这个方案。
分段
如果硬件能提供更多的内存机制,以便将程序员的内存视图映射到实际的物理内存,系统将有更多的自由管理内存。程序员有更自然的编程环境。
分段提供了这种机制。
基本方法
对于程序员来说,内存是一组不同长度的段,段之间没有顺序。程序员编写代码时不关心各种元素在内存中的位置。
段内的元素通过它们对段首的偏移来指定。
分段(segmentation):逻辑地址空间由一组段构成。每个段都有名称和长度。地址指定了段名称和段内偏移,用户也根据这两个变量指定地址。
为了方便实现,通常用段号替代段名称。逻辑地址的组成:<段号,偏移>
在编译用户程序时,编译器会自动构造段。
一个C编译器可能会创建的段:
-
代码
-
全局变量
-
堆
-
每个线程使用的栈
-
标准C库
在编译时连接的库可能分配不同的段。加载程序会装入所有这些段,为它们分配段号。
硬件分段
用户通过< 段号,偏移 >二维地址引用程序内的对象,实际物理地址内存仍是一维的字节序列。因此需要实现二者间的映射。
地址映射通过段表(segment table)实现。段表的每个条目都有段基地址(segment base)和段界限(segment limit),即段的起始地址和段长度。
段表的使用如图所示,段号(s)用作段表的索引,逻辑地址的偏移(d)位于0和段界限之间,否则会陷入系统操作(报错?),如果d合法就与基址相加得到物理内存地址。
分页
分段允许进程的物理地址空间是非连续的。**分页(paging)**是提供这种优势的另一种内存管理方法。
分页能避免外部碎片和紧缩,分段不行。
分页还避免了将不同大小的内存块匹配到交换空间的麻烦。在使用分页之前,备份存储中也有与内存一样的碎片问题,且访问更慢,因此不可能进行紧缩。
分页已被大多数OS采用。实现需要硬件和OS的协作。
基本方法
实现分页设计将物理内存分为固定大小的快,称为帧或页帧(frame),将逻辑内存也分为同样大小的块,称为页或页面(page)。需要执行一个进程时,页从文件或备份存储等位置加载到内存的可用帧。
硬件支持如图所示。CPU生成的地址分为两部分——页码(p)和页偏移(d)。页码作为页表的索引。页表包含每页所在物理内存的基地址。基地址+页内偏移=物理内存地址。
页大小(帧大小)由硬件决定,为2的幂,从512B到1GB不等。
如果逻辑地址空间为2m,且页大小为2n字节,则逻辑地址的高m-n位表示页码,低n位表示页偏移。如图所示:
在Linux中可以通过系统调用
getpagesize()
获取页的大小
一个简单的例子:逻辑地址n=2,m=4。帧大小为4B,物理内存为32B(8页)。
逻辑地址页码为0,查表得到对应帧为5,因此逻辑地址0映射到物理地址20(=5x4+0);
逻辑地址4所在页码为1,映射到帧6,逻辑地址4映射到物理地址24……
分页本身是一种动态重定位。逻辑地址由分页硬件绑定为物理地址,类似于基址重定位寄存器,每个基址对应一个内存帧。
内部碎片
采用分页不会产生外部碎片,但会有内部碎片。如果进程要求的内存不是帧大小的整数倍,则最后一帧空间用不完。最坏的情况下,申请内存量 % 帧大小 = 1B,即为了1个字节额外分配一整个帧。
页的大小
如果进程大小与页大小无关,则每个进程的内部碎片的均值为半页。
页表的每一项都有开销,所以页大小不一定是越小越好。
现在页大小通常为4KB~8KB。
通常对于32位的CPU,每个页表条目是4B长,大小可能改变。
一个32位的条目可能指向4 294 967 296(232)个物理帧中的任一个。如果帧为4KB(212)大,具有4B条目的系统可以访问16TB(244B,2(32+12)B)大小的物理内存。
系统需要执行进程时,帧的分配情况如图所示。
系统进程需要执行时,检查该进程的大小(按页计算),进程的每页都需要一帧。
进程的一页装入一个已分配的帧后,将帧码放入进程的页表中。(进程的页表,是PCB的数据?)
分页的另一个重要方面是:程序员视图的内存和实际的物理内存清楚区分。逻辑地址对物理地址的映射完全对程序员隐藏。页表只包括进程拥有的那些页,也就保证了程序对内存的正确访问。
OS管理内存,需要**帧表(frame table)**来记录帧的各种信息、使用情况。帧表中,每个条目对应一个帧。
OS为每个进程维护一个页表的副本,如同维护指令计数器、寄存器一样。当进行地址映射时,要用到副本;进程可分配到CPU时,CPU分派器也根据副本定义硬件页表。因此,分页增加了上下文切换的时间。
硬件支持
不同的OS保存页表的方式不同。有的为每个进程分配一个页表,页表的指针存入PCB。CPU分派器要启动一个进程时,先加载用户寄存器,根据用户页表来定义正确的硬件页表值;
有的OS提供一个或多个页表,以减少进程上下文切换的开销。
页表的硬件实现有多种方法。最简单的一种:将页表作为一组专用的寄存器来实现。修改页表的指令只有OS能使用。例子:DECPDP-11
每次访问内存都要经过分页映射,因此效率非常重要。
如果页表较小(如256个条目),页表还可以使用寄存器。但大多数现代计算机都允许页表非常大(100万个条目),对于这些机器,不能用快速寄存器实现,需要将页表存入内存中,并将**页表基地址寄存器(Page-Table Base Register,PTBR)**指向页表。要修改页表只要改变寄存器就行了。
问题:访问用户内存位置需要的时间。
例如:要访问用户内存位置i,则先利用PTBR的值,加上i的页码做偏移,查找页表。这一过程需要访问内存,根据得到的帧码,再加上页内偏移,获得实际物理地址。
这种情况下,访问一个字节需要2次内存访问(找页表条目、访问实际地址),这使得内存访问速度减半。
**解决方案:**采用专用的高速硬件缓冲——转换表缓冲区(Translation Look-aside Buffer,TLB)
TLB条目由2部分组成:键和值
TLB搜索速度很快,且几乎不添加性能负担。
TLB不应大,通常在32~1024之间(应该是B)。
有些CPU采用分开的指令和数据地址的TLB,这能将TLB条目数量扩大一倍,因为查找可以在不同的流水线步骤中进行。(自己的理解:有2个TLB,分别存指令和数据的相关页表条目。因为能并行查找,所以时间上不会有额外开销。)
TLB与页表配合使用:TLB含少数页表条目。CPU产生逻辑地址后,页码发送到TLB。如果找到页码,帧码立即可用。
如果没找到(未命中),就需要访问页表。由硬件自动处理或OS中断处理。得到帧码后,就能访问内存,同时将页码、帧码添加到TLB。
如果TLB条目已满,就选择一个替换。选择策略有 最近最少使用替换(LRU)、轮转替换、随机替换。通常,重要的内核代码会固定在TLB中。
有的TLB在每个TLB条目中还保存有地址空间标识符(Adress-Space Identifier,ASID)。ASID标识每个进程,并为进程提供地址空间保护。
TLB试图解析虚拟页码时,确保当前运行进程的ASID与虚拟页相关的ASID相匹配。如果不匹配就作为未命中。
ASID也允许TLB同时包括多个不同进程的条目。如果TLB不支持ASID,则上下文切换时,TLB应被刷新,确保不会残留上一个进程遗留的物理地址。
有效内存访问时间:
(例)0.8(命中率)100(访问一次内存的时间)+0.2200=120ns
有的CPU会提供多级TLB。
保护
分页环境下的内存保护,通过与每个帧关联的保护位实现。通常保护位存在页表中。
用一个位(bit?)就能定义一个页是可读写或只读。如果对页没有执行正确的操作,会向OS产生硬件陷阱。
扩展:创建硬件提供只读、读写、只执行保护;为每种类型提供单独的保护位,允许这些访问的任何组合。
还有一个位通常与页表中的每一条目相关联:有效-无效位(valid-invalid bit)。有效时,表示相关页在进程的逻辑地址空间内;反之不在。
可以捕捉到非法地址,OS可以设置该位,允许或禁止对某页的访问。
如图所示,试图访问产生页6或页7的地址时,OS会捕捉到非法操作。
问题:程序的地址为010468,页大小为2KB。因为对页5的访问有效,所以1046912287为止的地址都是有效的?(尽管那部分是页内碎片)
共享页
页的优点之一是能共享公共代码。对分时系统很重要。
可重入代码(reentrant code):不会自我修改的代码。
多个进程能执行相同的代码,而每个进程都有自己的寄存器副本和数据存储。
共享代码的只读属性应由OS来强制实现。