当前位置: 首页 > news >正文

并行并发丨C++ 协程、现场池 学习笔记

一、协程和线程池对比

特性
多线程 (线程池)
C++20 协程 (Asio)
分析
核心思想
任务抢占:多个线程抢占CPU时间片。
任务协作:任务主动放弃执行权。
协程模型更适合I/O等待,因为它避免了不必要的“空转”。
性能
受限于I/O:总时间 ≈ (总任务数 / 线程数) * I/O延迟。因为线程在等待时被阻塞。
流水线化:总时间 ≈ 总任务数 * 单个CPU时间 + I/O延迟。协程利用I/O等待时间去处理其他任务。
协程版本用更少的线程,获得了更高的吞吐量,因为它将CPU时间和I/O等待时间解耦,实现了高效的流水线作业。
资源消耗
高:每个线程都拥有KB到MB级的栈内存和内核对象,超过CPU核心数量的线程将会出现资源竞争,也就是不断有线程任务切换工作。
极低:每个协程的状态仅占数百字节,存于堆上。可以轻松创建数十万个。
协程在可扩展性(Scalability)上拥有压倒性优势。
上下文切换
昂贵:用户态 ↔ 内核态切换,耗时在微秒级。
廉价:纯用户态的函数调用,耗时在纳秒级。
这是协程高性能的关键原因之一。
编程模型
相对简单:线程池封装后,只需提交函数。但需要手动处理线程安全。
需要转变思维:必须使用非阻塞API(如asio::steady_timer),避免阻塞调用。代码可读性更高(无回调地狱)。
协程的co_await让异步代码看起来像同步代码,逻辑更清晰,但需要对异步模型有深入理解。
适用场景
CPU密集型任务,或混合型任务。
I/O密集型任务(网络、文件读写)、高并发场景。
为你的任务选择正确的工具:如果任务是纯计算,多线程能更好地利用多核并行。如果任务充满了等待,协程是最佳选择。
对于典型的I/O密集型任务,C++20协程是远优于传统多线程的选择。它用更少的系统资源,通过高效的协作式调度,实现了更高的任务吞吐量。而多线程方案虽然也能完成任务,但其固有的高昂开销和阻塞模型使其在处理海量并发I/O时效率低下,扩展性差。
namespace CoroutineDemo
{using namespace boost;// 使用协程的异步存储任务asio::awaitable<void> _async_io_task(int file_id) {Worker::do_cpu_work(file_id);// Asio提供的非阻塞等待。// co_await会挂起当前协程,但不会阻塞工作线程。// 工作线程可以去执行其他就绪的协程。asio::steady_timer timer(co_await asio::this_coro::executor);timer.expires_after(std::chrono::milliseconds(IO_LATENCY_MS));co_await timer.async_wait(asio::use_awaitable);}void run_coroutine_version() {// 仅使用少量线程来驱动海量协程unsigned int num_worker_threads = 4;std::cout << "\n--- C++20 协程版本 (使用 " << num_worker_threads << " 个工作线程) ---" << std::endl;Worker::Timer timer("协程");asio::thread_pool pool(num_worker_threads);// 用于等待所有协程完成的屏障std::mutex m;std::condition_variable cv;int tasks_remaining = NUM_FILES_TO_PROCESS;// 生产者:将任务“派生”(spawn)为协程在线程池上运行for (int i = 0; i < NUM_FILES_TO_PROCESS; ++i) {// co_spawn 在指定的执行器(pool)上启动一个协程asio::co_spawn(pool, _async_io_task(i),// 这是协程完成后的回调[&](const std::exception_ptr& e) {std::lock_guard<std::mutex> lock(m);if (--tasks_remaining == 0) {cv.notify_one();}});}// 等待所有协程执行完毕std::unique_lock<std::mutex> lock(m);cv.wait(lock, [&] { return tasks_remaining == 0; });pool.stop();pool.join();}
}namespace MutilThread {// 经典的线程安全任务队列class TaskQueue {public:void push(std::function<void()> task) {std::lock_guard<std::mutex> lock(mtx_);tasks_.push(std::move(task));cv_.notify_one();}std::function<void()> pop() {std::unique_lock<std::mutex> lock(mtx_);cv_.wait(lock, [this] { return !tasks_.empty() || stop_; });if (stop_ && tasks_.empty()) {return nullptr;}auto task = std::move(tasks_.front());tasks_.pop();return task;}void stop() {{std::lock_guard<std::mutex> lock(mtx_);stop_ = true;}cv_.notify_all();}private:std::queue<std::function<void()>> tasks_;std::mutex mtx_;std::condition_variable cv_;bool stop_ = false;};// 线程池class ThreadPool {public:ThreadPool(size_t num_threads) {for (size_t i = 0; i < num_threads; ++i) {workers_.emplace_back([this] {while (true) {auto task = tasks_.pop();if (!task) { // nullptr indicates stopbreak;}task();}});}}void submit(std::function<void()> task) {tasks_.push(std::move(task));}~ThreadPool() {tasks_.stop();for (auto& worker : workers_) {worker.join();}}private:TaskQueue tasks_;std::vector<std::thread> workers_;};// 模拟同步I/O的存储任务void _async_io_task(int file_id) {Worker::do_cpu_work(file_id);std::this_thread::sleep_for(std::chrono::milliseconds(IO_LATENCY_MS));}void run_thread_version() {// 使用与硬件核数相同的线程数unsigned int num_threads = std::thread::hardware_concurrency();if (num_threads == 0) num_threads = 8; // 兜底std::cout << "--- 多线程版本 (使用 " << num_threads << " 个线程) ---" << std::endl;Worker::Timer timer("多线程");ThreadPool pool(num_threads);// 生产者:向线程池提交所有任务for (int i = 0; i < NUM_FILES_TO_PROCESS; ++i) {pool.submit([i] { _async_io_task(i); });}// 线程池析构时会自动等待所有任务完成}
};

