进程、线程、协程

1. 进程

1.1 什么是进程?

我们都知道计算机的核心是CPU,它承担了所有的计算任务;而操作系统是计算机的管理者,它负责任务的调度、资源的分配和管理,统领整个计算机硬件;应用程序则是具有某种功能的程序,程序是运行于操作系统之上的。

进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。进程是一种抽象的概念,从来没有统一的标准定义。

在早期的单任务计算机中,用户一次只能提交一个作业,独享系统的全部资源,同时也只能干一件事情。进行计算时不能进行 IO 读写,但 CPU 与 IO 的速度存在巨大差异,一个作业在 CPU 上所花费的时间非常少,大部分时间在等待 IO。

为了更合理的利用 CPU 资源,把内存划分为多块,不同程序使用各自的内存空间互不干扰,这里单独的程序就是一个进程,CPU 可以在多个进程之间切换执行,让 CPU 的利用率变高。

为了实现 CPU 在多个进程之间切换,需要保存进程的上下文(如程序计数器、栈、内核数据结构等等),以便下次切换回来可以恢复执行。还需要一种调度算法,Linux 中采用了基于时间片和优先级的完全公平调度算法。

1.2 进程的组成

进程一般由程序、数据集合和进程控制块三部分组成。

  • 程序:描述进程要完成的功能,是控制进程执行的指令集。
  • 数据集合:程序在执行时所需要的数据和工作区。
  • 进程控制块:(Program Control Block,简称PCB),包含进程的描述信息和控制信息,是进程存在的唯一标志。

1.3 进程特征

进程具有的特征:

  • 动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的;
  • 并发性:任何进程都可以同其他进程一起并发执行;
  • 独立性:进程是系统进行资源分配和调度的一个独立单位;
  • 结构性:进程由程序、数据和进程控制块三部分组成。

1.4 进程的状态

通过观察,我们发现进程执行的过程遵循这样的 运行-暂停-运行 规律,虽然看起来十分简单,但是它的背后涉及到了进程状态的转换。

进程三态

进程的执行期间,至少具备三种基本状态,即运行态、就绪态、阻塞态。

上图状态的意义

  • 运行态(Runing):时刻进程占用 CPU
  • 就绪态(Ready):可运行,但因为其他进程正在运行而暂停停止
  • 阻塞状态(Blocked):该进程等待某个事件(比如IO读取)停止运行,这时,即使给它CPU控制权,它也无法运行

上图状态转换流程

  1. CPU  调度绪态进程执行,进入运行状态,时间片使用完了,回到就绪态,等待 CPU 调度
  2. CPU  调度绪态进程执行,进入运行状态,执行IO请求,进入阻塞态,IO请求完成,CPU收到 中断 信号,进入就绪态,等待 CPU 调度

进程五态

在三态基础上,做一次细化,出现了另外两个基本状态,创建态和结束态。

上图状态的意义

  • 创建态(new):进程正在被创建
  • 就绪态(Ready):可运行,但因为其他进程正在运行而暂停停止
  • 运行态(Runing):时刻进程占用 CPU
  • 结束态(Exit):进程正在从系统中消失时的状态
  • 阻塞状态(Blocked):该进程等待某个事件(比如IO读取)停止运行,这时,即使给它CPU控制权,它也无法运行

状态的变迁

  • NULL => 创建态(new):一个新进程被创建时的第一个状态
  • 创建态(new) => 就绪态(Ready):当进程创建完成,进入就绪态
  • 就绪态(Ready)=>  运行态(Runing):CPU 从就绪队列选择进程执行,进入运行态
  • 运行态(Runing)=> 结束态(Exit):当进程已经运行完成或出错时,进入结束状
  • 运行态(Runing) => 就绪态(Ready):分配给进程的时间片使用完,进入就绪态
  • 运行态(Runing) => 阻塞状态(Blocked):进程执行等待事件,进入阻塞态
  • 阻塞状态(Blocked) => 就绪态(Ready):进程事件完成,CPU 收到 中断 信号 ,进入就绪态

进程七态

其实进程还有一种状态叫挂起态,挂起态代表该进程不会占用内存空间,它会被换出到硬盘空间保存,当需要使用它的时候,会被换入,加载到内存,挂起态可以分为下面两种

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

结合上述的两种挂起态,就组成了进程七态:

