深入浅出C++20协程:从async/await到任务调度

在 C++20 标准中,协程(Coroutine)被正式纳入语言特性,为异步编程提供了更直观、更高效的支持。本文将从协程的基本概念入手,逐步展开 async/await 的实现细节,并讨论如何在实际项目中利用协程实现任务调度、并发 IO 与高性能网络编程。


1. 协程基础

1.1 什么是协程?

协程是一种可挂起的函数,允许在执行过程中暂停(yield)并在需要时恢复。与传统的线程或回调机制相比,协程可以:

  • 保持状态:函数内部的局部变量在挂起后依然保留,继续执行时从同一状态恢复。
  • 无栈分配:协程在调用时只分配一个协程句柄(std::coroutine_handle),不需要为每个协程单独分配完整的线程栈。
  • 编译器生成:C++ 编译器将协程代码转换为状态机,透明地管理挂起与恢复逻辑。

1.2 协程关键字

C++20 中引入了四个新关键字:

  • co_await:挂起当前协程并等待异步操作完成。
  • co_yield:在协程内部产生一个值,类似生成器。
  • co_return:返回协程结果。
  • co_spawn:C++23 引入,用于启动协程。

2. async/await 的实现细节

2.1 异步操作的封装

在 C++20 中,最常用的异步容器是 std::futurestd::promise,但它们是同步等待的。要真正支持 co_await,需要自定义 awaitable 对象。例如:

struct Timer {
    std::chrono::milliseconds duration;
    std::future <void> await() const {
        return std::async(std::launch::async, [=]() {
            std::this_thread::sleep_for(duration);
        });
    }
};

auto timer = Timer{std::chrono::milliseconds(1000)};
co_await timer;   // 会挂起协程,等待1秒后恢复

2.2 awaiter 的四个必备函数

一个满足 awaitable 接口的对象,需要实现以下四个成员函数:

  1. bool await_ready() noexcept;
    判断是否需要挂起。若返回 true,协程不挂起直接继续。

  2. void await_suspend(std::coroutine_handle<> h) noexcept;
    await_ready 返回 false 时调用。该函数负责注册恢复逻辑,例如将协程句柄加入事件循环。

  3. void await_resume() noexcept;
    协程恢复后调用,返回值可传递给调用者。

  4. using await_resume_t = ...;
    await_resume 的返回类型。

2.3 简单的事件循环

下面给出一个极简的事件循环实现,用于挂起和恢复协程:

class EventLoop {
public:
    void run() {
        while (!tasks.empty()) {
            auto task = tasks.front(); tasks.pop();
            task();   // 调用协程句柄
        }
    }

    void add_task(std::coroutine_handle<> h) {
        tasks.emplace([h](){ h.resume(); });
    }

private:
    std::queue<std::function<void()>> tasks;
};

在 awaiter 的 await_suspend 中将协程句柄注册到 EventLoop

void await_suspend(std::coroutine_handle<> h) noexcept override {
    loop.add_task(h);
}

3. 协程在网络编程中的应用

3.1 协程 + Asio

Boost.Asio 已经支持 C++20 协程。通过 asio::awaitable,可以像编写同步代码那样书写异步网络逻辑:

asio::awaitable <void> do_echo(tcp::socket sock) {
    char data[1024];
    std::size_t n = co_await sock.async_read_some(asio::buffer(data), asio::use_awaitable);
    co_await sock.async_write_some(asio::buffer(data, n), asio::use_awaitable);
}

3.2 高性能 TCP 服务器

借助协程,可以避免繁琐的回调嵌套,降低代码复杂度。结合 asio::strand 或自定义事件循环,能够在单线程中处理数十万连接。

asio::io_context ctx;
tcp::acceptor acceptor(ctx, tcp::endpoint(tcp::v4(), 8080));
for (;;) {
    tcp::socket sock = co_await acceptor.async_accept(asio::use_awaitable);
    co_spawn(ctx, do_echo(std::move(sock)), asio::detached);
}
ctx.run();

4. 协程与多任务调度

4.1 轻量级任务切换

协程句柄的挂起/恢复是 O(1) 操作,无需上下文切换成本。结合自定义调度器,可以实现:

  • 优先级调度:为每个协程指定优先级,调度器根据优先级决定恢复顺序。
  • 时间片轮转:为每个协程分配时间片,时间片用完自动挂起,交给下一个协程。

4.2 任务间通信

协程间可通过 std::promise/std::futureasync_channel 或自定义 awaitable_queue 进行同步。一个常见模式是:

template<class T>
class AwaitableQueue {
    std::queue <T> q;
    std::vector<std::coroutine_handle<>> waiting;
public:
    void push(T&& item) {
        if (!waiting.empty()) {
            waiting.front().resume();
            waiting.pop_front();
        } else {
            q.emplace(std::forward <T>(item));
        }
    }

    awaitable <T> pop() {
        // awaiter 实现 omitted
    }
};

5. 性能与内存考虑

  • 栈占用:协程使用协程句柄,不再需要为每个任务分配完整栈。真正的栈空间只有当协程被挂起时才需要为其生成保存状态的栈帧。
  • 抖动:在高并发场景中,频繁的挂起/恢复可能导致 cache line 抖动。可通过批量事件处理或 await_suspend 内部的轻量级任务队列降低抖动。
  • 异常处理:协程中使用 co_await 时,需要确保异常能被正确捕获并传播。建议在顶层协程中使用 try/catch,或者在 awaiter 的 await_resume 中抛出异常。

6. 结语

C++20 的协程为异步编程提供了接近同步代码的可读性与可维护性,同时保持了高性能与低内存占用。掌握 awaitable 的设计模式、事件循环的实现以及协程与现有网络库(如 Asio)的结合,能够让你在大规模网络、IO 密集型应用中实现高并发、低延迟的系统。希望本文能为你在 C++ 协程之路上提供一份清晰的参考。

发表评论