浅谈进程、线程和协程

2022/2/12 7:16:33

本文主要是介绍浅谈进程、线程和协程,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

开始前

我有个毛病,就是一个东西我学过之后,我必须在VSCode中以一个讲述者的身份,并以我希望我掌握的程度讲出来,所以我一直在坚持写着。

这篇文章也是算是一个学习后的报告?如果您有幸看到了,并发现文章中的某些概念、某些表述不恰当甚至错误,请您通过评论或者任何方式帮助我改正,万分感谢!

邮箱:1355265122@qq.com

对于读者,本篇文章是我自己的理解,难免有些地方不准确,如果您需要十分准确无误的掌握这些概念,我想我正在看的书 —— 《现代操作系统》应该能帮助到你。而且这篇笔记并不是书里那种事无巨细的风格,我只说一部分,其它的细节如果你想要了解,还是去读书~~~

文章结构

  • CPU、主存、IO设备以及操作系统
  • 回到单任务时代
    • 多个任务无法得到同时调度
    • IO操作时CPU在闲着
    • 分时处理多个任务
  • 进程 —— 一种便于多任务并发运行的模型
    • 进程状态
    • 进程控制块
  • 线程 —— 更细致的划分,让单个进程中的子任务并发执行
    • 同一个进程内并发执行
    • 进程创建、销毁、切换的成本
    • 进程间通信复杂
    • 单个进程在多核CPU上加快处理速度
    • 一个多线程程序的例子
  • 横插一嘴 —— 非阻塞调用
  • 线程的不同实现方式
    • 用户级线程
    • 内核级线程
    • 混合线程
  • CPU密集型 / IO密集型
    • CPU密集型
    • IO密集型
    • 正确的线程池大小很重要
    • NodeJS不适合处理CPU密集型任务!...吗?
  • 协程
    • 基于生成器的协程
    • 基于线程池调度的协程
    • 其它协程模式

CPU、主存、IO设备以及操作系统

计算机大概可以这样分类。

CPU是它的中央处理单元,它负责任务(程序)的实际执行,记录与当前执行任务相关的少量信息(比如当前执行的指令地址);主存就是内存,它负责存储运行期的任务产生的数据;IO设备是所有输入输出设备的统称,比如磁盘、打印机、显示器;操作系统则负责将任务以及这些硬件有条不紊的调度起来,让它们得以运行,并对用户和程序开发者提供一致清晰的接口。

所以,CPU主要负责计算,它的运算速度很快,而IO设备属于外设,它们都不是一台计算机运行起来所必要的设备,调度它们的速度相比来说会稍慢一些。CPU和IO任务是我们进行并发程序设计时经常要考虑的,我们想方设法调节二者的运行时段,使二者达到平衡,性能达到优良。

IO操作除了与外设进行交互(如读磁盘文件、打印)之外,还包括网络请求之类的操作,这种操作的共同点是它们相对来说比较慢,与CPU运算速度相比要差好多个数量级。

回到单任务时代

这是我没经历过的时代,所以我没办法像一些书里一样生动的向你讲述当时的故事,但是,当时的操作系统就是一次只能处理一个任务。比如当你编译一个程序,你没法在这个时候打开一首音乐来听,你必须等待它编译完成,系统才能闲下来去处理下一个任务。

多个任务无法同时得到调度

听起来没什么问题,毕竟我们人类也很难一心二用,不过一个问题在于,很多需要长时间运行的任务我们并帮不上什么忙,我们只能等待,而在等待的过程中,我们的计算机被这个任务独占,它没法去干点儿别的你想干的事。

现在我们的计算机系统都是多任务操作系统,我们一开机,很多程序就随着启动。比如我们的防病毒程序,它们会在后台以特定的频率扫描系统内是否有隐藏的风险;比如我们的邮件接收程序,它不断地监听我们的邮箱是否有新邮件,在有新邮件时提醒我......如果我们的系统一次只能处理一个任务,那这些任务将无法得以共同运行在后台。

IO操作时CPU在闲着

当发生IO操作时,如等待用户输入,这时如果在单任务系统中,CPU完全空闲,如果能将这段时间用于处理其它任务,CPU利用率将得到提升,但这在单任务系统中是无法实现的。

分时处理多个任务

一个思路——也是沿用至今的思路——就是分时运行多个任务。比如当前系统有任务A和B,让A先运行10毫秒,然后切换到任务B,再让B运行10毫秒,再切换回A。操作系统给任务分配的占用CPU的时间称作“时间片”。

现实情况下,任务切换的时间间隔可能不到10毫秒,也可能很长,这取决于你运行的系统类型以及系统上的主要任务类型。

这种任务的运行方式叫并发(Concurrency),可以理解为同时发起多个任务,但这些任务并没有同时执行,在同一时间在执行的任务只有一个(如果是单核CPU)。另一个概念叫并行(Parallel),这是多个任务在真正的同时执行,比如多核CPU上,让其中一核执行任务A,另一个执行任务B,这两个任务互不干涉。