从上图我们发现,创建态、就绪态、运行态,阻塞挂起态、阻塞态都可以转入挂起态,这时问题就产生了,什么情况会转入 挂起态 ,什么情况又会从 挂起态 转入到 非挂起态(就绪态与阻塞态), 操作系统会根据当前资源状况和性能要求、进程的优先级来进行挂起与激活操作,没有固定的说法。

1.5 进程的上下文切换

CPU把一个进程切换到另一个进程运行的过程,称为进程上下文切换。

在说进程上下文切换之前,先来聊聊 CPU 上下文切换。

CPU 上下文切换

CPU上下文 是指 CPU 寄存器 和 程序计数器

  • CPU 寄存器 是 CPU 内置的容量小,速度极快的缓存
  • 程序计数器是用来存储 是 CPU 正在执行的指令位置或即将执行的下一条指令位置

CPU 上下文切换 就很好理解了,就是把前一个任务的 CPU上下文  保存起来,然后在加载当前任务的 CPU上下文,最后再跳转到 程序计数器 所指的新位置,运行任务。

上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。

进程的上下文是怎么切换的

首先进程是由内核管理与调度的,所以 进程上下文切换 发生在内核态,进程上下文切换的内容包含用户空间资源(虚拟内存、栈、全局变量等)与内核空间资源(内核堆栈、寄存器等)。

在做上下文切换的时候,会把前一个 进程 的上下文保存到它的 P C B 中,然后加载当前 进程 的 P C B 上下文到 CPU 中,使得 进程 继续执行。

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

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

1.6 进程与程序的区别

进程:是程序在处理机上的一次执行过程,是一个动态的概念,进程是有一定生命期,进程是暂时的

程序:是指令和数据的有序集合,是一个静态的概念,程序可以作为一种软件资料长期存在,程序是永久的

  1. 进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data resign)和堆栈(stack resign)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。

  2. 进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(即操作系统执行它时),它才能成为一个活动的实体,叫做进程。

1.7 进程的调度

要想多个进程交替运行,操作系统必须对这些进程进行调度,这个调度也不是随即进行的,而是需要遵循一定的法则,下面是进程的调度算法。

1. 先来先服务算法(FCFS)

先来先服务调度算法,是一种最简单的调度算法,既可用于作业调度,也可用于进程调度

比较有利于长作业(进程),不利于短作业(进程)

适合于CPU繁忙型作业,不利于I/O繁忙型的作业(进程)

2. 短作业优先调度算法(SJ/PF)

是指对短作业或短进程优先调度的算法,既可用于作业调度,也可用于进程调度

对长作业不利,不能保证紧迫性作业(进程)不能及时处理,作业的长短时间被估算出来的

3. 时间片轮转法(RR)

每个进程在就绪队列中的等待时间和享受服务的时间成比例

在时间片轮转法中,需要将CPU的处理时间分成固定大小的时间片,eg:几十毫秒至几百毫秒。

如果一个进程在被调度选中之间用完了系统规定的时间片,但又未完成要求的任务,则它自行释放自己所占有的CPU而排到就绪队列的末尾,同时,进程调度程序又去调度当前就绪队列中的第一个进程。

轮转法只能用来调度分配一些可以抢占的资源。这些可以抢占的资源可以随时被剥夺,而且可以将它们再分配给别的进程。CPU是可抢占资源的一种,但是打印机等资源是不可被抢占的。作业调度是对除了CPU之外的所有系统硬件资源的分配,其中包含有不可抢占的资源,所以作业调度不使用轮转法。

时间选片很重要。  首先,时间片长度的选择会之间影响到系统的开销和响应时间。

  • 如果时间长度过短,则调度程序抢占处理机的次数增加,这将使进程上下文切换次数也大大增加,从而加重系统开销。
  • 如果时间长度选择过长;eg:一个时间片能保证就绪队列中所需执行时间最长的进程能执行完毕,则轮转法变成了先来先服务法。

时间片长度的选择是根据系统对响应时间的要求和就绪队列中所允许最大的进程数来确定的。

在轮转法中,加入到就绪队列的进程有三种情况:

  1. 分给它的时间片用完,但进程还未完成,回到就绪队列的末尾等待下次调度去继续执行

  2. 分给该进程的时间片还未用完,只是因为I/O或者由于进程的互斥与同步关系被阻塞。当阻塞被解除之后再回到队列

  3. 新创建的进程进入就绪队列

