3、进程管理

上下文切换
【os浅尝】话说进程和线程~

image

image

image

image

1、进程与线程

进程:CPU + 虚拟内存
线程:执行流 + 栈

CPU 总是运行一个进程,其他进程处于非运行状态

  • 一个进程可以包括多个线程,进程的内存空间是共享的,每个线程都可以使用这些共享内存
  • 某些共享内存,只能给 1 个线程使用,其他线程必须等它结束才能使用这一块内存
    一个防止他人进入的简单方法,就是门口加一把锁:先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去
    这就叫 "互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域
  • 某些共享内存,可以供给固定数目的线程使用
    这时的解决方法,就是在门口挂 n 把钥匙:进去的人就取一把钥匙,出来时再把钥匙挂回原处,后到的人发现钥匙架空了,就知道必须在门口排队等着
    这种做法叫做 "信号量"(Semaphore),用来保证多个线程不会互相冲突
    不难看出,mutex 是 semaphore 的一种特殊情况(n = 1 时)
    也就是说:完全可以用后者替代前者,但是因为 mutex 较为简单且效率高,所以在必须保证资源独占的情况下,还是采用这种设计

2、进程

运行中的程序被称为「进程」:硬盘的静态文件(代码)-> 二进制可执行文件 -> 装载到内存 -> CPU 执行程序中的每一条指令
进程与程序的关系:程序是静态的,进程是动态的,一个程序可以创建多个进程
进程切换:在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行

2.1、进程状态

  • NULL -> 创建状态:一个新进程被创建时的第一个状态
  • 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的
  • 就绪状态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程
  • 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理
  • 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行
  • 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I / O 事件
  • 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态

image

如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间
这显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为
所以在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存
image

那么就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态,这跟阻塞状态是不一样的,阻塞状态是等待某个事件的返回

挂起状态可以分为两种

  • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现
  • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行

导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况

  • 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程
  • 用户希望挂起一个程序的执行,比如在 Linux 中用 Ctrl + Z 挂起进程

这两种挂起状态加上前面的五种状态,就变成了七种状态变迁
image

2.2、进程的控制结构

在操作系统中是用数据结构进程控制块(process control block,PCB)来描述进程的
PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失

  • 进程描述信息
    进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符
    用户标识符:进程归属的用户,用户标识符主要为共享和保护服务
  • 进程控制和管理信息
    进程当前状态,如 new、ready、running、waiting 或 blocked 等
    进程优先级:进程抢占 CPU 时的优先级
  • 资源分配清单:有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I / O 设备信息
  • CPU 相关信息:CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行

PCB 通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列

  • 将所有处于就绪状态的进程链在一起,称为就绪队列
  • 把所有因等待某事件而处于等待状态的进程链在一起,就组成各种阻塞队列
  • 对于运行队列,在单核 CPU 系统中只有一个运行指针,因为单核 CPU 在某个时间,只能运行一个程序

除了链接的组织方式,还有索引方式(工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表)
一般会选择链表,因为可能面临进程创建、销毁、调度等,导致进程状态发生变化,所以链表能够更加灵活的插入和删除

image

2.3、进程的控制

创建进程

操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源

  • 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等
  • 为该进程分配运行时所必需的资源,比如内存资源
  • 将 PCB 插入到就绪队列,等待被调度运行

终止进程

进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)
当子进程被终止时,其在父进程处继承的资源应当还给父进程
而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作

终止进程的过程如下

  • 查找需要终止的进程的 PCB
  • 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程
  • 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管
  • 将该进程所拥有的全部资源都归还给操作系统
  • 将其从 PCB 所在队列中删除

阻塞进程

当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待,而一旦被阻塞等待,它只能由另一个进程唤醒

  • 找到将要被阻塞进程标识号对应的 PCB
  • 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行
  • 将该 PCB 插入到阻塞队列中去

唤醒进程

进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的
如果某进程正在等待 I / O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它

唤醒进程的过程如下

  • 在该事件的阻塞队列中找到相应进程的 PCB
  • 将其从阻塞队列中移出,并置其状态为就绪状态
  • 把该 PCB 插入到就绪队列中,等待调度程序调度
  • 进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句

2.4、进程的上下文切换

一个进程切换到另一个进程的过程,称为进程的上下文切换,包括 OS 上下文切换和 CPU 上下文切换(其实 OS 上下文包含 CPU 上下文)

  • OS 上下文切换:保存当前进程的 PCB,加载新进程的 PCB
  • CPU 上下文切换:先把前一个任务的 CPU 上下文(TSS)保存起来,然后加载新任务的 CPU 上下文(TSS),最后运行新任务

