找回密码
 立即注册
首页 业界区 业界 为什么协程能让程序不再卡顿?——从同步、异步到 C++ ...

为什么协程能让程序不再卡顿?——从同步、异步到 C++ 实战

孟清妍 昨天 09:55
1 引言

在图形界面(GUI)应用中,“卡顿”几乎是所有开发者都会遇到的老问题。一次复杂的计算、一次网络请求、一次磁盘读取,甚至一次大循环,都可能让界面在几百毫秒内完全失去响应,用户看到的就是——窗口半透明、按钮点不动、程序像“假死”了一样。
过去,在 C++ 程序中解决卡顿最常见的方法是:加一个线程。再加一个线程。然后用锁把它们绑在一起。但随着项目复杂度提升,多线程的调度开销、锁竞争、死锁风险,也让不少开发者叫苦不迭。而在另一些语言里——比如 JavaScript、C#、Python——同样的问题却可以用更轻量、更优雅的方式解决:异步 I/O + 协程(Coroutine)
协程并不是线程的替代品,而是一种更贴近业务逻辑的结构化异步方式。得益于协程,你可以写出“看起来同步、实际上异步”的代码;你可以在单线程中实现并发;你可以让 GUI 始终流畅响应,而复杂任务在后台悄然推进。
本文将从最基础的概念——同步与异步、线程与协程——逐步展开,解释为什么协程能让程序不再卡顿,并通过完整的 C++ 协程/Boost.Coroutine 实战展示其内部原理与适用场景。
2 基础

在进行实战之前,先学习一些比较基础的知识。
2.1 同步 VS 异步

所谓同步(Synchronous),是指调用一个函数时,必须等它执行完才能继续执行下一行。例如:
  1. auto img = LoadImage(); // 在这里等待
  2. Render(img);
复制代码
因此同步特点是当前线程会被阻塞,调用者需要等待才能继续执行。
所谓异步(Asynchronous),是指发起任务后不等待,任务完成后再通过回调、future、信号等方式告诉调用者。例如:
  1. LoadImageAsync([](Image img){
  2.     Render(img);  // 回调在之后执行
  3. });
复制代码
因此异步的特点是当前线程不会被阻塞,调用者不需要等待就能继续执行。
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、C++20 coroutine)都推荐协程,而不是大量线程。当然也不是绝对,使用线程池+任务队列的方式也可以达到同样的效果,不过实现起来复杂度较高,也不如使用协程的方案稳健,性能提升也有限。
2.4 协程和异步

很显然,多线程是实现异步的一种方式。不过,多线程的问题就是太异步了,两个线程被创建之后就如同两条平行线,相互之间不再有任何关联。但在实际的程序开发中,相互之间不进行联系的情况比较少,一般需要在关键的节点进行线程同步。
单线程同样可以实现异步。具体来说,通过 单线程 + 异步 I/O + 协程 方案,也可以实现满足超高并发需求的异步,这种方案在JavaScript、Python等环境中非常常见。正如前文中提到的,协程是一种“可以暂停/恢复”的轻量函数,因此,协程可以写出像同步代码一样的结构,通过“暂停—等结果回来—继续”的机制来实现异步。这种机制通常通过类似 yield() 的语法糖来控制。当然,如果协程体中从不 yield() ,或者没有异步 I/O 环境的支持,这个异步函数实现就会退化成同步函数。
协程本身不是为 GUI 开发设计的,但在 GUI(Qt、Unity、JavaScript)中常用协程解决卡顿,因为:

  • GUI 主线程不能长时间执行耗时操作
  • 协程能把耗时任务拆成小碎片
  • 每片执行后 yield,交回主线程让 UI 刷新
3 实现

异步实现在 JavaScript 中几乎随处可见,可以说 JavaScript 就是一门建立在异步实现上的编程语言。在 JavaScript 中,常见的异步实现有:
技术是否异步本质回调 callback✔异步通知函数Promise✔异步状态机async/await✔异步协程(基于 Promise)DOM 事件、定时器✔事件循环驱动的异步任务虽然以上实现的异步机制实现本质不同,但是最终都依赖于 event loop(事件循环)调度,而不是线程。
接下来具体探究一些协程或者异步的实现,不局限于 JavaScript :
3.1  JS 的 async/await

JS 的 async/await 是协程的一种“语法级实现”,严格来说是协程的语法糖。因为 async/await 做的事情是让函数可以 挂起(暂停)
  1. let x = await fetch(url); // 在这里挂起
复制代码
然后 恢复执行
  1. console.log(x); // 恢复后继续