4. 多级反馈队列

设置多个就绪队列,并为各个队列赋予不同的优先级。

  • 第一个队列的优先级最高,第二个次之,其余各队列的优先权逐个降低。在优先权越高的队列中,为每一个进程所规定的执行时间片就越小。

eg:第二个队列的时间片要比第一个队列的时间片长一倍,一直到第i+1个队列的时间片要比第i个队列的时间片长一倍。

  • 当 一个新进程进入内存后,首先将它放入第一队列的末尾,按FCFS原则排队等待调度。当轮到该进程执行时,如果它能在该时间片内完成,便可准备撤离系统;如果它在一个时间片结束时尚未完成,调度进程便将改进程转到第二队列的末尾,再同样按FCFS原则等待调度执行,如果它再第二队列中运行一个时间片后仍未完成,再依次将它放入第三队列,如此下去,当一个长作业(进程)从第一队列依次降到第n队列后,再第n队列便采取按时间片轮转的方式进行。
  • 仅当第一队列空闲时,调度程序才调度第二程序队列中的进程运行,仅当第1(i-1)队列均空时,才会调度第i队列中的程序运行。如果处理机正在第i队列中为某进程服务时,又有新进程进入优先权较高的队列(第i(i-1)中的任意一个队列),则此时新进程将抢占正在运行进程的处理机,即由调度程序把正在运行的进程放回到第i队列的末尾,把处理机分配给新到的高优先权进程。

无论是在多核还是单核系统中,一个CPU看上去都像是在并发的执行多个进程,这是通过处理器在进程间切换来实现的。

操作系统对把CPU控制权在不同进程之间交换执行的机制称为上下文切换(context switch),即保存当前进程的上下文,恢复新进程的上下文,然后将CPU控制权转移到新进程,新进程就会从上次停止的地方开始。因此,进程是轮流使用CPU的,CPU被若干进程共享,使用某种调度算法来决定何时停止一个进程,并转而为另一个进程提供服务。

1.8 并发与并行

并发:在操作系统中,某一时间段,几个程序在同一个CPU上运行,但在任意一个时间点上,只有一个程序在CPU上运行。

当有多个线程时,如果系统只有一个CPU,那么CPU不可能真正同时进行多个线程,CPU的运行时间会被划分成若干个时间段,每个时间段分配给各个线程去执行,一个时间段里某个线程运行时,其他线程处于挂起状态,这就是并发。并发解决了程序排队等待的问题,如果一个程序发生阻塞,其他程序仍然可以正常执行。

并行:当操作系统有多个CPU时,一个CPU处理A线程,另一个CPU处理B线程,两个线程互相不抢占CPU资源,可以同时进行,这种方式成为并行。

区别

  1. 并发只是在宏观上给人感觉有多个程序在同时运行,但在实际的单CPU系统中,每一时刻只有一个程序在运行,微观上这些程序是分时交替执行。
  2. 在多CPU系统中,将这些并发执行的程序分配到不同的CPU上处理,每个CPU用来处理一个程序,这样多个程序便可以实现同时执行。

知乎上高赞例子:

  • 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
  • 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
  • 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力。所以我认为它们最关键的点就是:是否是『同时』

2. 线程

2.1 什么是线程?

在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。

后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。直到后面,计算机科学家又提出了更小的能独立运行的基本单位,它就是线程。

在现代操作系统,进程是最小的资源分配单位,线程是最小的运行单位,一个进程下面能有一个或多个线程,每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。

线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。而进程由内存空间(代码、数据、进程空间、打开的文件)和一个或多个线程组成。

首先要明确的是,多进程和多线程都是并发,都可以提高处理器的利用效率,所以现在的关键是,多线程和多进程有啥区别。

在 Linux 中线程和进程基本没有区别。

为什么说 Linux 中线程和进程基本没有区别呢,因为从 Linux 内核的角度来看,并没有把线程和进程区别对待。

我们知道系统调用fork()可以新建一个子进程,函数pthread()可以新建一个线程。但无论线程还是进程,都是用task_struct结构表示的,唯一的区别就是共享的数据区域不同

换句话说,线程看起来跟进程没有区别,只是线程的某些数据区域和其父进程是共享的,而子进程是拷贝副本,而不是共享。就比如说,mm结构和files结构在线程中都是共享的,我画两张图你就明白了:

