【网络-性能】协程1-并发基础概念

声明:仅为个人学习总结,还请批判性查看,如有不同观点,欢迎交流。

摘要

简单介绍计算机组成、进程、线程、协程等与并发相关的概念,介绍进程、线程、协程的切换及其对比。


1 计算机组成

1.1 计算机模型

在现代计算机模型中,计算机由输入、运算器、控制器、存储器和输出五部分组成。

现代计算机模型

  • 输入设备(Input),输入数据和程序,它们会被存放到存储器(Memory)中,或传输给运算器;
  • 运算器(Arithmetic Logic Unit,ALU),从输入设备、存储器中获取数据,进行运算,运算结果会被存放到存储器,或传输给输出设备(Output);
  • 控制器(Controller),具有核心地位,对计算机的所有部件实施控制,协调整个系统有序工作。

也可以认为计算机由三个子系统组成:处理器子系统、存储器子系统和输入/输出子系统。

  • 处理器子系统:运算器和控制器被集成在一块芯片上,称为中央处理器(Central Processing Unit,CPU);
  • 输入/输出子系统:将输入、输出(Input and Output,I/O)看成一个整体。

计算机的三个子系统

总线(bus)是一组导线,连接了计算机的各种电路和设备,负责传输信息。

计算机组成示意

  • 内部总线主要用于存储器与接口电路之间交换数据;
    • 计算机内部主要是电子器件,速度较快,直接进行连接。
  • 外部的输入、输出设备(如键盘、显示器等)是慢速设备,需要经过“接口(Interface)”进行缓冲。
    • 内部数据先发送到接口,由接口转发到外部设备;
    • 外部设备的数据也是先发给接口,由接口通过内部总线发送到 CPU 或存储器。

1.2 并发任务类型

根据资源的利用情况,可以将计算机任务区分为“CPU 密集型”和“I/O 密集型”:

  • CPU 密集型:也叫计算密集型,需要通过 CPU 进行大量的计算,消耗 CPU 资源;
    • 并发建议:同时进行的任务数量最大为 CPU 核心数,以减少额外的任务切换开销。
  • I/O 密集型:需要进行大量的网络、磁盘等 I/O 操作,CPU 消耗很少,大部分时间都在等待 I/O 操作完成。
    • 并发建议:为了对 CPU 进行充分利用,尽可能增加同时进行的任务数量。

2 进程

计算机系统的各项功能,都通过 CPU 运行程序指令来体现,因此 CPU 是计算机系统中最重要的资源。
为了有效利用 CPU,操作系统同时把多个程序放入内存,让它们交替执行,当正在运行的程序由于某种原因(如 I/O 请求)暂停执行时,CPU 就立即转去执行另一个程序。这样,不仅 CPU 得到了充分利用,还提高了 I/O 设备和内存的利用率。

  • 程序(Program),是完成某一特定功能的指令序列,是静态的概念;
  • 进程(Process),是程序关于某个数据集合的一次执行过程,是动态的概念。
    进程也是操作系统进行资源分配(和调度)的基本单位。

为简单起见,我们先考虑早期操作系统中的“传统进程”,即不包含线程的进程。(在引入“线程”的操作系统中,进程不再是“执行实体”,而只是“资源分配的实体”。)

2.1 进程的结构

进程实体 = PCB + 程序段 + 相关数据段

进程结构

  • PCB(Process Control Block):是描述和控制进程运行的数据结构;
    包含进程标识符、进程的当前状态、进程中的程序段与数据段地址、进程资源清单(如文件和 I/O 设备等)、进程优先级、CPU 现场保护区、进程同步与通信机制、PCB 队列指针或链接字、与进程相关的其他信息(如家族、所属用户、占用 CPU 的时间等)。
  • 程序段:要执行的指令序列;
  • 相关数据段:程序要处理的数据。

2.2 进程的状态模型

在整个生命周期中,进程存在多种不同的状态。

