
1. 项目概述从“函数调用”到“协程”的认知跃迁如果你写过几年C对函数调用栈、局部变量生命周期这些概念应该已经刻在DNA里了。一个函数被调用它获得一块栈帧执行完毕栈帧销毁控制权交还给调用者整个过程是线性的、一次性的。但当你第一次听说“协程”Coroutine时尤其是C20将其纳入语言标准后可能会感到一种认知上的冲击一个函数居然可以执行到一半“暂停”过一会儿再“恢复”执行而且恢复时还能记住暂停时的所有状态这听起来像是魔法或者至少是操作系统线程的活儿。我最初也是这么想的直到我亲手拆解了几个协程的实现并深入研究了C20协程的底层机制后才恍然大悟。所谓的“协程”其本质并没有跳出我们熟悉的编程范式它就是一个函数加上一个状态机。这个认知是理解所有协程无论是C、Python还是Go的万能钥匙。C20的协程标准无非是为这套“函数状态机”的组合拳提供了一套官方、高效且与语言深度集成的语法糖和编译器支持。这篇文章我就带你从零开始抛开那些让人望而生畏的“promise_type”、“awaiter”等术语直击核心用最直观的方式理解C20协程到底是怎么一回事以及你该如何上手使用它。2. 核心概念拆解为什么是“函数”加“状态机”要理解这个本质我们得先忘掉“协程”这个词回到最基本的编程概念。2.1 传统函数的局限性一次性的执行流一个普通的C函数比如下面这个简单的下载函数std::string download_data(const std::string url) { auto connection open_connection(url); // 步骤1建立连接 auto data read_data(connection); // 步骤2读取数据 close_connection(connection); // 步骤3关闭连接 return data; }它的执行流程是固化的1 - 2 - 3 - 返回。在read_data这个可能很耗时的操作期间调用download_data的线程会被完全阻塞住什么也干不了。这就是同步阻塞模型的典型问题。我们想实现异步让出线程去干别的等数据就绪了再回来继续执行步骤3。2.2 状态机的引入记录“执行到哪里了”如何让一个函数“暂停”和“恢复”关键在于恢复时需要知道两件事1. 当时执行到哪个位置了2. 当时的局部变量状态是什么这天然就是一个状态机State Machine模型。我们可以把上面那个函数手动改造成一个状态机class DownloadStateMachine { enum class State { Connect, Read, Done }; State current_state State::Connect; Connection connection; std::string url; std::string result; public: DownloadStateMachine(std::string u) : url(std::move(u)) {} bool resume() { switch (current_state) { case State::Connect: connection open_connection(url); current_state State::Read; return false; // 未完成 case State::Read: result read_data(connection); // 假设read_data现在是异步的立即返回 // 问题read_data可能还没完成我们需要再次暂停 // 这里需要更复杂的机制来检查是否完成 current_state State::Done; return false; case State::Done: close_connection(connection); return true; // 完成 } return false; } std::string get_result() { return result; } };现在调用者可以周期性地调用resume()状态机根据current_state决定下一步做什么。这就是最原始的“协程”思想把函数的线性执行流拆分成由状态驱动的一个个离散步骤。注意这个手动状态机非常简陋它没有解决read_data异步等待的问题。真正的异步需要与事件循环Event Loop或回调结合这会使状态机变得极其复杂和难以维护。而这正是C20协程要解决的核心痛点。2.3 C20协程的魔法编译器生成的状态机C20协程做的事情就是让编译器自动为你生成类似上面DownloadStateMachine的代码但做得无比精巧和高效。当你将一个函数声明为协程通过使用co_await,co_yield,co_return等关键字编译器会函数变形将你的函数体改造成一个状态机的resume逻辑。状态打包将所有的函数参数、局部变量统称为“状态”打包到一个在堆上分配的“协程帧”coroutine frame对象里。这个对象生命周期独立于栈帧。控制转移提供标准的接口promise_type来管理协程的启动、暂停时值的返回co_yield、最终返回co_return以及异常。挂起与恢复通过co_await运算符及其相关的awaiter对象与外部调度器如I/O完成事件、定时器协作实现优雅的挂起与恢复。所以一个C20协程就是一个语法看起来像普通函数但底层由编译器自动生成了一个隐藏状态机的特殊函数。你写的co_await点就是状态机的“状态切换点”。3. C20协程核心组件深度解析理解了“函数状态机”的本质我们再来看C20协程的具体组成部分就不会觉得它们是天书了。它们都是为实现这个状态机模型而服务的工具。3.1 协程帧状态的容器这是核心中的核心。当协程首次被调用时编译器会在堆上分配一块内存称为“协程帧”。它里面存储了协程的参数。所有局部变量包括编译器生成的临时变量。表示当前执行位置的状态通常是整数或指针。promise_type对象。其他内部簿记信息。这个帧的存在使得协程在挂起时其全部状态得以保存而执行栈可以清空用于其他任务。这是协程能“暂停/恢复”的物质基础。// 编译器为你生成的伪代码框架概念性 struct __coroutine_frame { __coroutine_state state; // 状态机当前状态 promise_type promise; // 承诺对象 int local_variable_a; // 你的局部变量 std::string local_variable_b; // ... 参数和其他信息 void (*resume_fn)(__coroutine_frame*); // 恢复时跳转的函数指针 };3.2 Promise Type协程的“控制面板”promise_type是一个必须由你定义或使用库提供的的类型。编译器通过它来与你的代码交互管理协程的生命周期。你可以把它想象成协程状态机的“控制面板”。struct MyTaskPromise { // 协程开始时调用 MyTask get_return_object() { return MyTask{*this}; } std::suspend_always initial_suspend() noexcept { return {}; } // 启动后立即挂起 std::suspend_always final_suspend() noexcept { return {}; } // 结束后挂起便于清理 void unhandled_exception() { /* 处理异常 */ } void return_void() { /* 协程通过co_return;返回时调用 */ } // 如果协程返回T类型值则需要定义 T return_value(T) };get_return_object: 决定协程调用处得到什么对象通常是一个句柄。initial_suspend/final_suspend: 决定协程在开始后和结束前是否挂起。std::suspend_always表示挂起std::suspend_never表示不挂起。通常initial_suspend挂起可以让调用者获得协程句柄后再决定何时启动。unhandled_exception: 协程内发生未捕获异常时的处理入口。return_void/return_value: 对应co_return;或co_return value;。3.3 Coroutine Handle协程的“遥控器”std::coroutine_handle是一个不透明指针指向协程帧。通过它你可以手动恢复(resume())或销毁(destroy())一个挂起的协程。它通常由promise_type::get_return_object()返回的对象的某个成员持有。class MyTask { private: std::coroutine_handlepromise_type handle_; public: explicit MyTask(promise_type promise) : handle_(std::coroutine_handlepromise_type::from_promise(promise)) {} ~MyTask() { if (handle_) handle_.destroy(); } void resume() { if (handle_ !handle_.done()) handle_.resume(); } bool done() const { return !handle_ || handle_.done(); } };3.4 Awaitable 与 Awaiter挂起与恢复的协议这是协程异步能力的灵魂。co_await expr中的expr必须是一个可等待对象Awaitable。一个类型要成为Awaitable需要实现三个关键函数或者通过operator co_await重载来返回一个等待器Awaiter。Awaiter是实际干活的对象struct MyAwaiter { // 1. 是否立即挂起返回false则协程不挂起继续执行。 bool await_ready() const noexcept { return false; } // 2. 挂起协程前调用。用于安排异步操作返回一个coroutine_handle给调度器。 // 当异步操作完成时调度器应调用此handle.resume()来恢复本协程。 void await_suspend(std::coroutine_handle awaiting_coroutine) noexcept { // 例如将awaiting_coroutine注册到某个I/O多路复用器或线程池 scheduler::schedule_resume(awaiting_coroutine); } // 3. 协程恢复后调用其返回值就是co_await expr表达式的结果。 int await_resume() noexcept { return 42; } };await_ready: 检查是否已经就绪。如果为true则协程不会挂起直接执行await_resume并继续。await_suspend: 挂起前调用。这是实现非阻塞异步的关键。你可以在这里把恢复协程的句柄(awaiting_coroutine)交给一个异步IO框架、一个定时器或者另一个线程然后立即返回。当前线程就此解脱。await_resume: 当协程被外部力量如IO完成事件恢复后这个函数被调用它的返回值就是co_await表达式的结果。标准库已经提供了两个简单的Awaitablestd::suspend_always总是挂起和std::suspend_never从不挂起。它们通常用于promise的初始/最终挂起控制。4. 从零手写一个最小协程类型理论说再多不如动手。让我们抛开复杂的库从零构建一个最简单的协程类型LazyTask它不做任何异步IO只演示最基本的挂起、恢复和值传递。这将彻底揭开协程的神秘面纱。4.1 定义Promise Type和Task我们的目标是实现一个协程调用后它挂起当我们手动resume()它时它执行到下一个co_await或结束。#include coroutine #include iostream #include optional templatetypename T struct LazyTask { // 1. 定义内部的promise_type这是编译器寻找的约定名称 struct promise_type { // 协程的“产出值”存放在这里 std::optionalT value_; // 编译器调用获取返回给调用者的对象 LazyTask get_return_object() { // 从promise对象构造出coroutine_handle再封装进LazyTask return LazyTask{ std::coroutine_handlepromise_type::from_promise(*this) }; } // 初始挂起策略总是挂起这样调用者拿到LazyTask时协程还没开始执行。 std::suspend_always initial_suspend() noexcept { return {}; } // 最终挂起策略总是挂起这样我们能在协程外部检查是否完成并处理结果。 std::suspend_always final_suspend() noexcept { return {}; } // 异常处理简化版直接终止 void unhandled_exception() { std::terminate(); } // 处理 co_return value; void return_value(T value) { value_ std::move(value); } // 处理 co_return; (void) void return_void() { value_ std::optionalT{}; // 对于非void特化这可能需要不同处理 } }; // 2. LazyTask 类成员 std::coroutine_handlepromise_type handle_; // 构造函数和析构函数 explicit LazyTask(std::coroutine_handlepromise_type h) : handle_(h) {} ~LazyTask() { if (handle_) { handle_.destroy(); } } // 禁止拷贝允许移动 LazyTask(const LazyTask) delete; LazyTask operator(const LazyTask) delete; LazyTask(LazyTask other) noexcept : handle_(std::exchange(other.handle_, nullptr)) {} LazyTask operator(LazyTask other) noexcept { if (this ! other) { if (handle_) handle_.destroy(); handle_ std::exchange(other.handle_, nullptr); } return *this; } // 3. 提供给用户的接口 // 恢复协程执行一次 bool resume() { if (!handle_ || handle_.done()) { return false; } handle_.resume(); return !handle_.done(); } // 获取协程的最终结果必须在协程完成后调用 T get_result() { if (!handle_.done()) { // 更好的做法是抛异常或返回expected throw std::runtime_error(Coroutine not finished!); } return std::move(handle_.promise().value_.value()); } // 检查是否已完成 bool is_done() const { return !handle_ || handle_.done(); } }; // 特化 void 版本 template struct LazyTaskvoid { struct promise_type { LazyTask get_return_object() { /* 类似实现 */ } std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() { std::terminate(); } void return_void() {} // void 版本 }; // ... 其他成员类似但get_result()改为void };4.2 使用我们的LazyTask现在我们可以编写一个使用LazyTask的协程了。LazyTaskint simple_coroutine() { std::cout Coroutine started, about to suspend...\n; co_await std::suspend_always{}; // 第一个挂起点 std::cout Coroutine resumed for the first time.\n; co_await std::suspend_always{}; // 第二个挂起点 std::cout Coroutine resumed for the second time, about to return.\n; co_return 42; // 返回值并结束 } int main() { auto task simple_coroutine(); // 此时协程已创建但因initial_suspend而挂起 std::cout Got task, coroutine is suspended.\n; std::cout \n--- First resume ---\n; bool more1 task.resume(); // 恢复执行到第一个co_await之后第二个co_await之前 std::cout After first resume, more to do? std::boolalpha more1 \n; std::cout \n--- Second resume ---\n; bool more2 task.resume(); // 恢复执行到第二个co_await之后co_return之前 std::cout After second resume, more to do? more2 \n; std::cout \n--- Third resume (to completion) ---\n; bool more3 task.resume(); // 执行co_return协程结束。resume()内部会发现done()为true。 std::cout After third resume, more to do? more3 \n; std::cout Is task done? task.is_done() \n; // 获取结果 try { int result task.get_result(); std::cout Coroutine result: result std::endl; } catch (const std::exception e) { std::cout Error getting result: e.what() std::endl; } return 0; }运行这个程序你会看到清晰的步骤输出直观地展示了协程在控制下的分步执行。这完美印证了“状态机”模型每个co_await点就是一个状态切换点。4.3 实现一个简单的异步Awaitable为了让我们的LazyTask更有用我们实现一个简单的SleepAwaitable模拟异步延迟。#include chrono #include thread #include functional #include queue #include atomic class SimpleScheduler { using Clock std::chrono::steady_clock; using TimePoint Clock::time_point; using CoroHandle std::coroutine_handle; struct ScheduledTask { TimePoint wake_time; CoroHandle handle; bool operator(const ScheduledTask other) const { return wake_time other.wake_time; } }; std::priority_queueScheduledTask, std::vectorScheduledTask, std::greater queue_; std::mutex mutex_; std::condition_variable cv_; std::atomicbool stop_{false}; std::thread worker_; void run_loop() { while (!stop_.load(std::memory_order_relaxed)) { std::unique_lock lock(mutex_); if (queue_.empty()) { cv_.wait(lock, [this]{ return stop_.load() || !queue_.empty(); }); if (stop_) break; } auto now Clock::now(); auto top queue_.top(); if (top.wake_time now) { auto handle top.handle; queue_.pop(); lock.unlock(); // 在持有锁的情况下恢复协程是危险的可能死锁 if (handle !handle.done()) { handle.resume(); // 恢复被挂起的协程 } } else { cv_.wait_until(lock, top.wake_time); } } } public: SimpleScheduler() : worker_([this]{ run_loop(); }) {} ~SimpleScheduler() { stop_.store(true); cv_.notify_all(); if (worker_.joinable()) worker_.join(); } void schedule_resume(CoroHandle handle, std::chrono::milliseconds delay) { auto wake_time Clock::now() delay; { std::lock_guard lock(mutex_); queue_.push(ScheduledTask{wake_time, handle}); } cv_.notify_one(); } static SimpleScheduler global() { static SimpleScheduler instance; return instance; } }; struct SleepAwaiter { std::chrono::milliseconds duration; SimpleScheduler scheduler SimpleScheduler::global(); bool await_ready() const noexcept { return duration.count() 0; } void await_suspend(std::coroutine_handle handle) noexcept { scheduler.schedule_resume(handle, duration); } void await_resume() const noexcept {} // 无返回值 }; auto sleep_for(std::chrono::milliseconds ms) { return SleepAwaiter{ms}; } // 使用示例 LazyTaskvoid timed_coroutine() { std::cout [Start] Time: std::chrono::system_clock::now().time_since_epoch().count() \n; co_await sleep_for(std::chrono::milliseconds(100)); std::cout [After 100ms] Time: std::chrono::system_clock::now().time_since_epoch().count() \n; co_await sleep_for(std::chrono::milliseconds(200)); std::cout [After another 200ms] Time: std::chrono::system_clock::now().time_since_epoch().count() \n; co_return; }在这个例子中co_await sleep_for(...)时await_suspend将恢复协程的句柄和延迟时间提交给一个全局的简易调度器。调度器在另一个线程中等待指定时间后调用handle.resume()。主线程在调用task.resume()启动协程后协程很快因co_await挂起主线程可以继续做其他事。这就是异步非阻塞的雏形。5. 实战将同步阻塞操作改造为协程异步操作理解了基本机制后我们来看一个更贴近实际的例子将一次同步的HTTP GET请求假设使用阻塞式socket改造为基于协程的异步操作。这里我们使用一个虚构的、支持回调的异步HTTP客户端库作为底层驱动。假设我们有一个传统的异步HTTP客户端class AsyncHttpClient { public: using Callback std::functionvoid(std::error_code, std::string); void get_async(const std::string url, Callback cb); };它的get_async函数会立即返回并在请求完成或出错时调用回调函数。5.1 为异步客户端包装Awaitable我们的目标是在协程里这样写LazyTaskstd::string fetch_url(const std::string url) { AsyncHttpClient client; // 希望这里能“等待”异步操作完成而不阻塞线程 std::string response co_await async_get(client, url); std::cout Got response of size: response.size() std::endl; co_return response; }我们需要实现async_get这个辅助函数它返回一个Awaitable。struct AsyncHttpGetAwaiter { AsyncHttpClient client; std::string url; std::coroutine_handle continuation; // 用于保存恢复本协程的句柄 std::error_code error_; std::string result_; // 一个简单的包装器用于将成员函数绑定为回调 struct CallbackWrapper { AsyncHttpGetAwaiter* self; void operator()(std::error_code ec, std::string data) { self-error_ ec; self-result_ std::move(data); // 异步操作完成恢复挂起的协程 if (self-continuation) { self-continuation.resume(); } } }; bool await_ready() const noexcept { return false; } // 总是挂起因为操作是异步的 void await_suspend(std::coroutine_handle handle) noexcept { continuation handle; // 保存句柄以便回调中恢复 client.get_async(url, CallbackWrapper{this}); // 函数立即返回当前协程被挂起线程可执行其他任务 } // await_resume 返回最终结果这里我们返回string或抛出错误 std::string await_resume() { if (error_) { throw std::system_error(error_); } return std::move(result_); } }; auto async_get(AsyncHttpClient client, const std::string url) { return AsyncHttpGetAwaiter{client, url}; }5.2 整合与调度现在我们的fetch_url协程就可以工作了。但这里有一个关键点谁在驱动这些协程当异步HTTP请求完成回调触发并调用continuation.resume()时这个恢复操作发生在HTTP库的网络IO线程可能是某个后台线程中。这意味着协程的恢复可能不在主线程你需要考虑线程安全性。一个更健壮的模型是在回调中不直接resume()而是将continuation提交回一个主线程或特定的协程调度器如我们之前写的SimpleScheduler由调度器在正确的线程上下文中恢复它。这涉及到更复杂的调度策略也是像cppcoro、asio等库的核心价值之一。// 改进版将恢复任务提交到主线程调度器 void await_suspend(std::coroutine_handle handle) noexcept { continuation handle; client.get_async(url, [this](std::error_code ec, std::string data){ error_ ec; result_ std::move(data); // 将恢复操作提交到主线程调度队列 MainThreadScheduler::post([cont this-continuation]() mutable { if (cont !cont.done()) { cont.resume(); } }); }); }6. 常见陷阱、调试技巧与性能考量即使理解了原理在实际使用C20协程时依然会踩不少坑。下面是一些血泪教训。6.1 生命周期管理悬挂引用与内存泄漏这是协程最危险的地方。协程帧在堆上其生命周期可能长于创建它的作用域。陷阱1在协程内捕获局部变量的引用或指针。LazyTaskvoid dangerous_coroutine() { int local_value 42; // 启动一个异步操作并试图捕获局部变量的引用 co_await some_async_op().then([local_value](auto result){ std::cout local_value; // 灾难协程可能已挂起很久local_value早已销毁。 }); }解决按值捕获[]或[local_value]或者确保协程帧从而其局部变量的生命周期覆盖所有回调。陷阱2忘记销毁协程句柄coroutine_handle导致内存泄漏。解决遵循RAII原则像我们LazyTask所做的那样在包装类的析构函数中调用handle.destroy()。确保协程句柄的所有权清晰。6.2 调试难题协程的调试体验目前还比较差。函数调用栈在挂起时会断裂你看到的栈回溯可能只是调度器或回调的栈而不是原始的协程逻辑链。技巧大量使用日志在协程开始、每个co_await前后、恢复时、结束时打日志这是最可靠的跟踪手段。使用支持协程的调试器最新版本的Visual Studio、CLion和某些GDB/LLDB插件开始提供协程帧查看功能。你可以尝试打印coroutine_handle的地址或者查看特殊变量如GCC/Clang的__coroutine_frame。简化复现当遇到诡异问题时尝试创建一个最小的、不涉及复杂异步库的复现例子排除调度器或其他库的影响。6.3 性能考量堆分配每次协程调用都涉及一次堆内存分配协程帧。对于性能极其敏感的微小协程这可能成为瓶颈。编译器可能会进行优化如“协程省略”coroutine elision但不要完全依赖。对于高频调用的简单操作需权衡是否使用协程。动态分配大小协程帧大小取决于局部变量和临时对象的数量和大小。一个包含大std::vector的协程其帧也会很大。类型擦除与间接调用通过coroutine_handle恢复协程涉及一次间接函数调用。虽然开销很小但在纳秒级循环中仍需注意。与现有异步库的整合成本如我们所见将基于回调的异步API包装成Awaitable需要一些样板代码。虽然一劳永逸但初期有开发成本。6.4 与其它并发模型的对比与选择vs 回调Callback协程解决了“回调地狱”问题用同步写法表达异步逻辑代码更清晰、更易维护。这是协程最大的胜利。vs Future/Promisestd::future缺乏组合能力且.get()会阻塞。协程通过co_await可以自然地组合多个异步操作。C23的std::future可能会增加协程支持。vs 线程std::thread线程是操作系统资源创建和上下文切换成本高。协程是用户态线程数量可达百万级切换成本极低。协程适用于I/O密集型高并发场景线程适用于CPU密集型计算。vs 其他语言的协程Go goroutine, Python asyncioGo的goroutine有强大的运行时调度器。Python的asyncio基于事件循环。C20协程是更底层的机制不提供调度器这给了开发者最大的灵活性但也需要自己或借助库如asio来处理调度。7. 总结与最佳实践建议走到这里你应该已经不再觉得C20协程是黑魔法了。它就是一个编译器辅助实现的、高效的状态机。要用好它我个人的经验是从理解“状态机”开始每当写一个协程心里默念它会被编译器转换成一个大switch的状态机。这能帮你理清局部变量的生命周期和挂起点的含义。善用现有库除非有极特殊需求否则不要从头实现一整套协程调度和Awaitable。asio是当前生产级C协程生态的绝对主力它提供了完善的调度器、网络I/O、定时器等Awaitable。cppcoro库则提供了一系列通用的协程原语如生成器generatorT、单次事件single_consumer_event等非常适合学习或在非网络场景中使用。明确协程的调度上下文搞清楚你的协程会在哪个线程被挂起又在哪个线程被恢复。混用线程和协程时数据竞争和死锁问题依然存在。考虑使用线程安全的队列或专门的调度器来跨线程恢复协程。生命周期生命周期生命周期重要的事情说三遍。用RAII对象如LazyTask严格管理协程句柄。避免在协程中捕获可能失效的引用。性能热点处保持警惕在每秒处理数十万请求的核心路径上评估协程帧分配和切换的开销。对于极其简单的操作直接使用回调或手写状态机可能更高效。渐进式采用不必一次性将整个项目重构成协程。可以从新的、独立的异步模块开始尝试或者将性能瓶颈不明显但逻辑复杂的回调链改用协程重写体验其可读性带来的好处。C20协程是一把锋利的瑞士军刀它解开了高性能异步编程的一个死结。虽然入门曲线陡峭基础设施也还在完善中但其代表的“异步代码同步写”的思想无疑是未来的方向。理解其“函数状态机”的本质是你驾驭这把利器的第一步。