所以说,我们的多线程程序要利用锁机制,避免多个线程同时往同一区域写入数据,否则可能造成数据错乱。

那么你可能问,既然进程和线程差不多,而且多进程数据不共享,即不存在数据错乱的问题,为什么多线程的使用比多进程普遍得多呢

因为现实中数据共享的并发更普遍呀,比如十个人同时从一个账户取十元,我们希望的是这个共享账户的余额正确减少一百元,而不是希望每人获得一个账户的拷贝,每个拷贝账户减少十元。

当然,必须要说明的是,只有 Linux 系统将线程看做共享数据的进程,不对其做特殊看待,其他的很多操作系统是对线程和进程区别对待的,线程有其特有的数据结构。

在 Linux 中新建线程和进程的效率都是很高的,对于新建进程时内存区域拷贝的问题,Linux 采用了 copy-on-write 的策略优化,也就是并不真正复制父进程的内存空间,而是等到需要写操作时才去复制。所以 Linux 中新建进程和新建线程都是很迅速的

2.2 线程的优缺点

线程带来的好处有以下几点:

  • 一个进程中可以同时存在多个线程
  • 让进程具备多任务并行处理能力
  • 同进程下的各个线程之间可以共享进程资源 (同进程内的多线程通信十分简单高效)
  • 更轻量与高效

线程带来的坏处有以下几点:

  • 因为进程资源共享,所以会产生资源竞争,需要通过锁机制来协同
  • 当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃(一般游戏的用户设计不会采用多线程方式)

2.3 进程和线程的对比

  • 进程是最小的资源(包括内存、打开的文件等)分配单位,线程是最小的运行单位
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系(和进程大同小异)
  • 线程的创建、终止时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,所以线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们(线程管理的资源较少)
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了

线程比进程不管是时间效率,还是空间效率都要高。

进程和线程在 Linux 中没有本质区别,他们最大的不同就是进程有自己独立的内存空间,而线程(同进程中)是共享内存空间。

2.4 线程的上下文切换

线程上下文的切换分两种情况:

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

在进程切换时需要转换内存地址空间,而线程切换没有这个动作,所以线程切换比进程切换代价更小。

为什么内存地址空间转换这么慢?Linux 实现中,每个进程的地址空间都是虚拟的,虚拟地址空间转换到物理地址空间需要查页表,这个查询是很慢的过程,因此会用一种叫做 TLB 的 cache 来加速,当进程切换后,TLB 也随之失效了,所以会变慢。

所以线程的上下文切换相比进程,开销要小很多,线程是为了降低进程切换过程中的开销。

2.4 线程的模型

在说线程模式之前,先介绍3个概念:

  • 内核线程:在内核空间就实现的线程,由内核管理
  • 用户线程:在用户空间实现的线程,不归内核管理,是由用户态通过线程库完成线程的管理(用户态是指线程或进程在用户空间运行)
  • 轻量级进程:在内核中来支持用户线程(用户线程与内核线程的中间层,内核线程的高度抽象)

1. 内核线程

因为内核线程是由内核空间管理,所以它的 结构线程控制块(Thread Control Block, TCB)  在内核空间,操作系统对 T C B 是可见的:

内核线程有什么优点:

  • 内核线程的由内核空间管理,线程的创建、销毁、调度等,都不用你操心,全自动化,属于智能型
  • 内核线程能利用cpu多核的特性,实现并行执行(因为由内核管理,非常智能)
  • 内核线程阻塞,不会影响其他内核线程(因为由内核管理,非常智能)

内核线程有什么缺点:

  • 因为是内核管理,所以内核线程的大部分操作都涉及到内核态,即需要从用户态切换到内核态,开销较大
  • 因为内核资源有限,所以无法大量创建内核线程

2. 用户线程

因为 用户线程 在用户空间,是由 用户态 通过线程库来管理,所以它的 结构线程控制块(Thread Control Block, TCB) 也是在线程库里面,对于操作系统而言是看不到 T C B 的,它只能看到整个进程的 P C B(内核无法管理用户线程,也感知不到用户线程)

用户线程有什么优点:

  • 因为用户线程创建、销毁、调度等都不走内核态,直接在用户态进行操作,所以速度特别快
  • 不依赖内核,可用于不支持线程技术的操作系统
  • 可以大量创建用户线程,不消耗内核资源

