跳到正文

目录

Asio C++ 库深度拆解:异步网络编程的事实标准

Asio C++ 库深度拆解:异步网络编程的事实标准

核心判断

Asio 常被归进"网络库",但它的设计重心是通用的异步 I/O 调度框架io_context 负责把完成通知分发给你注册的回调,TCP / UDP / 定时器 / 串口只是它支持的一类服务。这个框架先于 C++ 标准协程出现,却早早就确定了"发起异步操作、完成时回调"的 API 形态,后来被 C++20 协程的原生语法承接。C++ 侧的许多高性能服务——游戏服务器、交易所后端、量化行情——直接建立在它之上。

学习目标与前置知识

读完本文,你应该能:

  • 说清 io_context 在一次异步操作里扮演的角色,以及 run() 与线程的关系;
  • 区分 Proactor 与 Reactor,解释为什么 Windows 与 Linux 对外语义一致、内部实现却不同;
  • 看懂并自己写出回调风格的 echo 服务,明白 async_read_someasync_read 的差别;
  • 需要共享状态时,用 strand 划定串行边界,而不是给每个 handler 加锁;
  • 判断项目该不该引入 Asio,以及回调和协程两种写法各自的代价。

前置要求不高:能读现代 C++(泛型、闭包、智能指针)。正文示例基于 C++11 及以上;协程一章需要 C++20 编译器(GCC 10+、Clang 14+、MSVC 19.28+)。下文聚焦心智模型,不展开构建系统细节。

项目坐标

维度数据
仓库chriskohlhoff/asio(独立版)/ Boost.Asio(Boost 子集)
Stars约 6.2k(波动,以仓库为准)
主语言现代 C++(C++11 / C++14 / C++17 / C++20)
LicenseBSL-1.0(独立版)/ Boost Software License(Boost 版)
起源作者 Christopher Kohlhoff,2003 年起持续维护
关系独立版是"上游",Boost 版本周期同步;截至 2026 年独立版已到 1.38.x

两个版本共享同一份代码库——独立版在 BSL-1.0 下发布,Boost 版由 Boost 维护者同步打包。独立版是纯头文件库:拉下来把 include 目录加进编译路径即可,无需额外链接库文件。若项目禁用 Boost,直接拉源码编译,不引入 Boost 依赖。

整体结构先有个地图,后面的章节按它展开:

      业务代码(协程 co_await / 回调 handler)
                    │  发起异步操作
        ┌───────────▼────────────┐
        │      io_context        │   事件分发 + 待执行队列
        └───────────┬────────────┘
                    │ 执行器 executor / strand(串行化)
       ┌────────────┼─────────────┐
       ▼            ▼             ▼
     epoll        kqueue         IOCP
    (Linux)      (macOS/BSD)   (Windows)
                    │  I/O 完成
        ┌───────────▼────────────┐
        │  完成通知:回调 / future / resume()  │
        └────────────────────────┘

为什么 Asio 重要

在 Asio 出现之前,C++ 网络编程的选择很有限:

  • 原始 BSD socket + select() / poll()——啰嗦,连接多了难以扩展
  • ACE——1990 年代的老牌 C++ 网络框架,API 沉重
  • libevent——2.x 之后不错,但是 C 风格,类型与对象模型弱

Asio 带来三点根本变化:

  1. 类型安全:把 socket、acceptor、serial port 抽象成模板化的"基础服务",许多错误被挪到编译期暴露。
  2. 完成通知式异步:以"操作完成即回调"(Proactor 模型)为一等公民,同一套模型向后延伸到了 C++20 协程。
  3. 跨平台同一 API:Linux 用 epoll,BSD 用 kqueue,Windows 用 IOCP,外部接口一致,没有平台分支。

