「操作系统」全面剖析进程、线程、协程(区别与联系)
参考&鸣谢
面时莫慌 | 进程 线程 协程 青Cheng序员石头
一文读懂什么是进程、线程、协程 回首笑人间
一、引言
从冯·诺依曼
发明世界上第一台机计算机后,很长一段时间内,计算机都没有操作系统。人们只能在计算机上运行单个程序,那时候使用计算机正常的流程为,输入运行指令、执行运行指令、得到运算结果。因为计算机只能按照步骤顺序执行,所以输入运行指令和执行运行指令不能并发的执行。导致输入运行指令浪费了宝贵的计算机资源。
在讲进程之前,我们先回顾一下计算机的组成。
-
中央处理器(CPU):是计算机的核心,负责一些运算和控制
CPU有两大核心部件,ALU(算数逻辑单元)和CU(逻辑控制单元),其中ALU负责一些运算,包括算数运算(加减乘除)、逻辑运算(与或非)及关系运算(大小等于);CU负责各个逻辑部件的协调工作,充当一个指挥官的角色。CPU中也有寄存器,只不过容量极小,负责缓存一些计算的中间结果。
-
主存储器:存储数据
分为RAM和ROM。RAM,随机存储器,掉电数据丢失,就是俗称的内存;ROM,只读存储器,掉电数据不丢失,就是俗称的磁盘。
-
IO:各种输入输出设备(鼠标、键盘、显示器、网卡、声卡、显卡等)
有了这些计算机的硬件支持,我们就可以做各种各样的事情了。如果我们手动来控制CPU和内存,那我们可能要写各种底层指令,而且肯定会出现各种各样的bug。操作系统在计算机硬件的基础之上,封装了硬件的实现细节,对上层抽象了一层更方便的系统调用指令,这就是操作系统。
在多任务系统中,操作系统接管了所有硬件资源并持有对硬件控制的最高权限。在操作系统中执行的程序,都以进程的方式运行在更低的权限中。所有的硬件资源,由操作系统根据进程的优先级以及进程的运行状况进行统一的调配。
二、进程
我们都知道计算机的核心是CPU,它承担了所有的计算任务;而操作系统是计算机的管理者,它负责任务的调度、资源的分配和管理,统领整个计算机硬件;应用程序则是具有某种功能的程序,程序是运行于操作系统之上的。
进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。进程是一种抽象的概念,从来没有统一的标准定义。
进程一般由程序、数据集合和进程控制块三部分组成。
- 程序用于描述进程要完成的功能,是控制进程执行的指令集;
- 数据集合是程序在执行时所需要的数据和工作区;
- 程序控制块(Program Control Block,简称PCB),包含进程的描述信息和控制信息,是进程存在的唯一标志。
进程具有的特征:
- 动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的;
- 并发性:任何进程都可以同其他进程一起并发执行;
- 独立性:进程是系统进行资源分配和调度的一个独立单位;
- 结构性:进程由程序、数据和进程控制块三部分组成。
三、线程
维基百科:线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
一个进程有多个线程,拿QQ为例子,需要有一个线程监听键盘的输入并转换为文字,需要有一个线程负责拉取对方发来的消息等。从操作系统的角度来看:
- 进程是最小的资源管理分配单元
- 线程是最小的执行单元
无论是进程还是线程都是需要操作系统来控制的。拿线程来举例,线程有多个执行状态:初始化、可运行、运行中、阻塞、销毁 五种状态。这五种状态的转化关系如下:
线程的状态转化是由操作系统内核中的TCB(thread control block)线程控制块来改变的,需要耗费一定的CPU资源。
总结一下,进程是一个程序的运行实例,它管理着各种资源;一个进程有多个线程,线程才是具体的执行单元,他们共享着进程中的部分资源,同样有着私有资源(PC程序计数器、执行栈等),线程间默认通过共享内存进行交互,线程间同步通过锁/信号量等进行互斥操作。由于线程切换需要在操作系统在内核/用户态间的切换才能改变状态,导致线程切换本身会非常耗费资源。
线程的生命周期
进程在运行过程有三种状态:就绪、运行、阻塞,创建和退出状态描述的是进程的创建过程和退出过程。
- 创建:进程正在创建,还不能运行。操作系统在创建进程时要进行的工作包括分配和建立进程控制块表项、建立资源表格并分配资源、加载程序并建立地址空间;
- 就绪:时间片已用完,此线程被强制暂停,等待下一个属于它的时间片到来;
- 运行:此线程正在执行,正在占用时间片;
- 阻塞:也叫等待状态,等待某一事件(如IO或另一个线程)执行完;
- 退出:进程已结束,所以也称结束状态,释放操作系统分配的资源。
任务调度
大部分操作系统(如Windows、Linux)的任务调度是采用时间片轮转的抢占式调度方式。
在一个进程中,当一个线程任务执行几毫秒后,会由操作系统的内核(负责管理各个任务)进行调度,通过硬件的计数器中断处理器,让该线程强制暂停并将该线程的寄存器放入内存中,通过查看线程列表决定接下来执行哪一个线程,并从内存中恢复该线程的寄存器,最后恢复该线程的执行,从而去执行下一个任务。
上述过程中,任务执行的那一小段时间叫做时间片,任务正在执行时的状态叫运行状态,被暂停的线程任务状态叫做就绪状态,意为等待下一个属于它的时间片的到来。
这种方式保证了每个线程轮流执行,由于CPU的执行效率非常高,时间片非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发(别觉得并发有多高深,它的实现很复杂,但它的概念很简单,就是一句话:多个任务同时执行)。多任务运行过程的示意图如下:
进程与线程的区别
前面讲了进程与线程,但可能你还觉得迷糊,感觉他们很类似。的确,进程与线程有着千丝万缕的关系,下面就让我们一起来理一理:
- 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
- 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
- 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
四、协程
**协程,又称微线程,纤程。英文名Coroutine。**比线程更加轻量级,就像一个进程有多个线程一样,一个线程可以有多个协程。协程可以理解为线程的线程。线程虽然提升了资源的利用率,但是也存在线程资源有限,而且大多数线程资源处于阻塞的状态,线程之间的开销虽然对比进程少了不少,但是上下文切换的切换开销也不小的问题。协程的出现在一定程度上解决了一些问题。协程的核心在于调度那块由他来负责解决,遇到阻塞操作,立刻放弃掉,并且记录当前栈上的数据,阻塞完后立刻再找一个线程恢复栈并把阻塞的结果放到这个线程上去跑,等达到一定条件后,再恢复原来的栈信息继续执行。
协程的目的
在传统的J2EE系统中都是基于每个请求占用一个线程去完成完整的业务逻辑(包括事务)。所以系统的吞吐能力取决于每个线程的操作耗时。如果遇到很耗时的I/O行为,则整个系统的吞吐立刻下降,因为这个时候线程一直处于阻塞状态,如果线程很多的时候,会存在很多线程处于空闲状态(等待该线程执行完才能执行),造成了资源应用不彻底。
最常见的例子就是JDBC(它是同步阻塞的),这也是为什么很多人都说数据库是瓶颈的原因。这里的耗时其实是让CPU一直在等待I/O返回,说白了线程根本没有利用CPU去做运算,而是处于空转状态。而另外过多的线程,也会带来更多的ContextSwitch开销。
对于上述问题,现阶段行业里的比较流行的解决方案之一就是单线程加上异步回调。其代表派是node.js以及Java里的新秀Vert.x。
而协程的目的就是当出现长时间的I/O操作时,通过让出目前的协程调度,执行下一个任务的方式,来消除ContextSwitch上的开销。
协程的特点
- 线程的切换由操作系统负责调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率。
- 线程的默认Stack大小是1M,而协程更轻量,接近1K。因此可以在相同的内存中开启更多的协程。
- 由于在同一个线程上,因此可以避免竞争关系而使用锁。
- 适用于被阻塞的,且需要大量并发的场景。但不适用于大量计算的多线程,遇到此种情况,更好实用线程去解决。
协程的原理
当出现IO阻塞的时候,由协程的调度器进行调度,通过将数据流立刻yield掉(主动让出),并且记录当前栈上的数据,阻塞完后立刻再通过线程恢复栈,并把阻塞的结果放到这个线程上去跑,这样看上去好像跟写同步代码没有任何差别,这整个流程可以称为coroutine,而跑在由coroutine
负责调度的线程称为Fiber
。比如Golang里的 go关键字其实就是负责开启一个Fiber
,让func
逻辑跑在上面。
由于协程的暂停完全由程序控制,发生在用户态上;而线程的阻塞状态是由操作系统内核来进行切换,发生在内核态上。
因此,协程的开销远远小于线程的开销,也就没有了ContextSwitch上的开销。
协程和线程的比较
比较项 | 线程 | 协程 |
---|---|---|
占用资源 | 初始单位为1MB,固定不可变 | 初始一般为 2KB,可随需要而增大 |
调度所属 | 由 OS 的内核完成 | 由用户完成 |
切换开销 | 涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP…等寄存器的刷新等 | 只有三个寄存器的值修改 - PC / SP / DX. |
性能问题 | 资源占用太高,频繁创建销毁会带来严重的性能问题 | 资源占用小,不会带来严重的性能问题 |
数据同步 | 需要用锁等机制确保数据的一直性和可见性 | 不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。 |
五、常见面试题
线程是依赖进程的资源的,具体是哪些资源呢?
当一个进程创建一个线程时,线程会共享进程的资源,这些资源包括:
内存空间:线程共享进程的地址空间,也就是说,线程可以访问进程中的全局变量和静态变量。例如,假设进程 A 中定义了一个全局变量 sum,线程 B 可以访问这个变量,并且可以修改它的值。如果线程 B 修改了 sum 的值,其他线程也会看到这个变化。
文件描述符:线程共享进程打开的文件描述符,也就是说,线程可以访问进程打开的文件。例如,假设进程 A 打开了一个文件 file.txt,线程 B 可以访问这个文件,并且可以读取或写入它的内容。如果线程 B 写入了一些数据到 file.txt,其他线程也可以看到这些数据。
信号处理函数:线程共享进程的信号处理函数,也就是说,线程可以处理进程接收到的信号。例如,假设进程 A 注册了一个 SIGINT 信号的处理函数,线程 B 也可以使用这个处理函数来处理 SIGINT 信号。
进程 ID 和进程组 ID:线程共享进程的进程 ID 和进程组 ID。例如,假设进程 A 的进程 ID 是 1234,线程 B 可以获取这个进程 ID,并且可以使用它来发送信号或者查询进程状态。
资源限制:线程共享进程的资源限制,如进程的最大打开文件数、最大内存使用量等。例如,假设进程 A 的最大打开文件数是 1024,线程 B 可以修改这个限制,并且可以打开更多的文件。
CPU 时间:线程共享进程的 CPU 时间,由调度器分配给进程和线程。例如,假设进程 A 中有两个线程 B 和 C,如果线程 B 占用了大量的 CPU 时间,线程 C 的执行会受到影响。
其他进程资源:线程共享进程的其他资源,如共享内存、消息队列等。例如,假设进程 A 中有一个共享内存区域,线程 B 和 C 可以访问这个共享内存区域,并且可以在其中读取或写入数据。
总之,线程是依赖进程的资源的,线程之间共享进程的资源,这使得线程之间的通信和协作变得更加容易。但是,线程之间的共享也会带来一些问题,如竞态条件和死锁等。因此,在编写多线程程序时,需要注意线程之间的同步和互斥。