进程的五态模型

  1. 进程的创建:系统为进程分配一个进程控制块(PCB),分配内存空间,并装入进程对应的程序和数据;
  2. 进程的终止:系统逐步释放为进程分配的系统资源,最后释放其 PCB;
  3. 运行状态:进程获得了 CPU 和其他所需资源,目前正在 CPU 上运行;
    对于单 CPU(单核)系统而言,只能有一个进程处于运行状态。
  4. 阻塞状态:进程运行中发生了某种等待事件(如等待 I/O 操作)暂时不能运行;
    处于阻塞状态的进程可以有多个。
  5. 就绪状态:进程获得了除 CPU 之外的所需资源,一旦得到 CPU 就可以立即运行。
    处于就绪状态的进程可能有多个。

2.3 进程的切换

回收当前运行进程对 CPU 的控制权,并将 CPU 控制权转交给新调度的就绪进程,叫做“进程切换”。

2.3.1 进程上下文

进程上下文:除进程实体之外,进程的运行还需要其他环境的支持。

  1. 系统级上下文,操作系统内核使用的进程上下文信息集合。
    主要包括 PCB、逻辑地址到物理地址转换的核心数据结构,如段表、页表及核心栈等。
  2. 寄存器上下文,CPU 中所有寄存器的信息集合。
    如通用寄存器、指令寄存器、程序计数器和栈指针(指向核心栈/用户栈)等。
  3. 用户级上下文,用户进程访问和修改的进程上下文信息集合。
    主要包括进程的程序段、数据段、用户栈和共享存储区,
    以及用户级上下文占用进程的虚拟地址空间,交换到外存的分页或分段。

当内核进行“进程切换”时,它需要进行新、旧进程的上下文切换,需要保存当前运行进程的进程上下文,以便再次执行该进程时,能够恢复到进程被切换前的运行现场和环境。

2.3.2 进程切换时机

进程切换是中断驱动的,引起进程切换的中断可分为三种:

  1. 中断:中断发生时,操作系统保存当前运行进程(旧进程)的现场信息,调度新进程运行;
    例如时间片耗尽、资源不足、高优先级进程抢占、硬件中断等。
  2. 异常:CPU 在执行一条指令时,检查到有一个或多个预定义的条件或错误产生;
    这时,终止当前运行进程的执行,CPU 转去执行“异常处理程序”。
  3. 系统调用:系统调用是对操作系统服务的一种显式请求;
    阻塞型系统调用发生时,当前运行进程会被阻塞,此时 CPU 转去执行“进程调度程序”。

2.3.3 进程上下文切换

在进程切换发生时,当前运行进程(旧进程)让出其占用的 CPU,由操作系统保存旧进程的上下文环境,并设置新进程的上下文环境,这一过程称为“进程的上下文切换”。

进程切换的主要步骤:

  1. 当前运行进程(旧进程)被中断时,保存其 CPU 现场信息;
  2. 对旧进程进行 PCB 更新,包括改变进程状态和其他相关信息;
  3. 将旧进程的 PCB 移入适当的队列(进程就绪队列 / 进程阻塞队列);
  4. 由进程调度程序选中一个就绪进程(新进程),为其设置执行的上下文环境,并对其 PCB 进行更新;
  5. 修改新进程的地址空间,更新新进程的内存管理信息;
  6. 恢复新进程最后一次进程上下文切换时所保存的 CPU 现场信息。

2.4 多进程并发

要使多个进程能够并发执行,操作系统需要进行以下操作:

  1. 创建进程:为其分配资源,包括内存空间、I/O 设备等,建立相应的进程控制块 PCB;
  2. 进程切换:即进程上下文切换;
  3. 销毁进程:先对进程所占用的资源进行回收,然后销毁其进程控制块 PCB。

作为“资源的拥有者”,进程在创建、切换及销毁的过程中,会消耗系统大量的时间和资源。
因此,系统中并发执行进程的数量不宜过多,进程切换的频率也不宜过高。也就是说,进程的并发执行程度不能太高。

2.5 引入线程

为了能够更好地并发执行,同时又尽量减少系统的开销,在操作系统中引入了线程。

  • 进程,作为资源分配的实体,不用频繁切换;
  • 线程,作为独立运行的实体,可以“轻装上阵”。

