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

3,481 阅读10分钟

前言

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

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

问题:为什么需要进程?

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

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

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

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

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

进程-1.png


进程

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

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

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

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

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

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

进程-2.png

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

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

进程-3.png

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

在 Linux 中可通过 top 和 ps 工具查看进程状态: S 列表示进程的状态,有 RDZSITX

$ 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 的就绪队列中,正在运行或者正在等待运行。
  • DDisk Sleep,不可中断状态睡眠,表示进程正在跟硬件交互,并且交互过程不允许被其他进程或中断打断。
  • ZZombie,表示僵尸进程,实际上进程已经结束了,但其父进程还没有回收它的资源。
  • SInterruptible Sleep,可中断状态睡眠,表示进程因为等待某个事件而被系统挂起。
  • IIdle,空闲状态,用在不可中断睡眠的内核线程上。可能实际上没有任何负载。
  • TStopped,表示进程处于暂停或者跟踪状态。
  • XDead,表示进程已经消亡,不会在 topps 命令中看到。

进程模型:单进程、多进程

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

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

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

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

进程-4.png

这种模式的弊端有三:

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

线程

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

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

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

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

进程-5.png

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

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

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

进程-6.png

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

多线程、ReactorProactor

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

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

进程-7.png

拓展, I/O 模型: 阻塞、非阻塞、同步、异步。 进程-8.png

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

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

进程-9.png

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

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

进程-10.png

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

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

进程-11.png


协程

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

进程-12.png

协程特点有:

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

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

  • 可看作轻量级线程: 占用更少的堆栈空间,并且需要的堆栈大小可以随着程序的运行需要动态增加或者空间回收。

  • 上下文切换发生在用户态: 切换速度比较快,并且开销比较小。

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

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

拿协程与线程比较:

比较项线程协程
占用资源1MB,固定不变2KB,可随需要变大
调度所属内核用户
切换开销涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP...等寄存器的刷新等只有三个寄存器的值修改 - PC / SP / DX
数据同步需要用锁等机制确保数据的一直性和可见性不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

进程 VS 线程 VS 协程:

执行体地址空间调度方时间片调度主动调度
进程不同执行体有不同地址空间操作系统内核基于时钟中断系统调用
线程不同执行体共享地址空间操作系统内核基于时钟中断系统调用
协程不同执行体共享地址空间用户态一般不支持包装系统调用

Go 使用协程

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

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

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

进程-13.png

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

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

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

进程-14.png

再看 Go 创建一个 goroutine 过程:

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