news 2026/5/14 1:22:06

C++协程从入门到放弃?不,是从入门到手搓调度器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++协程从入门到放弃?不,是从入门到手搓调度器

目录

理解协程

1. 为什么需要协程?

2. 协程的三个关键字

3. 最简示例:从编译错误开始

协程的三大概念

1. Promise Type(承诺类型)

2. Awaitable 与 Awaiter 接口

3. coroutine_handle(协程句柄)

4. 手动实现一个最简单的 Task

5. 手动实现一个生成器 Generator

手搓协程调度器

1. 为什么需要调度器?

2. 设计一个简易调度器

3. 支持 Task 的相互等待与串链

4. 调度器的线程安全

结尾


写 C++ 协程的启蒙教程,最坑的就是第一个示例:

写一个返回 Task 的协程,里面只放一句 co_return 21; 编译。
——error: 'promise_type' is not a member of 'Task'

再加一个空的 promise_type 编译。
——error: 'get_return_object' not found

再补 get_return_object 编译。
——error: 'initial_suspend' ...

啊,合着我在填一张没尽头的破表?

对,这就是 C++ 协程的见面礼,标准委员会把最小核心留给了我们,然后强制要求我们必须实现这堆玩意,不然就是:报错,报错,还是报错。

理解协程

1. 为什么需要协程?

早年间服务器写逻辑多爽啊,一个线程处理一个连接,代码从上到下 read(fd, buf, size) 一阻塞,操作系统帮我们切出去,醒过来数据就在buf里了,这看起来很完美。

那么问题在哪儿?线程是稀缺的物理资源,每个线程都得占内核栈、上下文切换要刷 TLB,一万个连接就是一万个线程,我们那64GB内存的服务器瞬间变烤箱,还得时刻提防一些小问题。

于是大家开始搞异步,epoll 配上非阻塞 IO,一个线程管上万个连接,代码就变成了:

void on_readable(int fd) { // 读取一部分数据... if (数据没收完) { // 注册继续读的回调,返回事件循环 return; } // 处理数据... // 发送响应,注册写回调... }

逻辑被撕成一地碎片,每次我们只能执行一小段,然后出让控制权,等下次事件触发再接着跑下一段。这种回调方法,状态要么靠全局变量逃逸,要么塞进一个 connection 结构体里传来传去。

读写一个完整的 HTTP 请求,我们那原本20行的同步代码现在变成四个回调函数,彼此间靠一个 state 枚举相认(state=HEADER,state=BODY,...),改 bug 的时候我们的表情和那枚举值一样扭曲。

然后协程来了,它本质上就是让我们用同步的写法,享受异步的性能。

协程是可暂停的函数,我们写:

std::string line = co_await async_read_until(socket, '\n'); co_await async_write(socket, "...");

编译器会把这两个 co_await 之间的代码切成三段,自动生成一个状态机,把局部变量存到堆上,所以协程帧通常在堆上分配(编译器在满足条件时可优化为栈上分配(HALO))。

暂停的时候,控制权返回给调用者/事件循环,事件就绪后再从这个暂停点恢复执行。代码形态是同步的,但执行时是异步的,线程不用傻等,我们也不用跟几十个回调函数玩耍。

协程让我们既保住了线程的高效复用,又找回了顺序编写业务逻辑的体面。它把我们那堆破事(状态保存、控制流转移)料理得明明白白,还不用我们给每个事件写一堆代码。

好了,现在是不是觉得协程简直是救世主?别急,这里是 C++,它的实现方式马上让我们哭。

2. 协程的三个关键字

C++20 给协程加了三个关键字:co_await、co_yield、co_return。

它们不是功能,只是标记。函数体里只要出现任何一个,这个函数就被编译器判定为协程,然后编译器会掀翻我们的代码,强插一大堆东西。

  • co_await:暂停点,等待一个可等待体就绪。如果没就绪,协程让出执行,保存当前状态,回头再从这里恢复。

  • co_yield:暂停并产出一个值,用来做生成器,比如懒序列生成。我们每次 for (auto x : gen()) 循环迭代,协程就跑一步,co_yield 一个值给我们,然后立刻暂停,等下一次循环再唤醒。

  • co_return:协程结束,返回最终值。这和普通 return 不同,它不会做栈展开销毁局部变量,那是协程帧销毁时做的。普通 return 在协程函数里属于违规操作,编译器会直接甩脸子。

就三个关键字,没了。我们以为学三个单词就够了?太天真了,C++ 表示:“给你最小核心,剩下你自己用库实现”。所以这三个关键字只是冰山露出水面的尖,水底下是 promise_type、coroutine_handle、awaiter 这一整套定制点。

别的语言里的协程开箱即用,C++ 嘛自由是自由,但该手搓的地方就得手搓。

3. 最简示例:从编译错误开始

来,我们写个最简单的协程,想返回一个 Task 对象,能 co_return 一个整数:

#include <coroutine> struct Task { // 啥都没写,先占个坑 }; Task my_coroutine() { co_return 21; }

我们兴冲冲按了编译,然后编译器立刻把我们摁在地上摩擦:

error: no type named 'promise_type' in 'Task'

编译器内心 OS:“你说 Task 是协程返回类型?那好,按照规矩 Task 内部必须得有个叫 promise_type 的类型,你没给我,这协程我生不出来。”

为什么?因为 C++ 协程是无栈协程,它的状态(局部变量、暂停点)和我们看到的返回类型是解耦的。当一个函数被判定为协程,编译器自动生成类似这样的伪代码:

Task my_coroutine() { using promise_t = Task::promise_type; // 必须从返回类型里萃取 auto* frame = new coroutine_frame(promise_t{}); // 在堆上分配协程帧 promise_t& promise = frame->promise; // ... 把我们的函数体拆成状态机塞进去 ... // 返回 Task 对象,由 promise.get_return_object() 创建 return promise.get_return_object(); }

所以 Task 必须提供一个 promise_type,这个 promise_type 里还需要实现一堆接口:get_return_object()、initial_suspend()、return_value()…… 少一个都过不了。

我们忍着气,加上最小实现:

struct Task { struct promise_type { Task get_return_object() { return {}; } std::suspend_never initial_suspend() { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_value(int val) { /* 存起来 */ } void unhandled_exception() { std::terminate(); } }; };

现在再编译:通过了,但我们运行什么都没干,因为 get_return_object() 返回了个空壳,值丢了。

而且我们发现 Task 对象还必须是可移动或可复制的,否则协程帧返回它时也会报错。更骚的是 final_suspend 如果返回 suspend_always,协程结束后控制权还给调用者,但协程帧还没释放,需要我们手动 destroy(),否则内存泄漏,这就需要 coroutine_handle 这个烫手山芋。

一个 hello world 级别的协程,强制要求我们理解 promise_type、awaiter、协程句柄生命周期。这就是 C++ 的仪式感,我们在写出第一行有效协程代码之前,必须亲手为它搭建框架,然后编译器笑眯眯地站旁边看我们调试段错误。

抱怨归抱怨,这条陡峭的学习曲线恰恰是 C++ 协程能接近零开销抽象的代价:它把调度、分配、异常处理全部开放给库作者,所以我们能定制出高性能的 generator、async_task,甚至把协程帧分配在自己发明的内存池里。这比虚拟机里靠全局锁的协程(无意冒犯某些语言)自由得多,代价就是第一眼看到它的时候,我们以为写了个bug,其实是特性。

协程的三大概念

1. Promise Type(承诺类型)

C++ 协程的 promise_type 是协程的内部控制中心。每次我们声明一个返回类型为 Task 的协程函数,编译器就会跑到 Task 结构里找 Task::promise_type,然后把它实例化出来塞进协程帧。

它强制我们实现的接口,每一个都对应协程生命周期的一个节点:

struct promise_type { // 协程一启动,先调这个,返回一个 awaiter 决定是否立即挂起 auto initial_suspend(); // 协程结束(co_return 或异常)调这个,返回 awaiter 决定是否最终挂起 auto final_suspend() noexcept; // 怎么把 promise 和返回的 Task 对象关联起来?靠这个 Task get_return_object(); // 处理 co_return 值或 void void return_value(T val); void return_void(); // 如果协程里抛异常且没被捕获,会走到这里 void unhandled_exception(); // 如果用 co_yield,还得有这个 auto yield_value(T val); };

有多少人第一次看到 initial_suspend 返回 std::suspend_never 心里想的是“能不能直接删掉?”。答案是不能,因为标准委员会非要给我们这个切入时挂起的灵活性,仿佛每个协程都希望在起步阶段先思考一下人生。

promise_type 和协程之间是通过承诺通信的:协程函数体里的 co_return 21 最终变成 promise.return_value(21);如果抛出异常且我们没 catch,就变成 promise.unhandled_exception(),然后一般我们在里面 std::current_exception() 拿到异常并妥善保管,以免协程静默挂掉留一堆内存泄漏。

2. Awaitable 与 Awaiter 接口

这是协程能东西的核心协议,co_await 后面可以跟三种东西:

  1. 直接是一个实现了 await_ready()、await_suspend()、await_resume() 的类(就是 Awaiter)。

  2. 一个重载了 operator co_await() 的类型,返回一个 Awaiter。

  3. 通过 promise 的 await_transform() 转换得到 Awaiter。

大部分高阶封装都是在 await_transform 里做文章,让我们能 co_await 另一个协程。但咱们先把最原始的 Awaiter 拆开:

struct Awaiter { bool await_ready(); // 如果返回true,根本不挂起,直接往下走 void await_suspend(coroutine_handle<>); // 决定挂起后,马上调这个,可以在这里把 handle 注册到 epoll/事件循环 T await_resume(); // 恢复时调,返回值直接给co_await表达式 };

await_suspend 可以返回 void、bool 或另一个 coroutine_handle。返回 true 代表立即恢复不用真挂起,返回 false 代表挂起等唤醒,返回另一个协程句柄那就是对称转移,从当前协程直接切过去,控制权不回到调用方,这是高性能无栈协程调度的精髓,也是把调试器看到脑裂的根源。

编译器在 co_await 处生成的代码大致是:“先调 await_ready,如果false,就保存当前状态,调 await_suspend(h),把控制权返还给调用者或调度器;将来有人调 h.resume(),再调 await_resume 把值交出来。”

3. coroutine_handle(协程句柄)

我们可以简单理解为指向协程帧的裸指针,但带类型安全。它是一个类似 void* 但知道怎么 resume/destroy 的东西。

  • coroutine_handle<promise_type>:特定协程的句柄,能拿到 promise 引用。

  • coroutine_handle<>(无类型):可以 resume,但拿不到 promise,适合当通用回调参数。

它身上我们必用的几个操作:

  • h.resume():恢复从暂停点继续执行。

  • h.destroy():释放协程帧(堆上的那坨状态机)。

  • h.promise():获得 promise_type 引用,从而访问里面的结果或异常。

  • h.done():检查协程是否已经结束(final_suspend 之后就是 done 状态)。

如果 final_suspend 返回 suspend_always,那么协程结束时会挂起在最终点,帧不自动释放,我们必须手动调 destroy(),否则内存泄漏。

有时候我为了省事用 suspend_never,那样协程结束自动销毁,但带来一个问题:我们如果持有 coroutine_handle 还想事后访问结果,帧已经被释放了,悬垂指针伺候。这就是为什么正经异步 Task 都用 suspend_always 做 final suspend,让调用者在取完结果后显式 destroy()。

另外 coroutine_handle 本身是个类似指针的小对象,拷贝它只是多了个引用,不做引用计数。我们在异步回调里持有它,必须保证协程帧在回调执行时还活着,否则我们就是在用 C++ 堵你的枪里没有子弹。

4. 手动实现一个最简单的 Task

这里我们就做一个能 co_return 的 Task,结果存起来外部能拿,没有调度器,同步方式使用:

template<typename T> struct Task { struct promise_type { T value; // 存结果 std::exception_ptr except; // 存异常 Task get_return_object() { // 从promise构造Task,handle后续跟promise关联 return Task{ std::coroutine_handle<promise_type>::from_promise(*this) }; } std::suspend_never initial_suspend() { return {}; } // 不挂起,直接开跑 std::suspend_always final_suspend() noexcept { return {}; } // 最后挂起,等之后手动销毁 void return_value(T v) { value = v; } void unhandled_exception() { except = std::current_exception(); } }; std::coroutine_handle<promise_type> handle; Task(std::coroutine_handle<promise_type> h) : handle(h) {} Task(const Task&) = delete; Task& operator=(const Task&) = delete; Task(Task&& other) noexcept : handle(other.handle) { other.handle = nullptr; } Task& operator=(Task&& other) noexcept { if (this != &other) { if (handle) handle.destroy(); handle = other.handle; other.handle = nullptr; } return *this; } ~Task() { // 因为final_suspend是suspend_always,所以done时帧还在,需要销毁 if (handle) handle.destroy(); } T get_result() { if (handle.promise().except) std::rethrow_exception(handle.promise().except); return handle.promise().value; } }; // 使用 Task<int> compute() { co_return 21; } int main() { auto t = compute(); // 协程在initial_suspend不挂起,直接执行完,然后在final_suspend挂起 std::cout << t.get_result() << std::endl; // 21 // t析构时destroy }

我们只是为了 co_return 21 就写了一坨东西出来,是不是觉得非常反人类?但这就是 C++,给予了我们自由——能决定 final_suspend 是挂起还是自动销毁,也能决定 get_return_object 是直接构造还是返回个惰性 Future。

5. 手动实现一个生成器 Generator

生成器是另一个流派:它每次 co_yield 一个值然后暂停,让调用者通过迭代器拿值,调用者 ++ 时恢复协程继续往下跑,直到 co_return 结束。

我们得让 Generator 支持范围 for 循环,就得实现一个迭代器,里面持有一个 coroutine_handle,用 resume 驱动。

template<typename T> struct Generator { struct promise_type { T current_value; std::exception_ptr except; Generator get_return_object() { return Generator{ std::coroutine_handle<promise_type>::from_promise(*this) }; } std::suspend_always initial_suspend() { return {}; } // 开始先挂起,等迭代器resume std::suspend_always final_suspend() noexcept { return {}; } std::suspend_always yield_value(T v) { current_value = v; return {}; // 挂起 } void return_void() {} void unhandled_exception() { except = std::current_exception(); } }; struct iterator { std::coroutine_handle<promise_type> h; bool done; T operator*() const { if (h.promise().except) std::rethrow_exception(h.promise().except); return h.promise().current_value; } iterator& operator++() { h.resume(); // 让协程跑下一个yield点 done = h.done(); return *this; } bool operator!=(const iterator& other) const { return done != other.done; } }; std::coroutine_handle<promise_type> handle; Generator(std::coroutine_handle<promise_type> h) : handle(h) {} Generator(const Generator&) = delete; Generator& operator=(const Generator&) = delete; Generator(Generator&& other) noexcept : handle(other.handle) { other.handle = nullptr; } ~Generator() { if (handle) handle.destroy(); } iterator begin() { if (!handle || handle.done()) return { nullptr, true }; handle.resume(); // 启动协程,跑到第一个yield点 return { handle, handle.done() }; } iterator end() { return { nullptr, true }; } }; // 示例 Generator<int> fib(int n) { int a = 0, b = 1; while (n--) { co_yield a; int tmp = a; a = b; b += tmp; } co_return; } // 使用 for (auto x : fib(10)) std::cout << x << ' ';

这里 initial_suspend 返回 suspend_always,是为了让协程在创建后先挂起,等 begin() 里手动 resume 才真正跑起来,这就是惰性生成器

如果用 suspend_never,协程会在构造时就跑到第一个 co_yield,如果没提前做好调用环境(比如在构造后就析构走了),容易逻辑混乱,所以生成器几乎清一色 initial_suspend = suspend_always。

还有,yield_value 返回 suspend_always 表示每次产出一个值后必须挂起,等待外部取用。如果我们返回 suspend_never,那协程就会无视调用者一股脑跑到完,那就不叫生成器,叫脱缰的野马。

手搓协程调度器

我们把协程的骨头架子搭起来了,Task 能跑了,Generator 也能产出了,但它的灵魂调度能力还是一片空白。

我们现在写的 Task<T> 一启动就一头撞进 initial_suspend 直接跑完(或挂起等我们手动 resume),而且完全不知道怎么跟 IO 事件卿卿我我。

1. 为什么需要调度器?

你可能会问:“我直接在 await_suspend 里把 coroutine_handle 丢给 epoll,事件来了 resume 一下,不就完事了?”

哦~香蕉啊,这太天真了!这种直连方式的确能跑一个协程,但稍微一复杂就爆炸:

  • 多协程协调:协程 A 想等协程 B 的结果,谁去 resume A?如果是 B 直接在 final_suspend 里 resume A,那 B 的协程帧还没销毁呢,控制权就跳到 A 了——对称转移,爽是真爽,但一旦出错,我们根本不知道该怪 A 还是怪 B,堆栈跟踪比我们的感情线还乱。

  • 优先级和公平性:一百万个协程同时就绪,我们全在一个 for 循环里 resume?CPU 瞬间爆炸,而且有的协程可能饿死。

  • 线程安全:多个线程同时完成 IO,同时去 resume 同一个协程?恭喜喜提数据竞争,精准踩中协程本身不是线程安全的红线。

  • 资源管理:调度器才是知道“哪些协程正在飞,哪些在等”的唯一权威,销毁时机、取消、超时,全得靠它居中调停。

所以调度器的本质是一个协程句柄的执行上下文。它持有一组就绪的协程句柄,决定下一个该执行谁;它提供一个挂载点,让 await_suspend 把当前协程挂起,并注册唤醒回调;它负责在恰当的时机(如 IO 就绪、锁释放)把协程句柄重新塞回就绪队列。

没有调度器的协程就像没有 shell 的 Linux 内核——能编译,但没法交互。

2. 设计一个简易调度器

我们就从最土的版本开始:单线程、纯用户态、无 IO。调度器只干一件事:维护一个就绪队列,里面是待执行的 coroutine_handle<>,然后一个主循环不断取出执行,直到队列空。

先定义调度器类,因为是单线程,连锁都不要:

class SimpleScheduler { public: void schedule(std::coroutine_handle<> h) { ready_queue.push(h); } void run() { while (!ready_queue.empty()) { auto h = ready_queue.front(); ready_queue.pop(); h.resume(); // 驱动协程跑一小段,直到下个挂起点 } } private: std::queue<std::coroutine_handle<>> ready_queue; };

就这么几行,已经是一个能用的协程调度器了。它用 std::queue 保证先就绪的协程先跑,简单公平,不会饿死。

那怎么让我们的 Task<T> 跟它配合?关键就在于 await_suspend。之前我们写的 struct Awaiter 需要拿到这个调度器引用,并在目标协程完成时把挂起的协程句柄安排回去。

但咱先不急,为了感受一下调度的威力,我们可以写一个最小的可调度 Awaiter,以及一个绑定调度器的 Task。这次我们不追求什么封装,就先粗鄙地用一个全局调度器指针(请原谅我的粗糙):

SimpleScheduler* g_scheduler = nullptr; // 全局调度器 struct SchedulerAwaiter { std::coroutine_handle<> awaited; bool await_ready() { return false; } // 总是挂起 void await_suspend(std::coroutine_handle<> h) { awaited = h; g_scheduler->schedule(h); } void await_resume() {} };

这个 SchedulerAwaiter 实际上就是个“让我暂时让出 CPU,回头再继续”的耍赖写法。

更实际的用法是在 await_suspend 里把 h 存起来,不马上调度,而是等某个异步事件完成后再 schedule(h),比如 IO 就绪回调里做这件事。

现在展示一个显式与调度器配合的示例:创建一个协程,内部 co_await 一个自定义的 Awaiter,这个 Awaiter 需要调度器稍后再唤醒它。

可以实现一个 async_sleep 函数,返回一个 Awaiter,它在 await_suspend 里把一个定时任务扔给调度器。

这里我们就不搞这么复杂的,就先模拟一下:直接 schedule(h) 让协程立刻就绪,跑个空转,体验一下调度器运转。这虽然毫无意义,但能让我们看到流程。

// 一个总是立即再次就绪的 awaiter struct YieldAwaiter { bool await_ready() noexcept { return false; } void await_suspend(std::coroutine_handle<> h) noexcept { // 不立即resume,而是塞回调度器队尾 g_scheduler->schedule(h); } void await_resume() noexcept {} };

然后在协程里:

Task<int> some_coro() { std::cout << "协程启动\n"; co_await YieldAwaiter{}; // 让出执行权,被调度器重新入队 std::cout << "从 yield 中回来\n"; co_return 21; } int main() { SimpleScheduler sched; g_scheduler = &sched; auto t = some_coro(); std::cout << "run()\n"; sched.run(); }

输出:

协程启动
run()
从 yield 中回来

跑起来后我们会看到这些输出,证明协程被暂停,又被调度器拉起来再跑完了。虽然只是在自己玩,但我们亲手摸到了“在挂起点把控制权归还给调度器,再由调度器恢复”的完整链路。以后我们要上 IO,只需把 schedule(h) 的调用挪到 epoll 事件回调里即可。

3. 支持 Task 的相互等待与串链

这才是调度器的真正价值:协程 A 等待协程 B 的结果,B 完成时,A 自动被调度器唤醒继续执行。

因为单线程下不需要锁,我们有两种选择:

  • 对称转移:B 完成时直接 resume A 的句柄,控制权不经过调度器,效率高但调用栈可能炸。

  • 调度器版本:B 完成时,把 A 的句柄 schedule 到调度器的就绪队列,然后 B 继续执行完销毁,调度器稍后挑出 A 来跑。这种会多一次入队出队,但线程安全扩展时更容易控制。

我们使用调度器版本,感受一下调度器编排多个协程的魔力。

依旧用全局 g_scheduler 来简化,先来修改 Task<T> 的 promise_type,加入 continuation 字段:

struct promise_type { T value; std::exception_ptr except; std::coroutine_handle<> continuation; // 等待本协程完成的那个协程句柄 // ... 其他不变 ... std::suspend_always final_suspend() noexcept { // 本协程结束,如果有等待者,则把它调度好 if (continuation) { g_scheduler->schedule(continuation); // 唤醒等待者 } return {}; // 仍然suspend_always,等外部destroy } };

然后实现 Task<T> 的 Awaiter,如果 other 已经完成(即协程 done()),那就不该挂起,直接取结果走人:

template<typename T> struct Task { // 其它定义略 // 内部 awaiter struct Awaiter { std::coroutine_handle<promise_type> src_h; // 被等的协程句柄 bool await_ready() { // 如果源协程已经完成,那就不用挂起,直接可以取结果 return src_h.done(); } auto await_suspend(std::coroutine_handle<> caller_h) { // 把caller_h注册到源协程的promise里,等待唤醒 src_h.promise().continuation = caller_h; } T await_resume() { // 恢复时,源协程的结果一定已经就绪,从promise里取 auto& promise = src_h.promise(); if (promise.except) std::rethrow_exception(promise.except); return promise.value; } }; // 给 co_await task 用 auto operator co_await() { return Awaiter{ handle }; } };

await_suspend 里我们把 caller_h 存到 src_h.promise().continuation 后,不需要手动调度任何东西,只是返回 void(表示挂起)。

控制权此时回到哪里?看谁调用了 h.resume() 驱动了协程。如果是调度器驱动,那 await_suspend 返回后,调度器会继续运行其他就绪协程。

稍后,当源协程(src_h)完成,它的 final_suspend 被调用,看到 continuation 非空,就把 caller_h schedule 进就绪队列,于是等待者被唤醒。

整理一下完整的调度器驱动流程:

  1. 调度器 run() 循环 dequeue 一个 handle。

  2. 协程执行到 co_await other_task,进入 Awaiter::await_suspend,把当前句柄存进 other_task 的 promise,然后返回 void(挂起)。

  3. resume() 返回到调度器的 while 循环。

  4. 调度器继续取下一个就绪协程运行。

  5. 当 other_task 执行到最后 final_suspend,发现 continuation 非空,schedule(continuation),把这个等待句柄放回就绪队列。

  6. 调度器后来又取出这个句柄,从 await_resume 拿到结果后继续执行。

使用示例:

// 一个辅助 awaiter,让出当前执行权 struct ScheduleAwaiter { bool await_ready() { return false; } // 总是挂起 void await_suspend(std::coroutine_handle<> h) { g_scheduler->schedule(h); } void await_resume() {} }; auto schedule() { return ScheduleAwaiter{}; } // 示例协程 Task<int> compute_slow(int base) { std::puts("compute_slow 开始计算"); co_await schedule(); // 主动让出,稍后恢复 std::puts("compute_slow 恢复执行"); co_return base * 2; } Task<int> use_result() { std::puts("use_result 准备调用 compute_slow(5)"); int val = co_await compute_slow(5); // 挂起,等待 compute_slow 完成 std::puts("use_result 恢复,得到结果并处理"); co_return val + 3; } int main() { SimpleScheduler scheduler; g_scheduler = &scheduler; auto task = use_result(); scheduler.run(); // 调度所有协程直到全部完成 int result = task.get_result(); // 取回最终返回值 std::cout << "最终结果:" << result << '\n'; }

输出:

use_result 准备调用 compute_slow(5)
compute_slow 开始计算
compute_slow 恢复执行
use_result 恢复,得到结果并处理
最终结果:13

这就是链式 Task 等待的一个模型。

4. 调度器的线程安全

单线程调度器已经能支撑大部分 IO 密集型应用,但当我们需要利用多核,或者有阻塞操作要放进线程池时,就必须让调度器线程安全。

首先明确一个雷区:同一个协程绝不能被多线程同时 resume。协程本身不是线程安全的,它的执行应该被绑定在某一个线程上。

因此,线程安全扩展通常不是让一个调度器多线程运行,而是采用调度器组模式:多个线程中每个线程拥有自己私有的调度器,协程被创建时指定跑在哪个线程的调度器上,如果需要跨线程通信,就通过线程安全的队列把 coroutine_handle 投递到目标线程的调度器里唤醒。

最简单的扩展:给我们的 SimpleScheduler 加上一个线程安全的投递队列,其他线程想唤醒一个协程,就把句柄投递到这个队列,而调度器主循环除了处理自己私有的 ready_queue,还要定期排空这个全局共享队列。

class SafeScheduler { public: void schedule(std::coroutine_handle<> h) { std::lock_guard lk(mut); queue.push_back(h); cv.notify_one(); } void run() { while (true) { std::coroutine_handle<> h; { std::unique_lock lk(mut); cv.wait(lk, [this]{ return !queue.empty() || stop; }); if (stop && queue.empty()) return; h = queue.front(); queue.pop_front(); } h.resume(); } } void request_stop() { std::lock_guard lk(mut); stop = true; cv.notify_all(); } private: std::deque<std::coroutine_handle<>> queue; std::mutex mut; std::condition_variable cv; bool stop = false; };

这已经是一个多生产者-单消费者的协程调度器,多个工作线程和 IO 线程都可以把就绪协程投递进来,主调度线程安全地去 resume。

听起来很美,但藏着几个坑:

  • 一旦队列上有竞争,吞吐量会下降,高性能方案通常用无锁队列。

  • 如果一个协程原本在调度器 A 的线程上跑,后来被调度器 B 的线程投递和 resume,那它在不同线程上执行了,里面的 thread_local 数据全乱套。所以设计时就应明确协程绑定线程的语义,或禁止迁移。

  • 跨线程调度意味着有可能在 schedule(h) 之后、目标线程还没 resume 之前,协程帧被其他线程销毁了,然后目标线程 resume 就炸。这就要求协程帧的所有权必须明确,或使用引用计数。

所以上了锁的调度器不是万能的,不到万不得已不要跨线程 resume。

结尾

C++20 的协程只是一个开始,还有许多的不足,很多东西都要我们自己实现。

不过 C++26 已经把 std::execution 塞进标准,用于管理通用执行资源上的异步执行。

但在能使用 C++26 写项目之前,想用协程写生产代码,我们只有三条路:找一个经过充分测试的第三方库(比如 folly::coro),或者自己手搓一个并承担所有边界条件,或者干脆等着——不过说实话,等标准委员会给我们把这些配齐,可能自家孩子都学会写协程了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/14 1:20:09

Java Web实现简易CRUD操作笔记

Java Web实现简易CRUD操作笔记&#xff08;基于ServletMySQL&#xff09; 一、项目概述 本次上课练习的代码是一套基于 Java Servlet MySQL 实现的简易CRUD&#xff08;增删改查&#xff09;系统&#xff0c;核心围绕animal&#xff08;动物&#xff09;、person&#xff08;人…

作者头像 李华
网站建设 2026/5/14 1:20:09

原创文档:溶剂热法制备NiCo-LDHs及其电催化析氧性能研究

摘要&#xff1a;氢能作为清洁能源的重要载体&#xff0c;其高效制备技术是实现碳中和目标的关键。电化学水分解制氢技术中&#xff0c;析氧反应&#xff08;OER&#xff09;因其缓慢的四电子转移动力学过程成为限制整体效率的瓶颈。层状双金属氢氧化物&#xff08;LDHs&#x…

作者头像 李华
网站建设 2026/5/14 1:19:07

告别工厂库存孤岛:用SAP特殊采购类型40实现集团物料需求统一采购与调拨

集团供应链协同革命&#xff1a;SAP特殊采购类型40的实战解析与业务价值重塑 当某家电制造集团在年度审计中发现&#xff0c;旗下五家工厂的同型号电机库存总量竟足够支撑八个月生产时&#xff0c;管理层意识到传统的分散采购模式正在吞噬企业利润。这正是许多制造企业面临的典…

作者头像 李华
网站建设 2026/5/14 1:17:06

RISC-V开源指令集架构:从设计哲学到商业落地的芯片设计新范式

1. 开源指令集架构的浪潮&#xff1a;从RISC-V研讨会看芯片设计新范式2015年6月底&#xff0c;加州大学伯克利分校的一场研讨会&#xff0c;意外地成为了半导体行业一个微小但意义深远的注脚。这场以RISC-V——一个源自伯克利的开源指令集架构——为主题的会议&#xff0c;不仅…

作者头像 李华
网站建设 2026/5/14 1:15:06

Koikatu HF Patch终极指南:200+插件整合与游戏体验全面升级

Koikatu HF Patch终极指南&#xff1a;200插件整合与游戏体验全面升级 【免费下载链接】KK-HF_Patch Automatically translate, uncensor and update Koikatu! and Koikatsu Party! 项目地址: https://gitcode.com/gh_mirrors/kk/KK-HF_Patch 你是否曾因为语言障碍而无法…

作者头像 李华
网站建设 2026/5/14 1:13:07

向量数据库与近似最近邻搜索:从算法原理到生产实践全解析

1. 向量数据库与近似最近邻搜索&#xff1a;从概念到实战全景解析如果你最近在搞AI应用&#xff0c;特别是RAG、推荐系统或者图像检索&#xff0c;那你肯定绕不开一个词&#xff1a;向量数据库。这玩意儿现在火得不行&#xff0c;但说实话&#xff0c;刚接触的时候&#xff0c;…

作者头像 李华