为什么协程能让程序不再卡顿?——从同步、异步到 C++ 实战
1 引言在图形界面GUI应用中“卡顿”几乎是所有开发者都会遇到的老问题。一次复杂的计算、一次网络请求、一次磁盘读取甚至一次大循环都可能让界面在几百毫秒内完全失去响应用户看到的就是——窗口半透明、按钮点不动、程序像“假死”了一样。过去在 C 程序中解决卡顿最常见的方法是加一个线程。再加一个线程。然后用锁把它们绑在一起。但随着项目复杂度提升多线程的调度开销、锁竞争、死锁风险也让不少开发者叫苦不迭。而在另一些语言里——比如 JavaScript、C#、Python——同样的问题却可以用更轻量、更优雅的方式解决异步 I/O 协程Coroutine。协程并不是线程的替代品而是一种更贴近业务逻辑的结构化异步方式。得益于协程你可以写出“看起来同步、实际上异步”的代码你可以在单线程中实现并发你可以让 GUI 始终流畅响应而复杂任务在后台悄然推进。本文将从最基础的概念——同步与异步、线程与协程——逐步展开解释为什么协程能让程序不再卡顿并通过完整的 C 协程/Boost.Coroutine 实战展示其内部原理与适用场景。2 基础在进行实战之前先学习一些比较基础的知识。2.1 同步 VS 异步所谓同步Synchronous是指调用一个函数时必须等它执行完才能继续执行下一行。例如代码语言javascriptAI代码解释auto img LoadImage(); // 在这里等待 Render(img);因此同步特点是当前线程会被阻塞调用者需要等待才能继续执行。所谓异步Asynchronous是指发起任务后不等待任务完成后再通过回调、future、信号等方式告诉调用者。例如代码语言javascriptAI代码解释LoadImageAsync([](Image img){ Render(img); // 回调在之后执行 });因此异步的特点是当前线程不会被阻塞调用者不需要等待就能继续执行。2.2 协程的本质协程Coroutine是一种可以主动让出执行权的“轻量级函数”。它允许函数在中途暂停yield稍后继续执行。通常具备如下几点要素可挂起Suspend可恢复Resume保持自己的栈和上下文但非常轻量不需要线程切换协程在当前线程运行线程是真实由 CPU 执行的实体是硬件资源调度的最小单位。在一般情况下一款 CPU 产品上可以存在多个核心(core)一个 CPU 核心一次只能运行一个线程的指令流instruction stream多个核心可以同时执行多个线程上的任务。但是对于协程来说是完全没有物理上的基础映射的——协程是纯软件层的概念。硬件上的 CPU、寄存器、调度器甚至硬件指令都不是为协程设计的操作系统OS层面也不知道协程的存在。协程本质上就是用户态下的可让出/恢复执行点的函数。调度协程的不是 CPU不是 OS而是程序员 / 语言运行时 / 框架。2.3 协程 VS 线程既然已经有了多线程那么为什么还需要协程因为在某些情况下线程太“重”了如下表所示特点线程协程调度由操作系统OS负责由程序员/框架负责切换成本高微秒级极低纳秒级栈大小MB 级KB 级最大数量很有限1 千级很多百万级上下文切换慢快适用场景CPU 密集I/O 密集、高并发线程适合 CPU 密集的任务如图像处理、AI、压缩、矩阵运算等协程适合 I/O 密集、高并发的任务比如爬虫、网络请求、数据库访问、web 服务等。以 I/O 密集的高并发任务来说使用协程非常合适栈小4K~64K切换快~100 ns单线程也能跑几十万协程I/O 等待不阻塞线程所以很多服务器框架Go、Rust tokio、Python asyncio、C20 coroutine都推荐协程而不是大量线程。当然也不是绝对使用线程池任务队列的方式也可以达到同样的效果不过实现起来复杂度较高也不如使用协程的方案稳健性能提升也有限。2.4 协程和异步很显然多线程是实现异步的一种方式。不过多线程的问题就是太异步了两个线程被创建之后就如同两条平行线相互之间不再有任何关联。但在实际的程序开发中相互之间不进行联系的情况比较少一般需要在关键的节点进行线程同步。单线程同样可以实现异步。具体来说通过单线程 异步 I/O 协程方案也可以实现满足超高并发需求的异步这种方案在JavaScript、Python等环境中非常常见。正如前文中提到的协程是一种“可以暂停/恢复”的轻量函数因此协程可以写出像同步代码一样的结构通过“暂停—等结果回来—继续”的机制来实现异步。这种机制通常通过类似yield()的语法糖来控制。当然如果协程体中从不yield()或者没有异步 I/O 环境的支持这个异步函数实现就会退化成同步函数。协程本身不是为 GUI 开发设计的但在 GUIQt、Unity、JavaScript中常用协程解决卡顿因为GUI 主线程不能长时间执行耗时操作协程能把耗时任务拆成小碎片每片执行后 yield交回主线程让 UI 刷新3 实现异步实现在 JavaScript 中几乎随处可见可以说 JavaScript 就是一门建立在异步实现上的编程语言。在 JavaScript 中常见的异步实现有技术是否异步本质回调 callback✔异步通知函数Promise✔异步状态机async/await✔异步协程基于 PromiseDOM 事件、定时器✔事件循环驱动的异步任务虽然以上实现的异步机制实现本质不同但是最终都依赖于 event loop事件循环调度而不是线程。接下来具体探究一些协程或者异步的实现不局限于 JavaScript 3.1 JS 的 async/awaitJS 的 async/await 是协程的一种“语法级实现”严格来说是协程的语法糖。因为 async/await 做的事情是让函数可以挂起暂停代码语言javascriptAI代码解释let x await fetch(url); // 在这里挂起然后恢复执行代码语言javascriptAI代码解释console.log(x); // 恢复后继续这个行为就是“协程的本质”可挂起、可恢复、用户态调度当然最终是通过 JS runtime 事件循环调度来实现。虽然 JS 语法没有暴露“yield control to scheduler”这样的命令但其行为确实和协程一致。因此JS 的 async/await 是异步协程asynchronous coroutine的一种形式。3.2 JS 的 PromiseJS 的 Promise 不能暂停函数不能恢复执行点也没有栈帧保存能力因此并不是协程。Promise 是一种异步状态机能够表达 3 个状态pending → fulfilled / rejected 。是用于管理异步结果的 数据结构异步容器。当然async/await 本身是用Promise 作为底层机制实现的。3.3 Qt 的信号/槽Qt 的信号槽 “有时异步有时同步”取决于连接类型Qt::ConnectionType同步/异步DirectConnection同步立即调用QueuedConnection异步事件队列中排队执行AutoConnection取决于接收者是否在另一个线程如果是跨线程或 QueuedConnection就是异步模型使用事件队列 调度器来执行槽函数。但是 Qt 的异步不是协程它是事件驱动不会“暂停函数并恢复”。3.4 C 的回调函数回调本身不是异步。回调只是一个函数指针什么时候执行取决于调用者是否异步由调用者决定如果驱动/库是异步的那么回调变成异步回调。如果是同步调用那么回调就是普通函数调用。例如同步回调代码语言javascriptAI代码解释int process(int x, int(*cb)(int)) { return cb(x); // 立即调用 }异步回调代码语言javascriptAI代码解释void read_async(int fd, void(*on_complete)(int result));如果回调函数什么时候调用使用者无法控制这种编程模型才叫做异步。3.5 C# 的 async/await和 JavaScript 类似C# 的async/await也是一种基于状态机的协程实现具有以下特点函数在await处挂起suspend当被 await 的任务Task完成时自动恢复执行编译器将async方法重写为一个状态机类state machine保存局部变量、执行位置等上下文默认在原始上下文如 UI 线程恢复执行通过SynchronizationContext代码语言javascriptAI代码解释async Taskstring FetchDataAsync() { var client new HttpClient(); string data await client.GetStringAsync(https://api.example.com); // 挂起 Console.WriteLine(data); // 恢复 return data; }这完全符合“协程”定义可挂起、可恢复、用户态调度由 .NET Task Scheduler 驱动。因此C# 的async/await是真正的轻量级协程asynchronous coroutine比 JS 更接近系统级协程如 Go 的 goroutine尽管仍基于回调和状态机而非独立栈。3.6 Unity 的 IEnumeratorUnity也可以使用 C# 的async/await但是 Unity 底层可能没有真正的异步 I/O 支持尤其在 WebGL 或移动平台从而造成主线程阻塞。Unity 还引入了另外一种伪协程pseudo-coroutine机制——IEnumerator用于在单线程游戏主循环中实现“看似并发”的逻辑控制。它不是真正意义上的协程没有独立栈、不能跨线程、不基于异步 I/O但通过迭代器iterator 主循环调度模拟了挂起与恢复的行为。例如代码语言javascriptAI代码解释IEnumerator CountDown() { for (int i 3; i 0; i--) { Debug.Log(i); yield return new WaitForSeconds(1); // 挂起 1 秒 } Debug.Log(Go!); } // 启动协程 StartCoroutine(CountDown());其中yield return是挂起点函数在此处暂停控制权交还给 Unity 引擎。Unity 主循环每帧检查协程状态当条件满足如时间到、帧结束等从挂起点继续执行。Unity 的IEnumerator更准确地说是一种基于帧的协作式任务调度器而非语言级协程。4. 实例4.1 无栈协程C20 已经提供了一种原生的协程方案 co_await/co_yield新项目如果能使用 C20 推荐使用。不过 C20 相对于 C17 的变动还是不小笔者使用的还是 C17那么就可以使用 boost 的协程方案 boost::coroutines2 。无论是 boost::coroutines2 还是 co_await/co_yield都是无栈协程的一种实现。所谓无栈协程指的是协程没有独立的调用栈其局部变量和执行状态由编译器或库通过状态机保存在堆上。与之相对的是有栈协程拥有自己的栈空间可任意挂起点包括深层函数调用中。应该来说主流语言倾向无栈协程JS、C#、Python、Rust、C20 都选择了无栈模型因其与事件循环、Future/Promise 模型天然契合且内存效率极高。一个使用 boost::coroutines2 的协程实现代码如下所示代码语言javascriptAI代码解释#include boost/coroutine2/all.hpp using namespace std; // 模拟“耗时任务” void long_running_task(boost::coroutines2::coroutinevoid::push_type yield) { for (int i 0; i 10; i) { std::cout Task: Processing iteration i std::endl; // 模拟耗时操作例如计算或 I/O std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 让出执行权允许主线程更新 UI yield(); } std::cout Task: Completed! std::endl; } // 模拟“UI 更新” void update_ui() { static int ui_update_count 0; std::cout UI: Updating UI, count ui_update_count std::endl; } int main() { using namespace boost::coroutines2; // 创建协程绑定耗时任务 coroutinevoid::pull_type task_source( [](coroutinevoid::push_type yield) { long_running_task(yield); }); // 主循环交替执行协程和 UI 更新 while (task_source) { // 执行协程的一部分 task_source(); // 更新 UI update_ui(); // 模拟 UI 处理时间 std::this_thread::sleep_for(std::chrono::milliseconds(300)); } return 0; }这段代码的关键在于理解协程核心组件 boost::coroutines2 代码语言javascriptAI代码解释using namespace boost::coroutines2; coroutinevoid::pull_type task_source( [](coroutinevoid::push_type yield) { long_running_task(yield); });其中coroutine 定义一个协程不传递值若需传值可用 coroutine 等。pull_type 协程的“拉取端”代表主控上下文用于启动和恢复协程。push_type协程的“推送端”在协程体内用于挂起并交出控制权。yield是一个函数对象调用 yield() 即挂起当前协程返回主控上下文。形象的说pull_type 是“消费者”主控方push_type 是“生产者”协程体而协程通过 yield 返回控制权给 pull 端这其实是一种非对称协程的设计。运行结果如下代码语言javascriptAI代码解释Task: Processing iteration 0 Task: Processing iteration 1 UI: Updating UI, count 1 Task: Processing iteration 2 UI: Updating UI, count 2 Task: Processing iteration 3 UI: Updating UI, count 3 Task: Processing iteration 4 UI: Updating UI, count 4 Task: Processing iteration 5 UI: Updating UI, count 5 Task: Processing iteration 6 UI: Updating UI, count 6 Task: Processing iteration 7 UI: Updating UI, count 7 Task: Processing iteration 8 UI: Updating UI, count 8 Task: Processing iteration 9 UI: Updating UI, count 9 Task: Completed! UI: Updating UI, count 104.2 异步框架在 C/C 程序开发中由于应用方向偏底层使用协程的情况比较少。即使是需要使用到协程的场景使用线程池任务队列的方案就可以平替掉。这其中还存在一个问题那就是 C/C 是 Native 环境没有语言运行时或者框架来帮助你构建一个异步的环境提供异步 I/O 的接口——那么使用协程的意义也不大因为协程往往需要异步机制的支持否则就会退化为同步代码。比如说笔者这里使用 C 的 Qt 环境进行 GUI 界面开发如果将 4.1 节的示例代码直接 移植到 Qt 的主线程中运行例如在某个按钮点击槽函数中执行这个 while 循环协程就可以正常运行并且界面不卡吗答案肯定是不行的即使笔者把 update_ui() 改成更新 QProgressBarQt 的界面仍然会卡死直到任务最终完成。这是因为代码中的 while 循环会完全占据 Qt 主线程不会返回控制权给 Qt 事件循环QEventLoop导致界面无法重绘进度条不动、窗口白屏即使调用了 progressBar-setValue(50)也不会立即显示因为重绘事件被阻塞了。