跳转到正文
zeno's blog
返回

asio(四):异步模型演进-从回调到C++20协程

专题: Asio

Table of contents

Open Table of contents

TL;DR

Asio 的异步操作只写一次实现,通过 async_result<CompletionToken, Signature> trait 自动适配回调、stackful 协程、stackless 协程、C++20 协程和 future。这是 Asio 最精妙的设计——一个 async_read 实现同时支持五种异步风格,新增风格只需特化一个 trait。


先看结果:同一个操作,五种写法

以「从 socket 异步读取数据」为例,同一个 async_read_some 支持所有写法:

1. 回调(Callback)

socket.async_read_some(asio::buffer(buf),
    [](asio::error_code ec, std::size_t bytes) {
        if (!ec) {
            // 处理数据
        }
    });

最原始的形式。handler 是一个函数对象(lambda/函数指针/bind 表达式),操作完成时被调用。

2. Asio 自带的 Stackless 协程

struct session : asio::coroutine {
    tcp::socket& socket_;
    std::array<char, 1024> buf_;

    void operator()(asio::error_code ec = {}, std::size_t n = 0) {
        reenter(this) {
            for (;;) {
                yield socket_.async_read_some(asio::buffer(buf_), *this);
                yield async_write(socket_, asio::buffer(buf_, n), *this);
            }
        }
    }
};

使用预处理器宏(reenteryield)实现,底层是 Duff’s Device(switch 语句技巧)。每个 yield 点记录行号,下次调用时跳转到上次暂停的位置。

不需要分配协程栈,状态存储在成员变量中。代价是语法怪异、调试困难。

3. Stackful 协程(boost::asio::spawn)

asio::spawn(io, [&](asio::yield_context yield) {
    for (;;) {
        auto n = socket.async_read_some(asio::buffer(buf), yield);
        async_write(socket, asio::buffer(buf, n), yield);
    }
});

基于 Boost.Context 纤程。每个协程有自己的执行栈(默认 64KB-1MB)。yield 作为完成令牌传入,挂起协程直到操作完成。

写起来像同步代码,可以在任意调用深度挂起。代价是每个协程都要分配栈内存。

错误处理:默认抛 system_error 异常。用 yield[ec] 捕获错误码:

asio::error_code ec;
auto n = socket.async_read_some(asio::buffer(buf), yield[ec]);
if (ec) { /* 处理错误 */ }

4. C++20 协程(co_await + use_awaitable)

asio::awaitable<void> session(tcp::socket socket) {
    std::array<char, 1024> buf;
    for (;;) {
        auto n = co_await socket.async_read_some(
            asio::buffer(buf), asio::use_awaitable);
        co_await async_write(socket,
            asio::buffer(buf, n), asio::use_awaitable);
    }
}

// 启动协程
asio::co_spawn(io, session(std::move(socket)), asio::detached);

编译器生成状态机,不需要分配协程栈(只分配编译器计算的 frame 大小)。语法最自然。

错误处理:默认把 error_code 转换为 system_error 异常抛出。三种替代方式:

// 方式 1:as_tuple —— 返回 tuple<error_code, result>
auto [ec, n] = co_await socket.async_read_some(
    asio::buffer(buf), asio::as_tuple(asio::use_awaitable));

// 方式 2:redirect_error —— 将错误码写入变量
asio::error_code ec;
auto n = co_await socket.async_read_some(
    asio::buffer(buf), asio::redirect_error(asio::use_awaitable, ec));

// 方式 3:自定义 awaitable 的默认 token
using default_token = asio::as_tuple_t<asio::use_awaitable_t<>>;
using tcp_socket = default_token::as_default_on_t<asio::ip::tcp::socket>;
// 之后所有操作默认返回 tuple,不需要每次写 as_tuple

5. std::future(use_future)

std::future<std::size_t> fut =
    socket.async_read_some(asio::buffer(buf), asio::use_future);

// 在其他地方等待结果
auto n = fut.get();  // 阻塞直到完成

std::future 桥接异步和同步世界。通常只在需要和非 Asio 代码集成时使用。

完成令牌(Completion Token)机制:一次实现,多种适配

核心问题

如果每种异步风格都要写一遍实现,Asio 的每个异步操作(async_readasync_writeasync_connect…)都要维护五份代码。这不现实。