进程是由内核管理和调度的,所以进程的切换只能发生在内核态
进程的上下文切换不仅包含了内核堆栈、寄存器等内核空间的资源,还包括了虚拟内存、栈、全局变量等用户空间的资源
进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换
image

发生进程上下文切换的场景

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程
    当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行
  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序

3、线程

3.1、为什么使用线程

我们举个例子,假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个

  • 从视频文件当中读取数据
  • 对读取的数据进行解压缩
  • 把解压缩后的视频数据播放出来

对于单进程的实现方式,我想大家都会是以下这个方式

image
对于单进程的这种方式,存在以下问题

  • 播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,Read 的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放
  • 各个函数之间不是并发执行,影响资源的使用效率

那改进成多进程的方式

image
对于多进程的这种方式,依然会存在问题

  • 进程之间如何通信,共享数据
  • 维护进程的系统开销较大
    • 创建进程时:分配资源、建立 PCB
    • 终止进程时:回收资源、撤销 PCB
    • 进程切换时:保存当前进程的状态信息

那到底如何解决呢

需要有一种新的实体,满足以下特性

  • 实体之间可以并发运行
  • 实体之间共享相同的地址空间
  • 这个新的实体,就是线程(Thread),线程之间可以并发运行且共享相同的地址空间

3.2、什么是线程

线程是进程当中的一条执行流程

同一个进程内多个线程之间可以:共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的

堆栈不能共享的原因

  • 堆栈用于保存函数中的局部变量
  • 两个线程之间执行的函数是不同的

线程的优点

  • 一个进程中可以同时存在多个线程
  • 各个线程之间可以并发执行
  • 各个线程之间可以共享地址空间和文件等资源

线程的缺点

  • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃
    这里是针对 C / C++ 语言,Java 语言中的线程崩溃不会造成进程崩溃,具体分析原因可以看这篇:线程崩溃了,进程也会崩溃吗
    举个例子,对于游戏的用户设计,则不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程

image

3.3、线程与进程的比较

线程与进程的比较如下

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如通用寄存器和栈
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系
  • 线程能减少并发执行的时间和空间开销

线程相比进程能减少开销,体现在

  • 线程的创建时间比进程快
    因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们
  • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多
  • 同一个进程内的线程切换比进程切换快
    因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表(不需要刷新 TLB)
    而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的(需要刷新 TLB)
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了

3.4、线程的上下文切换

线程是调度的基本单位,而进程则是资源拥有的基本单位
所以操作系统的任务调度,调度对象实际上是线程,而进程只是给线程提供了虚拟内存、全局变量等资源

  • 当进程只有一个线程时:可以认为进程就等于线程
  • 当进程拥有多个线程时:这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的
    线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的

线程上下文切换:得看线程是不是属于同一个进程

  • 当两个线程不属于同一个进程:则切换的过程就跟进程上下文切换一样
  • 当两个线程是属于同一个进程:因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器、栈等不共享的数据

3.5、线程的实现

线程主要有三种实现方式

  • 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理
  • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程
  • 轻量级进程(LightWeight Process):在内核中来支持用户线程

用户线程和内核线程:一对一、多对一、多对多
image
image
image

3.5.1、用户线程

用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的
对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB
所以用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等

用户级线程的模型,也就类似前面提到的多对一的关系,即多个用户线程对应同一个内核线程,如下图所示
image

用户线程的优点

  • 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器)
    TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统
  • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快

用户线程的缺点

  • 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了
  • 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行
    因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有
  • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢

3.5.2、内核线程

内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责
内核线程的模型,也就类似前面提到的一对一的关系,即一个用户线程对应一个内核线程,如下图所示
image

内核线程的优点

  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行
  • 时间片分配给线程,多线程的进程获得更多的 CPU 运行时间片

内核线程的缺点

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB
  • 线程的创建、终止和切换都是通过系统调用的方式来进行,因此线程的管理开销比较大

3.5.3、轻量级进程

轻量级进程(Light-weight process,LWP)是内核支持的用户线程
一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度

在大多数系统中,LWP 与普通进程的区别在于:它只有一个最小的执行上下文和调度程序所需的统计信息
一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息

在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种

  • 1 : 1,即一个 LWP 对应一个用户线程
  • N : 1,即一个 LWP 对应多个用户线程
  • M : N,即多个 LWP 对应多个用户线程

