一、进程vs线程vs协程

1、进程与线程

进程的切换主要涉及如下步骤:

上下文保存:操作系统保存当前进程的上下文信息,包括程序计数器、寄存器、栈指针、内存映射信息等,这些信息被储存在操作系统内核的进程控制块PCB中。

选择目标进程:操作系统根据调度算法选择即将切换到的新进程。

加载目标进程上下文:操作系统从目标进程对应的PCB中恢复进程上下文信息。

早期的操作系统都是以进程作为独立运行的基本单位,后面又诞生了更小的独立运行的基本单位—线程。相比进程线程具有更轻量级的切换过程,其切换过程与进程类似,不过由于线程共享内存等数据,其切换涉及到的上下文信息更少(主要是线程栈与寄存器)   ,由于资源共享,因此创建线程的开销也就更小。

无论是线程还是进程,其切换都涉及到系统调用,即操作系统需要从用户态陷入到内核态,再返回用户态。而系统调用是一种比较昂贵的操作,在对性能有高要求的场景下应该减少系统调用的次数。

2、了解协程

协程可以理解为用户级的线程,操作系统无法感知协程的存在,协程的调度由程序员自己控制,但需要注意的是协程依旧是依托在线程之上运行的,协程与线程的比例可以是1:1、n:1、n:m。

除了调度上的高效,协程相比进程与线程的优点可归纳为如下:

协程的创建,销毁和调度都在用户态,避免CPU频繁切换带来的资源浪费。

内存占用小。

可读性高,容易维护。

二、高并发模型

1. 传统阻塞 read/write 的问题

  • 如果用阻塞 I/O:

    • read() 阻塞时,线程停在这里等待客户端发送数据

    • write() 阻塞时,线程停在这里等待内核缓冲区可写

  • 问题:线程被单个客户端阻塞,无法处理其他连接

  • 高并发场景:每个连接都要一个线程 → 资源消耗大

2、线程池+epoll的工作机制

核心思想:epoll负责所有连接的事件监听,而线程池只用于处理已经就绪的I/O事件,避免线程被空闲连接阻塞。

工作流程

  1. 监听阶段

    • 主线程调用 epoll_create() 创建 epoll 实例。

    • 所有客户端连接的 socket(fd)通过 epoll_ctl(EPOLL_CTL_ADD) 注册到 epoll 中,监听 可读/可写 事件。

  2. 事件等待

    • 主线程调用 epoll_wait() 阻塞等待事件。

    • 内核监听所有 fd,如果有 可读/可写/异常 事件,就把这些 fd 放入就绪队列,并唤醒 epoll_wait()

  3. 任务分发

    • epoll_wait() 返回就绪 fd 列表(只包含真正有数据或可写的连接)。

    • 主线程把这些 fd 封装成任务,提交给 线程池

  4. 任务处理

    • 线程池中的空闲线程取任务,执行非阻塞的 read() / write()

    • 如果一次没有读/写完数据(EAGAIN),就重新把 fd 注册到 epoll 等待下一次事件触发。

  5. 循环执行

    • 主线程继续调用 epoll_wait() 等待下一个事件。

    • 线程池重复执行任务 → 高效复用线程处理成千上万个连接。

3、协程+io_uring

  1. 核心优势:

  • 协程在用户态管理任务,IO 请求通过 io_uring 提交给内核,内核直接完成操作并写回完成队列(CQ)。

  • 协程挂起等待 IO 时,线程可继续调度其他协程执行,CPU 充分利用。

  • 优势

    1. 减少内核态 ↔ 用户态切换:系统调用次数少,IO 内核直接完成。

    2. 减少线程切换开销:协程切换在用户态完成,一个线程可以调度大量协程。

  • 结果:单线程即可处理成千上万高并发连接,同时保持同步风格的业务代码。

三、有栈协程VS无栈协程

1、有栈协程与无栈协程

  • 有栈协程(stackful coroutine)
    每个协程都有自己独立的调用栈(像线程一样),可以在任何位置挂起,并且可以在深层函数里直接 yield

  • 无栈协程(stackless coroutine)(C++20 是这种)
    没有独立的调用栈,只能在当前函数的挂起点暂停。

    • 不能像有栈协程那样在任意深度的函数里直接挂起(必须让挂起沿调用链返回到协程函数本身)。

    • 状态机代码会被编译器“分解”到一个结构体里保存,这个结构体就是协程帧。