解决方案:async_result trait

async_result<CompletionToken, Signature> 是一个 trait,负责将完成令牌转换为实际的 handler,并决定发起函数的返回类型:

发起函数调用路径:

socket.async_read_some(buffer, token)

  ├─ token = lambda           → async_result 直接用 lambda 作为 handler,返回 void
  ├─ token = use_awaitable    → async_result 创建 awaitable 适配器,返回 awaitable<T>
  ├─ token = use_future       → async_result 创建 promise/future 对,返回 future<T>
  ├─ token = yield_context    → async_result 挂起当前协程,返回 T
  └─ token = deferred         → async_result 创建延迟执行器,返回可调用对象

具体来说,每种完成令牌通过特化 async_result 实现适配:

// 伪代码:async_result 的核心概念
template <typename CompletionToken, typename Signature>
struct async_result {
    using handler_type = /* 从 token 推导出的 handler 类型 */;
    using return_type = /* 发起函数的返回类型 */;

    explicit async_result(handler_type& h);

    return_type get();  // 返回发起函数的返回值
};

为什么这很重要

写一个新的异步操作时,只需要通过 async_initiate 描述操作逻辑:

template <typename CompletionToken>
auto async_my_operation(tcp::socket& socket, CompletionToken&& token) {
    return asio::async_initiate<CompletionToken, void(asio::error_code)>(
        [&socket](auto handler) {
            // 操作逻辑:只写一次
            // handler 的实际类型由 async_result 决定
        },
        token);
}

这个操作自动支持所有完成令牌——回调、协程、future、deferred——不需要额外代码。

deferred:惰性组合的利器

deferred(Boost 1.78+)是一种特殊的完成令牌,它不立即启动操作,而是返回一个可调用对象,调用时才启动:

// 创建一个延迟操作(不会立即执行)
auto op = socket.async_read_some(asio::buffer(buf), asio::deferred);

// 之后再决定如何执行
op(actual_handler);  // 用回调
co_await std::move(op);  // 或者 co_await

deferred 的价值在于操作组合——你可以先构建一个操作链,再一次性启动:

auto read_then_write = socket.async_read_some(asio::buffer(buf), asio::deferred)
    | asio::deferred([&](asio::error_code ec, std::size_t n) {
        return async_write(socket, asio::buffer(buf, n), asio::deferred);
    });

co_await std::move(read_then_write);

相比 use_awaitabledeferred 的优势是不为延迟操作本身分配协程帧。

协程取消(Cancellation in Coroutines)

C++20 协程中的取消机制:

asio::awaitable<void> my_coro() {
    // 查询当前取消状态
    auto cs = co_await asio::this_coro::cancellation_state;

    // 修改支持的取消类型
    co_await asio::this_coro::reset_cancellation_state(
        asio::enable_total_cancellation());

    // 操作...
}

新创建的协程默认只支持 cancellation_type::terminal(最安全的取消级别)。

取消类型的三个级别:

级别含义安全性
terminal取消后 I/O 对象只能 close/destroy最安全
partial取消可能有部分副作用(报告已传输字节数)中等
total取消无可观察副作用最灵活

各模型对比

维度回调Stackless 协程Stackful 协程C++20 协程future
C++ 版本C++11C++03C++11C++20C++11
内存开销最小最小每协程一个栈(64KB+)编译器计算的帧promise/future 对
挂起点不适用显式(yield 宏)任意调用深度显式(co_await)不适用
错误处理error_code 参数error_code 参数异常或 ec异常或 as_tuple异常
可读性差(嵌套)差(宏)最好中等
可组合性中等最好
调试难度中等困难中等中等简单

推荐选择

版本时间线

版本异步模型里程碑
Boost 1.47 (2011)signal_set
Boost 1.54 (2013)stackful 协程(spawn/yield_context)
Boost 1.72 (2019)C++20 协程支持(awaitable, use_awaitable, co_spawn)
Boost 1.78 (2021)deferred 完成令牌
Boost 1.80 (2022)experimental::parallel_group
Boost 1.84+ (2024)co_composed 改进

分享这篇文章:

上一篇
asio(五):操作系统I/O多路复用-epoll、kqueue、IOCP如何被统一
下一篇
asio(三):组件拆分-strand、timer、socket、buffer如何协作