C++20 协程入门实战:从 co_await 到一个 100 行的异步任务调度器

C++20 协程入门实战:从 co_await 到一个 100 行的异步任务调度器

上一篇写了 io_uring(io_uring 入门:从 epoll 的 readiness 到 completion),有读者问:C++20 协程和 io_uring 怎么配合?这篇先把协程本身讲透------co_await 背后到底发生了什么,再手写一个 100 行左右的最小异步任务调度器,最后给出和 io_uring 对接的思路。

一、协程不是线程

先纠正一个最常见的误解:协程不是并行执行单位,它是一个可以被暂停和恢复的函数。

  • 线程:操作系统调度,抢占式,切换成本在内核态。
  • 协程:用户态协作式,函数在 co_await 处主动让出,恢复点由调度器决定。

一个协程函数的标志是函数体内出现了 co_await、co_yield 或 co_return 三者之一。只要出现任何一个,编译器就会把这个函数改写成状态机:

cpp 复制代码
Task<int> answer() {
    int x = co_await read_from_network();  // 挂起点 1
    co_return x + 1;                        // 结束点
}

编译器大致会把它翻译成:

cpp 复制代码
// 伪代码:编译器视角
struct AnswerStateMachine {
    int state = 0;      // 当前执行到哪个挂起点
    int x;              // 跨挂起点存活的局部变量
    void resume() {
        switch (state) {
        case 0:
            state = 1;
            suspend_at(read_from_network());  // 挂起,保存现场
            return;
        case 1:
            return x + 1;                     // 恢复后从这里继续
        }
    }
};

理解了"协程 = 编译器生成的状态机 + 堆上保存的帧",后面所有概念都好懂了。

二、三个关键字与三类角色

C++20 协程的 Participating 关键字只有三个:

关键字 含义 典型用途
co_await 挂起,等一个 awaitable 完成 异步 IO、等待任务
co_yield 挂起并产出一个值 生成器、惰性序列
co_return 结束协程并返回值 任务结果

而要写一个可用的协程类型,需要理解三组角色:

1. promise_type------协程的"控制面板"

每个协程的返回类型必须能通过它找到 promise_type(约定是 Task::promise_type 或 std::coroutine_traits 特化)。promise 负责回答三个问题:

  • 协程开始时要不要立即执行?(initial_suspend)
  • 结束时要不要挂起等调用方取结果?(final_suspend)
  • 结果和异常放哪里?(return_value / return_void / unhandled_exception)

2. awaiter------co_await 右边的东西

co_await expr 时,编译器对 expr 求值后调用它的三个方法:

cpp 复制代码
bool await_ready();      // true = 不用挂起,直接继续
void await_suspend(h);   // 挂起动作:注册回调、把控制权交还调度器
T await_resume();        // 恢复时的返回值,成为 co_await 的结果

3. coroutine handle------恢复的把手

std::coroutine_handle<> 是恢复协程的入口。调用 h.resume() 就是"跳回挂起点继续执行"。上一节伪代码里的 resume(),对应的就是它。

三、手写一个最小 Task

下面是一个去掉一切安全装饰、但逻辑完整可跑的最小实现。总共 60 行左右:

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

template <typename T>
struct Task {
    struct promise_type {
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        // 关键决策 1:lazy start------创建时不执行,等 resume 才跑
        std::suspend_always initial_suspend() noexcept { return {}; }
        // 关键决策 2:结束后挂起,调用方取完结果再销毁
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_value(T v) { value_ = std::move(v); }
        void unhandled_exception() { eptr_ = std::current_exception(); }

        T value_{};
        std::exception_ptr eptr_;
    };

    explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
    ~Task() { if (handle_) handle_.destroy(); }

    Task(Task&& o) noexcept : handle_(std::exchange(o.handle_, {})) {}
    Task& operator=(Task&&) = delete;
    Task(const Task&) = delete;

