现代操作系统
现代操作系统:
抽象是管理复杂性的关键, 好的抽象可以把一个 不可能管理的任务 分为 两个可管理的部件。 抽象的定义和实现 + 用这些抽象解决的问题。
操作系统的任务 就是 创建好的抽象 并实现和管理它所创建的抽象对象。抽象内容是理解操作系统的关键。
作为资源管理者的操作系统:
资源管理包括 以下两种不同方式实现 多路复用(共享)资源:时间上 复用 + 空间上复用 。
-
时间复用 例子 比如 打印机, 问题: 如何决定下一个执行哪个任务? 以及 任务运行的时间
-
空间复用 比如硬盘、内存。 问题: 任务之间的公平, 保护问题。
网络操作系统 与 分布式 操作系统: 网络操作系统 与 单处理器的操作系统没有本质区别。 分布式操作系统 则 与 集中式系统 有本质的区别,用户根本不知道自己的文件存储在什么地方,任务在那个机器上运行,网络中的通信延迟导致 分布式算法必须能够适应信息不完备、信息过时、信息错误的情况。这与 单机形成对比, 单机系统中操作系统掌握着完全的信息。因此 需要更复杂的处理器调度算法 来获得 更大的并行度。
处理器:
- 访问内存 以得到指令或数据 的时间 要比执行指令花费的时间 长得多。所以 CPU内部都保存 一些用来 保存关键变量+临时数据的寄存器。
cpu包含:
- 程序计数器: 下一条指令的内存地址
- 堆栈指针: 内存中当前栈的顶端,用于 函数调用中的 参数传递
- 程序状态字 PSW program status word: 包含 条件码 + cpu优先级 + 用户态内核态 + 控制位 (在系统调用中、IO 中 PSW 作用非常重要)
- 指令流水线: 将指令分解为 取指 + 解码 + 执行 多个单元 并行执行的结构。
超标量CPU: 此设计中, 存在多个执行单元比如: 1个CPU 用于整数计算 + 1个cpu用于浮点数计算。 该设计 可能导致 程序中的指令 经常不按照 顺序执行。在多数情况下, 硬件负责 保证这种运算结果与顺序执行指令的结果相同,但是,应有部分 复杂情形 需要操作系统 来处理。
内核态 + 用户态: PSW 中存在 二进制 位 控制该模式,内核态: cpu 可执行 指令集中的任何一条命令, 用户态: 则 只能执行指令集的一个子集。(比如IO 操作) 为了从操作系统获得服务, 用户必须 使用 系统调用 以陷入内核 调用 操作系统。TRAP 指令 从用户态切换到内核态。
计算机使用陷阱 而非 一条指令来执行 系统调用, 而其他的多数陷阱 是由硬件引起的,用于 异常的发生,比如 除以 0。
多线程 和 超线程: 多线程允许 cpu 保持两个不同的线程状态, 然后在纳秒级别内进行切换,多线程并非真正的并行处理, 在同一时刻 只有一个进程运行,但是线程的切换时间 为纳秒级别
多核CPU
- GPU: Graphics Processing Unit 上万个微核组成的处理器, 擅长大量的并行简单运算,很难编程, 虽然GPU对于操作系统 非常有用(比如加密 ,图像处理 etc) 但是 操作系统本身 不太可能 运行在GPU上。
存储器系统: 寄存器 高速缓存 主存 磁盘
- 任何缓存系统 都需要考虑的问题:
- 何时把一个新的内容放到缓存
- 把新的内容 放在缓存的哪一行上
- 在需要时, 应该把那个内容村缓存移走
- 应该把新移走的内容放在某个较大存储器的何处
通常通过 所引用内存地址的 高位地址 计算应该使用的缓存行。比如 对于64字节的 4096个缓存行 以及32位地址, 其中 6-17位 用来定位缓存行,而 0-5 则用来确定缓存行中的字节
- 缓存层级设计:
- 缓存是好方法: 现代cpu有两个L1缓存,第一个 L1 总是在 CPU中, 用来将 已解码 的指令调入CPU的执行引擎。 第二个L1 则存放 频繁使用的数据字节。
- 二级缓存L2 用来存放最近使用的若干兆内存字节,L2 延迟 1-2个时钟, L1 则没有延迟
- 在多核芯片中, 设计师 需要确定缓存的位置, Intel 中多核 CPU 共享L2缓存, AMD 则 每个核 都有自己的 L2缓存。Intel 需要设计 更复杂的缓存同步问题, AMD 则需要在 缓存一致上 存在困哪。
- 存储设备类型:
- 内存: RAM random access Memory:
- ROM: read only memory
- Flash Memory:介于 RAM 与 磁盘之间, 与磁盘存储器不同, 如果 flash 擦除次数过多, 就会被磨损。
- CMOS: 用于保存时间 和日期,还可以用来保存 配置参数 等,比如 那个是启动磁盘。
- 磁盘:
- 固态硬盘: 数据存储在Flash memory (闪存)中, 应该是一份 大的 Flash。
- 虚拟内存: 将程序放在磁盘上,而将主内存作为一种缓存, 用来保存频繁访问的区域。能够提供大于内存 的 ”内存地址“ MMU 是其实现中的重要一环。
缓存 + MMU 对于系统的性能 具有重要的影响,程序间上下文切换, 需要修改MMU的配置,将 需要同步的数据 从内存写回到 磁盘中。
IO 设备包括: 设备控制器 + 设备本身, 控制器 从操作系统接受指令,完成数据的处理。 控制器的任务是为操作系统提供一个简单的接口, 来处理 设备提供的数据。
驱动程序
- 设备控制器的不同,导致需要不同的 设备驱动程序。 并为不同的操作系统提供相应的驱动程序。(即驱动程序 为 设备控制器 与 操作系统的桥梁。 设备控制器为 为了简化驱动程序 对操作系统的接口 而设计的,用来隐藏 设备的复杂性的控制设备)
- 大部分的驱动程序都需要装入操作系统中,这样他可以在 内核态 运行, 但是其也可以在 内核外运行。 现代的Linux与Windows也的确对该种方式提供了一定的支持,但绝大部分 驱动程序 依然需要在内核态运行, 非常少的 现代系统 能够在内核外运行全部驱动程序。
- 在用户态运行的驱动程序 必须能够 以某种受控的 方式访问设备,然后这并不容易。
将设备驱动程序 装载到 操作系统有三个方法:
- 将内核与驱动程序重新链接, 然后重启操作系统
- 在操作系统文件中设定一个入口,并通知该文件需要一个设备驱动程序, 然后重启系统, 在启动中, 操作系统 去寻找所需的设备驱动程序 并将其装载 (Windows 工作方式)
- 操作系统在运行时 接受新的设备驱动程序 并立即 将其安装好, 无需重启, 即是热插拔, USG IEEE 1394设备。
输入+输出的三种方式:
- 忙等待: 用户程序发出一个系统调用,内核将其翻译成对应的 设备驱动程序 调用, 然后设备驱动程序 在一个连续不断的循环中检查设备,查看该设备是否万和城呢够了工作, 当IO 接手后, 设备驱动程序 将数据送到指定的地方, 并返回,然后操作系统将控制返回给 调用者,确定为 占据CPU, CPU 会一直轮训 设备知道对应的IO操作完成。
- 设备驱动程序 启动设备并且让该设备在操作完成时 发出一个中断, 设备驱动程序在这个时候 返回, 操作系统 阻塞调用者,并进行其他工作, 当设备驱动程序 检测到该设备完成时候,将发出一个中断通知操作完成。其中中断是一个非常重要的概念,IO 分为三步, 1) 设备驱动程序 通过写设备寄存器通知设备控制器做什么,然后 设备控制器 启动该设备, 当设备控制器完成操作 2) 使用特定的总线发送信号给中断控制器芯片
- 中断控制芯片 接受中断,他会在CPU芯片的一个管脚上声明,
- 中断控制器将该设备的编号 放到总线上,这样 CPU可以读取总线,并指导那个设备完成了操作。 中断 处理程序: … d第三种) 使用特殊的直接存储器访问 (Direct Memory Access, DMA) 芯片, 它可以 控制内存和某些控制前 之间的位流, 而无需 持续的CPU 干涉,cpu 对dma芯片 进行设置, 说明 需要传送的字节数、有关设备、和内存地址 以及操作方向, 接着启动DMA 当DMA芯片完成时,它会 引发一个中断,。
中断: 中断会发生在非常不合适的时刻, 比如另外一个中断程序正在运行时发生,如果此时接受中断 可能导致 中断程序的递归处理。所以cpu 会关闭中断并在稍后在开启中断,中断关闭时: 任何已经发出中断的设备,可以继续保持其中断信号,但是cpu不会被中断,直到中断再次启用为止, 如果多个设备发生了中断,则 中断控制器 将决定先处理那个中断, 通常这取决于 事先赋予每个设备的静态优先级,最高优先级的设备得到优先处理,其他的设备则等待。
总线以及其演变
- 总线:单总线(IBM PC) 因为 处理器以 传输 需求的提升被废弃。
- PCIe: 其之前的总线 都是并行 且共享的。即: 共享总线架构, 多个设备使用一些相同的导线传递数据,因为多个设备同时需要发送数据时,需要进行仲裁来决定哪一个设备优先使用。其传输方式 是”并行的“ 即 通过多条导线发送数据的每一个字,例如 一个32位 数据通过 32条并行的导线进行发送。 PCIe 则使用分离的端到端的链路,进行串行总线架构,通过一套 被称为 数据通路的链路传递集合了所有位 的一条消息,类似网络包。 即: 同时可以多个设备共享使用总线。
- USB (universal serial bus) 是用来将所有的慢速设备 与计算机进行连接的。USB 是一种集中式总线, 其根设备 每1ms 轮训一次IO 设备,看是否有消息收发, usb 1.0 处理 12Mb/s, 2.0 提高到 480Mb/s 3.0 则可以达到 不小语言 5Gb/s 的速率。
- 在即插即用的IO设备之前, 每个IO卡 都有一个固定的 中断请求级别 和 用于 其IO寄存器的固定地址, 比如 键盘 中断级别为1, 并使用0x60-0x64 的IO地址, 即插即用 所做的操作 即是: 系统 自动的收集有关IO设备的信息,集中赋予 中断级别和 IO地址,然后通知设备控制器其所使用的数值, 这项工作 与 计算机的启动密切相关。
操作系统分类:
- 大型机操作系统 主要面向多作业的同时处理, 多数这样的作业 需要大量的IO能力, 系统主要提供三类服务: 批处理、事务处理、分时。事务处理系统负责大量小的任务, 比如航班预订任务, 每个业务量很小,但是 系统需要每秒处理上千个业务。在部分领域 大型机系统 正在被 Linux 取代
- 服务器操作系统: 服务器可以是 大型的个人计算、工作站、甚至是大型机, 他们通过网络为若干个用户服务,。
- 多处理器操作系统: 将大量CPU链接成单个系统。 根据连接和共享方式的不同,这些系统称为 并行计算机 、多计算机、多处理器。个人计算机也开始普及 多核芯片。
- 个人计算机操作系统
- 掌上计算机操作系统:
- 嵌入式操作系统
- 传感器节点操作系统: 许多用途需要微小传感器的节点网络。比如森林火灾探测,气象探测器。 此类传感器 能源资源有限。 每个节点上运行一个小型但是真实的操作系统,通常操作系统是事件驱动的,可以相应外部事件 或者基于内部的时钟进行周期性检测,该系统必须小而简单,InyOS 是一个知名的该类操作系统
- 实时操作系统: 系统的特征是将时间作为关键参数,比如工业过程控制系统,焊接机器人 焊接的太早 或者太晚 都有可能造成物品 损坏,所以需要在规定的时间内进行操作, 这就是硬实时系统。另一个则是软实时系统, 即: 偶尔的超时是可以接受的。
- 智能卡系统: 比如包含一块cpu芯片的信用卡
操作系统周边概念:
- 进程: 正在执行的程序,容纳一个程序所需要的所有信息的容器。包括相关的地址空间(存放 可执行程序, 程序数据,以及堆栈 ) + 进程相关的资源集 + 寄存器 + 打开的文件清单 + 信号 等 以及 运行进程需要的其他的信息。
- 进程表: 操作系统中存储 每一个进程有关的所有信息 放在一张表中:进程表。
- 系统调用: 创建进程 、 申请内存、等待进程结束 etc
- 信号: 无论进程在做什么,进程将被暂时挂起, 然后运行进程信号处理器, 这些信号 是软件模拟的硬件中断。
- 系统管理授权; 启动进程的用户UID, GID group id
地址空间: 虚拟内存技术, 本质上 操作系统创建了一个地址空间的抽象,作为进程可以引用地址的集合, 改地址空间与物理内存解耦,可能大于也可能小于 物理空间。 对地址空间和物理空间的管理组成了操作系统功能的一个重要部分。
文件系统:
- 文件: 支持操作系统的另一个关几年概念。 一项重要功能 即是:隐藏磁盘和其他IO设备的细节特性, 提供一个良好的清晰的独立与设备的抽象文件模型。
- 目录: 将文件分类成组。 目录中的目录项可以使文件或者目录, 从而产生了一个层次结构—-文件系统。
- 进程和文件层次都可以组成为树状结构,但是: 一般进程的树状结构 不会超过三层, 而文件结构 则 不受控制, 进程树 是暂时的,而目录层次则是 永久的。
- 每个进程 有一个 工作目录, 对于 非绝对路径 都将 从该工作目录下 开始寻找,
- 安装文件系统: 如果安装一个 DVD光盘 时候,我们需要mount 系统调用 来讲Cd-rom上的我呢间系统连接到程序所希望的根文件系统上。
- 特殊文件: 提供特殊文件是为了是IO设备看起来 想文件一般。 这样就可以使用系统调用 来读写文件, IO 设备也可以通过同样的系统调用 来进行读写,包含: 块特殊文件, 字符特殊文件。 可以随机存储区的块组成的设备,比如磁盘。 字符特殊文件 用于打印机, 调制解调器,输出为字符流的设备。 按照惯例,特殊文件 放在/dev 目录下。
- 管道: 虚拟文件, 可以连接两个进程,通过在管道上写读 来进行进程间通讯。
- 保护: 管理系统的安全依靠 操作系统。 例如文件只能授权用户使用。 在文件系统中,对每个文件 富裕一个 9位 的 二进制保护码
操作系统体系结构:
- 单体系统: 整个操作系统 在内核态 以单一程序的方式运行, 整个操作系统 以过程集合的方式编写, 链接成一个大型的可执行文件 的二进制程序。
- 层次式 系统: 其上层软件都是在下层软件的基础上进行构建的。Dijkstra 领导开发 的THE 系统 是此模式的第一个系统, 该系统分为六层。MuLTICS 系统 采用了更进一步的通用层次化概念。 采用同心环 构成, 内环比外环 有更高的级别,外环的过程调用内环过程时候,他必须执行一条等价于系统调用的trap指令, 内环对调用进行详细的参数检查校验,执行,然后返回给内环结果。
- 微内核: 背后的思想是: 为了实现高可靠性,将操作系统划分为小的、良好定义的模块,只有其中的一个模块 —微内核 运行在内核态, 其余的模块 由于功能相对较弱,则作为普通用户进程运行,特别的,由于把每个设备驱动程序 作为普通用户进程,这些模块的崩溃,并不会导致系统的死机。相对应的 单体系统中 因为所有的驱动程序都在内核中, 一个驱动程序的故障 很有可能导致系统的崩溃。
- 客户端-服务器模式:一个微内核思想的变体是将进程分为两类: 服务器 + 客户端, 每个服务器提供某种服务,客户单使用这些服务。 通常 在系统最底层是微内核。泛化的现象为: 服务器与客户端运行在不同的计算机上。他们通过网络连接, 成为 web服务。
- 虚拟机:
- 外核:
虚拟机: todo
进程 + 线程:
相关概念
- 伪并行: 并行的错觉,瞬间仍然是一个进程执行,只是在1s内能够运行多个进程。 区分于 多处理器系统。
- 前台进程: 与用户交互的进程。
- 守护进程:与用户交互 无关的,后台进程。(简单的定义,在Linux programming interface 中存在更准确的定义)
进程:
- 进程终止: 1) 正常退出 2) 出错 或 严重错误 3)被杀死
- 编译器在 main中添加 exit系统调用
- 除以0,非法操作 等错误,Linux 中能否定义 此类错误处理器?
- kill 系统调用
- 层次结构:
- windows 中 不存在进程间的层次结构。
- linux 中 init 启动之后, 会为每个终端创建一个新进程,这些进程等待用户登录,如果登录成功,该进程执行一个shell终端 来接受命令,所接受的命令会创建更多的 进程, 所以整个系统会成为一个以init 为根的进程树。
- 进程组: 进程 + 后代进程
- 进程状态以及其之间的转换:todo
- 理论来说:调度程序( 包含 中断处理, 启动、停止进程等 功能) 是 进程的根基。
- 假设: 程序的IO操作 / 停留在内存的时间(运行态) = P 则 n个进程的cpu利用率为 1 - P ** n。 该模型 可以简单的抽象 估计 进程数目 对于cpu利用率的提升。
线程:
- 特点:
- 并行实体, 共享同一个 地址空间、数据(即:全局变量 打开文件描述符,定时器, 信号处理器 etc)
- 比进程轻量,创建速度是进程的10-100 倍
- 在多核CPU中可以真正的并行
- 多线程 可以调用 阻塞系统调用,但是依然能够 实现并行。 否则需要 单进程 状态机来 执行非阻塞的调用 并在 各个状态机之间不断切换。即: 多线程 具有 不用更改代码 很容易的实现调用 阻塞系统调用,简单的实现 并行。
- 源于独立的两个概念: 资源分组 与 执行。 将两个概念分离开来 则形成了: 进程为系统的资源管理单位, 线程 为执行的单位。
- 相关调用 : thread_create, thread_exit, thread_join, threa_yield
- 线程可以存在层次关系,但是多数时候,并不需要,即: 线程之间是平等的。
- 实现方式: 1) 用户空间实现 2)内核实现 3) 混合实现
- 用户空间实现: 内核对用户中的多线程一无所知,即 内核视角中: 单进程
- 优点: 线程间的切换 无需 系统调用,不需要在用户空间 与 内核空间 中进行切换。所以 速度 比 内核快一个数量级。
- 方便的实现自己的调度算法
- 问题: 如何实现阻塞的系统调用
- 缺页中断问题:缺页中断 会阻塞整个进程导致进程中的所有线程阻塞
- 无时钟中断:即一个线程不自己yield,导致其一直占用cpu,导致其他thread饥饿
- 用户空间实现: 内核对用户中的多线程一无所知,即 内核视角中: 单进程
进程间通讯:
- 3个问题: 三个问题中,出第一个问题外, 2,3 问题对于 进程、线程来说同样适用。第一个问题对于线程来说 比较简单,因为他们共享同样的地址空间。
- 一个进程如何传递消息给另一个进程
- 确保两个或者更多的进程在关键活动中不会出现交叉
- 进程间正确的执行顺序,比如A进程产生数据而B进程进行打印,那么 B在打印之前 必须进行等待,知道A已经产生数据
- 简单概念:
- 竞争条件: 多核的普及,使条件竞争越来越普通。
- 临界区:对共享内存进行访问的程序片段 被称为 临界区。
- 互斥进入临界区 mutual exclusion 的几种方法:
- 屏蔽中断: 屏蔽所有中断,在离开时 再开打开中断。屏蔽中断后,时钟中断也被屏蔽。cpu只有发生时钟中断或其他中断时才会进行进程切换。这样屏蔽中断之后,cpu就不会切换到其他进程,所以进程能够安全的更改内存,而不必担心其他家吃的介入。 适合os使用(比如在更进程队列等数据时),并且只对单个cpu起作用。
- 设定锁变量: 普通的锁变量 对于 进程调度 产生的竞争条件 不起作用
- 严格轮换法: 两个进程相互等待对方的吧变量标志,严格的进行 进程A,B交替执行。
- Peterson 算法:
- TSL指令:(测试并加锁 test and set lock) 该指令将锁住 内存总线,已禁止其他CPU在被指令结束之前 访问内存。执行如下原子操作: 将内存字节lock读到寄存器RX中,然后在该内存地址上存储一个非零值。 一个可以代替该命令的命令为XCHG,该指令原子性的交换两个位置的内容。本质上同TSL指令一样
以上的锁都称为spin lock 自旋锁,即在没有获得锁的时候 进行 cpu的忙等待 busy waiting,因为此种方式 非常浪费cpu时间,所以同行应该尽量避免, 只有有理由认为 忙等待时间是非常短的时候 才使用忙等待
自旋锁可能存在的问题: 1)优先级反转问题: 即两个进程 高优先级H与低优先级L,调度规则为 只要H处于就绪状态 即可运行。 在某一时刻 L处于临界区中,H处于就绪态,准备进入临界区,此时H开始忙等待,由于H就绪就不会调度L运行,导致L无法完成工作,离开临界区。所以H将无限的等待下去
-
互斥进入临界区的 睡眠与唤醒 解法: 假设存在连个系统调用 sleep与wakeup,sleep引起系统阻塞,即被挂起。直到另外一个进程将其唤醒。wakeup 调用一个参数,即被唤醒的进程。
- 生产者消费者问题:
#define N 100
int count = 0;
void producer(void)
{
int item;
while(true){
item = produce_item();
if(count == N) sleep(); // 缓冲区满 则 休眠
insert_item(item);
count = count + 1;
if (count == 1) wakeup(consumer); // 缓冲区
}
}
void consumer(void)
{
int item;
while(true) {
if(count == 0) sleep(); // 缓冲区空,则进入休眠状态
item = remove_item();
count = count + 1;
if(count == N - 1) wakeup(producer); // 缓冲区满,则 唤醒 生产者
consume_item(item);
}
}
此代码结构中存在两个问题:1) 共同修改的count变量 未进行访问保护 2) 消费者 在逻辑上 未进行睡眠时,生产者的wakeup 信号丢失了。情况如下: 在 count 为0时,消费者 进行消费,读取了count 为0。此时调度器 决定执行 生产者,生产者 生产完毕,并执行 wakeup。因为消费者没有sleep所以生产者的wakeup信号丢失了,之后 消费者执行,count == 0 sleep操作。消费者将阻塞。而生产者 则一直生产 直到缓冲区已满 也阻塞起来。
信号量:Dijkstra 提出来的算法, 使用整型变量累计唤醒次数,供以后使用
- 其存在两种操作;down, up(类似于 sleep 与 wakeup)
- down操作; 检查信号量 数值是否大于 0,大于0 则将其数值减一。并继续,若该值为0 则进程将睡眠。操作为原子操作, 保证一个一个信号的操作完成,则 在改造做完成或阻塞之前,其他进程 均不允许访问该信号量。
- 原子性 对于解决 同步问题和避免竞争问题 是必不可少的。
up操作; 对信号数值+1, 如果一个或者多个进程在该信号量上 睡眠(即因为之前的down操作无法完成) 则由系统 选择其中的一个 进程,并允许该进程完成它的down操作。否则,将信号量数值+1。 则 对一个有进程在其上睡眠的信号量执行一个up操作之后,该信号量的值依然是0,但是其上的睡眠进程少了一个。区别于down操作,不会有进程在up操作上阻塞。 - 因为其中 依然需要保证原子操作,即: 依然需要使用 TSL或者XCHG 指令 来保证同一个时刻,只有一个cpu在对 信号量进行操作。但是需要注意 区别于 使用 TSL spin lock 自旋锁来 使生产者 等待 消费者 消费 是完全不同的。即:up down操作 中使用TSL 只是为了更新 信号量,然后睡眠或者唤醒进程,而自旋锁 则 对 消费者或者生产者 在临界区中的执行操作 一直进行等待
互斥锁: 不需要 信号量的计数功能, 将信号量的初始数值设定为1, 即 保证只有一个进程能够进入 临界区,称为 二元信号量,即为互斥锁。又称为互斥量。
- 使用信号量 来实现 生产者消费者问题:
#define N 100
typedef int semaphore;
semaphore mutex 1; // 将 信号量的数值设置为1,即为 二元信号量,互斥锁.
semaphore full 0;
semaphore empty N;
void producer(void)
{
int item;
while(true) {
item = produce_item();
down(&empty); // 将 空槽位 数量 - 1
down(&mutex); // 获取互斥锁
insert_item(item);
up(&mutex); // 释放互斥锁
up(&full); // 将 满槽数 + 1
}
}
void consumer(void)
{
int item;
while(true) {
down(&full); //将 满槽数 - 1
down(&mutex); // 获取互斥锁
item = remove_item();
up(&mutex); // 释放互斥锁
up(&empty); // 将 空槽位 数量 + 1
consume_item(&item);
}
}
解释: 其中信号量 full以及 empty 作为 同步使用,即保证 消费者 与 生产者 按照 正确的顺序发生。而mutex 则 互斥进入临界区。
- 进程实现的一些方法:
- 线程能够 共享内存空间,所以很方便的使用上述技术。但是进程是否可以呢? 答案是可以: 1) 使用 共享数据结构,比如信号量,可以放在内核中,并只能通过系统调用来访问。 2) Linux提供系统调用方法 来使多个进程共享 部分地址空间。 3)可以使用共享文件。
- 如果两个进程共享全部或者部分内存的话,进程和线程之间的差别就变得模糊起来。但无论怎样差别还是有的, 共享地址空间的进程依然依然持有各自打开的文件,定时器 以及其他的一些进程特性,多线程中 则共享全部进程特性。
快速用户互斥量 futex: 结合了 自旋锁 与 信号量的 优点。
- 短时间的自旋锁 会很快,但是 如果等待时间长,则会浪费cpu周期,如果有很多竞争,那么阻塞进程 并且当锁被释放时候 让内核接触阻塞会更加有效。
- futex 分为两部分: 内核服务 + 用户库。内和服务提供了一个队列,允许多个进程在一个锁上进行等待, 他们不会运行,除非显示的解除阻塞。将一个进程阻塞 放到等待队列中。
- 在没有竞争的情况下,futex 运行在用户空间中,线程通过原子操作TSL 来争夺锁。如果发生竞争, 即该锁被其他线程持有,那么线程必须等待,这种情况下 futex锁不自旋,而是使用系统调用将自己放到内核的等待队列上,当一个线程使用完该锁时,通过原子性操作释放锁之后,并检查内核的等待队列上是否有线程阻塞,如果有 则对一个或者多个线程解除阻塞。
条件变量:
- pthread: 中的互斥量。pthrea提供了许多的同步线程函数,其基本机制即是 使用一个可以被锁定和释放的互斥量 保护每个临界区。
- 除了互斥量外:还提供了另外一种同步机制 条件变量。条件变量 允许线程由于一些未达到的条件而阻塞。 大部分情况下 互斥量与条件变量是一起使用的。
- pthread 互斥量相关: todo 表格
- pthread与条件变量相关:todo表格
- 条件变量与互斥量经常一起使用,这种模式: 让一个线程锁住一个互斥量,进入临界区 当不能获得期望的结果时 等待一个条件变量。最后另一个线程向他发送信号,使它可以继续执行。pthread_cond_wait 原子性的调用并解锁他持有的互斥量,所以互斥量也是其参数之一。
-
值得指出的是:条件变量并不在内存中,如果将一个信号传递给一个没有线程等待的条件变量,那么这个信号将消失,编码时应该小心的避免信号丢失情况的发生。条件变量 并不能像信量那样积累信号以便 以后使用。
- 下面是生产者消费者的 互斥量 + 条件变量 使用方法:
#include <stdio.h>
#include <pthread.h>
#define MAX 1000000
pthread_mutex_t the_mutex;
pthread_cond_t condc,condp;
int buff = 0;
void *producer(void *ptr)
{
int i;
for(i = 1; i < MAX; i++){
pthread_mutex_lock(&the_mutex);
while(buff != 0) pthread_cond_wait(&condp, &the_mutex);
buff = i;
pthread_cond_signal(&condc);
pthread_mutex_unlock(&the_mutex);
}
pthread_exit(0);
}
void *consumer(void *ptr)
{
int i;
for(i =1; i<= MAX; i++){
pthread_mutex_lock(&the_mutex);
while(buff == 0) pthread_cond_wait(&condc, &the_mutex);
buff = 0;
pthread_cond_signal(&condp);
pthread_mutex_unlock(&the_mutex);
}
pthread_exit(0);
}
int main(int argc, char **argv)
{
pthread_t pro,con;
pthread_mutex_init(&the_mutex, 0);
pthread_cond_init(&condc, 0);
pthread_cond_init(&condp, 0);
pthread_create(&con, 0, consumer, 0);
pthread_create(&pro, 0, producer, 0);
pthread_join(pro, 0);
pthread_join(con, 0);
pthread_cond_destroy(&condc);
pthread_cond_destroy(&condp);
pthread_mutex_destroy(&the_mutex);
}
- 以上是使用 互斥量 + 条件变量 的典型实例。 注意其中 的pthread_cond_wait的使用 以及其前缀条件。 应该总是使用 while循环 来检查 线程被唤醒后,条件是否满足。
管程: 高级同步原语 其重要的特性为: 任一时刻 管程 中 只能有一个活跃的线程。 这一特性使管程能够有效的完成互斥。管程是编程语言的基本组成部分,编译知道如何 处理对管程的调用。 进入管程的互斥 通常由编译器负责。其通常的的做法为使用一个 互斥量。但是 依然存在问题, 在上面的例子中,我们使用 条件变量来使线程从 阻塞状态中恢复。所以 我们 也需要同样的 同步机制 。即 我们在管程中引入了 条件变量。
- 当一个管程过程发现自己 不能够继续进行下去的时候, 它将会在某个条件变量上进行wait操作,该操作将导致线程阻塞,并释放临界区的锁,导致其他的管程进入临界区域,并等待其他线程在条件变量上的唤醒操作。
- 当一个线程对阻塞在条件变量进行唤醒操作时, 即发送signal时,为了避免管程中同时存在两个活跃线程,我们需要一条规则来确定 唤醒操作 之后 应该怎么办:
- 两个方法: 1)让新唤醒的线程运行,而挂起另外一个。2)执行signal的线程立即退出管程,即signal应该为函数的最后一条语句。
- Java中的管程实现: 使用sychronized 标记管程函数,也不使用显示的条件变量,而是提供了对应的函数wait,notify。
总结:信号量的提供已经很好地解决了,线程之间的临界区同步,以及发生顺序问题,确 为什么依然 要提供 互斥量 + 条件变量的组合呢?
- 现实的情况比较复杂,. 信号量是一个比较高级的操作原语,其可以使用互斥量 + 条件变量 + counter来实现。对程序暴露出更低级的操作 是非常有必要的。
- 互斥量 可以简单地 实现 1个互斥量 + n个条件变量 的 组合实现。即 在临界区中 可以实现多种情况的 wait 与 signal
- 互斥量 + 条件变量 与 信号量 的使用 规则形式不同。信号量 需要先 down(full) 然后才 down(mutex) 进入临界区。而 互斥量 +条件变量的使用 ,则 是 先 lock(&mutex) 然后 根据条件 进行条件变量 的wait。
- 管程模式 导致 管程 中确实需要 条件变量。 因为管程 优先锁定互斥量进入临界区。
消息传递: message passing, 使用两条 通信原语:send receive,他们是 信号量而不像管程,是系统调用 而不是语言成分。
- send(destination, &message)
-
receive(source, &message)
-
设计要点: 系统面临着 许多信号量 与管程 所未涉及的问题和设计难点。 特别是 通过网络在不同机器之间进行通讯的情况。 可能会发生 网络问题,导致消息丢失等情况的发生。 为了防止消息丢失,需要接收方,向发送方回复 确认消息,防止发送方 未收到确认消息超时重发。
- 使用 消息传递解决 生产者-消费者问题:即:消费者 receive消息,而发送者 send消息。
- 消息传递方式 可以有多种形式: 一种方法则是 引入信箱(mail box )用来对一定数量的消息进行缓冲的地方。 一个进程向一个满的信箱发送消息时,进程将被挂起。直到信箱中有消息被取走。
- 通常 在并行程序设计系统中 使用消息传递。例如著名的消息传递接口(Message-Passing Interface MPI )
屏障: 用于同步进程组,一些应用中划分了若干阶段,并且规定,除非所有的进程 都准备就绪,否则任何进程都不能进入下一个阶段。 可以通过在每一个阶段的结尾安置 屏障 来实现此类型行为。 当一个进程到达屏障时,他就被阻拦,直到所有进程都到达 该屏障为止。屏障用于一组进程的同步,
调度: 当计算机同时存在多个进程或者线程同时竞争cpu,只要有两个或更多的进程处于就绪状态,如果只有一个cpu发生,那么就必须选择下一个要运行的进程,在操作系统中完成选择工作的这一部分称为 调度程序。该程序使用的算法称为 调度算法。
- 周边概念:
- 进程调度 与线程调度 没有本质区别,但是线程调度 依然会存在 一些独特的问题。
- 进程的切换 代价 是比较高的。 首先 用户态 必须 切换到内核态,然后保存当前进程的状态,包括在进程表 中存储寄存器的数值、内存映像等 以便进程的 重新装载,同时 进程切换 还要使整个内存高速缓存失效,强迫 从换内存中 动态重新装入两次(进入内核一次,离开内核一次)。即:进程切换会耗费cpu时间,
- 进程 行为: 几乎所有的进程的IO请求与计算都是 交替发生的。即:cpu 运行一段时间之后,然后发起一个系统调用 以 读写文件,在完成系统调用之后,cpu开始计算,直到他需要读取更多的 数据 为止。 请注意某些IO活动 可以看做计算 。 当一个进程 等待外部设备 完成工作 而被阻塞时,才是IO活动。
- 根据检查花费在 cpu计算与 IO上的时间 可以将进程划分为 两类:
- 计算密集型:较长时间的cpu集中使用 + 较小频繁的IO等待
- IO密集型: 较短时间的cpu集中使用 + 频繁的IO等待
随着cpu变得越来越快,更多的进程倾向为 IO 密集型。因为cpu的改进比磁盘的改进快得多。结果是, 未来对于 IO密集型进程的调度处理 更加重要。两个结论:1)IO密集型进程应该尽快得到调度,而使磁盘得到充分的发挥, 2) 如果进程是IO密集型,则可以 多运行一些这类进程 以便 保持cpu利用率。
- 何时调度: 调度时机
- 在创建一个新的进程后
- 在一个进程退出时
- 在一个进程阻塞在IO和信号量上 或由于其他原因阻塞时
- 在一个IO中断发生后
- 硬件时钟中断: 可以在 每个时钟中断 或者每k个时钟中断 时,做出决策。 感觉如何处理时钟中断可将调度算法 分为两类:
- 非抢占式 调度算法 挑选一个进程 然后让该进程运行直到被阻塞 或者 进程主动释放cpu,即是该进程运行数个小时,依然不会被挂起。这样的结果是,在时钟中断 发生时不会进行调度,在处理完 时钟中断后,如果没有更高优先级的进程 等待到时,则中断的进程会继续进行。
- 抢占式调度算法: 挑选一个进程并让该进程运行某个固定时段的最大值。如果该时间段结束,该进程依然在运行,将被挂起,调度程序将挑选另一个进程运行。抢占式调度算法。需要在时间末端发生时钟中断。以便把cpu控制返回给调度程序,如果没有可用的时钟,那么不能够实现 抢占式调度算法。
- 调度算法分类:不同的环境 需要不同的调度算法,以为需要不同的目标。分为三类环境:批处理、交互式、实时
- 批处理环境中: 可以运行非抢占式算法,
- 交互式环境中:抢占式必须要的,
- 实时系统中: 抢占有时是不需要的。以为进程知道他们可能会长时间得不到运行,而尽快完成任务。与交互环境的区别,在于:实时系统只运行那些用来推进现有应用的程序,而交互是系统是通用的。 他可以运行任意的非写作甚至是恶意的程序。
- 调度算法的目标:取决于环境目标 + 通用目标:
- 通用目标:
- 1)公平, 相似的进程得到的cpu时间是相似的,不同类型的进程 得到的计算 可以采用不同的方式,或者有差异。
- 2) 保持系统中的所有部分尽可能忙碌,例如cpu、磁盘。 在批处理系统中,调度程序控制那些任务的调入内存。 内存中既有 cpu密集型 与IO密集型 作业 要优于 先运算完 cpu密集型任务,然后再运算 IO密集型任务。 因为 在集中计算 所有的cpu密集型任务时,cpu 保持忙碌,任务保持对cpu的争抢,而磁盘保持闲置状态。显然 可以通过仔细的安排任务,能够 使整个系统运行的更好一些。
- 通用目标:
场景目标:
批处理系统: 吞吐量- 每小时做大作业量, 周转时间- 从提交到终止间的最小时间, CPU利用率- 保持cpu始终忙碌。
交互式系统: 响应时间–快速响应请求,均衡性– 满足用户的期望。
实时系统–满足截止时间: 避免丢失数据,可预测性- 在多媒体系统中避免低品质。
- 批处理作业中的 三个衡量指标: 吞吐量,周转时间,cpu利用率。
- 吞吐量(throught): 系统每个小时完成的工作量。每小时完成50个作业好于 完成40个作业。
- 周转时间(turnaround time): 从一个作业提交时刻开始直到 作业完成时刻为止 的统计平均时间。度量了 用户要得到输出所需要的平均时间,越小越好。
- cpu利用率: cpu利用比率。
吞吐量与 周转时间之间的冲突: 例如 对于确定的短作业 与长作业 的一个组合。 总是运行短作业而不是长作业,会获得出色的吞吐性能。但是其代价为 长的作业周转时间较差。即得到了较高的吞吐量 ,但是 平均周转较差。
交互式系统的不同指标: 最小响应时间: 从命令到得到响应之间的时间。均衡性: 用户对于一件事情完成需要多少时间 总是一个固定的期望。
实时系统: 特点是 必须满足截止时间要求。
批处理中的调度算法:
先来先服务:first-come first-serverd 算法。该算法 按照 进程请求cpu的顺序 使用cpu, 可以想象有一个 就绪进程的队列, 产生新作业时,新进程排在就绪队列 尾部。 当正在运行的进程由 就绪变为阻塞时,就绪队列中的第一个进程运行,当被阻塞的进程变为就绪状态时,就像一个新来的作业进程一样,排到就绪队列的末尾。 该类算法 实现起来比较简单。但是存在一个明显的缺点,即: 对于IO密集型进程不太友好,没有很好的考量 是系统中的 设备是否充分利用。 考虑 两个进程,一个cpu密集型进程 每次运行1s的cpu计算 和一个 IO密集型进程,很少利用cpu,但是需要都要运行1000次的磁盘读写操作才能完成任务。 如果 按照该类调度算法 的话,则IO密集型进程需要运行1000s才能够运行完成,但是如果 使用抢占式 调度算法的话,则可以 没10ms进行一次抢占调度,则 IO密集型进程将在10s内完成,而对于 cpu密集型进程 则没有太大影响。
最短作业优先: 适用于一种运行时间预知的非抢占式调度算法。 即:作业按照 完成时间 从小到大排列,即 shortest job first.
考虑有4各作业。其运行时间 分别为 a ,b,c,d 其平均周转时间为 (4a + 3b + 2c + d)/4,可见第一个作业a的完成时间,对于 平均周转 影响最大,而 吞吐量 同样为最大。 又必须要指出, 只有在所有的作业都可同时运行的情况下,最短作业优先算法才是最优的。反例: 5各作业,A-E,运行时间分别为:2,4,1,1,1 他们到达系统的时间分别为 0,0,3,3,3, 系统在开始时 只能选择A与B, 按照 该调度算法的排序 为A,B,C,D,E 其平均周转时间为4.6,但是按照 B,C,D,E,A的调度安排,时间为4.4,优于 最短作业优先。
最短剩余时间优先: 最短作业优先的抢占式调度版本。 调度程序总是选择 剩余运行时间 最短的那个进程 进行运行。当新加入的进程的剩余时间最短时,则当前进程就被挂起,运行新的进程。
交互式系统中的调度:
轮转调度(round robin): 简单公平的调度算法, 每个进程被分配一个时间段,称为时间片。 (quantum)
即允许 进程在该时间段内运行,如果在时间片结束时候该进程还在运行 则剥夺cpu并分配给另一个进程。如果该进程在时间片内 阻塞或者结束 则 cpu将立即切换。
轮转调度算法 实现非常简单,即维护一个 可以运行的进程列表。不断的从列表头上取出进程运行,时间片用完后,将进程放到列表队尾。
关于时间片的长度设定: 从一个进程切换到另一个进程 即 进程切换 也称为 上下文切换。 其中需要 保存和装入寄存器数值,更新各种列表,清除和重新调入内存 高速缓存等 操作。
如果时间片设定为4ms 而进程切换需要 1ms,则 cpu时间将花费 20% 的时间在进程的切换上。
如果将时间片设定为100ms,则cpu时间将花费1%在进程间的切换上。但是 可以想见的是,排在队尾的进程将在很久之后才能得到cpu的时间,对于交互式系统用户而言,将是缓慢的。
如果将时间片设置为长于平均的cpu突发时间,那么将不会经常发生抢占,相反 多数进程将在时间片内发生 阻塞操作,从而引起进程切换,抢占的消失改善了性能,因为进程只有在逻辑上需要的时候发生了切换,即:进程确实不能够在继续进行下去。
总结:时间片设定的太短,导致cpu利用率的降低,时间片设定的太长,导致交互请求的响应变长,普遍间时间片设定为 20-50ms范围内 。
优先级调度:
在轮转调度中 存在一个隐藏的假设,即: 所有的进程是同样重要的。实际上 进程的优先级是可以调整的。即进程之间存在重要性的差异。比如 在屏幕上显示视频的进程 比 后台发送邮件的进程具有较高的优先级。
从此可以看到 时钟滴答 与 时间片 是不同的概念。这里面的时钟中断 应该是 linux中 可以控制的时钟周期,即cpu频率保持不变,但是在 配置的滴答个数 可以 进行时钟中断,而 时间片的概念呢?是建立在 时钟中断上的吧?
两种方法:
为了防止 高优先级的进程一直无休止的运行下去,调度程序可能在每个时钟滴答(时钟中断)降低当前进程的优先级,如果这一行为导致该进程的优先级低于此高级优先级的进程,则进行进程切换。
给每个进程赋予一个允许运行的最大时间片。 当用完这个时间片 则 此高优先级的进程 便能获得运行机会,即进行进程切换。
优先级 可静态确定 或 动态确定。 比如 按照行政等级 确定 等级高的人 的任务具有更高的优先级。
动态确定: 的比如,将IO密集型进程设定更高的优先级,算法 1/f,f为进程在上次的时间片上用的时间片。IO密集型进程 其多数时间 用来等待 IO结束,当这样的进程需要CPU时候,应该立即分配给他,以便让其 启动下一个 IO请求,以充分利用cpu与IO设备。一个在50ms时间片中花费 1ms 时间的 进程 将获得 50的优先级,一个进行 2mscpu运算的进程将获得25的优先级。
多级队列: 优先级调度 中优先级之间 改变优先级 的另外一种方法: 设立优先级类,将最高优先级类的 进程运行一个时间片,此高级优先级的进程 运行 2个时间片, 下一个优先级的进程类运行 4个时间片。 以此类推,当一个进程用完分配的时间片之后,将被移到下一个优先级类。 这样的规则将导致 cpu密集型进程将获得更低的优先级,更大的时间片进行运行。 这里面的存在的缺陷在于,没有将 优先级提升的方法,如果 交互式进程因为 偶尔的 用完时间片的行为 将导致 不断的惩罚,将导致 降低为 低优先级。
最短进程优化:
再批处理系统中 使用 最暖进程优先算法是非常好的。 然而如何将其 应用在交互式系统中 则成为问题,因为 如何从当前进程中找到运行时间 最短的那个进程。 交互式 进程通常的模式 为: 等待命令、执行命令、等待命令、执行命令 如此往复。
一种办法为: 根据进程过去的行为进行推测。使用时间老化算法来估算 进程运行时间。 假设进程 之前进程运行的时间 为 T0,当前估计时间 T1,本次 实际时间 为 T0/2 + T1/2 则 aT0 + (1 - a) T1 通过选择 a,可以决定 尽快忘掉老的运行时间。当 a = 1/2时,我们可以得到如下的队列:
T0, T0/2 + T1/2, T0/4 + T1/4 + T2/2, T0/8 + T/8 + T2/4 + T3/2 在 3此过后 T0的权重 降低为 1/8
我们把 这种通过当前预测值 + 先前估计值 加权平均 而得到 一个 估计值 的技术 成为 老化技术。
保证调度: 向用户做出明确的性能保障,然后去实现它。一个简单的保证为: 如有n各用户登录,则保证用户将获得 1/n 的计算能力。 即系统 需要追踪 系统的运行参数, 诸如 用户进程 实际占用的cpu运行时间,以及 应该占用的cpu时间。其数值比率 大 则说明 获得时间多,而数值小 则说明 获得cpu时间少。 该算法 将优先调度 数值小的 进程 ,已实现 性能保证要求。
彩票调度: 保证调度 很难实现,一种近似实现 为 彩票调度(lottery scheduling) 基本思想为 为进程提供 各种系统资源 的彩票。 需要做出调度决策时,就随机抽取一张彩票,拥有该彩票的进程获得调度。
如果cpu出售了1000张彩票 ,某个进程 持有 20张,则每一次抽奖 该进程就有20%的几率 获得cpu, 在较长的运行时间中,该进程将获得20%的cpu。与 优先级调度 相比较,很难解释进程的优先级为 20 是什么意思,也更难预测 进程将获得与 优先级相关的多少cpu时间。而彩票调度 则 非常明确,拥有 f分彩票 进程 大约得到 系统资源的f份额。
彩票调度 可以用来解决一些 难题,比如 视频流服务器, 服务器上若干进程 为用户提供 帧速率 为10,20,25 的视频流, 只需要 分配 进程 对应的 彩票份额,10, 20,25 即可。
公平分享调度: 需要具体考虑 公平 的定义,用户公平的调度 则需要考虑 在 进程调度 期间 用户的进程 分配 cpu占用问题。
策略和机制: 调度机制 与 调度策略 分离开来。调度机制位于内核,而调度策略 则由用户进程 决定。
线程调度: 若干进程 由多个线程 时候,系统中就存在两个层次的并行: 进程和线程, 在这样的系统中 调度 处理 有本质上的差别。当然这取决于线程 的实现: 即 用户级线程 还是内核级线程。
在 用户级别线程下: 由于内核并不知道线程的存在 ,所以 内核 依然 按照 进程调度,即 用户线程调度 决定 选择那个一个线程。 调度算法 选取中 可以选用上面的任何一种,但 轮转调度 和优先级 调度更为常用。 缺陷则是缺乏 一个时钟中断中断运行时间 过长的线程,但是因为线程之间 为合作关系,所以通常并不是问题。
内核级线程: 对于内核 可以考虑优先调度同属于一个进程的线程,以为同进程间的线程调度 代价要小于 进程之间的调度。其他方面 则与 进程间调度没有什么不同。Linux中 线程与进程同为 调度实体。
用户级线程 与 内核级线程 之间的差别在于 性能: 用户级线程的线程切换 需要较少的机器指令,而内核级线程 切换 则需要 完整的上下文切换、修改内存映像、高速缓存失效(而同样归于同进程的线程 则是不需要) 等操作,这导致了数量级别的延迟。
用户级线程 可以使用 特定优化的调度算法,以为 应用程序 知道自己的应用场景,而 内核级线程 内核从来不了解 每个线程的作用。一般而言,应用定制的线程调度算法 能够比内核 更好的满足 应用的需要。
经典的IPC问题: 哲学家就餐问题, 读者-写者问题
哲学家就餐问题:
直观的解法: 显然是错误的,如果 五位哲学家 同时执行(拿起 左边的筷子 、拿起右边的筷子) 将会导致 没有人能够拿起 右边的筷子, 从而导致死锁。 一个简单的解决方法为: 在哲学家拿起 左边的筷子时候,查看右边的筷子 是否可用,如果不可用,则先放下左边的筷子 , 等一段时间,再重复这个过程。 但这个解法同样存在问题。 尽管原因不同,同一时间 所有的哲学家 都开始进行 该算法,同时 拿起左边的筷子,同时 查看右边筷子是否可用,放下,等待。 如此运行下去 所有的进程都在运行,但是却无法取得进展,该现象 我们称之为饥饿。
我们简单的将 上述 中的等待时间 设定为一个 动态的随机时间,而不是等待一个固定的时间 。这样发生饥饿的可能性就非常小了, 几乎在所有的程序中,稍后再试的办法并不会演化成为一个问题。例如 : 在流行的以太网上, 如果两台计算机同时发送包 ,那么每台计算机会稍等一哥随机时间之后 再次尝试,在实践中 该方案工作良好。 少数的程序中, 人们希望一种总是能够工作的方案,他不会因为一串 随机数字而失败。
#define N 5
vodi philosopher(int i)
{
while(true) {
think();
take_fork(i);
take_fork((i + 1) % N);
eat();
put_fork();
put_fork((i + 1) % N);
}
}
最优的解法: 这里应该搜索,没有理解其代码意思。
void philosopher(int i)
{
while(true) {
}
}
void take_forks(int i)
{
down(&mutex);
state[i] = hungry;
test(i);
up(&mutex);
down(&s[i]);
}
void put_forks(int i)
{
down(&mutex);
state[i] = thinking;
test(LEFT);
test(RIGHT);
up(&mutex);
}
void test()
{
if(state[i] == hungry && state[LEFT] != EATING && state[RIGHT] != EATING) {
state[i] = EATING:
up(&s[i]);
}
}
读者-写者问题:一个数据库访问模型: 多个进程可以同时读取数据库,但是如果一个进程 正在更新 写数据库,则所有的其他进程 都不能够访问数据库。
下面是一个读者优先访问示例代码:
typedef int semaphore;
semaphore mutex = 1;
semaphore db = 1;
int rc = 0;
void reader(void)
{
while(true {
down(&mutext);
rc = rc + 1;
if (rc === 1) down(&db);
up(&mutex);
read_data_base();
down(&mutex);
rc = rc - 1;
if (rc == 0) up(&db);
up(&mutex);
use_data_read();
}
}
void write(void)
{
while(true)
{
think_up_data() ;
down(&db);
write_data_base();
up(&db);
}
}
该解法中 隐含着一种情况:即 数据库当前存在一个读者 时候,另一个读者来了 以及 更多的读者来了,同样允许。这时 一个写者到来 由于写者是拍他的,不允许进入数据库,导致被挂起。需要等待所有的读者处理完成之后 才能开始进行处理写操作。即 读者比 写者拥有更多高的优先级, 写总是排在读后面,如果读者多的情况下 将导致 写 迟迟不能完成操作。
写优先的解法 需要查找 courtois等人的相关资料。
总结:
进程问题已经有了成熟的解决方案,几乎所有的系统都将进程视为一个容器, 用以管理相关的资源,比如地址空间、线程、打开的文件描述符等。 不同的系统管理进程资源的基本想法大致相同。 只是在工程处理上略有差异。
线程是比进程新的概念,但是也经过了深入了的研究。
进行执行过程的 记录和重放 也是一个非常活跃的研究领域。 重放技术 可以帮助开发者 追踪一些难以发现的程序漏洞。
调度问题 也是研究者感兴趣的问题。 一些研究主题包括移动设备上的低能耗调度, 超线程级调度 和偏执意识调度 。 智能手机的计算量 增加 因为电池容量有限。 一些研究者提出 可能的时候将进程迁移到云上某个更强大的服务器上执行。但实际系统的设计者 很少会因为没有合适的线程调度算法 而苦恼。所以这是一个由 研究者推送而不是 需求推动的研究类型。总而言之, 进程,线程 与调度已经不再是研究热点了,功耗管理 、虚拟化、云计算 、与安全问题 成为了热点主题。
习题答案 :(部分重要)
当代计算机中,为什么中断处理程序中 至少部分是由汇编程序来编的?
答: 高级语言不允许访问 必要的cpu资源,比如 一个中断处理程序中 可能需要开启、禁止 中断。 此外 中断处理程序 需要尽可能的快。
中断 或 系统调用 将控制权 转移到 操作系统时,为什么 通常会 用到与 被中断进程 的栈分离的 内核栈?
答: 为内核使用单独的堆栈有几个原因。 其中两个如下。 首先,您不希望操作系统崩溃,因为编写得不好的用户程序不允许有足够的堆栈空间。 其次,如果内核在从系统调用返回时将堆栈数据留在用户程序的内存空间中,恶意用户可能能够使用这些数据来查找有关其他进程的信息。
内存管理 :
不管存储器多大,程序都可以将其填满。
分层存储器体系: 在这个体系中,存在 MB 的快速、昂贵、易失性的高速缓存,GB的速度价格适中的同样易失的内存 ,以及 TB的低速、廉价、非易失的磁盘存储。
操作系统的工作之一 就是 将层次存储系统体系 抽象成为一个 有用的模型 并管理这个抽象。
分层存储器系统称为 存储管理系统: 他的任务是 有效的管理内存,即记录哪些 内存是正在使用的,哪些 是空闲的, 在进程需要时候为其分配内存 ,不需要时释放内存。
探索 内存抽象方案 的步骤:
无存储器抽象: 最简单的存储器抽象 即 没有抽象。 早期的大型机 小型计算机 都没有存储抽象。 每一个程序 都直接访问物理内存。
因此 那时候呈现给编程人员的存储器 即是: 从0到某个上限的地址集合,每个地址 对应一个 可容纳 一定数目的二进制数据,通常是 8位。
指令 MOve Register1, 100 即:将 100的物理内存的内容,转移到 register1中。
改种方案下, 系统中同时运行两个程序 是不可能的。如果进程 A在 地址 100位置写入数据,进程 2 在稍后的时间中 对地址 100进行了改写,可能导致 进程A的崩溃。
在该模式 内存的布局方式有以下三种:
操作系统位于 RAM 的底部
操作系统位于内存顶端 的ROM(只读存储器) 中
设备驱动程序位于顶端的ROM中,操作系统位于底端的RAM中
第二种方案 经常用在 一些掌上电脑,和嵌入式系统中。
第三种方案 用于早期的个人计算机中, ROM中的系统部分 为 BIOS(basic input output system)
在没有存储器抽象的情况下 同时运行多个程序。
操作系统 只需要 把当前的内存中的内容 保存到磁盘上 ,然后将下一个程序 读入到内存中 再次运行即可。 只要在同一时刻 内存中只有一个程序 即可。
IBM 360 的做法:
内存被划分为 大小2KB的块。 每个块 分配一个 4位的保护键, 保护键存储在cpu的特殊寄存器中, PSW中存在一个 4位码, 一个运行中的进程如果访问保护码 与 PSW中的不同的内存,硬件将会捕获该事件。 因为只有系统可以修改保护键,这样就可以放置用户进程 之间、用户和操作系统之间的相互干扰。
静态重定位技术: 即便 存在保护键,依然无法解决 两个进程 使用重复的绝对地址的问题,我们希望每个进程使用一套自己私有的内存地址来进行内存访问。IBM 360 采用的方法 即:在第二个 程序 装载到内存 100地址时, 常数100将被 加到 每一个 程序地址上。
缺少存储器抽象在嵌入式系统中 依然非常常见, 比如 洗衣机 微波炉 此类 设备 都已经完全被ROM形式的软件控制。 这种情况下,软件都采用 绝对地址寻址的方式,以为 所有的运行的程序 都可以被事先确定, 用户不需要运行自己的软件。
存储器抽象:地址空间
直接暴露物理地址的问题: 1) 用户程序可以简单的寻址内存地址,很容易地破坏 操作系统。 2) 同时运行多个程序将非常困难。
地址空间的概念:
要多个应用程序 同时处于内存 而不互相影响 需要解决两个问题: 1)保护 2)重定位 。
IBM 360的办法 实际效果并不好,因为 其 静态重定位 技术,不仅缓慢,还需要 额外的标记 来确定 那些地址 需要被加。
就像 进程的概念 创造了 cpu的抽象 为 进程使用 一样。
地址空间 为程序 创造了 一种抽象 的内存,地址空间 是一个进程可以用于寻址内存的地址集合。每个进程 都有自己的 地址空间,独立于 其他的 进程 地址空间。
地址空间的概念非常常见: 比如 电话号码、url等。
简单实现:动态重定位: 给cpu配置两个寄存器: 基址寄存器 、界限寄存器。 使程序 装载期间 无需 重定位。当一个进程运行时 程序的起始地址 装载到 基址寄存器中,程序的长度 装载到 界限寄存器中。每次 进程访问内存,取一条指令、读写一个内存地址时, cpu硬件会将 地址发送到内存总线前, 自动把基址寄存器的数值 + 进程发出的地址上。 同时检查 结果是否在 界限寄存器 范围内。 超过了 界限 则捕获错误。
基址寄存器 16384, 界限寄存器: 17000 指令 jmp 28 被翻译成 jmp 16412
此方法可以简单实现存储器抽象。
内存管理技术:
1)交换技术: 两种处理内存超载的方法: 最简单的策略 是 交换技术, 即 将把进程完整的调入内存中,使该进程运行一段时间,然后把他 存回磁盘。 虚拟内存: 使程序只有一部分被调入内存的情况下 运行。
空闲内存管理:
动态分配内存时, 操作系统 必须 对其进行管理 ,一般有两种办法: 1) 位图 2) 空闲去链表,
- 使用位图的存储管理: 内存可能被划分为 几个字 或 几千字的分配单位,每个分配单位 对应于图中的一位。0表示空闲,1表示占用。分配单元的大小是一个重要的设计元素, 分配单元小 则位图大,位图占用的内存空间的比例就会提升。分配单元大 ,则位图小。但进程分配的存在更多的浪费。
位图主要的问题是: 再决定分配一个 大小 为k个分配单元的进程调入内存时,存储管理器 必须搜索位图 已找到 k个连续的0串,因为 位图中该串可能跨越字的边界。 查找位图中 制定长度的连续0串是一个耗时操作。
使用链表的存储管理:
维护一个记录已分配内存段和空闲内存段的链表。
有几种方法 为 新创建的进程 分配内存:
1) 首次匹配算法: 沿着链表进行搜索, 直到找到一个足够大的空闲区间。 除非空闲空间与 需要分配的空间大小一样,否则将该空闲区分为两部分,一部分供进程使用,一部分 成为新的空闲区。 速度非常快的算法,因为它可以尽可能少的搜索节点。
2) 最佳适配算法:搜索整个链表, 找到能够容纳新进程的最小空闲区间。该算法试图找到最接近实际需要的空闲区。
其他算法: 快速适配法
虚拟内存: 虽然 基址寄存器 + 界限寄存器 能够很好的 应对 内存抽象管理要求,但是 随着软件的膨胀 需要运行的程序往往大到内存 无法容纳, 而系统必须能够支撑起多个程序的同时运行, 即使内存仅仅 可以满足其中一个的程序需要。
总体来看 程序对内存的需求 超过了实际内存大小。 交换技术 并不是一个很好的方案,因为 一个典型的SATA磁盘的峰值传输效率 只有几百兆每秒,这意味着需要好几秒 才能换入 一个1GB的程序。
该类问题 很早就出现了,初始的简单解决办法 成为 :覆盖 (overlay) 把程序分割成为许多片段, 程序开始,将覆盖管理模块转入内存,该管理模块立即 装并运行程序的片段0,在系统需要时 将由 管理模块 程序片段1装载到内存中,覆盖管理模块允许多个 片段 同时在内存中,部分在磁盘上,在需要时候动态的换入换出。
虽然 有管理模块来负责 换入换出操作,但是 依然需要程序员将程序 分割成多个片段,最后人们将这种费事重复性的操作 交给了计算机去做。
采用的这个方法 称为虚拟内存。 基本思想为: 每个程序拥有自己的地址空间,这个空间被分割成为多个块,每一块称作一页或页面(page), 每一页 都有连续的地址范围,这些页面被影射到 物理内存, 但并不需要所有页面都在内存中才能够运行程序, 当程序引用到一部分在物理内存中的地址空间时, 由硬件执行必要的映射, 当程序引用到 一部分不在物理内存中的地址空间时, 由 操作系统负责将缺失的部分程序装入到 物理内存并重新执行失败的指令。
分页: 大部分 虚拟内存使用的技术。
Mov reg, 1000 将内存地址 1000 的内存复制到 reg中,其中的1000地址 可以由 索引、基址寄存器、段寄存器 或其他方式产生。
由程序产生的地址称为虚拟地址(virtual address) 它们构成了一个虚拟地址空间。 没有虚拟内存的计算机上,虚拟地址 就是 内存物理地址, 使用虚拟内存的情况下, 虚拟地址 并不直接传送到内存总线上,而是 先传到 内存管理单元(memory management unit MMU) mmu将虚拟地址映射称为 物理内存地址 ,在传送到物理内存地址总线上。
虚拟地址 按照固定大小划分为称为 页面 (page)的单元, 物理内存中对应的为 页框(page frame) RAM与磁盘之间的交换总是以整个页面为单元进行的。
转换:
程序执行 指令 MOV REG, 0 将虚拟地址 0 送到MMU, MMU 看到u虚拟地址落在页面0,根据映射结果 该页面对应得是页框是2(8192-12287) 因此 MMU把地址变换为8192 并将地址 8192发送到总线上,内存对MMU一无所知,他知道看到一个读写地址 8192 的请求并执行它,MMU从而有效的将 程序虚拟地址空间0-4095 映射到 8192-12287。
通过且当的设定 MMU,可以讲16个虚拟页面,映射到 8个页表框中的任何一个。但是这并没有解决 虚拟地址空间大于物理内存空间的问题。 当程序需要访问 第9个页面的时候,会发生什么情况呢? MMU注意到 第9个页面并没有被影射到内存中, 于是使cpu陷入到操作系统 ,称为缺页中断 或者缺页错误。 操作系统找到一个很少使用的页框 并把他的内容写入到磁盘中,随后把需要访问的页面 读到刚才 回收的页框中,修改映射关系,然后重新执行引起缺页中断的指令。
缺页处理的具体步骤为:
1) 如果操作系统决定放弃页框1, 将虚拟页面8 装入物理地址4096,然后对MMU做两处修改,
1) 将虚拟页面1 标记为未映射,以便 以后任何对于 虚拟地址 4096-8191的访问 都会导致 缺页中断
2) 将虚拟页面8的标记页框为1,因此在重新执行引起缺页中断的指令时,能够将虚拟地址 32780 映射为物理地址 4108
MMU内部结构如下:
可以将16位的虚拟地址,划分为 4位的 页号 + 12 位的便宜里那个。 4位的页号 可以表达16个 页面 , 12位的偏移地址 可以为一页内部的全部4096个字节编码。
使用页号作为页表的索引。以找出对于该虚拟页面的页框号, 如果 不在 则 引发一个缺页中断, 如果在 ,则 将在页表中查找到的 页框号 + 虚拟地址的偏移量 构成一个 物理地址。 输出到物理内存地址总线上。
页表: