C++20 协程实战:手写 Task、单线程调度器与 awaiter 的三个钩子

C++20 协程实战:手写 Task、单线程调度器与 awaiter 的三个钩子

协程不是"更轻量的线程",而是一个能被编译器改写成状态机的函数 。

它不解决并行,只解决"在某个点挂起、之后从这个点继续 "。理解了这一点,co_await 就不再像魔法:它只是「检查能不能立刻完成 → 不能就把自己挂到某处 → 有人 resume 我时从这里返回」。

本文用 130 行代码实现一个可 co_await 的 Task 和一个单线程调度器,跑出可观测的交错执行,然后把最容易踩的坑一次说完。


1. 先分清:协程、线程、回调

谁在跑 切换成本 典型用途
线程 操作系统调度 微秒级(栈 + 内核) 真并行(多核)
回调 调用者的栈 零切换,但代码被打散 事件驱动 I/O
协程 调用者的栈(无独立栈) 纳秒级(一次函数调用) 用同步写法表达异步流程

协程的关键收益是把「状态」从人脑搬到编译器 :if (step == 2) ... 这种手写状态机,编译器会替你在协程帧里生成局部变量和 resume 点。代价是协程帧要自己管理生命周期------这正是本文后面一半在讲的事。

已有线程池/无锁队列的讨论见 thread_pool.md 与 lockfree_queue.md,它们解决的是并行 ;协程解决的是挂起,两者可以叠加(协程跑在线程池上),但不互相替代。


2. 五个钩子:编译器到底调了什么

一个能被 co_await 的对象,必须有三个钩子:

cpp 复制代码
bool await_ready();                                 // true → 不必挂起,直接 await_resume
std::coroutine_handle<> await_suspend(handle);      // 返回 void/bool/handle,决定交给谁
T await_resume();                                   // 恢复后拿结果

一个协程函数(函数体里出现 co_await / co_yield / co_return),编译器会生成一个协程帧,并在两个时刻调用 promise 的钩子:

cpp 复制代码
initial_suspend();   // 刚创建时:立刻跑(suspend_never)还是等被调度(suspend_always)
final_suspend();     // 函数体结束前:挂起(suspend_always)还是直接销毁

外加两个必备成员:

cpp 复制代码
get_return_object();     // 把 promise 包装成返回给调用者的对象
return_void() / return_value(v);   // co_return 的落点
unhandled_exception();   // 协程体内抛出的异常在哪儿被接住

记住一句话:co_await expr 的语义是「调用 expr 的 awaiter 的三个钩子」 ,和 expr 是不是协程没有关系。


3. 最小可用的 Task

先看骨架。Task 只做三件事:持有协程句柄、暴露 co_await、在析构时销毁帧。

cpp 复制代码
struct Task {
    struct promise_type {
        Scheduler* sched = nullptr;
        std::coroutine_handle<> continuation{};      // 谁在等我

        struct Initial {                             // 自定义 initial_suspend
            bool await_ready() noexcept { return false; }
            void await_suspend(std::coroutine_handle h) noexcept {
                h.promise().sched = g_sched;         // 第一次被调度时绑定调度器
            }
            void await_resume() noexcept {}
        };

        Task get_return_object() {
            return Task{std::coroutine_handle::from_promise(*this)};
        }
        Initial initial_suspend() noexcept { return {}; }

        struct FinalAwaiter {
            bool await_ready() noexcept { return false; }
            std::coroutine_handle<> await_suspend(std::coroutine_handle h) noexcept {
                auto cont = h.promise().continuation;
                return cont ? cont : std::noop_coroutine();   // 对称转移
            }
            void await_resume() noexcept {}
        };
        FinalAwaiter final_suspend() noexcept { return {}; }

        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    explicit Task(std::coroutine_handle h) : h_(h) {}
    Task(Task&& o) noexcept : h_(std::exchange(o.h_, {})) {}
    Task(const Task&) = delete;
    Task& operator=(const Task&) = delete;
    ~Task() { if (h_) h_.destroy(); }

    std::coroutine_handle handle() const { return h_; }

    struct Awaiter {
        std::coroutine_handle h;
        bool await_ready() const noexcept { return false; }
        std::coroutine_handle<> await_suspend(std::coroutine_handle<> caller) const noexcept {
            h.promise().continuation = caller;   // 子协程结束后回到父协程
            h.promise().sched->spawn(h);         // 子协程入队,先让别的任务跑
            return std::noop_coroutine();        // 父协程让出,控制权回到事件循环
        }
        void await_resume() const noexcept {}
    };
    Awaiter operator co_await() const noexcept { return Awaiter{h_}; }

    std::coroutine_handle h_;
};

三个设计决定,每个都能解释:

决定 原因
initial_suspend 用 suspend_always(自定义 Initial) 让 Task 创建即挂起,调度器决定何时启动;否则 co_await 之前就已经跑完了,失去"可调度"的意义
final_suspend 用 suspend_always(FinalAwaiter) 帧不能在校程函数体结束时立刻销毁------continuation 还要用它;销毁交给 Task 析构
await_suspend 返回 std::noop_coroutine() 这叫对称转移 (symmetric transfer):把控制权还给事件循环,而不是递归调用 caller.resume()。后者在链式等待时会不断加深调用栈,长流水线直接爆栈

4. 调度器:一个队列 + 两次 resume

cpp 复制代码
thread_local Scheduler* g_sched = nullptr;

struct Scheduler {
    std::queue> ready;

    void spawn(std::coroutine_handle<> h) { ready.push(h); }

    void run() {
        while (!ready.empty()) {
            auto h = ready.front(); ready.pop();
            g_sched = this;      // 记录"当前调度器",供 Initial 钩子读取
            h.resume();          // 整个程序只有这一处恢复协程
        }
    }

    struct Yield {           // co_await s.yield() 让出执行权
        Scheduler* s;
        bool await_ready() const noexcept { return false; }
        void await_suspend(std::coroutine_handle<> h) const { s->spawn(h); }
        void await_resume() const noexcept {}
    };
    Yield yield() { return Yield{this}; }
};

所有 resume() 都发生在 run() 里,所以协程之间天然没有数据竞争 ------这也是单线程事件循环(如 asio_strand_vs_multi_iocontext.md 里的 strand)能用"不加锁"换性能的原因。


5. 跑起来:交错执行 + 串行组合

cpp 复制代码
Task worker(Scheduler& s, const char* name, int steps) {
    for (int i = 0; i < steps; ++i) {
        std::printf("[%s] step %d\n", name, i);
        co_await s.yield();
    }
    std::printf("[%s] done\n", name);
}

Task pipeline(Scheduler& s) {
    std::printf("[P] start\n");
    co_await worker(s, "child-A", 2);      // 串行等待子协程
    std::printf("[P] after child-A\n");
    co_await worker(s, "child-B", 1);
    std::printf("[P] end\n");
}

int main() {
    Scheduler s;
    auto w1 = worker(s, "A", 3);
    auto w2 = worker(s, "B", 2);
    auto p  = pipeline(s);
    s.spawn(w1.handle());
    s.spawn(w2.handle());
    s.spawn(p.handle());
    s.run();
}

实测输出(g++ 12、-std=c++20、-Wall -Wextra 无警告;-fsanitize=address 亦无报错):

text 复制代码
[A] step 0
[B] step 0
[P] start
[A] step 1
[B] step 1
[child-A] step 0
[A] step 2
[B] done
[child-A] step 1
[A] done
[child-A] done
[P] after child-A
[child-B] step 0
[child-B] done
[P] end

两个现象值得注意:

  • A/B/P 是交错的 :因为每次 co_await yield() 都把自己塞回队尾,谁都没有"霸占"循环。
  • P 对 child-A 是串行的 :[P] after child-A 出现在 [child-A] done 之后,说明 continuation 生效------子协程 final_suspend 时直接把控制权交给父协程,中间不需要经过 run() 的队列。

6. 常见坑