另外,在 多 CPU/多核 计算机中,可以让不同的线程同时运行在不同的 CPU/核心 上,从而进一步提高进程的执行速度。

3 线程

线程(Thread),可以简单地理解为 CPU 调度和执行的最小单位。

一个进程可以包含一个或多个线程,线程的特点包括:

  1. 同一个进程中的所有线程共享该进程的公共资源;
    包括用户地址空间、线程间的互斥与同步机制、已打开的文件、已申请到的 I/O 设备、地址映射表等。
  2. 线程属于轻型实体,除了为保证其运行而必不可少的资源外,基本不拥有系统资源;
    包括线程控制块(TCB)、程序计数器(PC)、一组寄存器及栈空间。
  3. 线程并发执行程度高,同一个进程内部的多个线程、不同进程的多个线程,都可以并发执行。

3.1 线程的结构

进程与线程的结构

  • 传统进程不涉及线程概念,它由进程控制块、程序和数据空间、用户栈和核心栈等组成。
  • 在具有多线程的进程中,
    • 进程仍然具有进程控制块、程序和数据空间;
    • 每个线程有各自独立的线程控制块、用户栈和核心栈以及寄存器信息。

3.2 线程的状态模型

  1. 创建:操作系统在创建进程的同时,也会为该进程创建一个线程(初始化线程/主线程);
    • 运行过程中可以根据需要创建新的线程;
    • 线程总是在某个进程环境中创建,并且会在这个进程内部销毁;
    • 当进程退出时,该进程所产生的线程都会被强制退出并清除。
  2. 终止:在线程运行结束,或被其他线程取消时,会终止线程。
    • 通常线程被终止后并不立即释放它所占用的系统资源,
      只要线程尚未释放资源,就仍然可以被其他线程调用,从而使被终止的线程恢复运行。
    • 只有对线程进行“分离”(detach)后,被终止的线程才释放资源。

与进程类似,线程也存在运行、就绪和阻塞这三种基本状态,因为线程完全继承了进程的运行属性。

进程/线程的三态模型

  1. 就绪态:已满足运行条件,在等待处理器的调度;
    处于就绪态的原因可能是:刚刚被创建、刚刚从阻塞状态中恢复、被其他线程抢占等。
  2. 运行态:正在处理器中运行;
  3. 阻塞态:在等待处理器之外的其他条件,例如 I/O 操作、互斥锁的释放、条件变量的改变等。

3.3 线程的实现

线程是 CPU 调度和执行的最小单位,所以和进程一样,线程可以由操作系统管理(内核态线程)。
同时,线程也隶属于进程,所以线程也可以由进程直接管理(用户态线程)。

内核态线程,由操作系统对线程进行管理,线程控制块 TCB 放在操作系统内核空间。内核态线程特点:

  • 如果一个线程阻塞,操作系统可以调度另一个线程执行;
  • 在多处理器/多核环境下,操作系统能在不同的处理器/核心上调度线程,实现并行执行;
  • 内核态线程数量较少,每个内核态线程可以服务一个或多个用户态线程。

用户态线程,由线程库(如 POSIX Pthread)负责对线程进行管理,TCB 在用户空间的线程库里面,操作系统感知不到用户态线程。用户态线程特点:

  • 效率高,线程的创建、销毁、调度等操作直接在用户态进行,无须“陷入”到内核态;
  • 用户态线程数量较多;
  • 可以通过“多对一”、“一对一”、“多对多”等模型,将用户态线程映射到内核态线程。

用户态与内核态线程映射模型

陷入(trap,一种中断方式),用户程序在执行过程中,遇到系统调用等情况时,将控制交给操作系统,由操作系统进行相关处理,处理完毕后再返回到用户程序。切换过程会消耗一定的处理器资源。
线程从用户态陷入到内核态的情况包括:

  • 程序运行过程中发生中断或异常,系统将自动切换到内核态来运行中断或异常处理机制;
  • 程序进行系统调用,也会导致从用户态切换到内核态。
    例如调用 C++ 函数 cin,会进一步调用 C 函数 scanf,最终通过系统调用 read 函数来获取用户输入。

