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

1 引言

在图形界面(GUI)应用中,“卡顿”几乎是所有开发者都会遇到的老问题。一次复杂的计算、一次网络请求、一次磁盘读取,甚至一次大循环,都可能让界面在几百毫秒内完全失去响应,用户看到的就是——窗口半透明、按钮点不动、程序像“假死”了一样。

过去,在 C++ 程序中解决卡顿最常见的方法是:加一个线程。再加一个线程。然后用锁把它们绑在一起。但随着项目复杂度提升,多线程的调度开销、锁竞争、死锁风险,也让不少开发者叫苦不迭。而在另一些语言里——比如 JavaScript、C#、Python——同样的问题却可以用更轻量、更优雅的方式解决:异步 I/O + 协程(Coroutine)

协程并不是线程的替代品,而是一种更贴近业务逻辑的结构化异步方式。得益于协程,你可以写出“看起来同步、实际上异步”的代码;你可以在单线程中实现并发;你可以让 GUI 始终流畅响应,而复杂任务在后台悄然推进。

本文将从最基础的概念——同步与异步、线程与协程——逐步展开,解释为什么协程能让程序不再卡顿,并通过完整的 C++ 协程/Boost.Coroutine 实战展示其内部原理与适用场景。

2 基础

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

2.1 同步 VS 异步

所谓同步(Synchronous),是指调用一个函数时,必须等它执行完才能继续执行下一行。例如:

auto img = LoadImage(); // 在这里等待
Render(img);

因此同步特点是当前线程会被阻塞,调用者需要等待才能继续执行。

所谓异步(Asynchronous),是指发起任务后不等待,任务完成后再通过回调、future、信号等方式告诉调用者。例如:

LoadImageAsync([](Image img){
    Render(img);  // 回调在之后执行
});

因此异步的特点是当前线程不会被阻塞,调用者不需要等待就能继续执行。

2.2 协程的本质

协程(Coroutine)是一种可以主动让出执行权的“轻量级函数”。它允许函数在中途暂停(yield),稍后继续执行。通常具备如下几点要素:

  1. 可挂起(Suspend)
  2. 可恢复(Resume)
  3. 保持自己的栈和上下文(但非常轻量)
  4. 不需要线程切换(协程在当前线程运行)

线程是真实由 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 做的事情是让函数可以 挂起(暂停)

let x = await fetch(url); // 在这里挂起

然后 恢复执行

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 的回调函数

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

  • 如果驱动/库是异步的,那么回调变成异步回调。
  • 如果是同步调用,那么回调就是普通函数调用。

例如同步回调:

int process(int x, int(*cb)(int)) {
    return cb(x); // 立即调用
}

异步回调:

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
async Task<string> 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 的 IEnumerator

Unity也可以使用 C# 的 async/await ,但是 Unity 底层可能没有真正的异步 I/O 支持(尤其在 WebGL 或移动平台),从而造成主线程阻塞。

Unity 还引入了另外一种伪协程(pseudo-coroutine)机制——IEnumerator,用于在单线程游戏主循环中实现“看似并发”的逻辑控制。它不是真正意义上的协程(没有独立栈、不能跨线程、不基于异步 I/O),但通过 迭代器(iterator) + 主循环调度 模拟了挂起与恢复的行为。

例如:

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 无栈协程

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 的协程实现代码如下所示:

#include <boost/coroutine2/all.hpp>

using namespace std;

