1 进程
什么是进程,进程是现代分时操作系统的工作单元。
一些知识点
多道程序的设计目标是,无论何时都有进程执行,从而最大化CPU利用率。(CPU-IO执行周期中,等待IO很耗时)
分时系统的目的是在进程之间快速切换CPU,以便用户在程序运行时能与其交互。为了满足这些目标,进程调度器(process scheduler)选择一个可用进程到CPU上执行。
单处理器系统不会有几个正在运行的进程;如果有,则其它进程需要等待CPU空闲才能重新调度
进程状态
进程有五个状态:
- new
- ready
- running
- waiting
- terminated
Java线程状态
PCB
进程控制块。操作系统用PCB表示进程
它包含许多与某个特定进程相关的信息:
- 进程状态
- 程序计数器:进程将要执行的下个指令的地址
- CPU寄存器:负责数据的存储
- CPU调度信息
- 内存管理信息
- 记账信息
- I/O状态信息
调度准则
选择特定的算法对特定的进程有利,而我们需要一些准则去量化有利程度:
- CPU使用率
- 吞吐量:一个单元时间内进程完成的数量
- 周转时间:从进程提交到进程完成的时间段。(注意提交了但是不一定会立即执行!)
- 等待时间:进程在就绪队列中等待所花时间之和。(CPU调度算法不影响进程运行和执行IO的时间!它只影响进程在就绪队列中等待所需的时间。)
- 响应时间:用户提交请求到产生第一响应的时间。(是开始响应所需的时间,不是输出响应所需的时间!)
追求是:
-
最大化——CPU使用率、吞吐量
-
最小化——周转时间、等待时间、响应时间
调度队列
进程在进入系统时,会被加入作业队列
,这个队列包括所有进程
驻留在内存中的、就绪的、等待运行的进程就保存在就绪队列
上,这个队列通常用链表实现;其头结点有两个指针,用于指向链表的第一个和最后一个PCB块;每个PCB块还包括一个指针,指向就绪队列的下一个PCB。
等待特定I/O设备的进程列表,称为设备队列
,每个设备都有自己的设备队列
进程调度通常用队列图
来表示:
- 矩形框代表一个队列,这里有两个队列,就绪队列和设备队列;
- 圆圈表示服务队列的资源;
- 箭头表示系统内的进程流向。
调度程序
进程在整个生命周期中,会在各种调度队列之间迁移。操作系统为了调度必须按一定的方式从这些队列中选择进程。进程选择通过适当调度器或调度程序执行。
通常对于批处理系统,提交的进程多于可以立即执行的。这些进程会被保存到大容量存储设备(通常是磁盘)的缓冲池,一般以后执行。
调度程序是一个模块,用来将CPU控制交给由短期调度程序选择的进程。这个功能包括:
- 切换上下文
- 切换到用户模式
- 跳转到用户程序的合适位置,以便重新启动程序
调度程序停止一个进程而启动另一个进程所需的时间称为调度延迟
上下文切换
切换CPU到另一个进程需要保存当前进程状态和恢复另一个进程的状态,这个切换任务称为上下文切换
调度准则
选择特定的算法对特定的进程有利,而我们需要一些准则去量化有利程度:
- CPU使用率
- 吞吐量:一个单元时间内进程完成的数量
- 周转时间:从进程提交到进程完成的时间段。(注意提交了但是不一定会立即执行!)
- 等待时间:进程在就绪队列中等待所花时间之和。(CPU调度算法不影响进程运行和执行IO的时间!它只影响进程在就绪队列中等待所需的时间。)
- 响应时间:用户提交请求到产生第一响应的时间。(是开始响应所需的时间,不是输出响应所需的时间!)
追求是:
- 最大化——CPU使用率、吞吐量
- 最小化——周转时间、等待时间、响应时间
2 调度算法
1 先到先服务调度
CPU空闲时,调度程序调度就绪队列队首的进程,并且后来的进程的PCB会被链接到队列尾部
缺点:
- 平均等待时间很长
2 最短作业优先调度
当CPU空闲时,它会被赋给具有最短CPU执行的进程,如果进程的CPU执行长度相等,则按照先到先服务处理
更恰当的表示为,最短下次CPU执行算法
,这是因为调度取决于进程的下次CPU执行的长度,而不是其总长度
可以是抢占式和非抢占式的:
- 抢占式:如果有新的比当前CPU执行还要短的进程进入就绪队列,则将CPU赋给这个CPU执行更短的
3 优先级调度
每一个进程都有一个优先级与其关联,而具有最高优先级的进程会分配到CPU,如果优先级相等,则按照先到先服务处理
4 轮转调度
轮转算法是专门为分时系统
设计的。它类似先来先服务调度,但是增加了抢占以切换线程。
调度过程:
- 设置一个CPU时间量或时间片,大小通常为10~100ms
- 将就绪队列视为FIFO队列,新进程添加到队列尾部
- CPU选择就绪队列第一个进程
- 如果该进程在一个时间片内执行完成,则进程会自动释放CPU,接着处理队列的下一个进程
- 如果该进程没有在一个时间片内执行完成,则定时器会中断,进而中断操作系统,然后进行上下文切换,将进程加到就绪队列的尾部,接着CPU会执行就绪队列中的新的队首进程
5 多级队列调度
在进程容易分组的情况下,可以采用这种算法
原理:
- 通常将进程分成
前台进程
(或交互进程)和后台进程
(或批处理进程),因为它们对响应时间有不同的要求,而且前台进程往往有更高的优先级 - 多级队列调度算法将就绪队列分成多个单独队列,根据进程属性(如内存大小、进程优先级、进程类型等),一个进程永久分到一个队列
- 每个单独的队列有自己的调度算法。例如,有两个队列分别用于前台进程和后代进程,前台进程可以采用轮转调度,后代进程可以采用先来先服务调度
- 所有单独的队列之间应该有调度,通常采用固定优先级抢占调度,例如前台进程比后台进程有更高的优先级
6 多级反馈队列调度
多级队列调度算法的优点是开销低,但是不够灵活,因为进程一旦分配到了一个单独队列,就不能移动到其它队列,而多级反馈队列调度可以
多级反馈队列算法允许进程在队列之间迁移
思想:
- 根据不同的CPU执行特点来区分进程。
- 如果进程使用过多的CPU时间,那么它会被移动到优先级更低的队列。这种方案将IO密集型和交互进程放在更高的优先级队列上
- 在较低优先级等待时间过长的进程将被迁移到优先级更高的队列,这种形式的老化阻止了饥饿(迟迟不能获得CPU资源)的发生
unix就使用此调度算法
多处理器调度
多处理器调度的方法
非对称多处理
- 让一个处理器处理所有调度决定、IO处理以及其它系统活动,其它的处理器只执行用户代码
- 特点:简单,只有一个处理器访问系统数据结构,减少了数据共享的需要
对称多处理(SMP)
- 每个处理器自我调度
- 所有进程可能存在于公共的就绪队列,或者每个处理器有它自己的私有就绪队列
处理器亲和性
如果一个进程需要转移到另一个处理器上执行,那么原来的处理器缓存应该设为无效,第二个处理器缓存应该重新填充。正因如此,代价很高,大多数SMP系统试图避免将一个进程从原来的处理器转移到另一个处理器
软亲和性
:当操作系统试图保持进程在同一个处理器上运行
硬亲和性
:允许某个进程运行在某个子处理器子集上
许多操作系统提供软硬结合的方式
负载平衡
Load Balance设法将负载平均分配到SMP系统的所有处理器。
对于只有公共的就绪队列而言没有意义,这里只针对私有就绪队列而言。
负载平衡通常有两种方法:
- 推迁移
- 拉迁移
多核处理器
传统:SMP系统有多个物理处理器,以便允许多个线程并行执行
近来:多个处理器放置在同一物理芯片上,从而产生多核处理器,如图所示:
多核的SMP系统速度更快,功耗更低
如果一个线程停顿而等待内存,该核可以切换到另外一个线程
一般来说,处理器核的多线程有两种办法:粗粒度
和细粒度
:
- 粗粒度
- 线程一直在处理器上执行,直到一个长延迟时间(内存停顿,IO读写,SOCKET读写)发生。
- 用切换线程的方式来处理
- 代价高
- 细粒度
- 细粒度或者交错的多线程在更细的粒度级别上(如指令周期的边界)切换线程
- 细粒度系统的架构设计有切换线程的逻辑
- 代价较低
实时CPU调度
软实时系统:不保证调度关键实时进程,但是会保证优先于非关键进程
硬实时系统:一个任务要在它的deadline之前完成,否则就是没有完成
提供抢占的、基于优先级的调度程序仅保证软实时功能
硬实时系统应该保证实时任务在截止期限内得到服务
最小化延迟
事件延迟
:从事件发生到事件得到服务的时间
中断延迟
:从CPU收到中断到中断处理程序开始的时间
调度延迟
:从停止一个进程到启动另一个进程所需的时间
冲突阶段
:
- 抢占在内核中运行的任何进程
- 释放高优先级进程所需的、低优先级进程占有的资源
优先级调度
操作系统最重要的功能是:当一个实时进程需要CPU时,立即响应。因此实时操作系统的调度程序应支持抢占的基于优先级的算法。
Windows有32个不同的优先级,其中16-31是高级别。
调度进程的一些特性
调度进程是周期性的,也就是说它们需要CPU。一旦周期性获得CPU,它具有固定的处理时间t、CPU应处理的截止期限d和周期p。
三者的关系:0 <= t <= d <= p
周期的任务速率:1/p
调度程序可以利用这些特性,根据进程的d或1/p要求分配优先级。这种调度比较奇葩的地方是它要求进程向调度器公布其deadline要求,然后使用准入控制算法。
调度程序只做两件事之一:
-
它承认进程,保证进程完成;
-
不能保证任务在deadline之前得以服务,就拒绝请求
单调速率调度
此调度算法采用抢占的、静态优先级的策略,调度周期性任务
更频繁地需要CPU的任务应该分配更高的优先级
单调速率调度假定:对于每次CPU执行,周期性进程的处理时间是相同的,也就是说CPU执行长度相同
单调速率调度有一个限制:CPU的利用率是有限的,并不可能完全最大化占用CPU资源。
N个进程的最差CPU利用率:N(2^(1/N)-1)
最早截止期限优先调度(EDF)
根据截止期限动态分配优先级。
截止期限越早,优先级越高。
唯一的要求:进程在变成可运行时,应宣布它的截止期限。理论上CPU占用率可以是100%,但是由于上下文切换和中断,这是不可能的。
比例分享调度
很简单,谁要用的多,谁就占用得多
例如:
A:10,B:60,C:30
那么A占 10/(10+60+30) =10%,B占60%,C占30%
POSIX实时调度
这里只讨论实时线程调度有关的POSIX API,有两个API关于实时线程调度:
-
SCHED_FIFO:
- FIFO数据结构,先来先服务
- 同等优先级之间没有分时
- 因此优先级高的会一直占用直到阻塞或者终止
-
SCHED_RR
- 使用轮询策略
- 跟前者有点类似,但是它提供了同等优先级线程直接的分时
-
SCHED_OTHER
- 它的实现没有定义,取决于特定系统
- 有两个函数获取和设置调度策略
- 分时调度策略
前两个的相同点:
- RR和FIFO都只用于实时任务。
- 创建时优先级大于0(1-99)。
- 按照可抢占优先级调度算法进行。
- 就绪态的实时任务立即抢占非实时任务
操作系统例子
-
Windows/Solaris:内核线程调度
-
Linux:任务调度
Linux调度
-
当所有任务都采用分时调度策略时(SCHED_OTHER):
- 创建任务指定采用分时调度策略,并指定优先级nice值(-20~19)。
- 将根据每个任务的nice值确定在CPU上的执行时间( counter )。
- 如果没有等待资源,则将该任务加入到就绪队列中。
- 调度程序遍历就绪队列中的任务,通过对每个任务动态优先级的计算(counter+20-nice)结果,选择计算结果最大的一个去运行,当这个时间片用完后(counter减至0)或者主动放弃CPU时,该任务将被放在就绪队列末尾(时间片用完)或等待队列(因等待资源而放弃CPU)中。
- 此时调度程序重复上面计算过程,转到第4步。
- 当调度程序发现所有就绪任务计算所得的权值都为不大于0时,重复第2步。
-
当所有任务都采用FIFO调度策略时(SCHED_FIFO):
- 创建进程时指定采用FIFO,并设置实时优先级rt_priority(1-99)。
- 如果没有等待资源,则将该任务加入到就绪队列中。
- 调度程序遍历就绪队列,根据实时优先级计算调度权值,选择权值最高的任务使用cpu, 该FIFO任务将一直占有cpu直到有优先级更高的任务就绪(即使优先级相同也不行)或者主动放弃(等待资源)。
- 调度程序发现有优先级更高的任务到达(高优先级任务可能被中断或定时器任务唤醒,再或被当前运行的任务唤醒,等等),则调度程序立即在当前任务堆栈中保存当前CPU寄存器的所有数据,重新从高优先级任务的堆栈中加载寄存器数据到CPU,此时高优先级的任务开始运行。重复第3步。
- 如果当前任务因等待资源而主动放弃CPU使用权,则该任务将从就绪队列中删除,加入等待队列,此时重复第3步。
-
当所有任务都采用RR调度策略(SCHED_RR)时:
- 创建任务时指定调度参数为RR, 并设置任务的实时优先级和nice值(nice值将会转换为该任务的时间片的长度)。
- 如果没有等待资源,则将该任务加入到就绪队列中。
- 调度程序遍历就绪队列,根据实时优先级计算调度权值,选择权值最高的任务使用CPU。
- 如果就绪队列中的RR任务时间片为0,则会根据nice值设置该任务的时间片,同时将该任务放入就绪队列的末尾 。重复步骤3。
- 当前任务由于等待资源而主动退出CPU,则其加入等待队列中。重复步骤3。
-
系统中既有分时调度,又有时间片轮转调度和先进先出调度:
- RR调度和FIFO调度的进程属于实时进程,以分时调度的进程是非实时进程。
- 当实时进程准备就绪后,如果当前CPU正在运行非实时进程,则实时进程立即抢占非实时进程 。
- RR进程和FIFO进程都采用实时优先级做为调度的权值标准,RR是FIFO的一个延伸。FIFO时,如果两个进程的优先级一样,则这两个优先级一样的进程具体执行哪一个是由其在队列中的未知决定的,这样导致一些不公正性(优先级是一样的,为什么要让你一直运行?),如果将两个优先级一样的任务的调度策略都设为RR,则保证了这两个任务可以循环执行,保证了公平。
Windows调度
Windows采用基于优先级的、抢占调度算法来调度线程,调度程序
确保具有最高优先级的线程总是在运行
用于处理调度的Windows内核部分称为调度程序(dispatcher)
调度程序采用32级的优先级方案,以此来确定线程执行顺序:
- 可变类:1~16
- 实时类:17~31
- 内存管理:0
调度程序为每个调度优先级采用一个队列;从高到低检查队列,直到一个线程可以执行。如果没有找到则调度程序会执行空闲线程(idle thread)的特别线程。
3 进程间通信
通信主要有两种形式:共享内存和消息传递
这两种方式并不相互排斥,可以在同一OS上同时实现
命名
两个进程需要发送和接受信息,那么它们之间要有通信链路。通信链路的逻辑实现和操作send()/receive()
- 直接或间接的通信
- 同步或异步的通信
- 自动或显示的缓冲
同步
消息传递可以是阻塞或者非阻塞,也称为同步和异步
- 阻塞发送/阻塞接收
- 非阻塞发送/非阻塞接收
不同组合的都有可能。当发和收都是阻塞的,则发和收直接会有一个交会(rendezvous),类似接力棒接力的那个瞬间
缓存
进程交换信息总是驻留在临时队列中,队列简单实现有三种:
- 0容量:发送者阻塞,直到接收者接收消息
- 有限容量:队列满,发送者阻塞,直到接收者接收消息,腾出空间,发送者才能继续发送消息
- 无限容量:发送者从不阻塞
接下来讲述几种进程间通信方式
共享内存系统
共享内存方法要求,通信进程共享一些变量。进程通过使用这些共享变量来交换信息。
对于此方法,提供通信的任务交给了编程人员,而共享内存由操作系统提供。
生产者—消费者问题(有缓冲区、无缓冲区)
不恰当的例子:JVM的堆区
消息传递系统
消息传递方法允许进程交换信息。提供通信的责任可能在于OS本身。
不恰当的例子:Golang 的 chan
客户端/服务端
RPC
RPC是一种分布式通信。
一个进程(或线程)调用一个远程应用(不在当前主机上)的过程(方法,函数)
例如Dubbo、gRPC、Spring Cloud
Socket
套接字定义为通信的端点。一对应用程序之间的连接由一对套接字组成,通信的两端各有一个Socket
- 在Java Web中,Socket = IP + port
Pipe
管道提供了一个相对简单的进程间的相互通信
- 普通管道:
- 单向通信:标准的生产者—消费者方式进行通信
- 允许父进程和子进程之间的通信
- 命名管道:
- 通信允许是双向的
- 父子关系不是必须的
- 允许不相关进程之间的通信
- 实例
- UNIX汇总管道是FIFO,表现为文件,只支持字节流的数据
- Windows允许双工通信,通信进程可以不再同一个主机,支持字节流和消息流的数据
4 线程 & 多线程
线程是进程的一个执行路径,现代操作系统进程拥有多个线程,拥有多个线程的好处是提升了CPU的利用率。
- 由于现代CPU是多核的,因此多线程可以提升CPU利用率
- 一个线程在读写IO时,会产生阻塞,此时CPU时间分片不利用就会被浪费
- 但是创建过多线程会非常消耗资源,切换线程是一个耗资源的操作
- 多线程的共享资源和通信也比单线程更加复杂,在JVM中,堆区是共享的,有一个唯一的ThreadLocal对象是每个线程都有的
多线程的优点有如下四大类:
- 响应性
- 资源共享
- 经济可伸缩性
多线程模型
一对一
创建一个用户线程就要创建一个内核线程,开销极大
多对一
多个用户线程映射到一个内核线程。单核CPU
如果这个内核线程阻塞,那么全部gg。
多对多
多路复用多个用户级线程到同样数量或者更少数量的内核线程。
当一个线程阻塞系统调用时,内核可以调度另一个线程执行
多对多的一个变种,允许某个用户线程绑定到一个内核线程。几乎没人用了
这里可以看一下Golang的GMP如何设计的,理解一下为什么Golang的并发性能为什么那么强
以及,Java的多线程
5 同步
临界区
进程在执行该区时可以修改公共变量、更新一个表、写一个文件等、该系统的重要特征是:当一个进程在临界区内执行时,其它进程不允许在它的临界区内执行。也就是说没有两个进程可以同时在临界区内执行。
临界区问题:设计一个协议以便协作进程
进入区:在进入临界区前,每个进程应请求许可才能进入临界区
退出区:临界区退出后的区域
剩余区,退出区后剩余代码的区域
临界区问题的解决方案应满足如下三个要求:
- 互斥(mutual exclusion):如果一个进程在临界区内执行,那么其它进程不能在临界区执行
- 进步(progress):如果没有在临界区执行的进程,只有那些不在剩余区内执行的进程可以参加进入临界区的选择
- 有限等待(bounded waiting):从一个进程请求进入临界区到请求允许为止,其它进程进入临界区的次数有上限。避免进程过度饥饿
解决临界区问题常用的两个方法:
-
抢占式内核:允许处于内核模式的进程被抢占
- 难以设计,对于SMP(对称多处理,有公有和私有的就绪队列)系统,两个处于内核态的进程可以运行在不同的处理器上
- 抢占式内核响应速度快,(如果可能出现一个进程占用CPU很久的情况,那么可以通过设计内核代码让进程不会占用CPU过久的时间)
-
非抢占式内核:不允许处于内核模式的进程被抢占。处于内核模式的进程会一直执行,直到主动退出内核模式、阻塞或者自愿放弃CPU资源
硬件同步
现代操作系统提供特殊指令,用于检测和修改字的内容,或者用于原子地交换两个字
test_and_set()
:声明互斥compare_and_swap()
:CAS,无锁安全访问,但是会造成忙等待(自旋锁)
软件同步——传统
互斥锁
略
信号量
功能类似互斥锁,但是它提供了更高级的方法,以便进程能够同步活动
一个信号量S是个整型变量,它除了初始化外只能通过两个标准原子操作wait()
和signal()
。
操作系统通常区分:
- 计数信号量:值不受限制
- 可以用于控制访问具有多个实例的某种资源。
- 二进制信号量:值只能为0或1,类似互斥锁
一些点:
-
信号量的初值可以为可用资源数量,
wait()
减少信号量的计数,signal()
增加信号量的计数 -
信号量操作应原子执行
-
信号量的正确使用不依赖信号量链表的特定排队策略
优先级反转
假设有三个优先级为L<M<H的进程,由于是抢占式的,因此高优先级的进程会想方设法抢占锁资源,这时候就会造成混乱,难以治理。
优先级继承协议允许进程L临时继承进程H的优先级,从而防止进程M抢占执行。当进程L用完资源R时,它将放弃继承自H的优先级,以采用原来的优先级。由于进程H优先级比进程M高,因此进程H将执行。
管程
http://c.biancheng.net/view/1234.html
管程结构确保每次只有一个进程在管程内处于活动状态
然而,如到目前为止所定义的管程结构,在处理某些同步问题时,还不够强大。为此,我们需要定义附加的同步机制;这些可由条件(condition)结构来提供。 当程序员需要编写定制的同步方案时,他可定义一个或多个类型为 condition 的变量:
condition x, y;
对于条件变量,只有操作 wait() 和 signal() 可以调用。操作 x.wait();
意味着调用这一操作的进程会被挂起,直到另一进程调用 x.signal();
操作 x.signal()
重新恢复正好一个挂起进程。如果没有挂起进程,那么操作 signal() 就没有作用,即x的状态如同没有执行任何操作。这一操作与信号量的操作 signal()
不同,后者始终影响信号量的状态。
软件同步——多线程
传统上,如互斥锁、信号量、管程等技术用于解决竞争和死锁,但是随着处理器核的增加,设计多线程应用程序并且避免竞争条件和死锁变得越来越困难。
事务内存
事务内存:原子数据库理论,提供了一种进程同步的策略
内存事务:为一个内存读写操作的序列是原子的。如果事务中的所有操作都完成了事务就提交;否则就该终止并回滚。
软件事务内存(Software Transactional Memory):STM通过在事务块中插入检测代码来工作。代码由编译期插入,通过检查哪些语句并发运行和哪些地方需要特定的低级加锁,来管理每个事务。JVM就是这么干的
硬件事务内存(Hardware Transactional Memory):使用硬件告诉缓存层次结构和告诉缓存一致性协议,对设计驻留在单独处理器的高速缓存汇总的共享数据进行管理和解决冲突。
JVM vs. STM
OpenMP
OpenMP(Open Multi-Processing)是一套支持跨平台共享内存方式的多线程并发的编程API,使用C,C++和Fortran语言,可以在大多数的处理器体系和操作系统中运行(WIki)
OpenMP是一个跨平台的多线程实现,主线程(顺序的执行指令)生成一系列的子线程,并将任务划分给这些子线程进行执行。这些子线程并行的运行,由运行时环境将线程分配给不同的处理器。
其实就是类似Fork/Join的东西
函数式编程语言
函数式语言不维护状态,也就是说一旦一个变量被定义和赋予了一个值,它的值是不可变的,即它不能被修改。由于不可变,使用不需要关心竞争条件和死锁等问题。
举例
-
Erlang:Erlang编程语言由瑞典公司爱立信在20世纪80年代后期开发,最初用于实现容错电信系统
-
Scala:函数式和面向对象编程的混合,支持纯函数式和指令式编程
6 死锁
当一组进程内的每个进程都在等待一个事件,而这一时间只能由当前这一组进程的另一个进程引起,那么这租进程就处于死锁状态
必要条件:
- 互斥:一次只有一个进程可以使用非共享资源,如果另一个进程要用,必须等当前使用的进程释放
- 占用并等待:一个进程持有非共享资源,并且等待其它进程释放共享资源
- 非抢占:已分配的资源不能被抢占
- 循环等待
死锁的处理方法:
- 协议
- 检测并恢复
- 忽视
忽视死锁是Linux和Windows的做法。而防止死锁的任务就交给程序开发人员