用户线程有什么缺点:

  • 用户线程创建、销毁、调度等需要自己实现相应线程库
  • 用户线程阻塞会导致整个进程内的其他用户线程阻塞(整个进程阻塞),因为内核感知不到用户线程,所以无法去调度其他用户线程
  • 无法利用cpu多核特性,还是因为内核感知不到用户线程

3. 轻量级进程(Light-weight process,LWP)

轻量级进程(Light-weight process,LWP)可以理解成内核线程的高级抽象,一个 进程 可以有一个或多个L W P ,因为每个 L W P 与 内核线程 一对一映射,所以 L W P 都是由一个 内核线程 支持(用户线程关联L W P,即成为内核支持的用户线程)。

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

4. 一对一模型(内核级线程模型)

L W P就是一对一模型,即 进程 只需要创建使用L W P ,因为一个 L W P 由一个 内核线 程支持,所以最终是内核管理线程,可以调度到其他处理器上(再简单点解释,直接使用内核线程)

缺点:

  • 内核线程的数量有限,一对一模型使用的用户线程数量有限制。
  • 内核线程的调度,上下文切换的开销较大(虽然没有进程上下文切换的开销大),导致用户线程的执行效率下降。

JVM采用该模型实现线程,所以在Java中启动一个线程需要谨慎。

5. 一对多模型(用户级线程模型)

一对多模型,即多个用 户级线程 对用到同一个 L W P 上实现,因为是用户态通过用户空间的线程库对线程管理,所以速度特别快,不会涉及到用户态与内核态的转换。

多个用户线程映射到一个内核线程上,线程间的切换由用户态的代码来进行。用户线程的建立、同步、销毁都是在用户态中完成,不需要内核的介入。因此多对一的上下文切换速度快很多,且用户线程的数量几乎没有限制。

缺点:

  • 若一个用户线程阻塞,其他所有线程都无法执行,此时内核线程处于阻塞状态。
  • 处理器数量的增加,不会对多对一模型的线程性能造成影响,因为所有的用户线程都映射到了一个处理器上。

Python 中的协程就是通过该模型实现。

6. 多对多模型(两级线程模型)

多对多模型是集各家所长诞生的产物,它充分吸收前两种线程模型的优点且尽量避免它们的缺点。

首先它区别于多对一模型,多对多模型进程内的 多用户线程 可以绑定不同的内核线程 ,这点与 一对一模型 类似,其次又区别于一对一模型,进程内的 多用户线程 与 内核线程 不是一对一绑定,而是动态绑定,当某个 内核线程 因绑定的 用户线程 执行阻塞操作,让出 C P U 时,绑定该 内核线程 的其他 用户线程 可以解绑,重新绑定到其他 内核线程 继续运行。

所以多对多模型(m:n),即不是多对一模型完全靠自己实现的线程库调度,也不是一对一模型完全靠操作系统调度,而是一个中间态系统(负责自身调度与操作系统调度的协同工作),最后提一句Go语言使用的是多对多模型,这也是其高并发的原因,它的线程模型与Java中的ForkJoinPool非常类似。

多对多模型优点:

  • 兼具多对一模型的轻量
  • 由于对应了多个内核线程,则一个用户线程阻塞时,其他用户线程仍然可以执行
  • 由于对应了多个内核线程,则可以实现较完整的调度、优先级等;

多对多模型缺点:

  • 实现复杂(因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现)

2.5 线程的生命周期

线程的生命周期:

  • 创建:一个新的线程被创建,等待该线程被调用执行;
  • 就绪:时间片已用完,此线程被强制暂停,等待下一个属于它的时间片到来;
  • 运行:此线程正在执行,正在占用时间片;
  • 阻塞:也叫等待状态,等待某一事件(如IO或另一个线程)执行完;
  • 退出:一个线程完成任务或者其他终止条件发生,该线程终止进入退出状态,退出状态释放该线程所分配的资源。

2.6 线程的“并发”

只有在线程的数量 < 处理器的数量时,线程的并发才是真正的并发,这时不同的线程运行在不同的处理器上。但是当线程的数量 > 处理器的数量时,会出现一个处理器运行多个线程的情况