    // 手动驱动:跑完并取结果(这里偷懒用了忙等,见第四节)
    T get() {
        handle_.resume();
        auto& p = handle_.promise();
        if (p.eptr_) std::rethrow_exception(p.eptr_);
        return std::move(p.value_);
    }

private:
    std::coroutine_handle<promise_type> handle_;
};

几个设计决策值得单独说:

为什么 initial_suspend 用 suspend_always(lazy start)?

如果是 suspend_never,协程在创建的那一行就开始跑到第一个挂起点。这在"先创建、后调度"的场景下是灾难:对象还没交给调度器,函数体已经在跑了,悬空引用 bug 的头号来源。默认 lazy,需要 eager 时显式写,是现代 C++ 协程库(cppcoro、libunifex、std::execution)的共同选择。

为什么 final_suspend 也要挂起?

协程结束后自动 destroy 自己,但调用方还拿着 handle 要取 promise().value_。如果 final 挂起,控制权回到调用方时 handle 仍然有效,取完结果再手动销毁,顺序清晰。这也是 final_suspend 必须 noexcept 的原因------这里抛异常没人能接。

四、加一个调度器:从"单任务"到"事件循环"

上面的 get() 是忙等------真实场景里 co_await 应该把控制权交给调度器,由调度器在 IO 就绪后恢复协程。下面是最小可用的骨架:

cpp 复制代码
#include <deque>
#include <coroutine>

struct Scheduler {
    std::deque<std::coroutine_handle<>> ready_;

    void schedule(std::coroutine_handle<> h) { ready_.push_back(h); }

    // 核心循环:从就绪队列取协程恢复,直到队空
    void run() {
        while (!ready_.empty()) {
            auto h = ready_.front();
            ready_.pop_front();
            h.resume();   // 协程跑到下一个 co_await 又会 schedule 回来
        }
    }
};

// 一个"让出控制权"的 awaitable:挂起时把自己丢回队列
struct Yield {
    Scheduler& sched;
    bool await_ready() noexcept { return false; }
    void await_suspend(std::coroutine_handle<> h) { sched.schedule(h); }
    void await_resume() noexcept {}
};

用起来是这样的:

cpp 复制代码
Task<void> worker(Scheduler& s, int id) {
    for (int i = 0; i < 3; ++i) {
        printf("[worker %d] step %d\n", id, i);
        co_await Yield{s};   // 让出,排在队尾,轮到别人跑
    }
}

int main() {
    Scheduler s;
    auto a = worker(s, 1);   // 注意:此刻函数体还没执行(lazy)
    auto b = worker(s, 2);
    s.schedule(a.handle());
    s.schedule(b.handle());
    s.run();                  // 两个 worker 交替打印,输出交错
}

输出(两个协程交替执行):

复制代码
[worker 1] step 0
[worker 2] step 0
[worker 1] step 1
[worker 2] step 1
[worker 1] step 2
[worker 2] step 2

这就是单线程并发的本质:没有锁,没有数据竞争,因为同一时刻永远只有一个协程在跑 。切换点就是 co_await------也就意味着:一个协程里两次 co_await 之间,共享数据绝对安全;跨 co_await 的状态,可能已经被别人改了。

五、真正的异步:对接 io_uring

上面的调度器是"合作式轮流跑",还没有真正的异步 IO。接 io_uring 的思路其实非常自然:

cpp 复制代码
// 概念代码:一个 io_uring 版的 async_read awaitable
struct AsyncRead {
    int fd;
    void* buf;
    size_t len;

    bool await_ready() noexcept { return false; }

    void await_suspend(std::coroutine_handle<> h) {
        // 把协程 handle 作为 user_data 塞进 SQE
        io_uring_sqe* sqe = io_uring_get_sqe(&ring);
        io_uring_prep_read(sqe, fd, buf, len, -1);
        sqe->user_data = h.address();   // 关键:handle 就是"完成后的回调"
    }