值得说破的是第 2 点在不同平台上的落地差异。Asio 对外暴露的语义是 Proactor——你发起操作、等它完成、再取结果。但实现分两类:

  • Windows 走 IOCP,是真正的 Proactor。操作系统自己等待 I/O 并向完成端口投递一个完成包,应用直接拿到结果。
  • Linux / BSD 的 epoll、kqueue 本质是 Reactor——只通知"可读/可写"。Asio 在 Reactor 之上补一层:先注册兴趣,等就绪事件,再由 Asio 自己执行一次非阻塞 I/O,然后把完成结果交给 handler。

所以同一套"完成时回调"的 API 在两类平台都成立,代价是 Linux 上比 Windows 多了一次"就绪 → 完成"的内部转发。理解这一点,才能看懂为什么"完成通知"是 Asio 的心智模型,而不是"读写就绪"。

io_context:Asio 的调度核心

所有异步操作都绑定到一个 io_context

#include <asio.hpp>
#include <iostream>

int main() {
    asio::io_context io;

    asio::steady_timer timer(io, std::chrono::seconds(2));
    timer.async_wait([](const asio::error_code& ec) {
        std::cout << "timer fired: " << ec.message() << "\n";
    });

    io.run();   // 阻塞,直到没有待执行工作
}

io_context.run() 内部是事件循环:等待 OS 的完成通知,再把用户注册的 handler 交给执行器执行。可以开多个线程分别调用 run(),待执行的 handler 会被这些 worker 拉取分配,多线程拉取本身不锁;前提是 handler 各自不共享可变状态,若有共享就用 strand(见后文)。

io_context 自身不持线程。多线程并发是这么来的:你先建好若干线程,各自 run() 同一个 io_context;或者用官方封装 asio::thread_pool,由它为你开 worker 线程。另一个常用点是 asio::make_work_guard(io) 创建的 executor_work_guard——它会阻止 run() 在暂时没有任务时提前返回,适合"事件循环要一直活着"的守护进程场景。

一个 TCP echo 服务端

#include <asio.hpp>
#include <memory>
#include <iostream>

using asio::ip::tcp;

class Session : public std::enable_shared_from_this<Session> {
public:
    Session(tcp::socket socket) : socket_(std::move(socket)) {}

    void start() { do_read(); }

private:
    void do_read() {
        auto self = shared_from_this();
        socket_.async_read_some(asio::buffer(data_, max_length),
            [this, self](asio::error_code ec, std::size_t length) {
                if (!ec) {
                    do_write(length);
                }
            });
    }

    void do_write(std::size_t length) {
        auto self = shared_from_this();
        asio::async_write(socket_, asio::buffer(data_, length),
            [this, self](asio::error_code ec, std::size_t /*length*/) {
                if (!ec) do_read();
            });
    }

    tcp::socket socket_;
    enum { max_length = 1024 };
    char data_[max_length];
};

class Server {
public:
    Server(asio::io_context& io, short port)
        : acceptor_(io, tcp::endpoint(tcp::v4(), port)) {
        do_accept();
    }

private:
    void do_accept() {
        acceptor_.async_accept(
            [this](asio::error_code ec, tcp::socket socket) {
                if (!ec) {
                    std::make_shared<Session>(std::move(socket))->start();
                }
                do_accept();
            });
    }

    tcp::acceptor acceptor_;
};

int main(int argc, char* argv[]) {
    if (argc != 2) return 1;
    asio::io_context io;
    Server s(io, std::atoi(argv[1]));
    io.run();
}

这是 Asio 最经典的"回调风格"。两个细节值得记住:

  • 每个异步操作的最后一个参数都是"完成记号"(completion token),默认按回调写法调用 handler;换成别的 token,编译器就为这次操作生成对应的完成形态。
  • async_read_some 一次可能只读到部分数据。echo 对回显无所谓,但解析固定长度协议时必须改用 asio::async_read;后者是组合操作(composed operation),内部循环调用 async_read_some 直到缓冲区收满或出错。asio::async_readasio::async_write 同属这一类组合操作。

完成记号:一次发起,多种写法

同一套异步操作能适配三种书写风格,靠的是"完成记号"(completion token)在编译期选择 handler 的形态:

// 1) 回调:最省事,最简单
socket.async_read_some(asio::buffer(buf),
    [](asio::error_code ec, std::size_t n) { /* ... */ });

// 2) use_future:异步发起,同步等待,适合测试或做同步点
std::future<std::size_t> f = socket.async_read_some(asio::buffer(buf), asio::use_future);

// 3) use_awaitable:配合 C++20 协程,co_await 挂起等待
std::size_t n = co_await socket.async_read_some(asio::buffer(buf), asio::use_awaitable);

关键认识:异步操作本身只有一种实现,完成记号决定"回调怎么被包装"。换个写法不需要重写 I/O 逻辑,这正是 Asio 能在回调、future、协程三种范式间切换的原因。

C++20 协程集成

Boost.Asio 在 1.74(2020 年 8 月,对应独立版 1.19)起支持 C++20 协程。同样一个 echo 服务,可以写成几乎同步的风格:

asio::awaitable<void> session(tcp::socket socket) {
    try {
        char data[1024];
        for (;;) {
            std::size_t n = co_await socket.async_read_some(asio::buffer(data));
            co_await asio::async_write(socket, asio::buffer(data, n));
        }
    } catch (const asio::system_error&) {
        // 客户端断开
    }
}

asio::awaitable<void> listener(tcp::acceptor acceptor) {
    for (;;) {
        tcp::socket socket = co_await acceptor.async_accept();
        asio::co_spawn(acceptor.get_executor(),
            session(std::move(socket)), asio::detached);
    }
}

int main() {
    asio::io_context io(1);
    tcp::acceptor acceptor(io, {tcp::v4(), 8080});
    asio::co_spawn(io, listener(std::move(acceptor)), asio::detached);
    io.run();
}

协程带的优势是表达力,不是性能:

  • 同步的外表、非阻塞的内核——代码按顺序写,底层仍是异步分发。
  • 局部变量跨挂起保留——不再需要 enable_shared_from_this
  • 错误用 try/catch——co_await 出错时把 error_code 转成 system_error 抛出,告别逐级透传 error_code。

代价:要求编译器和标准库真正支持 C++20 协程(GCC 10+、Clang 14+、MSVC 19.28+)。co_spawn 的第一个参数是执行器,它决定了协程恢复后在哪条线程、以什么次序继续跑。

strand:让共享状态免锁

回调风格里,同一个 handler 的两次执行不会重叠——Asio 保证单个 socket 的操作不重入。但不同 socket(或不同协程)的 handler 完全可能并发。要让多份共享状态免于加锁,Asio 的答案是 strand

// 把需要串行访问的 socket/写缓冲包进同一个 strand
asio::strand<asio::io_context::executor_type> strand_ = asio::make_strand(io);
tcp::socket socket_(strand_);

strand_.post([this]{ /* 这段代码与同 strand 的其他任务互斥 */ });

同一条 strand 上的 handler 严格串行执行,读共享状态无需锁;不同 strand 之间则可以并行。它把"哪部分可以并发、哪部分必须串行"从编译边界提前到设计层面,配合"handler 里不阻塞"两条一起用,多线程模型才好把控。

与 Boost.Beast 的关系

Boost.Beast 是构建在 Asio 之上的 HTTP / WebSocket 协议层:

  • Asio:负责传输层——TCP / UDP / 定时器 / 串口,以及协程支撑。
  • Boost.Beast:负责协议状态机——HTTP/1.1 的解析与序列化、WebSocket 帧收发。

写一个 HTTP 服务端,标准路径是 Asio 管 socket,Beast 管 HTTP 报文。吞吐高、要自控协议细节的网关(反向代理的 HTTP/2 入口、API 网关)常见这个组合。需要注意:Beast 不实现 HTTP/2 与 HTTP/3,协议能被组合过来的主要是 HTTP/1.1 与 WebSocket。

与 libevent / libuv 的取舍