2、对称协程与非对称协程

非对称协程:协程切换必须回到主协程或调度器,由它决定下一个协程。

对称协程:协程可以直接切换到任意其他协程,不经过主协程。

怎么理解 C++ 协程是非对称协程?

在非对称协程中,协程的控制流是单向的,协程让出控制权时只能返回给它的直接调用者。C++20 协程通过 co_await 挂起时,会返回到调用者或恢复者,而不是直接切换到另一个协程,而对称协程让出控制权时可以随意指定协程。 

四、C++协程

1、C++协程的定义和执行

如果我们在 C++ 的函数体里添加了 co_await,co_return 或者 co_yield 关键字(必须有其一),那么该函数即视为协程。而被视作协程的函数也会受到诸多限制,例如不可以使用 return 语句,构造函数、析构函数、main 函数和 constexpr 函数均不能成为协程。

每一个协程函数都对应着一个协程对象,而协程对象与下述三种类型的数据相关联。

  • promise 对象。注意这里的 promise 仅仅是个概念名词,和 C++ 异步编程里的 std::promise 没有任何关联。协程的构造和运行需要编译器做很多幕后工作,而 promise 是编译器直接暴露给用户的一个对象,其与协程的运行状态相关联,用户可以通过 promise 的预定义方法实现调度协程、获取运行结果和异常捕获。

  • 协程句柄。协程句柄本质是一个指针,通过协程句柄用户可以访问对应的 promise 以及恢复和销毁协程。

  • 协程状态。协程为了实现随时暂停执行并随意恢复的功能,必须在内存空间中保存当前的协程状态,主要涉及协程当前运行位置(便于恢复时继续运行)以及生命周期未结束的局部变量。

通常激活帧被称作一块保留了函数运行状态的内存,对于普通函数激活帧就是栈帧,而对于协程激活帧由两部分组成:

  • 栈帧。与普通函数栈帧结构类似,在调用协程时产生栈帧,协程结束返回给调用者时释放栈帧。

  • 协程帧。用于记录协程中间状态便于协程暂停和恢复执行,主要包含上述介绍的三种与协程相关的数据对象。关于协程帧有两点需要注意:

    • 在创建协程时由编译器分配内存,但注意该内存需要用户手动释放,否则会造成内存泄漏。

    • 通常采用堆分配方式构建协程帧。c++ 协程提案中有一些规定,如果编译器能够证明协程的生命周期确实严格嵌套在调用者的生命周期内,则允许从调用者的激活帧中分配协程帧的内存。

2、C++ 协程之 promise 编程

1、promise_type

在 C++20 协程机制里,编译器对协程返回类型有一个硬性要求:这个返回类型(比如 Taskstd::futuregenerator 等)必须声明一个嵌套类型

struct promise_type { /* ... */ };

这是因为编译器会在编译协程函数时,根据返回类型 R 直接访问 R::promise_type 来生成协程的状态机和管理逻辑。
如果 R 里没有 promise_type,编译器就没法生成协程相关代码,编译会报错。

为何 C++ 协程需要单独定义一个面向用户的对象? 因为编译器对 promise 做了诸多限制,且 promise 持有协程运行的数据,而面向用户的对象可以让用户自定义如何去操作 promise 的数据,数据与处理逻辑分离开来算是设计上的解耦。

2、coroutine_handle:外部用以操作协程的句柄

coroutine_handle 本质上是一个指针,一般是通过调用 promise 的方法获得, coroutine_handle 可以和 promise 互相转化,当用户拿到协程句柄后可以使用下述方法操作协程:

  • handle.promise()。通过该方法可以从协程句柄获取 promise。

  • handle.done()。该方法用于判定协程是否执行结束。

  • handle.resume()。该方法可以使暂停的协程继续运行,注意如果此时 handle 关联的协程执行结束,调用该方法会产生 core dump。

  • handle.destroy()。该方法负责协程帧内存的回收,用户需要避免重复调用

用户可以直接使用 coroutine_handle<>即模板参数默认为空,这类似于 void*指针,可用于存储任意类型的 promise,但此时无法调用 handle.promise() 方法,用户若想获取存储的 promise 需要使用类型转换。

coroutine_handle<promise_type> handle = ... // 获取 handle。
auto& p = handle.promise(); // p 此时为 promise_type&类型。

