C++20协程co_yield返回值机制:从原理到双向通信生成器实战

📅 2026/7/14 18:54:58
C++20协程co_yield返回值机制:从原理到双向通信生成器实战
1. 项目概述为什么我们需要深入理解 co_yield如果你在C20的协程世界里摸爬滚打了一阵子大概率已经对co_await和co_return有了初步的体感。前者是“挂起等待”后者是“结束返回”逻辑上还算清晰。但当你看到co_yield时可能会有点懵它看起来像co_return但又不一样它也会产生一个值但又不像co_await那样去等待。这个关键字恰恰是构建生成器Generator这类“懒序列”协程的灵魂所在也是从“理解协程”到“用好协程”的关键一步。简单来说co_yield的核心职责是在协程执行过程中向调用者“产出”一个值然后立即挂起自身等待下一次被恢复。它不结束协程的生命周期只是按下了暂停键并把一个中间结果“递”了出去。这个机制完美契合了生成无限序列、处理流式数据、实现惰性求值等场景。但它的返回值——也就是co_yield expression这个表达式本身的值——却常常被忽略或误解。这个返回值直接关联到协程的恢复机制理解它你才能真正掌控协程的流程写出健壮、高效的异步或惰性代码。我见过不少开发者能照着例子写出一个生成斐波那契数列的协程但一旦需要根据co_yield的返回值来决定下一次产出什么或者处理外部传入的恢复参数时就卡壳了。这背后就是对co_yield返回值机制的不清晰。所以这篇文章我们不只讲语法更要深入到编译器转换后的代码逻辑结合实战案例把co_yield从原理到应用特别是它的返回值彻底讲透。无论你是想实现一个复杂的异步数据管道还是仅仅想优雅地生成一个序列这里的内容都会是你工具箱里的利器。2. 核心原理co_yield 的“两面性”与编译器魔法要理解co_yield的返回值我们必须先把它拆解成两个部分来看一是它向协程外部“产出”了什么二是它自身作为一个表达式“得到”了什么。很多混淆都源于没有区分清楚这两者。2.1 co_yield 的语法糖与底层转换首先co_yield expression在C20中是一个语法糖。根据标准co_yield e本质上等价于co_await promise.yield_value(e)。这是理解一切的基础。让我们把这个等价关系展开promise.yield_value(e)这是你定义的协程承诺类型promise_type中的一个成员函数。当执行到co_yield e时编译器会调用promise.yield_value(e)。这个函数的返回值类型决定了后续co_await的行为也间接关联到co_yield表达式的最终类型。通常这个函数会负责将值e存储到某个调用者能访问的地方比如 promise 对象自身的一个成员变量里。co_await作用于上述返回值然后对这个函数的返回值进行co_await操作。这意味着promise.yield_value的返回类型必须满足co_await的要求即存在相应的await_ready,await_suspend,await_resume函数或者是可等待体。关键点来了co_yield这个表达式最终的值就是co_await promise.yield_value(e)这个完整表达式的值。而这个值是由promise.yield_value(e)返回的可等待对象的await_resume()函数的返回值决定的。2.2 返回值的来源await_resume()为了更直观我们来看一个编译器可能进行的转换概念上的伪代码。假设我们有这样一个协程函数generatorint my_coro() { int value 10; // 重点在这一行 auto received co_yield value; // ... 后续可能使用 received }编译器会将其处理成类似下面的形式极度简化只为说明原理generatorint my_coro() { // 编译器生成的协程帧初始化... __coro_frame* frame ...; auto promise frame-promise; // 对应 int value 10; int value 10; // 对应 auto received co_yield value; // 1. 调用 promise.yield_value(value) auto awaitable promise.yield_value(std::move(value)); // 2. 检查 awaitable.await_ready()... // 3. 如果未就绪调用 awaitable.await_suspend(coro_handle)挂起协程。 // **此时控制权返回给调用者/恢复者**。 // 4. 当协程被恢复后继续执行到这里 auto received awaitable.await_resume(); // -- co_yield 表达式的值来源于此 // ... 后续代码 }看明白了吗co_yield value这行代码执行后变量received被赋的值并不是我们产出的value(10)而是awaitable.await_resume()的返回值。这完全是两回事。value(10) 通过promise.yield_value(10)被传递出去最终很可能被协程的调用者比如一个generator迭代器通过promise对象获取到。而received接收到的是协程在下一次被恢复时由恢复者resumer通过某种方式传递给await_resume()的值。注意这是一个极其重要的思维转换。co_yield向外发送一个值同时它自身也期待或可以接收一个来自外部的值作为本次“挂起-恢复”周期的一个反馈信号。这个反馈信号就是co_yield表达式的返回值。2.3 默认行为与自定义控制那么默认情况下await_resume()返回什么呢这取决于promise.yield_value返回的可等待体类型。标准库中std::suspend_always和std::suspend_never的await_resume()都返回void。这意味着如果你使用最简单的、返回std::suspend_always的yield_value那么co_yield expr这个表达式本身的类型就是void。你无法用它来接收值。struct my_promise { auto yield_value(int val) { // 存储 val 到某个地方... stored_value val; // 返回一个总是挂起的可等待体 return std::suspend_always{}; } int stored_value; }; generatorint coro() { // 下面这行会编译错误因为 co_yield 10 是 void 表达式 // auto x co_yield 10; // Error: cannot assign void to auto x co_yield 10; // 正确但无法获取返回值 }要想让co_yield有返回值你就必须自定义一个可等待体类型并在其await_resume()中返回你想要的类型。3. 实战解析构建一个带双向通信的生成器理解了原理我们通过一个完整的例子来巩固。目标是实现一个resumable_generatorT, U它不仅能产出T类型的值还能在每次co_yield后接收一个U类型的值作为反馈。3.1 设计承诺类型与可等待体首先定义我们的可等待体yield_awaiter。它需要持有从外部传入的、用于恢复的值的引用或指针。templatetypename U class yield_awaiter { public: // 构造函数接收一个指向“输入值存储位置”的指针 explicit yield_awaiter(U* input_ptr) : input_ptr_(input_ptr) {} // 通常我们直接挂起让调用者有机会提供输入值 bool await_ready() const noexcept { return false; } // 挂起协程。h 是当前协程的句柄调用者会保存它以便后续恢复。 void await_suspend(std::coroutine_handle h) noexcept { // 这里通常什么都不做只是挂起。调用者持有 h可以在填充 input_ptr_ 后恢复它。 } // 恢复时将存储的输入值返回作为 co_yield 表达式的结果。 U await_resume() noexcept { // 假设调用者已经将值写入了 *input_ptr_ return std::move(*input_ptr_); // 移动出去 } private: U* input_ptr_; // 指向一个外部可写的存储位置 };接下来设计承诺类型resumable_generator_promise。它需要管理产出的值、输入的值以及提供yield_value函数。templatetypename T, typename U class resumable_generator_promise { public: using value_type T; using input_type U; // 产出的值存储在这里 std::optionalT yielded_value_; // 输入的值存储在这里由恢复者写入 std::optionalU input_value_; // 当协程中调用 co_yield value 时编译器调用此函数 auto yield_value(T value) { // 1. 存储产出的值 yielded_value_ std::move(value); // 2. 返回一个可等待体该可等待体能获取到 input_value_ 的地址 return yield_awaiterU{input_value_.value()}; // 注意这里假设恢复前会先写入 // 更健壮的做法是使用指针并检查 optional 是否有值。这里为清晰先简化。 } // 其他必要的 promise 接口 auto get_return_object() { ... } auto initial_suspend() noexcept { return std::suspend_always{}; } auto final_suspend() noexcept { return std::suspend_always{}; } void return_void() {} void unhandled_exception() { std::terminate(); } // 提供给外部迭代器/恢复者获取产出值的接口 T value() { return *yielded_value_; } bool finished() const { return !yielded_value_.has_value(); } // 简化判断 // 提供给外部迭代器/恢复者设置输入值的接口 void set_input(U input) { input_value_ std::move(input); } };3.2 实现生成器与迭代器有了承诺类型我们就可以定义resumable_generator类及其迭代器。迭代器的operator将负责恢复协程并可以选择性地提供一个输入值。templatetypename T, typename U class resumable_generator { public: using promise_type resumable_generator_promiseT, U; // 迭代器类 class iterator { public: using iterator_category std::input_iterator_tag; using value_type T; using difference_type std::ptrdiff_t; using pointer T*; using reference T; explicit iterator(std::coroutine_handlepromise_type handle nullptr) : handle_(handle) { // 如果句柄有效且未结束确保我们有当前值 if (handle_ !handle_.done()) { // 首次解引用前值已经在 initial_suspend 后由协程产出 // 不我们需要先移动迭代器operator来启动/恢复协程。 // 所以初始构造时current_value_ 可能无效。 } } // 前缀 iterator operator() { if (!handle_ || handle_.done()) { handle_ nullptr; return *this; } // 在恢复协程前可以清空之前的产出值并准备输入值 handle_.promise().yielded_value_.reset(); // **关键步骤恢复协程** handle_.resume(); // 恢复后如果协程结束则将句柄置空 if (handle_.done()) { handle_ nullptr; } return *this; } // 一个允许传入输入值的 operator 变体 iterator operator(U input) { if (!handle_ || handle_.done()) { handle_ nullptr; return *this; } handle_.promise().yielded_value_.reset(); // **在恢复前设置输入值** handle_.promise().set_input(std::move(input)); handle_.resume(); if (handle_.done()) { handle_ nullptr; } return *this; } // 解引用获取产出的值 reference operator*() const { return handle_.promise().value(); } pointer operator-() const { return handle_.promise().value(); } bool operator(const iterator other) const { return handle_ other.handle_; } bool operator!(const iterator other) const { return !(*this other); } private: std::coroutine_handlepromise_type handle_; }; explicit resumable_generator(std::coroutine_handlepromise_type handle) : handle_(handle) {} ~resumable_generator() { if (handle_) handle_.destroy(); } iterator begin() { if (!handle_ || handle_.done()) return iterator{}; // 启动协程从 initial_suspend 点恢复 handle_.resume(); if (handle_.done()) return iterator{}; return iterator{handle_}; } iterator end() { return iterator{}; } private: std::coroutine_handlepromise_type handle_; };3.3 使用示例一个可交互的计数器现在我们可以使用这个生成器了。假设我们想要一个计数器每次产出当前值后能接收一个步长int用于决定下一次增加多少。resumable_generatorint, int interactive_counter(int start) { int current start; while (true) { // co_yield 产出当前值并等待一个步长作为返回值 int step co_yield current; // 这里的 step 来自 await_resume() if (step 0) { co_return; // 如果步长为0结束 } current step; } } int main() { auto gen interactive_counter(10); // 从10开始 auto it gen.begin(); // 启动协程执行到第一个 co_yield产出10挂起 std::cout First value: *it std::endl; // 输出 10 // 使用带输入参数的 operator传入步长 5 it.operator(5); // 恢复协程step被赋值为5current变为15循环产出15挂起 std::cout After adding 5: *it std::endl; // 输出 15 it.operator(-3); // 传入步长 -3current变为12产出12 std::cout After subtracting 3: *it std::endl; // 输出 12 it.operator(0); // 传入步长0协程内部判断后 co_return协程结束 // 此时 it 应该等于 gen.end() std::cout Generator finished. std::endl; return 0; }这个例子清晰地展示了co_yield返回值的用途它建立了一个从协程调用者到协程内部的反向通道。协程不再是单向地吐出数据而是可以接收外部的控制信号从而实现更灵活的交互逻辑。实操心得在设计这类双向生成器时要特别注意生命周期和状态管理。input_value_这个optional在await_resume被调用时必须有值否则就是未定义行为。一种更安全的模式是在await_suspend中不立即挂起而是通过一个同步原语如std::future/std::promise等待调用者提供输入值但这会引入阻塞失去了协程的异步优势。更常见的无阻塞模式是调用者先设置好输入值再恢复协程就像我们例子中那样。这要求调用者和协程之间有一个清晰的协议。4. 深入细节co_yield 返回值的类型推导与控制流4.1 类型系统如何工作co_yield expr的类型严格由promise.yield_value(expr)返回的可等待体类型的await_resume()返回值类型决定。编译器会进行完整的类型推导。struct MyAwaiter { bool await_ready(); void await_suspend(std::coroutine_handle); double await_resume(); // co_yield 表达式类型为 double }; struct MyPromise { MyAwaiter yield_value(int); }; // 在协程中auto x co_yield 42; // x 的类型是 double这意味着你可以通过定制await_resume来返回任何类型甚至是void、引用或复杂类型。这为协程的流程控制提供了极大的灵活性。4.2 利用返回值进行流程控制co_yield的返回值最常见的用途就是流程控制。除了上面例子中的“步长”控制还可以用于暂停/继续信号返回一个bool指示协程是否应该继续执行。错误码/状态传递返回一个std::error_code或枚举让协程知道外部环境的状态变化。配置更新返回一个配置结构体动态调整协程内部的行为参数。例如一个网络数据包生成器协程可以根据co_yield返回的“带宽限制”值来调整发包速率。struct Packet { /* ... */ }; struct RateLimit { int packets_per_sec; }; resumable_generatorPacket, RateLimit packet_generator() { RateLimit limit{1000}; // 默认速率 while (true) { // 产出一个数据包并获取最新的速率限制 limit co_yield generate_packet(limit); // 根据新的 limit 调整内部状态或等待 std::this_thread::sleep_for(std::chrono::milliseconds(1000 / limit.packets_per_sec)); } }4.3 与 co_await 的对比很多人会混淆co_yield和co_await因为它们都涉及挂起。但它们的语义焦点不同co_await焦点是“等待”。它等待某个异步操作完成其返回值是该操作的结果。它是消费者消费一个未来的值。co_yield焦点是“产出”。它产出一个值给调用者并挂起以等待可能的反馈。它是生产者生产一个现在的值并可能消费一个反馈。在co_await中你关心的是等待的对象和它的结果。在co_yield中你关心的是产出的值以及可选的恢复时带回的反馈值。它们的返回值机制类似都来自await_resume但使用场景和意图截然不同。5. 常见陷阱与性能优化指南5.1 生命周期与悬垂引用这是使用co_yield返回值时最容易出错的地方。await_resume()返回的通常是存储在 promise 对象或协程帧中的值。你必须确保在协程恢复执行、使用这个返回值时它所引用的内存仍然是有效的。陷阱示例generatorstd::string_view bad_coro() { std::string local_str hello; // 错误co_yield 返回一个 string_view它指向 local_str。 // 但协程挂起后local_str 作为局部变量可能已经销毁如果编译器优化或协程帧布局变化。 std::string_view sv co_yield local_str; // 危险 // 使用 sv... 可能导致未定义行为。 }安全做法按值返回对于内置类型或小类型await_resume()直接按值返回。返回协程帧内成员的引用如果返回值是存储在 promise 对象或协程帧长期生命周期对象中的成员可以返回引用但要确保协程生命周期内它一直有效。避免返回局部变量的引用或指针在yield_value或await_resume中绝对不要返回临时对象的地址或引用。5.2 异常安全co_yield表达式本身可能抛出异常。异常可能来自expr的求值。promise.yield_value(expr)的调用。await_resume()的调用。如果异常在co_yield处抛出协程会被视为因异常而退出调用promise.unhandled_exception()。你需要在自己的 promise 类型中妥善处理异常比如存储异常指针并在迭代器或生成器接口中重新抛出。struct my_promise { ... void unhandled_exception() { stored_exception_ std::current_exception(); } std::exception_ptr stored_exception_; }; // 在迭代器的 operator 中恢复后检查异常 iterator operator() { handle_.resume(); if (handle_.promise().stored_exception_) { std::rethrow_exception(handle_.promise().stored_exception_); } ... }5.3 性能考量避免不必要的拷贝在yield_value中使用移动语义传递产出的值。在await_resume中也考虑按值移动返回而非复制。内联与小对象优化自定义的可等待体如yield_awaiter应该设计为简单的、可内联的类。避免在await_ready、await_suspend、await_resume中进行昂贵的操作。协程帧大小promise_type和所有挂起点的局部变量都会占用协程帧。如果通过co_yield返回值机制传递大的对象考虑使用std::unique_ptr或std::shared_ptr来间接存储避免直接作为成员变量增大协程帧。挂起/恢复开销虽然协程挂起/恢复比线程上下文切换轻量但依然有开销。如果在一个紧密循环中频繁co_yield微小值可能需要评估性能是否满足要求。有时批量产出co_yield std::vectorT{}可能是更好的选择。5.4 与标准库std::generator的协同C23 引入了std::generator它是一个标准的、单向的惰性视图生成器。它的co_yield返回值是void因为它设计用于简单的值序列生成不支持双向通信。如果你需要双向通信就需要像我们上面那样自己实现或者寻找第三方库。了解std::generator的实现有助于理解最佳实践。它的yield_value通常返回std::suspend_always并且通过迭代器的operator*从 promise 中获取产出的值完全不需要co_yield的返回值。这是一种更简单、更安全的模式适用于大多数生成器场景。6. 高级应用场景与模式6.1 实现异步生成器Async Generator结合co_await和co_yield可以创建异步生成器用于处理流式异步数据源如网络套接字、文件流或消息队列。templatetypename T async_generatorT async_read_from_socket(asio::ip::tcp::socket socket) { std::arraychar, 1024 buffer; while (socket.is_open()) { // 异步读取数据co_await 等待读取完成 std::size_t bytes_read co_await socket.async_read_some( asio::buffer(buffer), asio::use_awaitable ); // 将读取到的数据块产出给调用者 co_yield std::string_view(buffer.data(), bytes_read); // 注意这里 co_yield 返回值可能是 void因为我们只产出不需要反馈。 // 但也可以设计为返回一个“是否继续读取”的信号。 } } // 使用 for co_await (const auto chunk : async_read_from_socket(sock)) { ... }6.2 协程间通信管道Pipeline多个协程可以通过co_yield和co_await连接起来形成处理管道。一个协程产出的值作为另一个协程的输入。generatorint producer() { for (int i 0; i 10; i) { co_yield i; } } generatorint transformer(generatorint source) { for co_await (int value : source) { // C20 协程范围 for co_yield value * 2; // 将输入值翻倍后产出 } } generatorint consumer(generatorint source) { int sum 0; for co_await (int value : source) { sum value; // 可以 co_yield 当前累加和作为进度报告 // auto should_continue co_yield sum; } // 最终 co_yield 或 co_return 总和 } // 连接auto p producer(); auto t transformer(std::move(p)); auto c consumer(std::move(t));6.3 状态机与协作式多任务co_yield的返回值非常适合实现显式的状态转移。协程在co_yield处挂起返回一个状态码给调度器调度器根据状态码决定何时以及如何恢复它。enum class TaskState { Ready, WaitingIO, Blocked, Finished }; struct TaskResult { TaskState state; std::optionalint io_resource_id; }; resumable_generatorTaskResult, void some_io_task() { // 状态1: 准备就绪 co_yield TaskState::Ready; // ... 做一些计算 // 状态2: 等待IO并告知需要哪个资源 co_yield TaskState::WaitingIO, 42 /* resource id */; // 假设外部事件恢复了我们表示IO完成 // 状态3: 继续执行 co_yield TaskState::Ready; // ... 最终结束 co_yield TaskState::Finished; } // 一个简单的调度器可以遍历所有任务根据 TaskState 决定是执行、等待还是恢复。这种模式将协程变成了一个可以主动报告状态、被动被调度的“任务”是实现轻量级用户态线程协程调度器的核心思想之一。理解co_yield的返回值就像拿到了协程双向通信的钥匙。它让协程从被动的值生产者变成了能与调用者对话的活跃实体。从简单的可调节序列生成到复杂的异步管道和状态机这一机制提供了底层控制能力。当然能力越大责任越大你需要仔细管理生命周期、异常和性能。希望这篇从原理到实战的拆解能让你下次在代码中写下co_yield时不仅知道它在做什么更清楚它能带来什么以及如何安全高效地驾驭它。