维度Asiolibeventlibuv
语言C++ 原生CC
协程支持C++20 协程无(Node 风格 callback)
文件 I/O✅(Asio 1.23+,Linux 走 io_uring)有限✅(含磁盘/文件)
HTTP 协议需 Beast需 libevent-http内置部分
学习曲线低(Node 风格)
文档优(专著 + 官方 docs)

Asio 的护城河是C++ 生态的深度集成:与 STL 类型无缝衔接、编译期类型检查、回调 API 与协程 API 共享同一实现。libevent 的价值在于纯 C、依赖轻;libuv 在于它同时把磁盘文件 I/O 也统一进来,且社区熟悉度来自 Node.js。若你只有 Node 背景、没有 C++ 异步心智,第一选择不一定是 Asio。

性能特征

后端分布:Linux = epoll,Windows = IOCP,BSD = kqueue。单次分发到底层事件循环的开销,Asio 与直接手写 epoll 处于同一量级,它不构成相对原生方案的性能短板。

这里需要压一个误导点:不存在能直接照搬的"Asio 能跑多快"的数字。一条连接上完成一次读写的耗时里,Asio 分发 handler 的开销只占很小一部分;真正决定吞吐和延迟的是业务逻辑、报文大小,以及是否发生跨线程同步。任何"达到 XXX Gbps / P99 多少微秒"的说法,都必须在你自己的负载与机器上实测才算数,从别的项目迁移数字没有意义。

可以放心说的只有两点:

  • Asio 不会把你拉到手写 epoll 之下,它的价值是把样板代码收进库,而不是在性能上领先原生。
  • 若在 handler 里做阻塞、加锁或要求强同步,再高效的分发模型都救不回来——性能先设计"连接数与共享状态",再谈堆线程。

扩展性上,多线程 run()thread_pool 都只是把 handler 分发到更多 CPU;能利用多少核取决于业务是否可以无锁拆分,先设计好 strand 的串行边界,比盲目加线程可靠。

常见坑

1. handler 生命周期

回调风格最经典的坑:handler 里引用了对象,但异步操作完成时对象已被析构。解法是用 shared_from_this 把会话生命周期绑定到操作上(见本文件上面的 Session 类)。缓冲区同理——async_read_some 持有的缓冲区引用必须活到 handler 执行完。

2. 线程安全与重入

io_context.run() 可被多线程调用,但同一个 socket 的 handler 不会并发触发,Asio 保证不重入。这允许你在单个 handler 里写非线程安全代码;一旦状态要被不同 socket 的 handler 共享,就得用 strand 或锁。

3. 错误处理:抛异常还是 error_code

每个异步函数都可以接收 error_code,但回调风格里默认是抛异常——只有显式传出 error_code 参数的重载才不抛。新手的崩溃源大多从这里来。协程风格里 co_await 出错会抛 system_error,用 try/catch 接住。

4. 协程帧的栈

C++20 协程在挂起点会分配一块"协程帧",通常几 KB 到几十 KB。大量并发协程会累积内存,Profile 时不要忽略。

何时用 / 何时不用

适合 Asio

  • 高并发 TCP / UDP 服务(IM、游戏后端、金融行情)
  • 嵌入式网络协议栈
  • 要跨 Linux + Windows + macOS 一套代码
  • 想用 C++20 协程写"同步风格"的异步代码

不适合

  • 只做简单 HTTP 调用——直接用 cpr / curl 之类 HTTP 客户端库更省事
  • 已深度绑定某框架(如 gRPC、Thrift)——那套框架有自己的 I/O 层
  • 需要 HTTP/3、QUIC——Asio 生态还在演进,协议成熟度不如 nghttp2 等专司的方案

阅读路径

  1. 官方文档 Asio Documentation——先读 Overview 与 Tutorial。
  2. 《Asio C++ Network Programming Cookbook》——偏实战取舍。
  3. 源码 asio/include/asio/——从 io_context 出发,跟踪一次异步读的调用链。
  4. Boost.Beast 示例——看 HTTP 服务端如何搭建在 Asio 之上。

参考资源

参与讨论

使用 GitHub 登录。欢迎补充事实、异议与实践。