coroutine_handle<> handle = ... // 获取 handle。
// auto& p = handle.promise(); // 报错,因为不存在 promise 方法。
auto specific_handle = std::coroutine_handle<promise_type>::from_address(handle.address()); // 类型转换
auto& p = specific_handle.promise(); // p 此时为 promise_type&类型。

3、promise:协程的核心

1、get_return_object

// 函数原型
UserFacing promise::get_return_object();

用户调用协程时获取的 UserFacing 对象是编译器通过 promise 的 get_return_object 函数构造出来的,该函数参数为空,返回类型需要与协程的返回类型一致。

2、initial_suspend

// 函数原型
awaiter promise::initial_suspend();

当用户调用协程并构造完协程帧后,编译器会调用协程关联的 promise 对象的 initial_suspend 方法通过返回的 awaiter 来决定是直接运行协程还是暂停执行转移控制权,比如用户想在创建协程后做一些异步的准备工作,此时可以暂停执行等准备工作完成后再回复协程的执行。awaiter 的具体细节暂未讲到,但读者只需要知道 C++ 官方提供了默认的 awaiter 实现:

  • std::suspend_always。暂停协程执行,执行权返回给调用者。

  • std::suspend_never。协程继续执行。

3、final_suspend

// 函数原型
awaiter promise::final_suspend();

如果 final_suspend 返回了 suspend_never,那么编译器会接着执行后续的资源清理操作,如果 UserFacing 在析构函数中再次执行 handle.destroy,那么会出现 core dump,所以一般建议不要返回 suspend_never,因为资源的释放最好在用户侧来做。

4、co_return & return_value

// 函数原型
co_return T;
void promise::return_value(T);

co_return;
void promise::return_void();

协程的 co_return 就像普通函数的 return 一样,用于终止协程并选择性的返回值。根据 co_return 是否返回值,编译器会做出不同的处理:

  • 不返回值。此时 co_return 仅用于终止协程执行,编译器随后调用 promise.return_void 方法,此函数可实现为空,在某些情况下也可以执行协程结束后的清理工作,但用户必须为 promise 定义 return_void 方法。

  • 返回值。假设 co_return 返回值的类型为 T,此时编译器调用 promise.return_value 方法,并将 co_return 的返回值作为参数传入,用户可以自定义 return_value 函数的参数类型,就像调用正常函数一样,只要 T 可以转换为该参数类型即可。

需要注意的是 C++ 标准规定 return_value 和 return_void 函数不能同时存在,并且当协程不存在 co_return 关键字时用户也需要定义 return_void 方法,因为协程执行结束后编译器会隐式调用该函数。

5、co_yield & yield_value

// 函数原型
co_yield T;
awaiter promise::yield_value(T);

与 co_return 不同的是,co_yield 之后协程的运行并不一定结束,所以 yield_value 通过返回 awaiter 类型来决定协程的执行权如何处理,一般返回 std::suspend_alaways 转移控制权到调用者,用户也可返回自定义的 awaiter,但通常不要返回 std::suspend_never 等让协程继续运行的 awaiter,因为此时协程继续运行的话如果再次碰到 co_yield 那么上次 yield 的值就会被覆盖。

4、C++ 协程之 awaiter 编程

C++ 协程设计了多种类型的调度点,而这些调度点的具体逻辑均在 awaiter 内实现,C++ 协程标准要求 awaiter 必须实现下列三个方法:

  • await_ready

  • await_suspend

  • await_resume

co_await: awaiter 执行的触发器

co_await 属于 C++ 协程三大关键字之一,我们知道 awaiter 负责实现协程调度逻辑,但逻辑只是实现了,需要 co_await awaiter 才能执行此调度逻辑。

任何 awaiter 的执行一定附带 co_await,用户可以在协程体内显示执行 co_await awaiter 语句,而像一些内置方法,如 promise 的 initial_suspend 函数返回的 awaiter,会由编译器隐式生成 co_await initial_suspend 语句。

那 co_await 后面只能跟 awaiter 吗?答案是否定的,可以被 co_await 的还有 awaitable 对象——可被转换为 awaiter 的对象。

await_ready

// 函数原型
bool awaiter::await_ready();

用户代码执行 co_await awaiter 时,编译器首先执行 awaiter.await_ready 方法,该方法返回 bool 类型,如果是 true,如同字面意思 ready 一样,代表当前协程已就绪,当前协程选择继续运行而非暂停,并且 await_suspend 方法不会被调用。