std::this_thread::sleep_for() 和 co_await timer.async_wait()是两种性质完全不同的“等待”。
std::this_thread::sleep_for()
  • 语义:请让当前线程进入休眠状态,在时间d内不要给它分配任何CPU时间。
  • 效果:阻塞。线程被操作系统标记为“等待中”,完全不可用。如果线程池里所有线程都在sleep,整个程序就卡住了。这是一种对资源的浪费。
std::this_thread::sleep_for()
  • 语义:请让当前协程进入挂起状态。我(这个协程)对执行不感兴趣了,直到定时器到期。请把控制权还给你(事件循环)。
  • 效果:非阻塞。协程的状态被保存,但承载它的线程**立即返回**并可以去执行其他任务。资源得到了充分利用。

二、为啥IO操作可以使用异步操作?

整个电脑是由多个芯片组成,CPU是 它是大脑,负责运行程序、处理数据。当程序需要读写文件或收发网络包时,CPU会发出指令,也就是任务发起者,等待接收任务的结果。
比如
  • CPU(总经理)告诉DMA控制器(快递员):“请把内存地址A到B的数据,送到SATA控制器(仓库主管)那里去”。
  • DMA开始工作,它直接与内存和SATA控制器沟通,搬运数据。
  • 在此期间,CPU完全解放,可以去处理其他计算任务(比如运行其他协程)。
  • DMA工作完成后,通过中断通知CPU。
备注: DMA不是一个独立的芯片,而是一种功能,被集成在PCH和各种专用的I/O控制器内部。

2.1 一个通用的“发起-等待”模型

所有这些任务都遵循同一个高效的模式:
  • 发起 (Initiate):CPU执行一小段代码,配置好任务,并委托给一个专门的“协作者”(硬件控制器或OS调度器)。
  • 执行 (Execute):协作者独立地、并行地执行耗时操作。
  • 等待 (Wait):CPU(具体来说是发起任务的那个线程或协程)在此期间是自由的,可以去执行其他不相关的计算任务,这是性能提升的关键
  • 通知 (Notify):任务完成后,协作者通过一个低成本的信号(通常是硬件中断)来通知CPU。
  • 恢复 (Resume):CPU响应通知,让之前等待的程序(线程或协程)从挂起点继续执行。

2.2 可以异步的操作

好的,我们来逐一详细描述CPU可以发起并等待结果的各类任务,不再使用表格形式。

1. 磁盘 I/O (Disk I/O)

这包含了所有与硬盘(HDD)或固态硬盘(SSD)进行数据交换的操作,是异步编程最经典的场景,因为机械硬盘的寻道和旋转,或固态硬盘的读写延迟,相对于CPU的速度来说都极其缓慢。
  • 具体操作示例: 读取一个大文件到内存、向数据库写入一条记录、将程序日志保存到磁盘、加载大型游戏关卡的模型和纹理资源。
  • 核心协作方 (Specialist): SATA/NVMe 控制器DMA (直接内存访问) 控制器。当CPU发起任务后,真正的执行者是主板上管理存储设备的控制器,它会直接命令硬盘进行操作。而DMA控制器则像一个高效的搬运工,在无需CPU干预的情况下,将数据在内存和硬盘之间来回传输。
  • CPU的“等待”机制 (如何被通知): 硬件中断 (Interrupt)。当DMA控制器完成了所有数据的搬运后,磁盘控制器会向CPU发送一个高优先级的“中断”信号。这个信号会打断CPU当前正在做的任何事,CPU会立即响应这个中断,操作系统随之将等待这个I/O结果的程序(如一个协程)标记为就绪,等待被再次调度执行。

