`co_await` 按下暂停键之后:从零看懂 C++20 协程

协程最迷人的地方,是它能用接近同步代码的写法表达异步流程:

cpp 复制代码
auto data = co_await socket.read();
auto result = parse(data);
co_await socket.write(result);

代码从上到下,像普通函数一样自然;但等待网络数据时,线程并没有停在那里。

这看起来像魔法,其实背后只有三件事:编译器生成状态机,协程主动保存现场,运行时在条件满足后恢复它。 本文从这三件事出发,逐步拆开协程如何创建、暂停、调度和恢复,并解释它为什么适合解决高并发 I/O 问题。

1. 协程不是轻量级线程

"轻量级线程"是一个方便但不够准确的比喻。

线程由操作系统调度,拥有独立调用栈,可以在任意指令附近被抢占。协程通常由应用程序调度,没有独立的传统线程栈,只会在约定的暂停点主动交出执行权。

更合适的理解是:

  • 线程是执行代码的工人
  • 协程是可以暂停的任务单
  • 调度器决定哪个工人处理哪张任务单

一个线程可以先执行协程 A。A 等待网络时暂停,这个线程马上转去执行协程 B。数据到达后,A 被放回就绪队列,未来可能由原线程恢复,也可能由另一个线程恢复。

因此,协程减少的不是计算量,而是等待造成的线程占用。

2. 暂停之后,局部变量去了哪里

普通函数的状态主要保存在调用栈中。函数返回后,栈帧销毁,局部变量和执行位置也随之消失。

协程需要在暂停后保留这些信息。编译器会把协程函数改写成状态机,并创建一块协程帧,通常包含:

  • 函数参数;
  • 跨越暂停点仍要使用的局部变量;
  • 当前执行到哪个暂停点;
  • Promise 对象;
  • 恢复和销毁协程所需的管理信息。

例如:

cpp 复制代码
Task download() {
    std::string url = "https://example.com/data";
    auto body = co_await async_get(url);
    save(body);
}

等待 HTTP 响应时,url 之后仍可能被使用,执行位置也必须被记住。它们会随协程状态保存在协程帧中,而不是依赖当前线程的调用栈一直存在。

std::coroutine_handle 可以看作指向协程帧的轻量句柄:

cpp 复制代码
handle.resume();   // 从上次暂停的位置继续执行
handle.done();     // 查询协程是否已经结束
handle.destroy();  // 销毁协程帧

句柄通常只是一个指针大小的对象。真正需要认真管理的是它背后的协程帧。

3. Promise:协程与外部世界的接口

C++20 没有提供唯一的 Task 类型,而是提供了一套让库作者自定义协程行为的协议。只要一个函数使用了 co_awaitco_yieldco_return,编译器就会通过返回类型找到 promise_type

下面是一个只用于解释结构的最小 Task

cpp 复制代码
#include <coroutine>
#include <exception>
#include <utility>

class Task {
public:
    struct promise_type {
        Task get_return_object() {
            return Task{
                std::coroutine_handle<promise_type>::from_promise(*this)
            };
        }

        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() noexcept {}
        void unhandled_exception() { std::terminate(); }
    };

    using Handle = std::coroutine_handle<promise_type>;

    explicit Task(Handle h) : handle_(h) {}
    Task(Task&& other) noexcept
        : handle_(std::exchange(other.handle_, {})) {}
    Task(const Task&) = delete;

    ~Task() {
        if (handle_) handle_.destroy();
    }

    void resume() {
        if (handle_ && !handle_.done()) handle_.resume();
    }

private:
    Handle handle_;
};

这几个函数控制了协程的关键时刻:

  • get_return_object():把新建的协程帧包装成用户能持有的对象;
  • initial_suspend():决定函数创建后立即运行,还是先暂停;
  • final_suspend():决定函数结束时如何交还控制权和回收资源;
  • return_void():处理没有返回值的 co_return
  • unhandled_exception():处理逃出协程体的异常。

示例中的 initial_suspend() 返回 suspend_always,意味着调用协程函数只会创建任务,不会立即执行函数体。外部必须调用 resume()。这类行为常被称为惰性启动

final_suspend() 同样选择暂停,因为"协程执行结束"和"协程帧可以销毁"不是一回事。若外部仍持有句柄,立即释放协程帧会留下悬空指针。

这段代码只展示语言协议,不是完整异步任务。真正的 Task 还要处理结果、异常传播、父子协程衔接、所有权转移和并发安全。

4. co_await 到底调用了什么

co_await expression 最终会得到一个 Awaiter。Awaiter 通常提供三个方法:

cpp 复制代码
bool await_ready();
/* void、bool 或 coroutine_handle */ await_suspend(coroutine_handle h);
Result await_resume();

可以把它们记成三个问题。

结果已经准备好了吗

await_ready() 返回 true 时,协程不会暂停,直接调用 await_resume()。这条路径称为快路径

例如异步读操作可以先尝试一次非阻塞读取。数据已经在内核缓冲区中时,没必要经历"暂停---调度---恢复"的完整流程。

如果没有,谁来唤醒我

await_suspend() 收到当前协程的句柄。它可以把句柄登记到定时器、I/O 事件循环、互斥锁等待队列或消息通道中。

它的返回类型还会影响控制流:

  • 返回 void:当前协程确定暂停;
  • 返回 booltrue 表示暂停,false 表示取消暂停;
  • 返回另一个协程句柄:直接把控制权转移给那个协程,这叫对称转移。

恢复后得到什么

协程被恢复时会调用 await_resume()。它负责返回结果,或者在异步操作失败时抛出异常。

所以,co_await 不是"等待一下"的语法糖,而是一份明确的暂停协议:

text 复制代码
检查结果 -> 必要时登记当前协程 -> 交还线程
         -> 外部事件发生 -> 恢复协程 -> 读取结果

5. 用异步定时器看懂暂停与恢复

假设运行时提供一个线程安全的定时器队列:

cpp 复制代码
class TimerQueue {
public:
    void add(std::chrono::steady_clock::time_point deadline,
             std::coroutine_handle<> handle);
};

可以为它编写一个 Awaiter:

cpp 复制代码
class SleepAwaiter {
public:
    SleepAwaiter(TimerQueue& timers,
                 std::chrono::milliseconds duration)
        : timers_(timers), duration_(duration) {}

    bool await_ready() const noexcept {
        return duration_.count() <= 0;
    }

    void await_suspend(std::coroutine_handle<> handle) {
        timers_.add(std::chrono::steady_clock::now() + duration_, handle);
    }

    void await_resume() const noexcept {}

private:
    TimerQueue& timers_;
    std::chrono::milliseconds duration_;
};

用户代码便可以写成:

cpp 复制代码
Task heartbeat(TimerQueue& timers) {
    while (true) {
        send_heartbeat();
        co_await SleepAwaiter{timers, std::chrono::seconds(1)};
    }
}

一次暂停与恢复按以下顺序发生:

  1. await_ready() 判断需要等待;
  2. await_suspend() 把当前协程句柄和截止时间放进定时器队列;
  3. 协程暂停,执行它的线程可以处理其他任务;
  4. 定时器线程发现时间到期;
  5. 运行时把协程句柄放入就绪队列;
  6. 某个工作线程取出句柄并调用 resume()
  7. await_resume() 返回,循环从 co_await 后继续。

需要特别注意:定时器线程不一定要直接调用 resume()。把句柄放回统一的就绪队列,通常更容易控制并发、线程亲和性和生命周期。

6. 调度器如何实现 M:N

只有暂停能力,还不能构成异步运行时。系统还需要一个调度器,把 M 个协程安排到 N 个工作线程上:

text 复制代码
                   +----------------+
新任务 ----------> |    就绪队列     |
                   +-------+--------+
                           |
              +------------+------------+
              v            v            v
          Worker 1      Worker 2      Worker N
              ^            ^            ^
              +------------+------------+
                           |
                    I/O 或定时器就绪

一个最小调度循环的核心很简单:

cpp 复制代码
while (!stopped) {
    auto handle = ready_queue.pop();
    if (handle) {
        handle.resume();
    } else {
        wait_for_work();
    }
}

实际实现会复杂得多。它通常还需要:

  • 全局队列和线程本地队列;
  • 工作窃取,用于平衡不同 Worker 的负载;
  • 无任务时的休眠与唤醒,避免空转浪费 CPU;
  • 防止同一协程被重复调度;
  • 任务取消、异常传播和运行时关闭协议。

M:N 不表示 M 个任务同时并行。真正能同时执行的任务数量仍受 Worker 数量和 CPU 核心限制。它解决的是:大量任务都可能存在,但只有真正就绪的任务占用线程。

协程调度通常还是协作式的。一个协程若持续进行计算,从不到达 co_await 或其他让出点,就会长期占住 Worker。CPU 密集任务需要主动分段、显式让出,或交给专门的计算线程池。

7. 真正困难的是生命周期

写出一个能暂停的 Awaiter 并不难。困难的是保证它在所有情况下都只完成一次,而且协程帧在此之前始终有效。