image

1 : 1 模式

一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型

  • 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP
  • 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大

N : 1 模式

多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见

  • 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高
  • 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的

M : N 模式

根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3

  • 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源

组合模式

如上图的进程 5,此进程结合 1:1 模型和 M:N 模型,开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案

4、调度

4.1、调度时机

在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度,比如以下状态的变化都会触发操作系统的调度

  • 从就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行
  • 从运行态 -> 阻塞态:当进程发生 I / O 事件而阻塞时,操作系统必须选择另外一个进程运行
  • 从运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行

因为这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行

另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断,把调度算法分为两类

  • 非抢占式调度算法:挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情
  • 抢占式调度算法:挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时该进程仍然在运行,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程
    这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制

4.2、调度原则

五种调度原则

  • 原则一:如果运行的程序,发生了 I / O 事件的请求,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回,这样的过程,势必会造成 CPU 突然的空闲
    所以为了提高 CPU 利用率,在这种发送 I / O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行
  • 原则二:有的程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)的降低
    所以要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量
  • 原则三:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间
    进程的周转时间越小越好,如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生
  • 原则四:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更快的在 CPU 中执行
    所以就绪队列中进程的等待时间也是调度程序所需要考虑的原则
  • 原则五:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了
    所以对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则

针对上面的五种调度原则,总结成如下

  • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率
  • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量
  • 周转时间:周转时间 = 进程运行时间 + 阻塞时间 + 等待时间的总和,一个进程的周转时间越小越好
  • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意
  • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准

说白了,这么多调度原则,目的就是使得进程要「快」

4.3、调度算法

调度算法

基础调度算法

  • 先来先服务(First Come First Serve,CFS)
    每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行
    FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I / O 繁忙型作业的系统
  • 最短作业优先(Shortest Job First,SJF)
    优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量
    一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行
  • 高响应比优先(Highest Response Ratio Next,HRRN)
    每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」=(等待时间 + 要求服务时间)/ 要求服务时间
    要求的服务时间是不可预估的,所以高响应比优先调度算法是「理想型」的调度算法,现实中是实现不了的
    • 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行
    • 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程
      因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会

高级调度算法

  • 时间片轮转(Round Robin,RR)
    每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行
    如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;如果设得太长又可能引起对短作业进程的响应时间变长
    如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外一个进程;如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换

  • 最高优先级(Highest Priority First,HPF)
    从就绪队列中选择最高优先级的进程进行运行,可能会导致低优先级的进程永远不会运行,进程的优先级可以分为,静态优先级和动态优先级

    • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化
    • 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级

    该算法也有两种处理优先级高的方法,非抢占式和抢占式

    • 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程
    • 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行
  • 多级反馈队列(Multilevel Feedback Queue)

    • 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短
    • 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列

image

  • 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短
  • 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度
    如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成
  • 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行
    如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行

可以发现,对于短作业可能可以在第一级队列很快被处理完
对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也变更长了
所以该算法很好的兼顾了长短作业,同时有较好的响应时间

5、协程

【协程第一话】协程到底是怎样的存在?
【协程第二话】协程和IO多路复用更配哦~

image

6、进程间通信方式

进程间有哪些通信方式

管道、消息队列、共享内存、信号量、信号、Socket

  • 匿名管道:数据在内核、先进先出、无格式的流并且大小受限、通信的方式是单向的、只能用于存在父子关系的进程间通信
  • 命名管道:突破了匿名管道只能在亲缘关系进程间的通信限制
  • 消息队列:数据在内核的「消息链表」,每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程
  • 共享内存:最快的进程间通信方式,它直接分配一个共享空间,每个进程都可以直接访问,多个进程竞争共享资源会造成数据的错乱
  • 信号量:不仅可以实现访问的互斥性,还可以实现进程间的同步
  • 信号:异步通信机制,进程有三种方式响应信号(执行默认操作、捕捉信号、忽略信号)
  • Socket:如果要与不同主机的进程间通信,那么就需要 Socket 通信了

同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量
所以对于线程间的通信,关注的不是通信方式,而是多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步

7、多线程

7.1、竞争与协作