await_suspend

// 函数原型 1
void awaiter::await_suspend(std::coroutine_handle<>);
// 函数原型 2
bool awaiter::await_suspend(std::coroutine_handle<>);
// 函数原型 3
std::coroutine_handle<> awaiter::await_suspend(std::coroutine_handle<>);

如果 await_ready 方法返回 false,此时编译器会调用 awaiter.await_suspend 方法。

await_suspend 参数为当前协程的 coroutine_handle,返回值有三种形式:

  • void。当前协程暂停,执行权返回给当前协程的调用者。

  • bool。如果值为 true 则协程暂停,执行权返回给当前协程的调用者,否则协程继续运行。

  • coroutine_handle。返回的协程句柄会被编译器隐式调用 resume 函数,即该句柄关联的协程会继续运行,也可直接返回参数中的协程句柄,这意味着当前协程会继续运行。

注意返回值为 coroutine_handle 时,如果想转移协程执行权,C++ 内置了 std::noop_coroutine 类,返回该类代表使协程处于 suspend 状态。

await_resume

// 函数原型
T awaiter::await_resume();

在讲解 promise时我们提到协程通过 co_return 返回值,协程的调用者通过 UserFacing 的方法获取该返回值,但获取返回值的过程不够优雅。如果协程返回的 UserFacing 可以被转换为 awaiter 且调用者也是协程的话可以有更简洁的写法:

// 写法 1
UserFacing obj = run();
T value = obj.get_return_value();

// 写法 2(需要在协程体内)
T value = co_await run();

awaiter 的生命周期

如果在执行了 co_await 操作后产生了临时的 awaiter 对象,那么在执行完 await_resume 后编译器会立刻执行 awaiter 的析构,对于非临时 awaiter 就是随着作用域结束析构。

协程间的状态转移

await_suspend 函数可以控制协程执行权的转移,但关于执行权转移的细节还是要重点强调一下。

C++ 对协程的设计是基于状态机的,我们先分析一下各种 case 下协程状态的变化:

  • await_suspend 返回 void: 此时协程陷入 suspend 状态。

  • await_suspend 返回 bool: 如果为 true 则协程陷入 suspend 状态,否则恢复运行。

  • await_suspend 返回协程句柄: 如果返回自身句柄,则恢复运行,如果返回其他句柄,那么当前协程陷入 suspend 状态,执行权转移至返回句柄对应的协程,即使返回 noop_coroutine 当前协程也会陷入 suspend 状态。

如果协程陷入 suspend 状态那么协程内部信息会记录该状态,然后根据协程嵌套调用产生的栈逐层向上直到遇到普通函数或者非 suspend 状态的协程,所以如果父级调用者是协程但也处于 suspend 状态那么父协程是不会恢复执行的。

⚠️协程执行状态的变更只有通过陷入 suspend 和 resume 才会变更,在执行行过程中只要未出现协程调度点那么任何协程状态都不会变化

五、io_uring

1、Linux 下传统 IO 的缺陷

对于最为基础的同步 I/O 其缺陷不言而喻,线程需要阻塞等待结果返回,这在 I/O 密集型应用中会产生严重的性能低下问题。

对于多路复用 I/O 的代表 epoll,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率,在网络编程领域占据了重要地位,但其存在一个致命缺陷:只支持 network sockets 和 pipes,甚至连基础存储文件的 I/O 都不支持。

同步 I/O(阻塞 I/O)

对于同步 I/O(阻塞 I/O),用户态调用 read()write() 时,线程会阻塞:read() 会阻塞直到内核把数据拷贝到用户缓冲区,write() 会阻塞直到内核把数据拷贝到内核发送缓冲区。这种方式的缺点是线程阻塞导致 CPU 利用率低,在高并发场景下需要大量线程,带来上下文切换开销。

异步 I/O(AIO / io_uring)

而异步 I/O(AIO / io_uring)允许用户态提交 I/O 请求(读或写)给内核而不阻塞线程,内核异步完成数据传输,并通过事件或完成队列通知用户态。其优点在于无需阻塞用户线程、减少上下文切换,同时数据直接从内核缓冲区写入用户缓冲区(读)或从用户缓冲区写入内核缓冲区(写),并且支持多种 I/O 类型,包括网络、文件和 timerfd 等。

io_uring