一个挂起的协程至少涉及三类所有权:

  • 谁拥有协程帧;
  • 谁拥有正在进行的异步操作;
  • 谁负责在成功、失败或取消时恢复协程。

如果协程帧先被销毁,事件循环中保存的句柄就会变成悬空指针;如果两个事件都尝试恢复同一协程,就会发生重复恢复;如果关闭连接时忘了通知等待者,协程会永远停在暂停状态。

可靠的异步操作必须覆盖所有出口:

  • 正常完成;
  • 超时;
  • 主动取消;
  • 对端关闭;
  • 系统调用失败;
  • 运行时整体关闭。

常见做法是为异步操作创建共享状态,并使用原子状态机保证只有一个出口赢得"完成权"。失败的竞争者只负责清理自己的事件,不再触碰协程。

还有一条实用原则:恢复协程之前,先把结果或错误完整写入共享状态。 协程一旦恢复,就可能立即读取结果,甚至销毁整个异步操作对象。

8. 协程不会消除并发问题

同一个协程内部的代码看起来是顺序执行的,但两次恢复可能发生在不同 Worker 上。多个协程访问同一份数据时,仍然需要同步。

适合协程的同步原语通常不会阻塞系统线程。例如异步互斥锁获取失败后,可以把协程句柄放进等待队列;解锁时再调度队首协程。消息通道在没有数据时暂停接收者,在缓冲区满时暂停发送者。

但"异步锁"不是鼓励在锁内等待 I/O。持锁跨越不可控的 co_await 会放大竞争,也容易形成循环等待。更稳妥的习惯是:

  1. 获取锁;
  2. 快速复制或更新共享状态;
  3. 释放锁;
  4. 再执行可能长时间暂停的异步操作。

9. 初学者最容易踩的误区

co_await 不等于异步

如果 Awaiter 内部调用的是阻塞函数,线程照样会被阻塞。异步能力来自非阻塞系统调用、事件循环和正确的恢复协议,不来自关键字本身。

协程不一定更快

协程擅长处理大量等待型任务。纯计算任务不会因为改写成协程而自动加速,还可能增加状态管理和调度成本。

暂停点也是并发边界

到达 co_await 后,其他任务可能修改共享状态;恢复也可能发生在另一线程。跨暂停点保存引用、指针或迭代器时,要重新检查对象生命周期和并发条件。

不能只设计成功路径

网络断开、超时和取消不是边角情况,而是异步系统的日常。每次保存协程句柄时,都应同时设计它在所有异常出口下如何被安全唤醒和回收。

10. 阅读协程代码的三个问题

面对一段陌生协程代码,不妨在每个 await_suspend() 处停下来,问三个问题:

  1. 协程帧由谁保活?
  2. 哪个事件负责恢复它?
  3. 超时、取消和错误发生时如何收尾?

如果这三个问题都有明确且唯一的答案,暂停与恢复通常就是可靠的。如果其中一个答案含糊,问题往往会在压力、断连或关闭阶段出现。

结语

协程没有让等待消失,只是把等待从线程手中拿走,交给事件系统管理。

编译器把函数变成可暂停的状态机,Awaiter 把协程句柄交给外部事件,调度器再把已经就绪的协程交给工作线程。看懂这条链路后,co_await 就不再像魔法,而是状态机、所有权和调度协议的一次简洁握手。

真正值得掌握的也不只是协程语法,而是这条贯穿异步系统的原则:暂停必须有人保管,恢复必须有且只有一次,结束必须覆盖所有出口。

相关推荐
知无不研1 小时前
c语言和c++中的静态关键字
开发语言·c++·静态关键字
2601_949818092 小时前
Vector从入门到应用(C++ STL动态数组万字全解
开发语言·c++
汉克老师3 小时前
GESP2026年6月认证C++八级( 第三部分编程题(1、线网建设))精讲
c++·最小生成树·排序·kruskal·并查集·gesp8级
胖大和尚3 小时前
Linux 内核工程师、HPC工程师、C++工程师
c++·kernel·hpc
IguoChan3 小时前
4. d2l — 模型选择、欠拟合和过拟合
后端
shepherd1263 小时前
一次把 Spring MVC 文件上传参数“查没了”的排查:multipart、Filter 与 request body 的连环坑
java·后端·spring·mvc
chh5634 小时前
C++--list
开发语言·数据结构·c++·学习·算法·list
贺国亚4 小时前
Prompt-Injection攻防与OWASP-LLM
后端
卷无止境4 小时前
《Deep Analysis with Polars》进阶分析技巧——深入解读
后端·python