  1. sched 绑定错时机。 很多示例在 get_return_object 里就把 sched 设好,但那时协程还没被任何调度器接管。本文放在自定义 initial_suspend 里、由 g_sched 赋值,含义是「第一次被 resume 时才知道自己在哪个循环里 」。⚠️ thread_local 的代价是:从别的线程 resume 同一个协程,g_sched 指错------单线程循环里没问题,跨线程就必须显式传调度器。
  2. Task 必须 move-only,且析构要 destroy()。 协程帧是手动管理的堆内存(编译器帮你 operator new,没有人帮你 delete)。Task 拷贝会导致双重销毁;忘记销毁就是泄漏。这也解释了为什么 std::generator/std::task 都设计成 move-only。
  3. co_await 的临时 Task 会在整条表达式结束时析构。 co_await worker(...) 中的临时对象,在 await_resume 之后立刻 destroy() 子帧------此时子协程停在 final_suspend,是安全的;但如果你在 await_suspend 里保存了子句柄,稍后再用它,就是悬垂引用。
  4. final_suspend 绝不能返回 suspend_never。 函数体一结束帧就被销毁,continuation 指向的内存已经回收,对称转移过去直接崩。
  5. await_suspend 里不要同步 resume()。 那等于把"挂起"变成"递归调用",深度随任务链增长。要"立刻让别人跑"就用 noop_coroutine() 返回给事件循环。
  6. 异常必须处理。 unhandled_exception() 里默认 std::terminate();想把异常传给 co_await 的调用方,要在 promise 里存 std::exception_ptr,并在 await_resume 里 std::rethrow_exception。
  7. 协程不是免费的。 帧是一次内存分配(可被 HALO 优化掉一部分),co_await 是一次间接调用。在热点路径上,一个普通函数调用仍然比协程便宜------别把协程当语法糖到处撒。

7. 什么时候值得用

  • 有明确的"等待"语义:I/O、定时器、信号量、另一个任务的完成;
  • 想把回调地狱改写成同步写法(co_await read(); co_await write(););
  • 单线程事件循环里需要并发发起多个逻辑任务(协程互相让出,而不是抢占)。

反过来,纯 CPU 计算、没有挂起点、或者只需要"并行"时,线程池就够了------参考 thread_pool.md。

一句话:协程把状态机交给编译器,代价是帧的生命周期交还给你;co_await 只是三个钩子,写对它只需要记住"挂起前登记、恢复后清理"。


参考

相关推荐
Android系统攻城狮1 小时前
Linux FFmpeg深度解析之avcodec_alloc_context3调用流程与实战(十四)
linux·运维·ffmpeg·车载音视频·音视频进阶·ffmpeg音视频进阶
自由彩虹2 小时前
靶场通关15--bossplayersctf
linux·服务器·网络·安全·web安全·网络安全
叼烟扛炮2 小时前
Linux 学习笔记(四十五):Web 安全基石——Cookie、Session 与 HTTPS 原理解析
linux·前端·网络·笔记·学习·tcp/ip·安全
Linux_cy2 小时前
nginx 502 排查手册:九种原因分类、定位命令与检查顺序
linux·运维·服务器·网络·数据库·nginx·ssh
椒颜皮皮虾྅3 小时前
模型部署不再卡在环境上:DeploySharp 四大推理引擎与 Windows/Linux 离线交付实战
linux·运维·开发语言·人工智能·windows·计算机视觉·cuda
垆边人似月.3 小时前
Ubuntu 配置 Samba 共享文件夹并映射到 Windows 完整指南
linux·windows·ubuntu
Lazionr3 小时前
【Linux 修炼录】:文件查看、时间查询与查找
linux
corey933 小时前
Linux 学习 - 第十四周作业
linux·运维·学习
可乐_不渴4 小时前
# C语言文件操作全解析:从 fopen 到 fclose,一文吃透文件 IO
linux·服务器·c语言