    int await_resume() noexcept {
        // CQE 就绪后被调度器 resume 回来,取出结果
        return last_result_;
    }
};

调度器的主循环从"弹就绪队列"变成"弹 CQE":

cpp 复制代码
void run() {
    for (;;) {
        io_uring_submit(&ring);
        io_uring_cqe* cqe;
        io_uring_wait_cqe(&ring, &cqe);
        auto h = std::coroutine_handle<>::from_address(cqe->user_data);
        // 把结果存进协程的 promise,再恢复
        h.resume();
        io_uring_cqe_seen(&ring, cqe);
    }
}

coroutine_handle 天然是一个"轻量级 callback" :user_data 一个指针就够,不需要堆分配闭包。这正好接上了 io_uring 的完成模型------epoll 时代"readiness + 回调地狱"的痛点,协程 + io_uring 是目前 C++ 里最优雅的解法之一。

六、三个必踩的坑

坑 1:悬空引用------协程参数按引用传递

cpp 复制代码
Task<void> bad(const std::string& s) {   // 危险!
    co_await something();
    use(s);   // 如果 s 的所有者早已析构,这里就是 UB
}

协程的帧在堆上,但参数不在帧里 ------按引用传的参数生命周期由调用方保证。协程是 lazy 的,调用方那行代码早就返回了。规则:跨挂起点使用的对象,按值传,或用 shared_ptr。

坑 2:忘掉 .handle() 就泄漏

Task 析构时 handle_.destroy() 释放协程帧。但如果把 handle 塞进了调度器队列而 Task 对象先析构了,调度器 resume 时就是 use-after-free。工程上的解法是 cppcoro 那套:Task 析构时检查是否还在 final_suspend 状态、由 Task 独占生命周期,调度器只持有非拥有引用。

坑 3:协程函数里 this 的陷阱

成员函数协程里的 this 和参数一样是外部引用。lambda 协程更隐蔽:捕获列表的值拷贝存在协程帧里,但按引用捕获不存在帧里 。C++23 的 Coroutine_parameter_preserved(P2688 提案方向)正在解决这个,但当下只能靠纪律。

七、小结

概念 一句话
协程 编译器生成的状态机 + 堆上的帧
co_await 在挂起点让出,awaiter 决定何时何地恢复
promise_type 定制协程行为(何时启动/结束/存结果)
coroutine_handle 恢复协程的把手,天然适配 completion-based IO
调度器 一个持 handle 的队列 + run 循环,就这么多

下一篇计划直接上实战:把这篇的 Task + 调度器和 io_uring 封装成一个 500 行以内的 echo server,对比一下它和 epoll + 回调版本的代码量与性能。有兴趣可以关注一波。

参考:

相关推荐
前进吧-程序员1 小时前
实战:C++20 协程 + io_uring 写一个 400 行的 echo server
网络·c++·c++20
云飞云共享云桌面2 小时前
机械制造研发部门:一台SolidWorks云主机替代 10 台传统图形工作站
运维·服务器·网络·3d·自动化·制造
loulanyue_3 小时前
从碎片文档到工业知识网络:TDengine工业知识图谱架构设计与大模型落地破局——读涛思数据OpenDay实录
网络·知识图谱·tdengine
东方佑3 小时前
去魅:工具调用、Agent 调用、A2A、MCP、Harness 到底都是什么
服务器·网络·数据库
上海云盾-小余3 小时前
七层 CC 攻击检测难点:如何区分真实用户与攻击请求
网络·人工智能·windows·tcp/ip·udp
2401_885885043 小时前
巴林语音通知接口如何对接?系统告警语音API接入教程
android·前端·网络·python·语音识别
JSON_L4 小时前
TCP 粘包拆包解析及示例
网络·网络协议·tcp/ip
科技小E4 小时前
私有化部署:私有化/本地化AI模型训推工作站DLTM数据不出内网的“安全锁”
网络·安全·自动化
汤愈韬4 小时前
OSPF外部路由引入
网络·网络协议