更加细致的概念,可以查阅:分时操作系统

进程 —— 一种便于多任务并发运行的模型

进程就是一个程序在运行期的实例。我们先来了解一下“程序”。

我们先把眼光放开一点,不局限于计算机科学中的“程序”。程序是一个静态概念,它是关于如何做好某些事情的描述,生活中处处都需要程序,才能让我们的生活有条不紊。程序需要存储在一个媒介上,比如你会在纸上规划,或者只是记在心里。当你去真正的按照程序去做事时,静态的程序转为动态的,这时,我们真正的在处理这件事情的“进程”中。

同样,计算机中的“程序”也是一个静态的任务,它被存储在磁盘上、网络中或者其他媒介上,当你要执行这个程序时(用户发起或者系统发起),这个程序被调入主存,同时CPU开始执行这个程序,这时就产生了一个该任务的“进程”。我们可以这样理解程序和进程,同时,进程也是一个方便多任务并发运行的模型

一个CPU的一个核心在同一时刻只能运行一个进程。

进程状态

进程分为三种状态

  1. 运行态
  2. 就绪态
  3. 阻塞态
  • 如果当前进程正在执行,也就是说时间片在当前进程上,那么该进程就是处于运行态。
  • 如果该进程已经准备好运行了,但由于CPU尚未给它分配时间片,它需要等待某个或某些进程执行完毕才能运行,那么该进程处于就绪态。
  • 如果该进程由于等待某些外部事件(如用户输入,网络或磁盘IO)导致它没法运行,那么它处于阻塞态,等待外部事件满足后,它会转换成就绪态。
  • 运行态的进程如果需要等待外部事件会转换成阻塞态,如果它由于时间片结束,需要让出CPU,它会转换成就绪态。
  • 就绪态的进程如果得到了CPU分配的时间片,那么它会转换成运行态。

分配进程的时间片;决定所有处于就绪态的进程哪个先变为运行态;处理进程切换的是操作系统中的“调度程序”,这其中的细节因系统的不同而有所差别,所以无论你写什么程序,只要你需要运行在其他系统上,就不要对这些细节进行任何假设(譬如同时处于就绪态的进程A和B,A会先于B运行)。

进程控制块(进程表)

一个进程可能会打开一些子进程;打开一些文件;被设置优先级;在不同的工作目录被打开等等,这其中有些信息是进程本身所依赖的,比如工作目录,打开的文件,还有些信息是操作系统对进程调度进行分析所用到的信息,比如进程使用的CPU时间。它们要被存在进程控制块中,当切换当前运行的进程时,所有这些信息都是要被保存到进程控制块中以便下次它处于运行态时进行恢复的。

线程 —— 一种进程内并发的更细致的划分

进程解决了一些问题,但还是会有一些问题。

同一个进程内并发执行

进程就是我们编写的一个程序的运行时(Runtime),如果并发操作的粒度只能在进程的范围内,那我们的程序内部无法获得并发的能力。

回想没有进程时的问题,即一个任务运行时其他任务不能同时运行(尽管这种同时是假的)。我们的程序通常不是只处理一个任务,它会同时处理很多任务,所以进程的特性,进程内部也需要。

比如用户发起网络IO请求上传文件时,进度条也能及时被更新,这是两个任务,一个网络任务和一个UI绘制的任务。我们不希望当用户上传文件时,界面完全不响应。

线程就是“进程中的小进程”,它由进程创建,用于在一个进程内部实现并发操作。

一个进程至少有一个线程,就是该进程的主线程。

进程创建、销毁、切换的成本太高

线程常常被称作轻量级进程(类似的,协程也常常被称为轻量级线程,确实技术总是反反复复的)。

线程的创建,销毁,切换并不用像进程那样高的成本,这是进程的一个优势,讽刺的是,这也是协程席卷天地时的理由之一。

进程间通信(IPC)复杂

安全起见,进程间享受着独立的内存空间,这导致它们之间的通信显得复杂笨重。目前常见的进程间通信方式有管道、消息队列、网络套接字和共享内存来实现。这些方式都各自有着一些问题。而线程因为在同一个进程中,所以它们的内存本就是共享的,所以线程间通信要容易的多。

一旦涉及到进程间/线程间通信,就会产生通信冲突,因为多个进程/线程总会竞争相同的资源,这会产生一些并发问题,这不是本文讨论的范畴,但值得注意的是,进程上有的并发问题,线程也在面临着。

单个进程在多核CPU上加快速度

即使你是多核CPU,一个进程在同一时刻也只能运行在其中的一个核上,如果你是多核CPU,并且你只运行了一个进程,那么其它的核将被浪费。但有了线程,它们有了同时并行的运行在多个核心上的能力。