在单个处理器运行多个线程时,并发是一种模拟出来的状态。操作系统采用时间片轮转的方式轮流执行每一个线程。现在,几乎所有的现代操作系统采用的都是时间片轮转的抢占式调度方式。如我们熟悉的Unix、Linux、Windows及macOS等流行的操作系统。

3. 协程

3.1 什么是协程?

协程,英文Coroutines,是一种基于线程之上,但又比线程更加轻量级的存在,这种由程序员自己写程序来管理的轻量级线程叫做『用户空间线程』,具有对内核来说不可见的特性。

因为是自主开辟的异步任务,所以很多人也更喜欢叫它们纤程(Fiber),或者绿色线程(GreenThread)。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:
协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。

协程也并不是 Go 提出来的,协程是一种编程思想,并不局限于特定的语言。Go、Python、Kotlin 都可以在语言层面上实现协程,Java 也可以通过扩展库的方式间接支持协程。

协程比线程更加轻量级,可以由程序员自己管理的轻量级线程,对内核不可见。

3.2 协程的目的

当我们的程序是 IO 密集型时(如 web 服务器、网关等),为了追求高吞吐,有两种思路:

  1. 为每个请求开一个线程处理,为了降低线程的创建开销,可以使用线程池技术,理论上线程池越大,则吞吐越高,但线程池越大,CPU 花在切换上的开销也越大

    线程的创建、销毁都需要调用系统调用,每次请求都创建,高并发下开销就显得很大,而且线程占用内存是 MB 级别,数量不能太多
    为什么线程越多 cpu 切换越多?准确来说是可执行的线程越多,cpu 切换越多,因为操作系统的调度要保证绝对公平,有可执行线程时,一定是要雨露均沾,所以切换次数变多

  2. 使用异步非阻塞的开发模型,用一个进程或线程接收请求,然后通过 IO 多路复用让进程或线程不阻塞,省去上下文切换的开销

这两个方案,优缺点都很明显,方案1实现简单,但性能不高;方案2性能非常好,但实现起来复杂。有没有介于这两者之间的方案?既要简单,又要性能高,协程就解决了这个问题。

协程是用户视角的一种抽象,操作系统并没有这个概念,其主要思想是在用户态实现调度算法,用少量线程完成大量任务的调度。

协程需要解决线程遇到的几个问题:

  • 内存占用要小,且创建开销要小
  • 减少上下文切换的开销

第一点好实现,用户态的协程,只是一个数据结构,无需系统调用,而且可以设计的很小,达到 KB 级别。

第二点只能减少上下文切换次数来解决,因为协程的本质还是线程,其切换开销在用户态是无法降低的,只能通过降低切换次数来达到总体上开销的减少,可以有如下手段:

  1. 让可执行的线程尽量少,这样切换次数必然会少
  2. 让线程尽可能的处于运行状态,而不是阻塞让出时间片

在传统的 J2EE 系统中都是基于每个请求占用一个线程去完成完整的业务逻辑(包括事务)。所以系统的吞吐能力取决于每个线程的操作耗时。如果遇到很耗时的 I/O 行为,则整个系统的吞吐立刻下降,因为这个时候线程一直处于阻塞状态,如果线程很多的时候,会存在很多线程处于空闲状态(等待该线程执行完才能执行),造成了资源应用不彻底。

最常见的例子就是 JDBC(它是同步阻塞的),这也是为什么很多人都说数据库是瓶颈的原因。这里的耗时其实是让 CPU 一直在等待 I/O 返回,说白了线程根本没有利用 CPU 去做运算,而是处于空转状态。而另外过多的线程,也会带来更多的 ContextSwitch 开销。

对于上述问题,现阶段行业里的比较流行的解决方案之一就是单线程加上异步回调。其代表派是 node.js 以及 Java 里的新秀 Vert.x。

而协程的目的就是当出现长时间的 I/O 操作时,通过让出目前的协程调度,执行下一个任务的方式,来消除 ContextSwitch 上的开销。

3.3 协程的特点

  1. 线程的切换由操作系统负责调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率。
  2. 线程的默认Stack大小是1M,而协程更轻量,接近1K。因此可以在相同的内存中开启更多的协程。
  3. 由于在同一个线程上,因此可以避免竞争关系而使用锁。
  4. 适用于被阻塞的,且需要大量并发的场景。但不适用于大量计算的多线程,遇到此种情况,更好实用线程去解决。

3.4 协程的原理