3.4 线程的切换

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

  • 不同进程的线程,切换的过程与进程上下文切换一样,开销最大;
  • 同一进程的线程,只需要切换线程的寄存器、栈等私有数据,共享的进程资源保持不动;
    • 对于内核态线程,需要从用户态陷入内核态,再回到用户态,开销较大;
    • 对于用户态线程,直接在用户态切换,开销较小。

如果进程中只有一个线程(单线程进程),可以认为线程等于进程。

3.5 多进程与多线程

从运行效率方面,对多进程和多线程进行对比:

对比项进程线程补充说明[3]
创建开销系统要为进程分配较大的私有空间,占用资源较多,用时较长。多个线程共享进程资源,线程本身占用资源较少,创建用时更短。在 Solaris2 操作系统中,创建进程的时间大约是创建线程的 30 倍。
切换开销需要切换整个进程上下文环境。只需要切换线程的上下文环境,效率更高。在 Solaris2 操作系统中,线程切换的速度大约是进程切换的 5 倍。
资源访问不同进程拥有各自独立的地址空间,需要通过信号、管道、共享内存等方式(系统调用)进行通信。同一进程的线程之间共享进程的地址空间,可以直接访问共享数据,更高效和方便。

4 协程

协程(Coroutine):是一种可以挂起和恢复的函数(泛化的 subroutine)。

  • 函数只有两个操作:调用(call)、返回(return);
  • 相比函数,协程增加了三个操作:挂起(suspend/yield)、恢复(resume)、销毁(destroy)。
    • 当协程“主动”挂起时,会暂停执行,将执行权返回给调用者(caller)或恢复者(resumer);
    • 当协程“被动”恢复时,会从挂起点(suspend-point,挂起的位置)继续执行,直到再次挂起,或返回;
    • 除了在执行时“主动”返回,协程还可以在挂起状态下“被动”销毁,不再恢复执行,并释放相关资源。

生成斐波那契数列的函数与协程对比

函数与协程代码对比

函数与协程的执行时序对比

函数与协程时序对比

协程的优势:

  • 通过更有效的利用系统资源,提升程序的性能;
  • 通过以同步的方式编写异步代码,提升代码的可读性,减少在异步编程中常见的回调地狱问题。

4.1 堆栈空间

程序编译时,链接的起始地址都是相同的,是一个虚拟地址;程序运行时,会进行虚拟地址到物理地址的转换。
对于一个拥有 4GB 大小虚拟地址空间的应用程序进程,通常 0~3GB 为用户空间,给应用程序使用;3~4GB 为内核空间,供操作系统运行。应用程序只能通过中断或系统调用来访问内核空间。

进程虚拟地址空间

  1. 函数翻译成二进制指令放在程序段中;
  2. 全局变量和静态局部变量放在数据段中;
  3. 使用 malloc 等申请的动态内存在堆空间分配,由用户自己申请和释放;
    堆内存一般在数据段的后面,随着用户动态申请的内存越来越多,堆空间不断往高地址增长。
  4. 函数调用过程中的局部变量、实参等保存在栈中,由系统维护。
    栈空间挨着内核空间,栈(递减栈)指针从用户空间的高地址往低地址不断增长。

栈(stack),是一种先进后出(First Input Last Output,FILO)的数据结构。

  • 有入栈(push)和出栈(pop)两种基本操作,入栈是把一个元素压入栈中,而出栈则是从栈中弹出一个元素;
  • 入栈和出栈都靠栈指针 SP(Stack Pointer)来维护,SP 会随着入栈和出栈在栈顶上下移动;
  • 处理器一般会使用专门的寄存器来保存栈的起止地址,x84_64 处理器一般使用 RSP(栈顶指针)和 RBP(栈底指针)来管理栈。

4.2 函数调用