2. 网络 I/O (Network I/O)

所有通过网络进行的数据交换都属于此类。网络延迟和带宽限制使得网络操作成为典型的、不可预测的耗时任务。
  • 具体操作示例: 使用浏览器发起一个HTTP请求来加载网页、在聊天软件中接收一条WebSocket消息、通过客户端发送一封邮件、游戏中连接到远程服务器进行数据同步。
  • 核心协作方 (Specialist): 网络接口卡 (NIC)DMA。无论是主板集成的有线网卡还是独立的无线Wi-Fi模块,都有一个专门的芯片(NIC)来负责处理网络协议、打包和解包数据。同样,DMA控制器也在这里扮演关键角色,负责在内存和NIC的缓冲区之间高效传输数据。
  • CPU的“等待”机制 (如何被通知): 硬件中断。与磁盘I/O类似,当NIC成功接收到一个完整的数据包,或者确认已将数据成功发送出去后,它会立即通过硬件中断来通知CPU,告知网络事件已经发生。

3. GPU 计算 (GPU Calculation)

这是一种将大规模、高度并行的计算任务“外包”给专门硬件的模式。GPU拥有成百上千个核心,处理这类任务远比CPU高效。
  • 具体操作示例: 游戏中渲染复杂的3D场景、训练深度学习模型、使用Adobe Premiere进行视频编解码、进行大规模科学模拟计算。
  • 核心协作方 (Specialist): GPU (图形处理器)。GPU本身就是一个功能强大的、独立的并行处理器。CPU将计算任务(如顶点数据、着色器程序、AI模型的权重)和相关数据传输给GPU,然后GPU会独立完成这些繁重的计算。
  • CPU的“等待”机制 (如何被通知): 硬件中断 或 事件。当GPU完成了一整批的渲染或计算任务后,它会通过驱动程序向CPU发送一个中断或同步事件,通知CPU可以来获取计算结果(比如已经渲染好的图像)或可以提交下一批任务了。

4. 定时器/延时 (Timer/Delay)

即使是程序中简单的“等待一段时间”,也是一个可以被CPU发起并等待的异步任务,它避免了让CPU在循环中“空转”浪费能源。
  • 具体操作示例: sleep_for这类延时操作、为一个网络请求设置超时机制、调度一个需要在5分钟后执行的定时任务。
  • 核心协作方 (Specialist): 系统定时器 (System Timer)。这是硬件层面提供的一个或多个计数器,通常集成在主板芯片组或CPU内部。CPU可以设置一个倒计时值并启动它。
  • CPU的“等待”机制 (如何被通知): 硬件中断。当定时器倒计时到零时,硬件会产生一个中断,通知CPU预设的时间已经过去。在异步模型中,这个中断会唤醒因为等待这个定时器而挂起的协程。

5. 用户输入 (User Input)

程序与用户的实时交互充满了不可预测的异步事件。程序的大部分时间都在等待用户的下一个动作。
  • 具体操作示例: 等待用户的下一次键盘敲击、等待鼠标的移动或点击、等待手机触摸屏的触摸或滑动事件。
  • 核心协作方 (Specialist): 管理各种输入端口的控制器,如 USB 控制器 (用于键盘鼠标)、I2C/SPI 控制器 (常用于触摸屏和传感器)。
  • CPU的“等待”机制 (如何被通知): 硬件中断。当设备状态发生改变时(例如,键盘上的一个按键被按下,电路闭合),对应的控制器会立即向CPU发送一个中断,确保用户的操作能得到最及时的响应。

6. 跨线程/进程通信

这类等待发生在软件层面,不直接与外部硬件I/O设备交互,而是等待程序内部其他部分的工作结果。
  • 具体操作示例: 主线程等待一个工作线程的计算结果 (如使用 std::future::get())、一个线程尝试获取另一个线程持有的锁 (std::mutex::lock())。
  • 核心协作方 (Specialist): 操作系统调度器 (OS Scheduler)。这里的“专家”是操作系统内核本身,它负责管理系统中所有线程和进程的生命周期与状态。
  • CPU的“等待”机制 (如何被通知): 软件信号/唤醒。这与硬件中断不同。当一个线程(比如线程A)在等待线程B的结果时,操作系统会把线程A标记为“阻塞”或“等待”状态,并把它移出可运行队列。当线程B完成任务或释放锁时,它会执行一个系统调用来通知操作系统。操作系统收到通知后,就会找到正在等待的线程A,将其状态改回“就绪”,并放回可运行队列中,等待下一次被CPU调度。