复制代码
这个行为就是“协程的本质”:可挂起、可恢复、用户态调度,当然最终是通过 JS runtime 事件循环调度来实现。虽然 JS 语法没有暴露“yield control to scheduler”这样的命令,但其行为确实和协程一致。因此,JS 的 async/await 是异步协程(asynchronous coroutine)的一种形式。
3.2 JS 的 Promise

JS 的 Promise 不能暂停函数,不能恢复执行点,也没有栈帧保存能力,因此并不是协程。Promise 是一种 异步状态机,能够表达 3 个状态:pending → fulfilled / rejected 。是用于管理异步结果的 数据结构(异步容器)。当然,async/await 本身是用 Promise 作为底层机制 实现的。
3.3 Qt 的信号/槽

Qt 的信号槽 “有时异步,有时同步”,取决于连接类型:
Qt::ConnectionType同步/异步?DirectConnection同步(立即调用)QueuedConnection异步(事件队列中排队执行)AutoConnection取决于接收者是否在另一个线程如果是跨线程或 QueuedConnection,就是异步模型,使用事件队列 + 调度器来执行槽函数。但是 Qt 的异步不是协程,它是事件驱动,不会“暂停函数并恢复”。
3.4 C 的回调函数

回调本身不是异步。回调只是一个函数指针,什么时候执行取决于调用者;是否异步由调用者决定:

  • 如果驱动/库是异步的,那么回调变成异步回调。
  • 如果是同步调用,那么回调就是普通函数调用。
例如同步回调:
  1. int process(int x, int(*cb)(int)) {
  2.     return cb(x); // 立即调用
  3. }
复制代码
异步回调:
  1. 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)
  1. async Task<string> FetchDataAsync()
  2. {
  3.     var client = new HttpClient();
  4.     string data = await client.GetStringAsync("https://api.example.com"); // 挂起
  5.     Console.WriteLine(data); // 恢复
  6.     return data;
  7. }
复制代码
这完全符合“协程”定义:可挂起、可恢复、用户态调度(由 .NET Task Scheduler 驱动)
因此,C# 的 async/await 是 真正的轻量级协程(asynchronous coroutine),比 JS 更接近系统级协程(如 Go 的 goroutine),尽管仍基于回调和状态机而非独立栈。
3.6 Unity 的 IEnumerator

Unity也可以使用 C# 的 async/await ,但是 Unity 底层可能没有真正的异步 I/O 支持(尤其在 WebGL 或移动平台),从而造成主线程阻塞。
Unity 还引入了另外一种伪协程(pseudo-coroutine)机制——IEnumerator,用于在单线程游戏主循环中实现“看似并发”的逻辑控制。它不是真正意义上的协程(没有独立栈、不能跨线程、不基于异步 I/O),但通过 迭代器(iterator) + 主循环调度 模拟了挂起与恢复的行为。
例如:
  1. IEnumerator CountDown()
  2. {
  3.     for (int i = 3; i > 0; i--)
  4.     {
  5.         Debug.Log(i);
  6.         yield return new WaitForSeconds(1); // 挂起 1 秒
  7.     }
  8.     Debug.Log("Go!");
  9. }
  10. // 启动协程
  11. StartCoroutine(CountDown());
复制代码
其中:

  • yield return 是挂起点:函数在此处暂停,控制权交还给 Unity 引擎。
  • Unity 主循环每帧检查协程状态,当条件满足(如时间到、帧结束等),从挂起点继续执行
Unity 的 IEnumerator 更准确地说是一种 基于帧的协作式任务调度器,而非语言级协程。
4. 实例

4.1 无栈协程

C++20 已经提供了一种原生的协程方案 co_await/co_yield,新项目如果能使用 C++20 推荐使用。不过 C++20 相对于 C++17 的变动还是不小,笔者使用的还是 C++17,那么就可以使用 boost 的协程方案 boost::coroutines2 。
无论是 boost::coroutines2 还是 co_await/co_yield,都是无栈协程的一种实现。所谓无栈协程,指的是协程没有独立的调用栈,其局部变量和执行状态由编译器或库通过状态机保存在堆上。与之相对的是有栈协程,拥有自己的栈空间,可任意挂起点(包括深层函数调用中)。应该来说,主流语言倾向无栈协程,JS、C#、Python、Rust、C++20 都选择了无栈模型,因其与事件循环、Future/Promise 模型天然契合,且内存效率极高。
一个使用 boost::coroutines2 的协程实现代码如下所示:
[code]#include using namespace std;// 模拟“耗时任务”void long_running_task(boost::coroutines2::coroutine::push_type& yield) {  for (int i = 0; i < 10; ++i) {    std::cout

相关推荐

您需要登录后才可以回帖 登录 | 立即注册