在程序的执行过程中,往往存在多级函数调用,无论调用嵌套有多深,程序总能正确返回原来的位置,这就要依赖于栈、RSPRBP

  • 函数内部定义的局部变量、传递的实参、编译器生成的临时变量等都会被保存在栈中;
  • 每一个函数都有自己专门的栈空间来保存这些数据,函数的栈空间被称为“栈帧”,每一个“栈帧”都有“栈顶”和“栈底”;
  • 函数的栈帧还会用来保存函数的上下文,包括函数调用者的返回地址、栈帧基址(栈帧的开始地址,即栈底);
  • 无论函数调用执行到哪一级,RSP 总是指向当前正在执行函数栈帧的栈顶,而 RBP 则指向对应栈帧的栈底。

假设函数调用关系为 main()f()g(),那么栈的结构如下图所示。

函数栈帧

  1. 调用 main() 函数时,会创建 main() 函数的栈帧;
  2. 调用 f() 函数时,会继续创建(push)f() 函数的栈帧
    • “调用函数”(main() 函数)将自己的栈帧基址 BP、返回地址 LR 存储到“被调函数”(f() 函数)的栈帧中;
    • “调用函数”将传递给“被调函数”的参数的值存储到“被调函数”的栈帧中,以便“被调函数”可以访问它们;
    • “调用函数”将执行权转移到“被调函数”的开头,设置寄存器 RSPRBP 指向“被调函数”的栈帧;
    • 另外,还需要将当前保存在 CPU 寄存器中的所有值保存到内存中,以便后续在函数恢复执行时可以根据需要还原这些值。
  3. 同样,当调用 g() 函数时,继续创建(push)g() 函数的栈帧,重复上述“调用函数”的暂停执行准备工作;
  4. g() 函数运行结束退出时
    • “被调函数”(g() 函数)首先将返回值(如果有的话)存储到“调用函数”(f() 函数)的栈帧;
    • “被调函数”销毁所有局部变量、所有参数对象,销毁(pop)其栈帧;
    • 设置寄存器 RSPRBP 指向“调用函数”栈帧的“栈顶”和“栈底BP”;
    • 恢复为“调用函数”保存的 CPU 寄存器中的值;
    • 跳转到“调用函数”的“返回地址LR”继续执行。
  5. 同样,当 f() 函数运行结束退出时,也会保存返回值,销毁(pop)其栈帧,并返回 main() 函数继续执行。

4.3 协程调用

相比函数,可以从逻辑上将协程数据划分为“栈帧”和“协程帧”两个部分:

  • 栈帧部分,在栈中存储,只需要在协程执行时保留,协程挂起/返回时即销毁;
  • 协程帧部分,在堆中存储,协程挂起后仍然保留(这样在协程恢复时,能够从挂起时的状态继续执行),协程返回时销毁。
generator<int> fibo(int n) {
  int a = 0, b = 1; // 参数 n,局部变量 a、b、i(位于挂起点作用域内),在协程帧中存储
  for (int i = 0; i < n; i++) {
    co_yield b; 
    auto next = a + b; // 临时变量 next(位于挂起点作用域外), 在栈中存储
    a = b;
    b = next;
  }
}

协程执行时的栈帧和协程帧示意(TODO:待进步确认)

协程执行状态

当协程到达挂起点时,按以下步骤保存协程当前的状态:

  • 将寄存器中的所有值写入协程帧;
  • 向协程帧中写入一个值,该值表示协程的挂起点。
    这使得后续的恢复操作知道在哪里继续执行协程,或者让后续的销毁操作知道哪些值需要被销毁。

协程挂起时的栈帧和协程帧示意(TODO:待进步确认)

协程挂起状态

4.4 应用场景示例

4.4.1 异步编程

task<> tcp_echo_server() 
{
    char data[1024];
    while (true) {
        // 使用 C++20 协程关键字 co_await 挂起协程,并等待异步操作的完成(不需要使用回调函数)
        std::size_t n = co_await socket.async_read_some(buffer(data));  // 异步接收网络数据
        co_await async_write(socket, buffer(data, n));                  // 接收完成后,异步发送网络数据
    }
}
  • 使用传统的异步编程模型,需要用到回调函数(callbacks),会使代码变得难以理解和维护;
  • 通过协程,可以用同步的方式来书写和理解异步代码,提升了代码的可读性和可维护性。