一个多线程程序的例子

最常见的多线程程序就是WEB服务器,这类应用程序通常有一个调度线程(Dispatcher Thread),这个线程用来接收客户端发来的请求,然后为了快速的抽出身来响应下一个用户的请求,调度线程会把该请求移交给工作线程(Work Thread)来处理,它自己则继续接收客户端的请求。

横插一嘴 —— 非阻塞调用

考虑这样一种情况,在不用多线程的情况下,在同一个进程中,在等待IO事件时,能否让CPU继续忙起来,而不是空等?

有一种办法就是使用非阻塞调用。比如NodeJS中,没有多线程的概念,它本身也不具备异步处理的能力(针对早先的NodeJS,现在情况不一样了),它之所以能够完成一些异步任务,原因是它的所有需要阻塞等待的操作都是非阻塞的(也有阻塞版本)。

读取文件,显然是一个IO操作,如果按照正常的情况,它肯定要阻塞,当前进程将无法再处理其他任务。但非阻塞调用则不会阻塞,相当于你向系统提交了一个请求,告诉系统你要读这个文件,然后你就去干别的了,系统把这些IO操作处理完成后,它通过某种方式通知你,你再去着手处理。像下面一样。

readFile(path, 'utf-8', function(err, data) {
  if(!err) {
    // data is the file content
  }
});

上面我们只是提供了NodeJS中的例子,一般的操作系统都有提供IO操作的非阻塞版本。

需要注意的是,无论是进程和线程,它们都有自己的堆栈,它们被CPU来回切换,但它们切换前后所做的操作是不会丢失的。但如果你使用非阻塞调用,当系统通知你调用完成之后,调用发起前的操作和状态你需要自行想办法找到(如有必要的话)。在NodeJS中我们通常使用闭包技术来完成这一需求,在现代编程语言中,这一操作并不复杂。

线程的不同实现方式

通常,线程有两种实现方式,一是用户级线程,一是内核级线程。在这之前,我们要先说说操作系统中的用户态和内核态。

操作系统控制着计算机上所有的软硬件资源,如果不加以保护,有心之人很容易就能够摧毁我们的计算机,当然,更可能的是由于人的误操作。人是会犯错的,即使是经验丰富的操作员。

我们工作在用户态,而一些危险的操作工作在内核态,我们没有权限来使用那些危险的操作,如必要,我们可以使用一些方式来陷入(Trap)内核,这些方式包括系统调用、异常和外围设备中断。

我们一般情况下能用到的就是系统调用,比如读一个文件,创建一个线程等,都需要系统调用。陷入内核需要用户态到内核态的转换,这其中有很多任务要做,比如切换堆栈,保存寄存器和用户态的状态等等,所以说发起一次系统调用需要一些时间成本。

用户级线程

用户级线程就是完全在用户态来实现线程,不需要任何系统调用,完全由进程自己来操作这些线程的运行时间分配、状态转换以及调度阻塞等。

它的优点:

  1. 操作系统完全不知道线程的存在,所以即使在不支持多线程的环境下也能够使用多线程。
  2. 不需要系统调用,所以它在创建、销毁等操作时比内核级线程更加高效。
  3. 线程调度完全由进程本身管理,不确定性更低,在不同操作系统上的一致性更高,我们可以更加大胆的对线程调度器的行为做一些假设。

缺点:

  1. 无法使用阻塞的系统调用。因为阻塞操作系统并不知道线程的存在,所以阻塞操作会直接阻塞整个进程。
  2. 当发生中断进程被迫阻塞时,也是所有线程都会被阻塞。

我只知道JVM标准允许线程包使用用户级线程,某些JVM虚拟机使用的也确实是用户级线程,至于其它的,眼界有限,我就不知道了。

内核级线程

内核级线程即我们上文一直讨论的线程实现,调度,状态轮转、切换、保存等都由系统来实现。

它的缺点和优点大概是和用户级线程反过来。

混合线程

混合使用用户级线程和内核级线程。这也是被JVM虚拟机规范所允许的。

CPU密集型 / IO密集型

经常听到说法,说什么哪个语言适合处理CPU密集型任务,哪个语言适合处理IO密集型任务。结合上面的内容,我们再来重新审视一下这些说法。

CPU密集型

CPU密集型的任务就是经常要用到CPU进行计算的任务,比如渲染视频就是CPU密集型任务。

这类任务的特点是,开很多个线程没有啥卵用。

CPU的运算能力就那么多,假如你的CPU是8核,那么最好情况下,8个核心并行的运行你的任务,那么8个线程就足够了,再多也无济于事,反倒会增加线程上下文切换时的时间开销。

所以Kotlin中的协程调度器中用于执行CPU密集型任务的Dispatchers.Default中的线程数就等于CPU数量。当时我还在想为啥就这么点儿呢。。。真抠搜。结果是我蠢货了。

