
协程最迷人的地方是它能用接近同步代码的写法表达异步流程autodataco_awaitsocket.read();autoresultparse(data);co_awaitsocket.write(result);代码从上到下像普通函数一样自然但等待网络数据时线程并没有停在那里。这看起来像魔法其实背后只有三件事编译器生成状态机协程主动保存现场运行时在条件满足后恢复它。本文从这三件事出发逐步拆开协程如何创建、暂停、调度和恢复并解释它为什么适合解决高并发 I/O 问题。1. 协程不是轻量级线程“轻量级线程”是一个方便但不够准确的比喻。线程由操作系统调度拥有独立调用栈可以在任意指令附近被抢占。协程通常由应用程序调度没有独立的传统线程栈只会在约定的暂停点主动交出执行权。更合适的理解是线程是执行代码的工人协程是可以暂停的任务单调度器决定哪个工人处理哪张任务单。一个线程可以先执行协程 A。A 等待网络时暂停这个线程马上转去执行协程 B。数据到达后A 被放回就绪队列未来可能由原线程恢复也可能由另一个线程恢复。因此协程减少的不是计算量而是等待造成的线程占用。2. 暂停之后局部变量去了哪里普通函数的状态主要保存在调用栈中。函数返回后栈帧销毁局部变量和执行位置也随之消失。协程需要在暂停后保留这些信息。编译器会把协程函数改写成状态机并创建一块协程帧通常包含函数参数跨越暂停点仍要使用的局部变量当前执行到哪个暂停点Promise 对象恢复和销毁协程所需的管理信息。例如Taskdownload(){std::string urlhttps://example.com/data;autobodyco_awaitasync_get(url);save(body);}等待 HTTP 响应时url之后仍可能被使用执行位置也必须被记住。它们会随协程状态保存在协程帧中而不是依赖当前线程的调用栈一直存在。std::coroutine_handle可以看作指向协程帧的轻量句柄handle.resume();// 从上次暂停的位置继续执行handle.done();// 查询协程是否已经结束handle.destroy();// 销毁协程帧句柄通常只是一个指针大小的对象。真正需要认真管理的是它背后的协程帧。3. Promise协程与外部世界的接口C20 没有提供唯一的Task类型而是提供了一套让库作者自定义协程行为的协议。只要一个函数使用了co_await、co_yield或co_return编译器就会通过返回类型找到promise_type。下面是一个只用于解释结构的最小Task#includecoroutine#includeexception#includeutilityclassTask{public:structpromise_type{Taskget_return_object(){returnTask{std::coroutine_handlepromise_type::from_promise(*this)};}std::suspend_alwaysinitial_suspend()noexcept{return{};}std::suspend_alwaysfinal_suspend()noexcept{return{};}voidreturn_void()noexcept{}voidunhandled_exception(){std::terminate();}};usingHandlestd::coroutine_handlepromise_type;explicitTask(Handle h):handle_(h){}Task(Taskother)noexcept:handle_(std::exchange(other.handle_,{})){}Task(constTask)delete;~Task(){if(handle_)handle_.destroy();}voidresume(){if(handle_!handle_.done())handle_.resume();}private:Handle handle_;};这几个函数控制了协程的关键时刻get_return_object()把新建的协程帧包装成用户能持有的对象initial_suspend()决定函数创建后立即运行还是先暂停final_suspend()决定函数结束时如何交还控制权和回收资源return_void()处理没有返回值的co_returnunhandled_exception()处理逃出协程体的异常。示例中的initial_suspend()返回suspend_always意味着调用协程函数只会创建任务不会立即执行函数体。外部必须调用resume()。这类行为常被称为惰性启动。final_suspend()同样选择暂停因为“协程执行结束”和“协程帧可以销毁”不是一回事。若外部仍持有句柄立即释放协程帧会留下悬空指针。这段代码只展示语言协议不是完整异步任务。真正的Task还要处理结果、异常传播、父子协程衔接、所有权转移和并发安全。4.co_await到底调用了什么co_await expression最终会得到一个 Awaiter。Awaiter 通常提供三个方法boolawait_ready();/* void、bool 或 coroutine_handle */await_suspend(coroutine_handle h);Resultawait_resume();可以把它们记成三个问题。结果已经准备好了吗await_ready()返回true时协程不会暂停直接调用await_resume()。这条路径称为快路径。例如异步读操作可以先尝试一次非阻塞读取。数据已经在内核缓冲区中时没必要经历“暂停—调度—恢复”的完整流程。如果没有谁来唤醒我await_suspend()收到当前协程的句柄。它可以把句柄登记到定时器、I/O 事件循环、互斥锁等待队列或消息通道中。它的返回类型还会影响控制流返回void当前协程确定暂停返回booltrue表示暂停false表示取消暂停返回另一个协程句柄直接把控制权转移给那个协程这叫对称转移。恢复后得到什么协程被恢复时会调用await_resume()。它负责返回结果或者在异步操作失败时抛出异常。所以co_await不是“等待一下”的语法糖而是一份明确的暂停协议检查结果 - 必要时登记当前协程 - 交还线程 - 外部事件发生 - 恢复协程 - 读取结果5. 用异步定时器看懂暂停与恢复假设运行时提供一个线程安全的定时器队列classTimerQueue{public:voidadd(std::chrono::steady_clock::time_point deadline,std::coroutine_handlehandle);};可以为它编写一个 AwaiterclassSleepAwaiter{public:SleepAwaiter(TimerQueuetimers,std::chrono::milliseconds duration):timers_(timers),duration_(duration){}boolawait_ready()constnoexcept{returnduration_.count()0;}voidawait_suspend(std::coroutine_handlehandle){timers_.add(std::chrono::steady_clock::now()duration_,handle);}voidawait_resume()constnoexcept{}private:TimerQueuetimers_;std::chrono::milliseconds duration_;};用户代码便可以写成Taskheartbeat(TimerQueuetimers){while(true){send_heartbeat();co_awaitSleepAwaiter{timers,std::chrono::seconds(1)};}}一次暂停与恢复按以下顺序发生await_ready()判断需要等待await_suspend()把当前协程句柄和截止时间放进定时器队列协程暂停执行它的线程可以处理其他任务定时器线程发现时间到期运行时把协程句柄放入就绪队列某个工作线程取出句柄并调用resume()await_resume()返回循环从co_await后继续。需要特别注意定时器线程不一定要直接调用resume()。把句柄放回统一的就绪队列通常更容易控制并发、线程亲和性和生命周期。6. 调度器如何实现 M:N只有暂停能力还不能构成异步运行时。系统还需要一个调度器把 M 个协程安排到 N 个工作线程上---------------- 新任务 ---------- | 就绪队列 | --------------- | ------------------------ v v v Worker 1 Worker 2 Worker N ^ ^ ^ ------------------------ | I/O 或定时器就绪一个最小调度循环的核心很简单while(!stopped){autohandleready_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会放大竞争也容易形成循环等待。更稳妥的习惯是获取锁快速复制或更新共享状态释放锁再执行可能长时间暂停的异步操作。9. 初学者最容易踩的误区co_await不等于异步如果 Awaiter 内部调用的是阻塞函数线程照样会被阻塞。异步能力来自非阻塞系统调用、事件循环和正确的恢复协议不来自关键字本身。协程不一定更快协程擅长处理大量等待型任务。纯计算任务不会因为改写成协程而自动加速还可能增加状态管理和调度成本。暂停点也是并发边界到达co_await后其他任务可能修改共享状态恢复也可能发生在另一线程。跨暂停点保存引用、指针或迭代器时要重新检查对象生命周期和并发条件。不能只设计成功路径网络断开、超时和取消不是边角情况而是异步系统的日常。每次保存协程句柄时都应同时设计它在所有异常出口下如何被安全唤醒和回收。10. 阅读协程代码的三个问题面对一段陌生协程代码不妨在每个await_suspend()处停下来问三个问题协程帧由谁保活哪个事件负责恢复它超时、取消和错误发生时如何收尾如果这三个问题都有明确且唯一的答案暂停与恢复通常就是可靠的。如果其中一个答案含糊问题往往会在压力、断连或关闭阶段出现。结语协程没有让等待消失只是把等待从线程手中拿走交给事件系统管理。编译器把函数变成可暂停的状态机Awaiter 把协程句柄交给外部事件调度器再把已经就绪的协程交给工作线程。看懂这条链路后co_await就不再像魔法而是状态机、所有权和调度协议的一次简洁握手。真正值得掌握的也不只是协程语法而是这条贯穿异步系统的原则暂停必须有人保管恢复必须有且只有一次结束必须覆盖所有出口。