在单核 CPU 系统里,为了实现多个程序同时运行的假象

  • 操作系统通常以时间片调度的方式,让每个进程执行每次执行一个时间片,时间片用完了就切换下一个进程运行,由于这个时间片的时间很短,就造成了「并发」的现象
  • 操作系统也为每个进程创建巨大、私有的虚拟内存的假象,这种地址空间的抽象让每个程序好像拥有自己的内存
    而实际上操作系统在背后秘密地让多个地址空间「复用」物理内存或者磁盘
  • 如果一个程序只有一个执行流程,也代表它是单线程的,当然一个程序可以有多个执行流程,也就是所谓的多线程程序
    线程是调度的基本单位,进程则是资源分配的基本单位
  • 所以线程之间可以共享进程的资源,比如代码段、堆空间、数据段、打开的文件等资源,但每个线程都有自己独立的栈空间
    那么问题就来了,多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱

可以看到 i++ 并不是原子操作

  • 设想我们的线程 1 进入这个代码区域,它将 i 的值(假设此时是 50)从内存加载到它的寄存器中,然后它向寄存器加 1,此时在寄存器中的 i 值是 51
  • 一件不幸的事情发生了:时钟中断发生,因此操作系统将当前正在运行的线程的状态保存到线程的线程控制块 TCB
  • 现在更糟的事情发生了,线程 2 被调度运行,并进入同一段代码
    它也执行了第一条指令,从内存获取 i 值并将其放入到寄存器中,此时内存中 i 的值仍为 50,因此线程 2 寄存器中的 i 值也是 50
    假设线程 2 执行接下来的两条指令,将寄存器中的 i 值 + 1,然后将寄存器中的 i 值保存到内存中,于是此时全局变量 i 值是 51
  • 最后,又发生一次上下文切换,线程 1 恢复执行,还记得它已经执行了两条汇编指令,现在准备执行最后一条指令
    回忆一下,线程 1 寄存器中的 i 值是 51,因此执行最后一条指令后,将值保存到内存,全局变量 i 的值再次被设置为 51
  • 简单来说,增加 i(值为 50)的代码被运行两次,按理来说,最后的 i 值应该是 52,但是由于不可控的调度,导致最后 i 值却是 51

image

互斥

上面展示的情况称为竞争条件(race condition),当多线程相互竞争操作共享变量时
由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate)

由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行

我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了就是这段代码执行过程中,最多只能出现一个线程

image

同步

我们都知道在多线程里,每个线程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进
但有时候我们又希望多个线程能密切合作,以实现一个共同的任务

  • 线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的
  • 线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理

所谓同步,就是并发进程 / 线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程 / 线程同步

  • 互斥就好比:「操作 A 和操作 B 不能在同一时刻执行」
  • 同步就好比:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等

7.2、互斥与同步的实现和使用

为了实现进程 / 线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种

  • 锁:加锁、解锁操作
  • 信号量:P、V 操作

7.2.1、锁

cmpxchg

根据锁的实现不同,可以分为「忙等待锁」和「无等待锁」

  • 忙等待锁:Compare and Swap + 自旋
  • 无等待锁:Compare and Swap + 锁的等待队列

7.2.2、信号量

信号量表示资源的数量,控制信号量的方式有两种原子操作:P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的

  • 一个是 P 操作,这个操作会把信号量减去 1
    相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待
    相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行
  • 另一个是 V 操作,这个操作会把信号量加上 1
    相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行
    相加后如果信号量 > 0,则表明当前没有阻塞中的进程

进程互斥:临界资源只能互斥访问,信号量 = 1,临界区前 P 操作,临界区后 V 操作
进程同步:保证进程的执行顺序,信号量 = 0,前 V 后 P(先执行的线程结束后 V 操作,后执行的进程开始前 P 操作)
image

生产者 - 消费者问题(注意:互斥操作一定要位于同步操作里面)

image

7.2.3、哲学家就餐问题

image

  • 5 个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面
  • 巧就巧在,这个桌子只有 5 支叉子,每两个哲学家之间放一支叉子
  • 哲学家围在一起先思考,思考中途饿了就会想进餐
  • 奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐
  • 吃完后,会把两支叉子放回原处,继续思考

那么问题来了,如何保证哲学家们的动作有序进行,而不会出现有人永远拿不到叉子呢

方案一

我们用信号量的方式,也就是 PV 操作来尝试解决它

#define N 5                     // 哲学家个数
semaphore fork[5];              // 信号量初值为 1, 也就是叉子的个数

void smart_person(int i)        // i 为哲学家编号 0 ~ 4
{
    while(TRUE)
    {
        think();                // 哲学家思考

        P(fork[i]);             // 去拿左边的叉子
        P(fork[(i + 1) % N]);   // 去拿右边的叉子

        eat();                  // 进餐

        V(fork[i]);             // 放下左边的叉子
        V(fork[(i + 1) % N]);   // 放下右边的叉子
    }
}