io_uring 的基本逻辑与 aio 类似,同样为用户提供提交 I/O 的接口和接收完成事件的接口,但其内核设计与 aio 完全不同:

  • io_uring 是真正异步的,调用其接口仅仅是与内核数据结构做一次交互,绝对不会像 aio 一样发生预期外的阻塞。

  • 支持任意类型的 I/O。

  • 交互逻辑简单,用户仅需要提交 I/O,完成之后 I/O 事件会自动出现在完成队列里。

  • 接口灵活、可拓展性强。基于 io_uring 甚至能重写 Linux 下的系统调用。

相比其他 I/O 模型,io_uring 具有明显的优势。它通过用户态和内核态共享提交队列(Submission Queue)和完成队列(Completion Queue),减少了系统调用的次数和上下文切换的开销。在 io_uring 中,应用程序只需将 I/O 请求放入提交队列,内核会在后台处理这些请求,并将结果放入完成队列,应用程序可以随时从完成队列中获取结果,无需频繁进行系统调用和轮询。此外,io_uring 支持更多的异步系统调用,不仅适用于存储文件的 I/O 操作,还能很好地应用于网络套接字的 I/O 处理,具有更广泛的适用性和更高的灵活性。

2、io_uring 实现原理

io_uring 实现异步 I/O 的本质是利用了一个生产者-消费者模型,每个 uring 在初始化时会在内核中创建提交队列(sq)和完成队列(cq),其数据结构均为固定长度的环形缓冲区。用户向 sq 提交 I/O 任务,内核负责消费任务,完成后的任务会被放至 cq 中由用户取出,为了降低用户态与内核态之间的数据拷贝,io_uring 使用 mmap 让用户和内核共享 sq 与 cq 的内存空间。

从图中可以看出核心数据并不存储在 sq 中,而是存储在 sqe array 中,sqe array 包含多个 sqe entry(sqe),每个 sqe 是一个结构体存储了 I/O 请求的详细信息,比如操作类型、缓冲区地址、缓冲区长度和文件描述符等等,sq 只存储索引项,用户操作的完整流程包含如下步骤:

  • 用户调用接口获取空闲的 sqe entry 并填充 I/O 信息。

  • 用户向 sq 提交 sqe,sq 记录其索引信息。

  • 内核从 sq 获取 sqe entry 并处理,完成后将结果封装成 cqe entry 放入 cq 中,cqe entry 存储了 I/O 操作的结果。

  • 用户从 cq 中获取 cqe entry,处理结束后标记该 cqe entry,这样相关联的 sqe entry 回到空闲状态等待再利用。

io_uring 的核心系统 API 有如下三个:

3、liburing 结合 eventfd

liburing 允许io_uring实例与 eventfd 绑定,那么什么是 eventfd 呢?

eventfd 是 Linux 下的轻量级的用于事件通知的文件描述符,使用方法包含下列两个读写接口以及初始化接口:

#include <sys/eventfd.h>
/* Return file descriptor for generic event channel.  Set initial
   value to COUNT.  */
extern int eventfd (unsigned int __count, int __flags) __THROW;
/* Read event counter and possibly wait for events.  */
extern int eventfd_read (int __fd, eventfd_t *__value);
/* Increment event counter.  */
extern int eventfd_write (int __fd, eventfd_t __value);

eventfd 实现的逻辑是累计计数,当计数为 0 则读操作阻塞,否则读取的是当前的计数值并将 eventfd 的计数清 0。写操作是不会阻塞的,但写入的值并不会直接作为 eventfd 的计数,而是以累加到原本计数的方式存储。这样就可以实现事件通知机制了,之所以称为轻量级是因为对 eventfd 的操作基本在 1us 左右。

4、io_uring 和 eventfd 的关系

  • io_uring 内核支持通过 IORING_REGISTER_EVENTFD 把一个eventfd注册进 io_uring。

  • 注册后,每当有新的 CQE(完成事件)写入 CQ 环形缓冲区时,内核会自动往 eventfd 写入 1

  • 这样用户进程只要epoll_wait监控这个 eventfd,就能知道 io_uring 队列里有新完成的 I/O

👉 重点:

  • 不是提交时写 eventfd,而是 完成时写 eventfd

  • 提交 SQE 时,内核只是把请求排队,不会触发 eventfd。

  • 等请求真的完成了,内核往 CQ 写入一个 CQE,同时往 eventfd 写入一个整数。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