三、协程编码

综述所属,在执行任务不是本进程的需要CPU执行的情况下,使用协程能提高任务执行效率,同步减少资源开销。请注意,任务总共耗时,还受
到 任务接收者能消化的速度。
注意:CPU密集型的操作,还是线程池或者多线程效率高一点。

3.1 关键字 co_await 切换线程上下文

在CPU中申请挂起任务,等待异步IO完成操作后,在通过中断接收信息。并不总是异步挂起。一个“可等待对象”可以决定是立即返回(同步完成)还是挂起(异步完成)。

工作原理:

  • co_await 后面跟着一个“可等待对象” (Awaitable)。编译器会检查这个对象,并调用其内部的三个特殊函数来决定行为:
  • await_ready(): “需要等待吗?”。如果返回 true(例如,数据已在缓存中),则协程根本不挂起,直接继续执行,零开销。
  • await_suspend(): “如果需要等待,该怎么办?”。如果 await_ready 返回 false,则调用此函数。这是魔法发生的地方。它负责保存协程状态,并将控制权返回给调用者或事件循环。它还可以决定在哪个线程上恢复协程。
  • await_resume(): “等待结束了,结果是什么?”。当异步操作完成后,协程恢复执行时,会调用此函数。它的返回值就是整个 co_await 表达式的结果。

3.2 关键字 co_yield

产生一个值给调用者,然后挂起自己,并在下次被调用时从挂起点恢复。 在你需要的时候,调用一下 然后挂起。

典型应用场景:

  • 创建一个惰性求值的序列(如斐波那契数列)。
  • 逐行或逐块地解析一个大文件,而无需一次性加载到内存。
  • 实现一个可以产生无限数据的序列(如随机数)。
#include <iostream> // 需要一个 Generator 的实现,这里为了演示简化 #include "your_generator_library.h" Generator<int> count_to(int n) { for (int i = 0; i < n; ++i) { std::cout << "即将产生: " << i << std::endl; co_yield i; // 产生一个值,然后挂起 } std::cout << "生成器结束" << std::endl; } int main() { auto generator = count_to(3); // 使用 range-based for 循环来消耗生成器 for (int value : generator) { std::cout << " 从生成器获取到值: " << value << "\n" << std::endl; } }

3.3 关键字 co_return

结束协程的执行,并(可选地)提供一个最终的返回值。

典型应用场景:

  • 在异步任务(使用co_await的协程)中,返回最终的计算结果。
  • 在任何类型的协程中,明确地标记其执行的终点。
http://www.dtcms.com/a/278038.html

相关文章:

  • 闲庭信步使用图像验证平台加速FPGA的开发:第十三课——图像浮雕效果的FPGA实现
  • 语言模型常用的激活函数(Sigmoid ,GeLU ,SwiGLU,GLU,SiLU,Swish)
  • 算法-汽水瓶兑换
  • Spring AI 项目实战(十七):Spring Boot + AI + 通义千问星辰航空智能机票预订系统(附完整源码)
  • 【webrtc】gcc当前可用码率3:x264响应码率改变
  • 系规备考论文:论IT服务部署实施方法
  • 西藏氆氇新生:牦牛绒混搭液态金属的先锋尝试
  • 分布式锁踩坑记:当“防重“变成了“重复“
  • JAVA并发——什么是Java的原子性、可见性和有序性
  • Redis缓存设计与性能优化指南
  • 使用Starrocks替换Clickhouse的理由
  • C++封装、多态、继承
  • 在 Ubuntu 下安装 MySQL 数据库
  • 从文本中 “提取” 商业洞察“DatawhaleAI夏令营”
  • 电路分析基础(02)-电阻电路的等效变换
  • Matlab批量转换1km降水数据为tiff格式
  • 【LeetCode100】--- 5.盛水最多的容器【复习回顾】
  • ssm学习笔记day05
  • QT 多线程 管理串口
  • 《[系统底层攻坚] 张冬〈大话存储终极版〉精读计划启动——存储架构原理深度拆解之旅》-系统性学习笔记(适合小白与IT工作人员)
  • springboot高校竞赛赛事管理系统 计算机毕业设计源码23756
  • Java行为型模式---策略模式
  • 第1章 概 述
  • dll文件缺失解决方法
  • C++——static成员
  • HiPPO: Recurrent Memory with Optimal Polynomial Projections论文精读(逐段解析)
  • QT控件命名简写
  • Linux内核高效之道:Slab分配器与task_struct缓存管理
  • 编译器优化——LLVM IR,零基础入门
  • 学习C++、QT---23(QT中QFileDialog库实现文件选择框打开、保存讲解)