不过这种解法存在一个极端的问题:假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了
这样就没有人能够拿到他们右边的叉子,也就说每一位哲学家都会在 P(fork[(i + 1) % N]) 这条语句阻塞了,很明显这发生了死锁的现象

方案二

既然「方案一」会发生同时竞争左边叉子导致死锁的现象,那么我们就在拿叉子前,加个互斥信号量

#define N 5                     // 哲学家个数
semaphore fork[5];              // 信号量初值为 1, 也就是叉子的个数
semaphore mutex;                // 互斥信号量, 初值为 1

void smart_person(int i)        // i 为哲学家编号 0 ~ 4
{
    while(TRUE)
    {
        think();                // 哲学家思考
        P(mutex);               // ----------进入临界区----------

        P(fork[i]);             // 去拿左边的叉子
        P(fork[(i + 1) % N]);   // 去拿右边的叉子

        eat();                  // 进餐

        V(fork[i]);             // 放下左边的叉子
        V(fork[(i + 1) % N]);   // 放下右边的叉子

        V(mutex);               //----------退出临界区----------
    }
}

互斥信号量的作用就在于:只要有一个哲学家进入了「临界区」,也就是准备要拿叉子时,其他哲学家都不能动,只有这位哲学家用完叉子了,才能轮到下一个哲学家进餐
虽然能让哲学家们按顺序吃饭,但是每次进餐只能有一位哲学家,而桌面上是有 5 把叉子,按道理是能可以有两个哲学家同时进餐的,所以从效率角度上,这不是最好的解决方案

方案三

既然方案二使用互斥信号量,会导致只能允许一个哲学家就餐,那么我们就不用它
方案一的问题在于,会出现所有哲学家同时拿左边刀叉的可能性,那我们就避免哲学家可以同时拿左边的刀叉,采用分支结构,根据哲学家的编号的不同,而采取不同的动作

让偶数编号的哲学家「先拿左边的叉子后拿右边的叉子」,奇数编号的哲学家「先拿右边的叉子后拿左边的叉子」
下面的程序,在 P 操作时,根据哲学家的编号不同,拿起左右两边叉子的顺序不同,V 操作是不需要分支的,因为 V 操作是不会阻塞的,即不会出现死锁,也可以两人同时进餐

#define N 5                         // 哲学家个数
semaphore fork[5];                  // 信号量初值为 1, 也就是叉子的个数

void smart_person(int i)            // i 为哲学家编号 0 ~ 4
{
    while(TRUE)
    {
        think();                    // 哲学家思考

        if (i % 2 == 0)
        {
            P(fork[i]);             // 去拿左边的叉子
            P(fork[(i + 1) % N]);   // 去拿右边的叉子
        }
        else
        {
            P(fork[(i + 1) % N]);   // 去拿右边的叉子
            P(fork[i]);             // 去拿左边的叉子
        }

        eat();                      // 进餐

        V(fork[i]);                 // 放下左边的叉子
        V(fork[(i + 1) % N]);       // 放下右边的叉子
    }
}

8、锁

8.1、互斥锁

当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的

  • 互斥锁加锁失败后:线程会释放 CPU ,给其他线程
  • 自旋锁加锁失败后:线程会忙等待,直到它拿到锁

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的
当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行
image

所以互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本
那这个开销成本是什么呢:会有两次线程上下文切换的成本

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行
  • 当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行

上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间
如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长
所以如果你确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁

8.2、自旋锁

自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说会快一些,开销也小一些

  • 第一步:查看锁的状态,如果锁是空闲的,则执行第二步
  • 第二步:将锁设置为当前线程持有

使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁
这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量

自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用
需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程),否则自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU

自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式
但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系

8.3、读写锁

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁
    这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据
  • 一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞

根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」

读优先锁期望的是:读锁能被更多的线程持有,以便提高读线程的并发性
它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞
并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁
image

而「写优先锁」是优先服务写线程
其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞
并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁
image

8.4、乐观锁与悲观锁

前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁,乐观锁做事比较乐观,它假定冲突的概率很低
它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作

8.5、死锁

死锁、活锁、饥饿:如何设计策略实现 Java 多线程死锁检测和撤销

posted @ 2023-08-11 16:46  lidongdongdong~  阅读(36)  评论(0编辑  收藏  举报