// 使用回调的异步代码,具有复杂的回调函数结构,容易形成回调地狱(callback hell)
getDataAsync([](Data data) {
    processDataAsync(data, [](Result result) {
        useResult(result);
    });
});

// 使用协程的异步代码,直接按照执行顺序编写和阅读(同步编程模式)
auto data = co_await getDataAsync();
auto result = co_await processDataAsync(data);
useResult(result);

4.4.2 生成器

// 无限的 Fibonacci 数列生成器,可以在每次恢复执行时进行计算,生成新的值
generator<int> fibo()
{
  int a = 0, b = 1;
  while (true) {
    co_yield b; // 使用 C++20 协程关键字 co_yield 挂起协程,并返回一个新的 Fibonacci 数
    auto next = a + b;
    a = b;
    b = next;
  }
}

4.4.3 并发编程

// 在处理 I/O 操作或者网络请求时,同时启动多个异步任务,在任务执行过程中,可以挂起协程,进行其它处理
task<> download_files(const std::vector<url>& urls) 
{
    std::vector<task<file>> tasks;  // 创建一个保存所有任务的容器

    for (const auto& url : urls) {
        auto task = async_download_file(url);  // 启动一个异步下载任务
        tasks.push_back(task);                 // 将任务添加到任务容器中
    }

    for (auto& task : tasks) {
        auto file = co_await task;  // 通过 co_await 挂起协程,等待当前文件下载完成
        process_file(file);
    }
}

一个线程可以调用多个函数,同样,一个线程也可以运行多个协程。
在线程内部,虽然多个协程只能串行执行;但是,通过挂起和恢复操作,多个协程能够实现“多个任务的并发执行”。

网络 I/O 多协程并发时序示意

多协程并发执行时序

协程并发特点:

  • 灵活可控:协程为程序提供了一种更细粒度的并发执行机制,将程序中的异步任务分割成多个小的执行单元,完全由程序进行控制,当某个任务处于等待状态时,可以将执行权交给其他的任务;
  • 轻量级:相比传统线程,协程占用的资源更少,上下文切换(Context Switching)只发生在用户态,所以更为轻量级。

4.5 多线程和多协程

从运行效率方面,对多线程和多协程进行对比:

对比项线程*协程
创建开销资源单位 MB,初始后固定不变初始资源约 2KB,可按需增加,创建开销小
切换开销由操作系统内核调度,需要在内核态和用户态之间进行切换由程序调度,完全在用户态执行,切换开销小
资源访问需要采用互斥与同步机制来保证数据一致性线程内部的协程串行执行,不存在数据读写冲突,资源访问效率高

5 进程 vs 线程 vs 协程

结构示意

进程、线程、协程结构

上下文切换比较

对比项进程线程*协程
切换者操作系统操作系统用户程序
切换策略系统切换策略(用户无感)系统切换策略(用户无感)用户程序决定
切换内容页表
内核栈
寄存器上下文
内核栈
寄存器上下文
寄存器上下文
切换内容保存保存在内核栈中保存在内核栈中保存在用户栈或堆中
切换过程用户态-内核态-用户态用户态-内核态-用户态用户态(没有陷入内核态)
切换效率

参考

  1. 陆汉权主编.计算机科学基础(第2版)(大学计算机规划教材).电子工业出版社.2015.
  2. 胡元义,黑新宏主编.操作系统原理.电子工业出版社.2018.
  3. 朱文伟,李建英著.Linux C/C++ 服务器开发实践.清华大学出版社.2022.
  4. Uppsala Course: Implementing threads
  5. 王利涛编著.嵌入式C语言自我修养:从芯片、编译器到操作系统.电子工业出版社.2021.
  6. cppreference:Coroutines (C++20)
  7. Lewis Baker: Coroutine Theory
  8. CSDN 文章:探索 C++20 协程基本框架的使用
  9. Some Others

宁静以致远,感谢 King 老师。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值