当出现IO阻塞的时候,由协程的调度器进行调度,通过将数据流立刻yield掉(主动让出),并且记录当前栈上的数据,阻塞完后立刻再通过线程恢复栈,并把阻塞的结果放到这个线程上去跑,这样看上去好像跟写同步代码没有任何差别,这整个流程可以称为coroutine,而跑在由coroutine负责调度的线程称为Fiber。比如Golang里的 go关键字其实就是负责开启一个Fiber,让func逻辑跑在上面。

由于协程的暂停完全由程序控制,发生在用户态上;而线程的阻塞状态是由操作系统内核来进行切换,发生在内核态上。因此,协程的开销远远小于线程的开销,也就没有了 上下文切换 上的开销。

3.5 协程和线程的比较

3.6 Go中的协程

Java 在 Linux 操作系统下使用的是用户线程+轻量级线程,一个用户线程映射到一个内核线程,线程之间的切换就涉及到了上下文切换。所以在 Java 中并不适合创建大量的线程,否则效率会很低。可以先看下 Go 的协程:

Goroutine

goroutine 是 golang 实现的协程,其特点是在语言层面就支持,使用起来非常方便,它的核心是MPG调度模型:

  • M:内核线程
  • P:处理器,用来执行 goroutine,它维护了本地可运行队列
  • G:goroutine,代码和数据结构
  • S:调度器,维护M和P的信息

除此之外还有一个全局可运行队列。

  1. 在 golang 中使用 go 关键字启动一个 goroutine,它将会被挂到 P 的 runqueue 中,等待被调度

  1. 当 M0 中正在运行的 G0 阻塞时(如执行了一个系统调用),此时 M0 会休眠,它将放弃挂载的 P0,以便被其他 M 调度到

  1. 当 M0 系统调用结束后,会尝试“偷”一个 P,如果不成功,M0 将 G0 放到全局的 runqueue 中
  2. P 会定期检查全局 runqueue,保证自己消化完 G 后有事可做,同时也会从其他 P 里“偷” G

从上述看来,MPG 模型似乎只限制了同时运行的线程数,但上下文切换只发生在可运行的线程上,应该是有一定的作用,当然这只是一部分。

golang 在 runtime 层面拦截了可能导致线程阻塞的情况,并针对性优化,他们可分为两类:

  • 网络 IO、channel 操作、锁:只阻塞 G,M、P 可用,即线程不会让出时间片
  • 系统调用:阻塞 M,P 需要切换,线程会让出时间片

所以综合来看,goroutine 会比线程切换开销少。

4. 总结

从单进程到多进程提高了 CPU 利用率;从进程到线程,降低了上下文切换的开销;从线程到协程,进一步降低了上下文切换的开销,使得高并发的服务可以使用简单的代码写出来,技术的每一步发展都是为了解决实际问题。

更多进程、线程、协程相关内容和面试题可以见这篇文章:
操作系统并发三剑客:进程/线程/协程 (qq.com)

5. 操作系统调度

5.1 调度原则

CPU 利用率

  • 运行程序发生了I/O 事件的请求,因此阻塞,导致进程在等待硬盘的数据返回。这样的过程,势必会造成 C P U 突然的空闲。所以为了提高 C P U 利用率,发生等待事件使 C P U 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。(PS:调度程序应确保 C P U 一直保持匆忙的状态,可提高 C P U 的利用率)

系统吞吐量

  • 程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 C P U,会造成系统吞吐量的降低。所以要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。(吞吐量表示的是单位时间内 C P U 完成进程的数量,长作业的进程会占用较长的 C P U 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量)

周转时间

  • 从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,如果进程的等待时间很长,而运行时间很短,那周转时间就很长,调度程序应该避免这种情况发生。(周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好)

等待时间

  • 处于就绪队列的进程,也不能等太久,希望这个等待的时间越短越好,因为可以使进程更快的在 C P U 中执行。所以就绪队列中,进程的等待时间,也是调度程序所需要考虑的原则(这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待时间越长,用户越不满意)。

响应时间

  • 对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了。所以,对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则( 用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准)。

5.2 调度算法

不同的算法适用不同的场景,下面介绍几个单核中常见的调度算法

先来先服务算法(First Come First Severd, FCFS)

