一文搞懂进程、线程、协程

时间:2022-09-29 18:05:59

前言

操作系统的主要目标是执行用户程序,但也需要顾及内核之外的各种系统任务。

系统由一组进程组成: 操作系统进程执行系统代码,用户进程执行用户代码。

问题:为什么需要进程?

早期的计算机系统只允许一次执行一个程序,这种程序对系统有完全的控制,能访问所有的系统资源。

现代计算机系统允许将多个程序调入内存并发执行,这一要求对各种程序提供更严格的控制和更好的划分。

这些需求产生了进程的概念,即执行中的程序,进程是现代分时系统的工作单元。

  • 多道程序设计的目的是:无论何时都有进程在运行,从而使​​CPU​​ 利用率达到最大化。
  • 分时系统的目的是:在进程之间快速切换​​CPU​​ 以便用户在程序运行时能与其进行交互。

本文将围绕这两个问题和一个案例展开:

一文搞懂进程、线程、协程

进程

程序与进程区别: 进程是活动实体

  • 程序只是被动实体,如存储在磁盘上包含一系列指令的文件内容(指:可执行文件)。
  • 当一个可执行文件被装入内存时,一个程序才能成为一个要执行的命令和相关资源集合。

所以,进程可看做是正在执行的程序

进程需要一定的资源(如 ​​CPU​​、时间、内存、文件和 ​​I/O​​ 设备)来完成其任务。 这些资源在创建进程或者执行进程时被分配。

进程的组成有:PCB​、程序段、数据段

  • PCB​(进程控制块,​process control block​):保存进程运行期间相关的数据,是进程存在的唯一标志。
  • 程序段:能被进程调度程序调度到​​CPU​​ 运行的程序的代码段。
  • 数据段:存储程序运行期间的相关数据,可以是原始数据也可以是相关结果。

一文搞懂进程、线程、协程

进程在执行时会改变状态,进程的状态有 5 种:

  • 创建:进程正在被创建。
  • 运行:指令正在被执行。
  • 等待:阻塞,进程等待某个事件的发生(如​​I/O​​ 完成或收到信号)。
  • 就绪:进程等待分配处理器。
  • 终止:进程完成执行。

一文搞懂进程、线程、协程