之前我在写我的项目的时候针对一个CPU密集型的任务开了好多线程,后来我发现性能并没有得到明显的提升,当时的我很迷惑。就像几乎没有人在一个普普通通的排序操作上使用多线程一样,它往往无法带来太多性能上的提升,反而还带来了由于竞态条件而引入的编程复杂性。

IO密集型

IO密集型任务通常是要和外部进行交互的任务,这类任务往往需要等待外部事件,等待的时候,CPU不用为该任务做任何计算,只是挂起当前线程。比如读磁盘、网络IO。

这类任务,你可以多给一些线程,因为这类任务并不大幅依赖CPU的运算能力,CPU在处理此种任务时几乎在闲置,所以多开一些线程无妨。这样就能在同一时间处理更多的IO任务。

正确的线程池大小很重要

我之前编程时并不理解线程、进程以及CPU、操作系统之间的运作,所以我迷信多线程能无限的加快效率,所以对于线程池的大小,我经常十分慷慨。事实是,你需要在你任务需要的最大线程数和线程切换或其他操作所带来的开销之间权衡,一味的增加线程数可能反而会让程序在时间和空间上都不占优势。

NodeJS不适合处理CPU密集型任务!... 吗?

很久以前,在NodeJS还是传统的(JS遗留的)单进程事件驱动模型时,它确实不适合。因为它没有线程的概念,一个程序内部没有办法并发执行,如果你想处理CPU密集型任务,你就得再开一个NodeJS程序。

既然没有办法并发执行,那么它应该也不适合处理IO密集型任务啊!但之前也说了,NodeJS中的耗时操作都是非阻塞调用,IO操作并不会阻塞NodeJS进程,所以它可以处理IO密集型任务。

但NodeJS现在已经支持Web Worker,可以实现多线程,所以也不能说它不适合处理CPU密集型任务了。

协程

协程是很早以前的概念了,但最近总是被提及,各种语言都有自己的协程实现,Java新版本也要支持协程了。

什么是协程?我没法给一个明确的定义,因为各家的实现都不太一样。但协程,在我看来是在线程基础上又更细一步的产物。线程与协程的关系就像进程与线程的关系。但这也不能这样说,像Kotlin的协程实现就不满足我上面所说的特征。但总结起来,协程大概具有如下特征:

  1. 在线程中分时复用多个协程
  2. 挂起时间和恢复时间由程序员把控(而非系统)
  3. 切换在用户态完成
  4. 将异步代码同步化

再次声明要注意的是,各家的协程实现不一样,也许某一个协程实现只满足上面的一条,比如Kotlin的协程大概只满足第四条。

同时,对于第2条,我的看法是,对于CPU密集型任务,我们能够把控挂起时间和恢复时间,我们能主动的限制协程该运行多久后让出CPU,但是对于IO任务,协程往往给不出明确的挂起和恢复时间,这取决于外部事件何时完成。我个人还是认为,协程比较重要的一个特性就是可以将异步代码同步化。

基于生成器的协程

JS、Python的协程都是基于生成器的,这导致它们看起来有点儿相似,下面我用JS来说明。

生成器给了一个函数在执行过程中暂停,并在稍后由外界恢复的特性,我们可以利用这个特性挂起和恢复一个函数的执行,那么,这不就是我们自己在把控一个函数运行多少时间吗。

生成器中可以用yield让当前函数让出执行权,并返回一个值

function *calculate() {
  for(/*循环表达A式*/) {
    // 执行复杂计算...
    yield result;
    // 外界恢复时,继续循环
  }
}

当程序执行遇到yield时,生成器函数会被挂起,并返回一个值,这个值被生成器调用者接收,并且CPU的执行权限在生成器挂起后回到了生成器调用者手里。

generator = calculate();
let result = generator.next();
// 做点什么...

这时生成器调用者喜欢做什么就做什么,甚至调用另一个生成器,当它做够了,觉得之前的生成器需要恢复运行了,那么他就可以继续调用next,让之前的生成器恢复运行。

这就是使用生成器手动来进行运行时间的分配,这其中并不需要系统调用,并不需要用户态和内核态的转换,但和用户级线程一样,它也有那些缺点

同时JS和Python也都提供了asyncawait让异步代码同步化。

基于线程池调度的协程

Kotlin的协程基于线程池调度,用户可以轻松地在线程间进行切换,它简化了传统的多线程开发流程,让异步代码同步化。

fun main() = runBlocking {
  sendNetworkMessage();
  doOtherWork();
}

suspend fun sendNetworkMessage() {
  // 切换到IO线程
  withContext(Dispatchers.IO) {
    // ...
  }
}

其它协程模式

略,可以了解下GO语言的协程。



这篇关于浅谈进程、线程和协程的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程