先来先服务算法简称 F C F S,顾名思义,谁先来,谁先被 C P U 执行,后到的就乖乖排队等待,十分简单的算法,C P U每次调度 就绪队列 的第一个进程,直到进程退出或阻塞,才会把该进程入队到队尾,然后接着继续调度第一个进程,依此类推。

F C F S算法看似很公平,但是当一个长作业先运行了,后面的短作业等待的时间就会很长,所以不利于短作业,会降低系统吞吐量。

F C F S对长作业有利,适用于 C P U 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。

最短作业优先算法(Shortest Job First, SJF)

同样也是顾名思义,它会优先选择运行时间最短的进程,有助于提高系统吞吐量。但是对长作业不利,所以很容易造成一种极端现象。比如,一个 长作业 在就绪队列等待运行,而这个就绪队列有非常多的短作业,最终使得 长作业 不断的往后推,周转时间变长,致使长作业长期不会被运行(适用于 I/O 繁忙型作业的系统)。

高响应比优先算法 (Highest Response Ratio Next, HRRN)

因为前面的「先进先出算法」和「最短作业优先算法」都没有很好的权衡短作业和长作业,所以高响应比优先算法主要是权衡了短作业和长作业。

每次进行进程调度时,先计算「响应比优先权」,然后把「响应比优先权」最高的进程投入运行。

从上面的公式,可以发现:

如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「优先权」就越高,这样短作业的进程容易被选中运行(如果等待时间较短,进程的运行时间越短,优先权就会越高 =>  等待时间较短的短作业进程)

如果两个进程「要求的服务时间」相同时,「等待时间」越长,「优先权」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会(如果要求服务时间比较长,进程的等待时间越长,优先权就会越高 => 等待时间较长的长作业进程)

时间片轮转(Round Robin, RR)算法

时间片轮转是最古老、最简单、最公平且使用最广的算法,给每个进程分配相同时间片(Quantum),允许进程在该时间段中运行。

  • 如果时间片用完,进程还在运行,将会把此进程放入就绪队列,并继续调度另外一个进程,依此类推
  • 如果该进程在时间片结束前阻塞或结束,则调度另外一个进程
  • 进程时间片用完,需要被重新分配时间片

需要注意的是,如果时间片设置的太短,会导致CPU上下文切换态频繁,太长又可能引起对短作业进程的响应时间变长,所以时间片设为 20ms~50ms 通常是一个比较合理的折中值

最高优先级(Highest Priority First,HPF)算法

前面的「时间片轮转算法」让所有的进程同等重要,不偏袒谁,大家的运行时间都一样。但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,希望调度程序能从就绪队列中选择最高优先级的进程运行,这就是最高优先级(Highest Priority First,HPF)算法。

进程的优先级可以分为:

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

有两种处理优先级高的方法:

  • 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
  • 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。

但是依然有缺点,可能会导致低优先级的进程永远不会运行。

多级反馈队列(Multilevel Feedback Queue)算法

多级反馈队列(Multilevel Feedback Queue)算法 是基于「时间片轮转算法」和「最高优先级算法」演进而来,如同它的名字一样,根据优先级分组成多个队列,在算法中涉及两个概念:

  • 「多级」表示有多个队列,每个队列优先级从高到低,优先级越高的队列拥有的时间片越短
  • 「反馈」 表示有新的进程进入优先级高的队列时,停止当前运行进程,去运行优先级高的队列

工作流程:

  • 多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短
  • 新进的 进程 会被放入 第一级队列 尾部,按先来先服务的原则排队等待被调度,如果第一级队列时间片用完,还有进程没有执行,把第一级队列剩余的进程 放入 第二级队列的尾部,依此类推
  • 当优先级高队列为空,正在运行低优先级队列的进程时,有新进程 进入 高优先级队列,这时立即停止当前运行进程,把当前进程放入 原队列 尾部,转而去 运行 高优先级队列的进程。

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

参考感谢
进程、线程与协程傻傻分不清?一文带你吃透! (qq.com)
写了一年golang,来聊聊进程、线程与协程 (qq.com)
面试官:换人!他连进程线程协程这几个特点都说不出 (qq.com)
深入分析 Java、Kotlin、Go 的线程和协程! (qq.com)
一文读懂什么是进程、线程、协程 - 云+社区 - 腾讯云 (tencent.com)
【面试高频问题】线程、进程、协程 - 知乎 (zhihu.com)