​Tips​​ :将 ​​CPU​​ 切换到另一个进程需要保存当前进程的状态并恢复另一个进程的状态,这一任务称为上下文切换(context switch

在 Linux​ 中可通过 ​top​ 和 ​​​ps​​​ 工具查看进程状态:​S​​ 列表示进程的状态,有 ​​R​​、​​D​​、​​Z​​、​​S​​、​​I​​ 和 ​​T​​ 、​​X​

$ top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28961 root 20 0 43816 3148 4040 R 3.2 0.0 0:00.01 top
620 root 20 0 37280 33676 908 D 0.3 0.4 0:00.01 app
1 root 20 0 160072 9416 6752 S 0.0 0.1 0:37.64 systemd
1896 root 20 0 0 0 0 Z 0.0 0.0 0:00.00 devapp
2 root 20 0 0 0 0 S 0.0 0.0 0:00.10 kthreadd
4 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/0:0H
复制代码
  • ​R​​ :Running,表示进程在​​CPU​​ 的就绪队列中,正在运行或者正在等待运行。
  • ​D​​:​​Disk Sleep​​,不可中断状态睡眠,表示进程正在跟硬件交互,并且交互过程不允许被其他进程或中断打断。
  • ​Z​​:​​Zombie​​,表示僵尸进程,实际上进程已经结束了,但其父进程还没有回收它的资源。
  • ​S​​:​​Interruptible Sleep​​,可中断状态睡眠,表示进程因为等待某个事件而被系统挂起。
  • ​I​​:​​Idle​​,空闲状态,用在不可中断睡眠的内核线程上。可能实际上没有任何负载。
  • ​T​​:​​Stopped​​,表示进程处于暂停或者跟踪状态。
  • ​X​​:​​Dead​​,表示进程已经消亡,不会在​​top​​ 和​​ps​​ 命令中看到。
进程模型:单进程、多进程

服务器高性能的关键之一在于:并发模型,其有两个关键设计点:

  • 如何管理连接?
  • 如何处理请求?

传统 UNIX​ 网络服务器采用模型有:

  • ​PPC​​(​​Process Per Connection​​):指每次有新的连接就创建一个进程去专门处理这个连接。
  • ​Prefock​​ :提前创建进程,预先创建好进程才开始接受用户的请求(省去​​fork​​ 进程的操作)。

一文搞懂进程、线程、协程

这种模式的弊端有三:

  1. fork​ 代价高:创建一个进程,需要分配很多内核资源,需要将内存映像从父进程复制到子进程。
  2. 父子进程通信复杂:父进程 “fork”子进程时,文件描述符可以通过内存映像复制从父进程传到子进程,但“fork”完成后,父子进程通信就比较麻烦了,需要采用 IPC(Interprocess Communication)之类的进程通信方案。
  3. 支持的并发连接数量有限:进程上下文切换消耗大、进程创建占资源大。一般情况下,​​PPC​​ 方案能处理的并发连接数量最大也就几百。

线程

为什么需要线程? 为了更好地使多道程序并发执行。

如果新进程与现有进程执行同样的任务,那么为什么需要这些开销呢?

如果一个具有多个线程的进程能达到同样的目的,那么将更为有效。

  • 线程是 CPU​ 使用的基本单元,由线程​​ID​​、程序计数器、寄存器集合和栈组成。
  • 进程由一个或多个线程组成:​Linux​​ 中创建一个进程自然会创建一个线程,也就是主线程。
  • 调度切换:线程上下文切换比进程上下文切换快。
  • 进程创建很耗时间和资源:
  • 创建进程:需要为进程划分出一块完整的内存空间,有大量的初始化操作,比如要把内存分段(堆栈、正文区等)。
  • 创建线程:只需要确定 PC 指针和寄存器的值,并且给线程分配一个栈用于执行程序,同一个进程的多个线程间可以复用堆栈。

一文搞懂进程、线程、协程

有两种不同方法来提供线程支持:

  • 用户层的用户线程:适合于​​IO​​ 密集型任务,受内核支持,而无须内核管理。
  • 内核层的内核线程:计算密集型任务,由操作系统直接支持和管理。

用户线程与内核线程之间对应关系有三种: 多对一模型、一对一模型、多对多模型

一文搞懂进程、线程、协程

  • 多对一模型缺点:任一时刻只有一个线程能访问内核,多个线程不能并行运行在多处理器上。
  • 一对一模型缺点:每创建一个用户线程就需要创建相应的内核线程。限制了系统所支持的线程数量。
  • 多对多模型:没有以上两者的缺点。开发人员可创建任意多的用户线程,并且相应内核线程在多处理器系统上并发执行。
多线程、​​Reactor​​、​​Proactor​

常见服务器高性能的多线程模式:

  • ​TPC​​:每次有新连接就创建新线程。
  • ​Prethread​​:提前创建好线程,例如线程池,每次有新连接就从线程池里拿取。

一文搞懂进程、线程、协程

拓展, I/O​ 模型: 阻塞、非阻塞、同步、异步。

一文搞懂进程、线程、协程

Reactor​ 是同步非阻塞网络模型:

  • 有三种典型实现方案:单​​Reactor​​单线程、单​​Reactor​​多线程、主从Reactor​多线程(常用)。
  • 处理三类事件:连接事件、写事件、读事件。
  • 三个关键角色:​reactor​​专门监听和分配事件、​​acceptor​​ 处理连接事件、​​handler​​ 处理读写事件。

一文搞懂进程、线程、协程

举个栗子:Netty主从Reactor​多线程

  • ​BossEventLoopGroup​​ :负责监听客户端的​​Accept​​ 事件,当事件触发时,将事件注册至​​WorkerEventLoopGroup​​ 中的一个​​NioEventLoop​​ 上。
  • 每新建一个​​Channel​​, 只选择一个​​NioEventLoop​​ 与其绑定。
  • ​WorkerEventLoopGroup​​:负责处理​​Read​​ 和​​Write​​ 事件。

一文搞懂进程、线程、协程

最后看 Proactor 可以理解为,“来了事件我来处理,处理完了我通知你”

  • “我”:操作系统内核。
  • “事件”:指​​I/O​​事件,有新连接、有数据可读、有数据可写。
  • “你”:用户线程。

一文搞懂进程、线程、协程

协程

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

一文搞懂进程、线程、协程

协程特点有:

  • 非阻塞 :具有挂起和恢复的能力。当前协程进行阻塞操作时,

协程不会与特定的线程绑定,它可以在不同的线程之间灵活切换,而这其实也是通过“挂起和恢复”来实现的。

  • 可看作轻量级线程: 占用更少的堆栈空间,并且需要的堆栈大小可以随着程序的运行需要动态增加或者空间回收。
  • 上下文切换发生在用户态: 切换速度比较快,并且开销比较小。

同时,协程可以分为两类:

  1. ​stackfull co-routine​​:切换协程的时候,需要使用栈来保存信息,速度稍慢,实现更容易。
  2. ​stackless co-routine​​:切换协程的时候,不需要使用栈来保存信息,速度更快,实现更复杂。

拿协程与线程比较:

比较项

线程

协程

占用资源

1MB,固定不变

2KB,可随需要变大

调度所属

内核

用户

切换开销

涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP...等寄存器的刷新等

只有三个寄存器的值修改 - PC / SP / DX

数据同步

需要用锁等机制确保数据的一直性和可见性

不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

进程 VS 线程 VS 协程:

执行体

地址空间

调度方

时间片调度

主动调度

进程

不同执行体有不同地址空间

操作系统内核

基于时钟中断

系统调用

线程

不同执行体共享地址空间

操作系统内核

基于时钟中断

系统调用

协程

不同执行体共享地址空间

用户态

一般不支持

包装系统调用

​Go​​ 使用协程

在 ​​Go​​ 中,使用 ​​go​​ 关键字跟上一个函数,就创建一个 ​​goroutine​​。

​goroutine​​ 而不是 ​​coroutine​​ 是因为其不是完全协作式的,也存在抢占式调度:

  • 协作式调度:依靠被调度方主动弃权。
  • 抢占式调度:依靠调度器强制将被调度方被动中断。

一文搞懂进程、线程、协程

先理解 ​​Go​​ 线程模型的 3 个概念:内核线程(M)、goroutine​(G)、逻辑处理器(P)

​Go​​ 的运行时调度 ​​goroutine​​ 在逻辑处理器(​​P​​)上运行。

在 ​​Go​​ 中每个逻辑处理器(​​P​​)会绑定到某一个内核线程上,每个逻辑处理器(​​P​​)内有一个本地队列,用来存放 ​​Go​​ 运行时分配的 ​​goroutine​​。

一文搞懂进程、线程、协程

再看 Go​ 创建一个 ​goroutine​ 过程:

  1. 创建的​​goroutine​​ 会被放入​​Go​​ 运行时调度器 schedt​ 的全局运行队列中。
  2. ​Go​​ 运行时调度器会把全局队列中的​​goroutine​​ 分配给不同的逻辑处理器(​​P​​)。
  3. 分配的​​goroutine​​ 会被放到逻辑处理器(​​P​​)的本地队列中,当本地队列中某个​​goroutine​​ 就绪后,待分配到时间片后就可以在逻辑处理器上运行了。