// 模拟“耗时任务”
void long_running_task(boost::coroutines2::coroutine<void>::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;

  // 创建协程,绑定耗时任务
  coroutine<void>::pull_type task_source(
      [&](coroutine<void>::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 :

using namespace boost::coroutines2;

coroutine<void>::pull_type task_source(
    [&](coroutine<void>::push_type& yield) {
        long_running_task(yield);
    });

其中:

  • coroutine :定义一个协程,不传递值(若需传值,可用 coroutine 等)。
  • pull_type :协程的“拉取端”,代表主控上下文,用于启动和恢复协程。
  • push_type:协程的“推送端”,在协程体内用于挂起并交出控制权。
  • yield:是一个函数对象,调用 yield() 即挂起当前协程,返回主控上下文。

形象的说,pull_type 是“消费者”(主控方),push_type 是“生产者”(协程体),而协程通过 yield 返回控制权给 pull 端,这其实是一种非对称协程的设计。

运行结果如下:

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 = 10

4.2 异步框架

在 C/C++ 程序开发中,由于应用方向偏底层,使用协程的情况比较少。即使是需要使用到协程的场景,使用线程池+任务队列的方案就可以平替掉。这其中还存在一个问题,那就是 C/C++ 是 Native 环境,没有语言运行时或者框架来帮助你构建一个异步的环境,提供异步 I/O 的接口——那么使用协程的意义也不大:因为协程往往需要异步机制的支持,否则就会退化为同步代码。

比如说,笔者这里使用 C++ 的 Qt 环境进行 GUI 界面开发,如果将 4.1 节的示例代码直接 移植到 Qt 的主线程中运行(例如在某个按钮点击槽函数中执行这个 while 循环),协程就可以正常运行并且界面不卡吗?答案肯定是不行的,即使笔者把 update_ui() 改成更新 QProgressBar,Qt 的界面仍然会卡死,直到任务最终完成。这是因为代码中的 while 循环会完全占据 Qt 主线程,不会返回控制权给 Qt 事件循环(QEventLoop),导致界面无法重绘(进度条不动、窗口白屏),即使调用了 progressBar->setValue(50),也不会立即显示,因为重绘事件被阻塞了。

Qt 的 UI 更新(包括 setValue、repaint、update)只是将重绘请求放入事件队列,必须等当前函数退出、回到 QApplication::exec() 的事件循环后才会真正绘制。

在 Web 的浏览器环境中,协程通常配合异步 I/O 接口来实现;那么在 Qt 环境中,就应该配合 Qt 的异步环境,也就是 Qt 事件循环。更为具体的说,可以使用定时器组件QTimer的信号槽。具体实例如下所示:

#include <QApplication>
#include <QProgressBar>
#include <QThread>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
#include <boost/coroutine2/all.hpp>
#include <iostream>

// 声明协程类型
using coroutine_t = boost::coroutines2::coroutine<void>;

// 模拟耗时的子函数,支持 yield
void do_heavy_subtask(int outer_i, coroutine_t::push_type& yield) {
  for (int j = 1; j <= 20; ++j) {
    QThread::msleep(200);  // 模拟子任务耗时
    std::cout << "    Subtask: " << outer_i << "." << j << std::endl;
    yield();  // 子任务也可以让出执行权
  }
}

class CoroutineWidget : public QWidget {
  Q_OBJECT

 public:
  CoroutineWidget(QWidget* parent = nullptr)
      : QWidget(parent), progress(0), timer(new QTimer(this)) {
    progressBar = new QProgressBar(this);
    progressBar->setRange(0, 100);
    progressBar->setValue(0);

    auto* layout = new QVBoxLayout(this);
    layout->addWidget(progressBar);

    // 外层协程体
    coroutine = std::make_unique<coroutine_t::pull_type>(
        [this](coroutine_t::push_type& yield) {
          for (int i = 1; i <= 10; ++i) {
            QThread::msleep(300);  // 模拟主任务耗时
            this->progress = i * 10;
            std::cout << "Main loop i = " << i << std::endl;

            // 调用耗时子任务函数,并传递 yield
            do_heavy_subtask(i, yield);

            yield();  // 主任务也可以选择挂起
          }
        });

    connect(timer, &QTimer::timeout, this, &CoroutineWidget::updateTask);
    timer->start(100);  // 每100ms调用一次
  }

 private slots:
  void updateTask() {
    if (*coroutine) {
      (*coroutine)();  // 执行协程一小段
      std::cout << progress << std::endl;
      progressBar->setValue(progress);  // 更新 UI
    } else {
      timer->stop();
    }
  }

 private:
  using coroutine_t = boost::coroutines2::coroutine<void>;

  int progress = 0;
  QProgressBar* progressBar;
  QTimer* timer;
  std::unique_ptr<coroutine_t::pull_type> coroutine;
};

#include "main.moc"

int main(int argc, char* argv[]) {
  QApplication app(argc, argv);

  CoroutineWidget w;
  w.show();

  return app.exec();
}

在这段代码实现中,不仅实现了在协程体的顶层函数中yield

// 外层协程体
coroutine = std::make_unique<coroutine_t::pull_type>(
    [this](coroutine_t::push_type& yield) {
      for (int i = 1; i <= 10; ++i) {
        QThread::msleep(300);  // 模拟主任务耗时
        this->progress = i * 10;
        std::cout << "Main loop i = " << i << std::endl;

        // 调用耗时子任务函数,并传递 yield
        do_heavy_subtask(i, yield);

        yield();  // 主任务也可以选择挂起
      }
    });

还实现了在顶层函数的子函数中yield

// 模拟耗时的子函数,支持 yield
void do_heavy_subtask(int outer_i, coroutine_t::push_type& yield) {
  for (int j = 1; j <= 20; ++j) {
    QThread::msleep(200);  // 模拟子任务耗时
    std::cout << "    Subtask: " << outer_i << "." << j << std::endl;
    yield();  // 子任务也可以让出执行权
  }
}

这是因为虽然无栈协程并不支持任意挂起,但是可以把 yield(即 push_type&)作为参数传递给子函数,从而实现了细粒度的让出控制。

另一方面,通过定时器 QTimer 来恢复控制权:

connect(timer, &QTimer::timeout, this, &CoroutineWidget::updateTask);
timer->start(100);  // 每100ms调用一次

在响应函数而不是 while 循环中执行协程和 UI 更新:

void updateTask() {
  if (*coroutine) {
    (*coroutine)();  // 执行协程一小段
    std::cout << progress << std::endl;
    progressBar->setValue(progress);  // 更新 UI
  } else {
    timer->stop();
  }
}

从而实现了协程与 Qt 异步模型(事件循环 + 信号槽)的融合,改善了界面交互。

4.3 深入认识

从上述两个实例中可以看到,协程有个很重要的优势,就是可以写出看起来像同步顺序执行,但实际上可以挂起/恢复的异步代码。换句话说,协程只是 让异步代码长得像同步的语法工具。协程本身不会加速单个任务的执行,也不会带来真正的并行计算(CPU 并发),但是可以极大提升 I/O 密集型应用的吞吐量。此外还具有另外两点优势:

第1点是解决了异步代码难以维护的问题。比如说回调函数,它将“时间上的先后顺序”强行转换为“空间上的嵌套结构”,破坏了代码的线性逻辑,导致可读性、可维护性、可扩展性全面下降:

readFile(a, (x)=>{
  readFile(b, (y)=>{
    db.query(z, ()=>{
      ...
    });
  });
});

而协程将异步操作“拉回”线性执行流,使异步代码在语法和逻辑上都保持同步风格的清晰结构,从而在不阻塞线程的前提下,显著提升了可读性、可维护性和开发体验:

let x = await readFile(a);
let y = await readFile(b);
await db.query(z);

第2点则是显著改善了用户的 GUI 交互体验,有效避免后台任务导致界面卡顿或无响应。以 Qt 环境中来说,虽然 Qt 更加推荐使用 QThread + 信号槽的方式来解决卡顿的问题,但是其实不是所有的任务都可以放在后台的线程中执行的。比如渲染绘制任务,通过 QPainter 绘制二维图形只允许在主线程中进行,这个时候就可以通过协程来分担比较繁重的绘制任务。

5 优化

笔者使用协程是想通过协程改进地图的绘制。绝大多数情况下,二维/三维图形的绘制都是只能在主线程中进行,但是地图的绘制任务可能会随着图层的增加而更加繁重,造成 GUI 页面卡顿。在这种情况下,协程可以将多个图层的绘制任务拆碎,每绘制一个图层,就通过 yield 让出控制权;配合定时器QTimer的信号槽机制,在 GUI 空闲的时候恢复控制权进行持续绘制,从而改善地图 GUI 的绘制交互体验。

更进一步的,如果单个图层内部的绘制也很耗时,那么可以在单个图层内部通过协程进一步拆分任务,实现颗粒度更细的让出控制。比如绘制瓦片地图,如果等所有的地图瓦片都经过远端读取->合并处理->可视化绘制的流程,那么地图 GUI 界面一定很卡,因为涉及到的地图瓦片可能非常多。因此,合理的处理方法就是每绘制一个地图瓦片就 yield 让出控制权;这样界面就能在绘制过程中持续响应用户的操作——比如平移、缩放或点击——从而让用户获得更流畅、更即时的交互体验。

另一个典型的例子是 QGIS/ArcGIS 等软件加载矢量地图。如果数据量很大,矢量地图往往是分批加载,地图是分批可视化出来的,并且GUI界面完全不卡。这种技术完全可以通过协程来实现。
QGIS 加载大数据量矢量地图

不过,即使采取上述协程的操作也不是完全就没有问题了。关键的地方就在于远端访问并不是一个确定就能成功的操作,访问失败,访问超时,访问成功但是耗时很长都是很常见的现象。在这种情况下,即使每次只绘制一个瓦片,也很可能会造成 GUI 界面卡顿的问题。问题的关键就在于,C++ 环境下大多数远端访问的接口都是同步操作,没有异步 I/O 接口的支持,协程就不能最大程度的发挥价值。

据说 C++ 还是有一些远端访问的异步接口实现,但是笔者没有尝试去找。说到底要提升速度确实也只能依靠多线程并行,远端访问的操作确实也很适合放到多线程中。不过使用线程也有问题,因为线程的代价很高,不能需要获取一个瓦片就启动一个线程,那么就需要使用线程池。另一方面,也要考虑到获取瓦片的线程与主线程如何通信的问题,可以使用线程异步 std::future。伪代码如下所示:

std::future<std::shared_ptr<cv::Mat>> GetTileAsync(
    const std::string& remoteAddress,
    const std::filesystem::path& localFilePath) {
  return threadPool.Submit([this, remoteAddress, localFilePath]() {
    return GetTile(remoteAddress, localFilePath);
  });
}

void Read(){
  //...

  //异步下载瓦片
  auto future = GetTileAsync(tileAddress, cachePath);

  // 主动轮询 future 状态,未完成时 yield 出去
  while (future.wait_for(std::chrono::milliseconds(0)) !=
        std::future_status::ready) {
    if (yield) yield();
  }

  std::shared_ptr<cv::Mat> img = future.get();
  if (!img) continue;

  //...
}

这段代码的意思是,主动轮询 future 状态:如果线程池中的任务完成,就继续往下执行;如果线程池中的任务没有完成,就 yield 让出。这样既不会堵塞住主线程,也可以让获取远端瓦片的任务放在主线程,同时保证用户的体验和性能效率。

在这里,笔者想说的是协程并不是万能的,尤其是在 C/C++ 环境中可能缺少的异步机制的支持,可能还是需要多线程的支持。另外,笔者的实践可能也不是最好的,比如说 while 轮询,间隔时间长了会造成堵塞;间隔时间短了又会造成 CPU 空转。有时间的话再进行进一步改进。

posted @ 2025-12-19 08:57  charlee44  阅读(332)  评论(0)    收藏  举报