C++coroutines协程基础与挂起

2026-04-11 21:00:30 220阅读 0评论

C++协程不是“线程简化版”,挂起的本质是状态机的手动拍快门

写过 co_await 的人,大概率都经历过这种时刻:代码跑起来没问题,但调试时发现控制流像被施了隐身术——函数进了又出、出了又进,变量值在不同调用间“凭空”延续。这不是编译器抽风,而是协程在悄悄把你的函数拆成一帧帧状态快照,而挂起(suspend)就是你亲手按下的暂停键

C++20 的协程不是语法糖,也不是线程的轻量替代品。它不自动调度,不隐式并发,甚至不保证栈切换。它的核心契约只有三条:你能定义“函数执行到哪该停”,能决定“停完之后去哪继续”,以及“怎么存住当前上下文”。其余一切——调度谁来干?内存谁来管?异常怎么流转?——全交给你。

先看最朴素的挂起点:co_await 表达式。很多人以为 co_await expr 就是“等 expr 完成”,其实它真正做的是三件事:

  1. 调用 expr.await_ready() 判断是否立刻可继续(比如一个已完成的任务,直接跳过挂起);
  2. 若需挂起,调用 expr.await_suspend(handle),传入一个 coroutine_handle——这就是你的“续命凭证”;
  3. 最后调用 expr.await_resume() 拿回结果,无论中间挂了几次。

关键来了:await_suspend 的返回值决定了协程接下来的命运。它有三种合法返回:

  • void:挂起后立即让出控制权,后续必须由外部显式恢复(比如另一个线程调用 handle.resume());
  • booltrue 表示已挂起,false 表示取消挂起、继续执行;
  • coroutine_handle<>直接跳转到另一个协程继续运行(常用于尾调用优化,避免栈增长)。

这第三种返回值,是教科书里很少提、但实战中极有用的一招。比如实现一个无栈协程调度器时,你可以让当前协程挂起的同时,顺手 resume() 下一个待执行任务——整个过程不经过事件循环中转,零开销接力。

挂起不是休眠,它不阻塞线程。协程挂起时,栈帧并未销毁,局部变量仍驻留在分配的协程帧(coroutine frame)里。这个帧通常堆上分配(除非用 std::coroutine_traits 特化为栈分配),里面存着所有 auto 变量、this 指针、甚至 try/catch 的异常处理信息。所以你能放心在 co_await 前后读写同一个 int counter,它不会“失忆”。

但这也带来一个隐形陷阱:协程帧的生命周期必须长于协程本身。常见翻车现场是 co_await 一个栈上创建的 awaiter 对象——它一出作用域就被析构,而 await_suspend 可能还拿着它的地址去注册回调。解决方法很简单:把 awaiter 搬到堆上,或确保其生存期覆盖整个挂起/恢复周期。

再来看一个真实痛点:如何让协程在某个条件满足前一直挂起?比如等待一个原子标志位变为 true。有人会写:

while (!flag.load()) co_await std::suspend_always{};

这看似合理,实则危险——每次循环都触发一次挂起/恢复,CPU 空转,且可能饿死其他任务。正确做法是把轮询逻辑下沉到 await_suspend

struct wait_for_flag {
    std::atomic<bool>& flag;
    bool await_ready() { return flag.load(); }
    void await_suspend(std::coroutine_handle<> h) {
        // 注册回调:flag 变 true 时 resume h
        register_wake_up_callback(flag, h);
    }
    void await_resume() {}
};

这里 await_ready() 是第一道快速通道,await_suspend() 才是真正的“挂起动作发生地”。挂起的决策和执行是分离的——前者回答“要不要停”,后者决定“停了之后怎么办”。

最后说个容易被忽略的事实:co_return 也会触发挂起。当协程执行到 co_return expr;,编译器会先调用 promise.return_value(expr),再调用 promise.final_suspend()。而 final_suspend() 的返回值,决定了协程结束时是否自动销毁帧。如果你返回 std::suspend_always,协程终止后帧依然存在,你可以安全地检查 handle.done() 或手动 destroy() 它——这对实现 generator<T> 这类需要多次迭代的类型至关重要。

协程的价值,从来不在“写起来多像同步代码”,而在于你获得了对控制流切片的完全主权。挂起不是让编译器替你管理状态,而是你亲手把函数切成若干段,每一段的启停、跳转、数据留存,都由你定义。它笨重、裸露、需要你直面内存与调度,但也正因如此,它能在嵌入式中断处理、游戏引擎帧同步、数据库连接池这些对确定性要求极高的场景里,给出线程模型无法企及的精确控制。

下次看到 co_await,别只把它当“异步等待”。想想你刚按下的那个暂停键——它后面连着的,是一台你亲手组装、随时可调校的状态机。

文章版权声明:除非注明,否则均为Dark零点博客原创文章,转载或复制请以超链接形式并注明出处。

发表评论

快捷回复: 表情:
验证码
评论列表 (暂无评论,220人围观)

还没有评论,来说两句吧...

目录[+]