C++20协程实战:从零构建异步任务框架Task<T>

发布时间:2026/7/17 4:34:12

C++20协程实战:从零构建异步任务框架Task<T> 1. 项目概述为什么我们需要C20协程如果你写过C的异步网络服务或者处理过需要等待I/O的密集型任务大概率会对“回调地狱”和复杂的状态机管理感到头疼。传统的异步编程模型无论是基于回调函数、std::future还是第三方库的Promise/Future在逻辑复杂时都容易让代码变得支离破碎难以阅读和维护。C20引入的协程Coroutines正是为了解决这个问题而来它允许你以近乎同步的、顺序执行的代码风格来编写异步逻辑。简单来说协程是一个可以暂停执行并在之后恢复的函数。这个“暂停”不是操作系统线程的切换而是一种更轻量级的、由用户态控制的控制流转移。当协程遇到需要等待的操作比如网络数据到达、文件读取完成时它会挂起自己将控制权交还给调用者或调度器而不会阻塞当前线程。当等待的条件满足后协程可以从上次暂停的地方继续执行所有局部变量都保持挂起前的状态。这个项目教程的目标就是带你从零开始亲手构建一个基于C20协程的、可用的异步任务框架。我们不会停留在理论层面而是通过实现一个简单的TaskT类型来深入理解协程的三大核心co_await、co_return和co_yield并最终用它来编写一个清晰的异步示例。你会发现用协程重写后的异步代码其可读性几乎与同步代码无异。2. 核心概念与机制拆解在动手写代码之前我们必须先吃透C20协程的几个核心抽象。很多教程一上来就扔给你一堆promise_type的成员函数让人摸不着头脑。其实你可以把协程的运行看作编译器和你的代码之间的一场“合作”。2.1 协程的“三驾马车”co_await, co_return, co_yield一个函数之所以成为协程是因为它的函数体内出现了这三个关键字之一。co_await这是协程的“暂停点”。当执行到co_await expr;时协程会计算expr得到一个“可等待对象”Awaitable然后根据这个对象的逻辑决定是立即继续执行还是挂起协程。挂起后控制流返回线程可以去做别的事情。这主要用于异步I/O等待。co_return用于结束协程的执行并返回一个值或void。它最终会调用promise_type的return_value或return_void函数。co_yield可以看作是co_await的一个特化版本用于生成器Generator。co_yield value;等价于co_await promise.yield_value(value);。它会将value传递给调用者然后挂起协程等待调用者下次请求数据时再恢复。这是实现惰性序列的利器。2.2 协程的“幕后黑手”Promise与Coroutine Handle这是理解协程机制最关键的部分。每个协程在运行时编译器都会在堆上或优化后在栈上为其创建一个“协程状态”对象。这个状态对象内部包含两个由你定义的核心部件Promise对象这是协程的“内部管理者”。你通过定义promise_type来定制协程的行为。它负责创建返回值get_return_object()方法创建并返回给协程的调用者。这个返回值通常包装了一个“协程句柄”。控制初始/最终挂起initial_suspend()和final_suspend()决定协程在开始和结束前是否挂起。处理返回值/异常return_value()/return_void()处理co_return的值unhandled_exception()处理协程体内未捕获的异常。转换可等待对象await_transform()可以拦截co_await后面的表达式让你进行自定义处理。协程句柄Coroutine Handle这是一个std::coroutine_handlepromise_type类型的对象它是“外部”代码操控协程的把手。通过它你可以恢复协程调用.resume()。销毁协程帧调用.destroy()。检查是否完成调用.done()。访问Promise对象调用.promise()。一个重要的心智模型promise_type是协程“内向”的接口用于定义协程自身如何运行和产出结果coroutine_handle是“外向”的接口用于外部世界来驱动和操控这个协程。你的协程返回类型比如我们即将实现的TaskT就是连接这两者的桥梁。2.3 协程的生命周期一步步发生了什么假设我们有一个协程函数Taskint foo()调用它时auto task foo();幕后会发生一系列标准化的步骤分配与构造编译器在堆上分配协程状态内存并拷贝/移动参数。然后在协程状态中构造promise_type对象。获取返回对象调用promise.get_return_object()其结果即我们的Taskint对象会暂时保存。注意此时协程函数体还未开始执行。初始挂起调用co_await promise.initial_suspend()。这里通常返回std::suspend_always惰性启动或std::suspend_never立即启动。如果是suspend_always协程在此处第一次挂起第2步得到的Taskint对象被返回给调用者task。调用者拿到的是一个尚未开始执行的协程句柄。执行协程体当外部调用task的某个方法比如resume()时协程从initial_suspend之后恢复开始执行函数体内的代码。遇到co_await/co_yield执行到这些表达式时会挂起协程并将控制权返回。对于co_yield还会通过promise.yield_value()传递值出去。最终处理当执行到co_return或函数体结束时会调用promise.return_xxx()然后co_await promise.final_suspend()。强烈建议final_suspend()返回std::suspend_always这能保证协程在最终挂起后其状态和Promise对象依然有效让你有机会去获取最终结果或处理异常。如果返回suspend_never协程状态可能在co_return后立即被自动销毁导致访问已销毁的对象。销毁外部代码通过协程句柄调用.destroy()或者协程句柄离开作用域被析构时会按顺序析构Promise对象、参数副本最后释放协程状态内存。实操心得理解这个生命周期至关重要尤其是第3步和第6步。initial_suspend()决定了你是想要“拉”模式惰性调用者控制何时开始还是“推”模式立即开始。final_suspend()返回suspend_always是避免悬空引用和访问已释放内存的关键防御性编程习惯。3. 从零实现一个简易Task 框架理论说得再多不如动手写一遍。我们将实现一个最简化的、但功能完整的TaskT模板它能够包装一个异步计算并通过co_await来获取结果。3.1 定义Promise类型TaskT的核心是它的promise_type。我们先定义一个基础版本。#include coroutine #include exception #include utility // for std::exchange templatetypename T struct Task; templatetypename T struct TaskPromiseBase { // 存储协程的最终结果或异常 std::variantstd::monostate, T, std::exception_ptr result; // 用于在结果未就绪时让等待者挂起的协程句柄 std::coroutine_handle continuation; void unhandled_exception() noexcept { result.template emplace2(std::current_exception()); } // final_suspend 永远挂起让外部有机会获取结果 std::suspend_always final_suspend() noexcept { return {}; } }; // TaskT 的特化 templatetypename T struct TaskPromise : TaskPromiseBaseT { TaskT get_return_object() noexcept; std::suspend_always initial_suspend() noexcept { return {}; } // 惰性启动 // 处理 co_return value; templatestd::convertible_toT U void return_value(U value) noexcept(std::is_nothrow_constructible_vT, U) { this-result.template emplace1(std::forwardU(value)); } }; // Taskvoid 的特化 template struct TaskPromisevoid : TaskPromiseBasevoid { Taskvoid get_return_object() noexcept; std::suspend_always initial_suspend() noexcept { return {}; } // 处理 co_return; void return_void() noexcept { this-result.template emplace1(); // 将索引1的位置置为void通过monostate? 这里需要调整 // 实际上对于void我们可能只需要一个bool标志位。为了简化我们用一个特殊的标记。 // 更正确的做法是使用 std::optionalstd::exception_ptr这里我们用result的索引1代表“已完成无异常”。 // 我们调整一下Base设计。 } };上面的TaskPromiseBase设计有个小问题void无法放入variant。我们调整一下设计采用更常见的模式使用一个std::optionalT存储值一个std::exception_ptr存储异常并用一个bool标志位表示是否完成。3.2 调整后的Promise与Task定义让我们采用更清晰、更通用的设计#include coroutine #include exception #include optional #include utility templatetypename T struct Task; templatetypename T struct TaskPromise { std::optionalT value; // 存储计算结果 std::exception_ptr exception; // 存储异常 std::coroutine_handle waiter; // 正在等待本Task的协程句柄即调用者 TaskT get_return_object() noexcept; // 总是惰性启动让调用者控制 std::suspend_always initial_suspend() noexcept { return {}; } // 最终挂起保留协程状态以便获取结果 auto final_suspend() noexcept { // 我们需要一个自定义的awaiter在最终挂起后恢复等待者如果有的话 struct FinalAwaiter { bool await_ready() noexcept { return false; } // 总是挂起 std::coroutine_handle await_suspend(std::coroutine_handleTaskPromise h) noexcept { // h是当前正在结束的协程的句柄 auto promise h.promise(); // 如果有其他协程在等待我就恢复它 if (promise.waiter) { return promise.waiter; // 将控制权转移给等待者 } // 否则返回一个空句柄代表协程结束控制权返回给顶层调用者如resume的调用处 return std::noop_coroutine(); } void await_resume() noexcept {} }; return FinalAwaiter{}; } // 处理 co_return value; templatetypename U requires std::convertible_toU, T void return_value(U val) noexcept(std::is_nothrow_constructible_vT, U) { value.emplace(std::forwardU(val)); } void unhandled_exception() noexcept { exception std::current_exception(); } }; // Taskvoid的特化 template struct TaskPromisevoid { std::exception_ptr exception; std::coroutine_handle waiter; bool is_ready false; // 用于标记void任务已完成 Taskvoid get_return_object() noexcept; std::suspend_always initial_suspend() noexcept { return {}; } auto final_suspend() noexcept { struct FinalAwaiter { bool await_ready() noexcept { return false; } std::coroutine_handle await_suspend(std::coroutine_handleTaskPromise h) noexcept { auto promise h.promise(); promise.is_ready true; // 标记完成 if (promise.waiter) { return promise.waiter; } return std::noop_coroutine(); } void await_resume() noexcept {} }; return FinalAwaiter{}; } void return_void() noexcept { is_ready true; } void unhandled_exception() noexcept { exception std::current_exception(); } };3.3 实现Task 类本身TaskT类是对外暴露的接口它持有一个协程句柄并实现awaitable接口使得一个Task可以被另一个协程co_await。templatetypename T class [[nodiscard]] Task { public: using promise_type TaskPromiseT; using handle_type std::coroutine_handlepromise_type; private: handle_type coro_; public: explicit Task(handle_type h) noexcept : coro_(h) {} ~Task() { if (coro_) { coro_.destroy(); } } // 禁止拷贝允许移动 Task(const Task) delete; Task operator(const Task) delete; Task(Task other) noexcept : coro_(std::exchange(other.coro_, nullptr)) {} Task operator(Task other) noexcept { if (this ! other) { if (coro_) coro_.destroy(); coro_ std::exchange(other.coro_, nullptr); } return *this; } // 使得 TaskT 本身成为一个 Awaitable bool await_ready() const noexcept { return false; // 我们总是希望挂起让出控制权 } // 当有协程 co_await 这个Task时会调用此函数 // h 是“等待者”即调用 co_await 的那个协程的句柄 void await_suspend(std::coroutine_handle waiter) noexcept { // 将等待者的句柄记录到被等待Task的promise中 coro_.promise().waiter waiter; // 然后恢复被等待的Task即启动它或继续执行它 coro_.resume(); } // co_await 表达式的结果即Task的计算值 T await_resume() { // 此时被等待的Task已经执行完毕结果在promise中 auto promise coro_.promise(); if (promise.exception) { std::rethrow_exception(promise.exception); } if constexpr (!std::is_void_vT) { // 对于非void类型返回存储的值 // 注意这里假设value一定有值因为exception为空 return std::move(*promise.value); } // 对于void类型直接返回 } }; // 连接 TaskPromise 和 Task templatetypename T TaskT TaskPromiseT::get_return_object() noexcept { return TaskT{handle_type::from_promise(*this)}; } Taskvoid TaskPromisevoid::get_return_object() noexcept { return Taskvoid{handle_type::from_promise(*this)}; }关键点解析[[nodiscard]]提醒调用者不要忽略返回值因为Task代表一个异步计算忽略它可能意味着逻辑错误。移动语义协程句柄资源是独占的所以只允许移动禁止拷贝。await_suspend逻辑这是实现链式等待的核心。当协程Aco_await一个TaskB时waiter参数就是协程A的句柄。我们将A的句柄存入TaskB的promise中这样当TaskB完成时就知道该恢复谁。然后我们resume()TaskB的协程如果它是第一次被等待则从initial_suspend后开始执行。TaskB执行直到完成进入final_suspend。在FinalAwaiter::await_suspend中它会检查自己的promise里有没有waiter即协程A如果有就返回A的句柄。这个返回值会告诉调度器“不要恢复我TaskB了去恢复A吧”。这样就实现了A - B - A的控制流自动传递。await_resume当协程A被恢复时会执行到这里。此时TaskB已经完成我们可以安全地从其promise中取出结果或抛出异常。3.4 一个简单的同步等待器为了让我们的Task在普通的同步函数如main中也能方便地运行我们实现一个简单的同步等待函数sync_wait。这个函数会阻塞当前线程直到Task完成。#include iostream #include cassert templatetypename T T sync_wait(TaskT task) { // 一个简单的哨兵协程用于在栈上同步等待 struct SyncWaitTask { struct promise_type { std::optionalT result; std::exception_ptr eptr; SyncWaitTask get_return_object() { return {}; } std::suspend_never initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_value(T val) { result.emplace(std::move(val)); } void unhandled_exception() { eptr std::current_exception(); } }; }; // 启动任务 auto handle task.coro_; if (!handle.done()) { handle.resume(); // 这会驱动整个异步链执行直到最顶层的Task完成 } // 此时传入的task已经完成结果在其promise中 auto promise handle.promise(); if (promise.exception) { std::rethrow_exception(promise.exception); } if constexpr (!std::is_void_vT) { assert(promise.value.has_value()); return std::move(*promise.value); } } // void 特化版本 template void sync_waitvoid(Taskvoid task) { auto handle task.coro_; if (!handle.done()) { handle.resume(); } auto promise handle.promise(); if (promise.exception) { std::rethrow_exception(promise.exception); } assert(promise.is_ready); }4. 实战用我们的Task框架编写异步代码现在让我们用刚刚实现的Task框架来写几个例子感受一下协程带来的异步编程体验。4.1 基础示例链式异步调用#include iostream #include thread #include chrono using namespace std::chrono_literals; // 模拟一个异步操作比如网络请求 Taskint async_fetch_data(int id) { std::cout Fetching data id on thread: std::this_thread::get_id() std::endl; // 模拟耗时I/O std::this_thread::sleep_for(100ms); co_return id * 10; // 返回处理后的数据 } Taskint async_compute(int input) { std::cout Computing with input input on thread: std::this_thread::get_id() std::endl; // 可以等待其他异步任务 int data1 co_await async_fetch_data(input); int data2 co_await async_fetch_data(input 1); co_return data1 data2; } Taskvoid run_example() { std::cout Start run_example on thread: std::this_thread::get_id() std::endl; try { int result co_await async_compute(5); std::cout Final result: result std::endl; // 输出: Final result: 110 (因为 (5*10) (6*10) 110) } catch (const std::exception e) { std::cerr Error: e.what() std::endl; } } int main() { // 同步等待整个异步链完成 sync_wait(run_example()); return 0; }运行这个程序你会看到所有的输出都在同一个线程上主线程。这是因为我们的Task框架目前还没有引入任何线程池或调度器co_await只是挂起并立即在当前线程恢复。但这已经清晰地展示了逻辑代码是顺序写的但async_fetch_data内部的“等待”是非阻塞的。4.2 引入简单的线程切换让我们增强一下让异步操作能在另一个线程上执行。我们需要修改async_fetch_data使其返回的Task在被co_await时能将实际工作派发到另一个线程。#include future Taskint async_fetch_data_threaded(int id) { // 关键创建一个自定义的Awaiter struct ThreadSwitchAwaiter { int id_; bool await_ready() { return false; } // 总是挂起 // 当协程挂起后这个函数被调用 void await_suspend(std::coroutine_handle handle) { // 将恢复协程的工作派发到新线程 std::thread([handle, this]() mutable { std::cout Working on data id_ on thread: std::this_thread::get_id() std::endl; std::this_thread::sleep_for(100ms); // 工作完成后恢复原来的协程 handle.resume(); // 注意这里直接resumehandle可能在不同线程 }).detach(); // 分离线程简单演示生产环境应用线程池 } int await_resume() { return id_ * 10; } }; co_return co_await ThreadSwitchAwaiter{id}; } Taskvoid run_threaded_example() { std::cout Main thread: std::this_thread::get_id() std::endl; int a co_await async_fetch_data_threaded(1); std::cout Got a a on thread: std::this_thread::get_id() std::endl; int b co_await async_fetch_data_threaded(2); std::cout Got b b on thread: std::this_thread::get_id() std::endl; } int main() { // 注意由于工作线程完成后会resume主线程上的协程我们需要保证主线程的事件循环这里用sleep简单模拟 auto task run_threaded_example(); // 启动协程惰性启动需要先resume if (!task.coro_.done()) { task.coro_.resume(); } // 主线程需要等待足够长时间让工作线程完成并resume协程。 // 在生产环境中你会使用io_context或EventLoop。 std::this_thread::sleep_for(500ms); // 注意这个例子有竞态条件仅用于演示原理。task的析构可能会在子线程resume之前发生导致崩溃。 }重要警告上面的ThreadSwitchAwaiter示例是高度简化的并且不安全。它分离了线程并且没有考虑协程句柄handle的生命周期管理task对象可能在子线程尝试resume之前就被析构了。在实际项目中你需要一个可靠的调度器来管理线程和协程生命周期。这个例子只是为了展示await_suspend如何将协程的执行切换到另一个线程。5. 常见问题、调试技巧与进阶方向5.1 编译与工具链编译器支持你需要GCC 10、Clang 13或MSVC 2019 (16.8) 并开启/std:clatest或/std:c20。确保-stdc20或/std:c20标志已设置。链接库C20协程是语言特性但标准库支持部分如std::coroutine_handle,std::suspend_always需要链接到相应的C标准库。通常不需要特殊操作。调试协程的调试体验在逐步改善。在GDB中你可以打印std::coroutine_handle但查看协程帧内的局部变量可能比较困难。设置断点在await_suspend和await_resume中可以帮助你跟踪控制流。5.2 典型问题与排查协程没有被执行检查initial_suspend()是否返回了std::suspend_always。如果是你需要手动调用.resume()来启动它。我们的Task设计是惰性的所以需要外部驱动比如另一个协程的co_await或者手动resume。访问已销毁的局部变量悬空引用这是协程编程中最常见的坑。Taskint bad_coro() { int local_var 42; co_await some_async_op(); // 协程在此挂起 // 当恢复时bad_coro的栈帧可能早已销毁local_var是悬空引用 std::cout local_var; // 未定义行为 co_return 0; }解决方案所有需要在挂起点之后使用的局部变量必须存储在协程帧即堆内存中。编译器会自动将跨越挂起点的局部变量包括*this放入协程帧。但引用和指针要格外小心。如果协程是类的成员函数并且挂起后this对象可能被销毁那么通过this访问成员变量也是危险的。通常建议按值传递所需数据或使用std::shared_ptr管理生命周期。内存泄漏如果协程在最终挂起后final_suspend返回suspend_always没有人调用.destroy()就会发生内存泄漏。我们的Task析构函数中调用了.destroy()确保了资源释放。确保你的协程返回类型如Task在析构时负责清理。co_await一个已经结束的协程这通常会导致未定义行为。我们的TaskAwaiter的await_ready()始终返回false并且依赖final_suspend的机制来恢复等待者避免了重复resume已完成的协程。异常安全确保promise_type的unhandled_exception()正确存储异常并且在await_resume()中重新抛出。我们的实现做到了这一点。5.3 性能考量与进阶优化无栈协程C20协程是无栈的挂起和恢复只涉及保存少量寄存器和一个指针跳转比线程上下文切换轻量数个数量级。但每次协程调用都可能涉及一次堆分配协程帧。分配优化如果协程的调用者生命周期完全包含协程的生命周期并且协程帧大小在编译期可知编译器可以优化掉堆分配将协程帧嵌入调用者栈帧称为“协程省略”。编写小而简单的协程有助于此优化。自定义分配器通过为promise_type提供自定义的operator new你可以使用内存池、栈分配器等来管理协程帧的内存这对高性能服务器至关重要。调度器集成我们简陋的ThreadSwitchAwaiter不是生产级方案。真正的异步框架如asio::awaitable、folly::coro::Task会与一个io_context或Executor调度器紧密集成。await_suspend会将协程句柄提交给调度器由调度器决定在哪个线程、何时恢复它。生成器Generator除了异步任务协程另一个完美应用场景是生成器。你可以用co_yield实现一个惰性的、可缓存的序列比如我们之前看到的斐波那契数列生成器。标准库在C23中提供了std::generator。5.4 现有库与生态不建议所有项目都从头实现一套协程框架除非你有非常特殊的需求。可以考虑以下成熟方案cppcoro一个高质量的、头文件-only的协程库提供了taskT,generatorT,async_generatorT,sync_wait等丰富组件。Boost.Asio从1.78版本开始Asio提供了asio::awaitableT与Asio的io_context无缝集成是编写异步网络服务的绝佳选择。Folly CoroutinesFacebook的Folly库中的协程组件与Folly的Executor框架深度集成。从我们这个简易的Task实现出发理解其原理后再学习这些工业级库会容易得多。你会看到它们如何处理调度、分配器、取消操作、超时等更复杂的问题。

相关新闻