【C++三方组件】Asio 上篇:回调式 TCP 客户端与服务端

【C++三方组件】Asio 上篇:回调式 TCP 客户端与服务端

【摘要】:网络篇最陡一段分两篇爬。上篇只讲回调一代:io_context 驱动、session 保活、accept 循环------TCP 客户端与服务端在 asio 框架下的典型写法;再与不借框架的对照组逐项对照:BSD socket 阻塞客户端、select 单线程服务端。asio 接管了 fd 管理、就绪循环、短读短写与跨平台,实测三套程序字节互通、可任意组合。协程一代留给下篇。

【关键词】:Asio、io_context、Proactor、回调、select、BSD socket

【版本基准】:asio 1.38.2 独立版(BSL-1.0,头文件库;与 Boost.Asio 同源)|示例 C++17|文中输出均为本机实测(MSVC v145/VS2026,Windows;MinGW g++ 16.2 编译通过)

1. What:网络篇接口复杂度的顶峰

ZeroMQ 站在抽象阶梯的中层,本篇往下一层------Asio 不给你任何通信语义,给你全部零件 :异步 TCP/UDP、定时器、信号、串口、SSL,外加现代 C++ 的全套复杂度(模板、执行器、协程)。它是 Chris Kohlhoff 2004 年起步的作品、C++ Networking TS 的蓝本(标准网络库提案直接以它为原型,虽至今未入标准)------「C++ 网络编程的事实标准」这个头衔,asio 拿了二十年。HTTP 之上用不到它;写网关、代理、长连接服务、自定义协议时绕不开它。

asio 的写法分两代:回调一代 (C++11 起可用,C++17 足够)与协程一代 (C++20 起)。本篇只讲回调一代的 TCP 客户端与服务端典型写法,并回答一个问题:它与不借框架的手写版差在哪------对照组是教科书级的 BSD socket 阻塞客户端与基于 select 的 IO 多路复用单线程服务端。协程一代是下篇(第 32 篇)的内容,概念在这篇全部立好。

2. 项目接入

json 复制代码
// vcpkg:vcpkg.json
{ "dependencies": [ "asio" ] }
cmake 复制代码
# 头文件库:无需链接目标,给 include 路径即可
target_include_directories(app PRIVATE deps/asio/include)
# Windows:链接 ws2_32 mswsock(实测必需)

vcpkg 的 asio 端口即独立版;源码集成就是官网下一个 tarball 解压(本篇实测 1.38.2)。Boost.Asio 还是独立 asio? 同一个作者、同一份代码,双发行:独立版是 asio.hpp / namespace asio,随时发版;Boost 版是 boost/asio.hpp / namespace boost::asio,随 Boost 节奏发版。取舍只看一件事实:项目已经在用 Boost 吗?在用(或要用 Beast------它只活在 Boost.Asio 上)→ Boost 版;不想引入 Boost 依赖 → 独立版。代码差异基本只有 namespace 与头文件前缀。

3. 核心概念:io_context 与 Proactor 完成语义

libevent/libuv 的 Reactor 说「fd 可读了,来收 」;asio 的 Proactor 说「读到这些字节了,来处理」:

复制代码
Reactor(libevent/libuv)
  就绪通知: fd 可读 → 回调里自己 read(可能读不满)

Proactor(asio)
  完成通知: async_read_some(buf) → buf 里已有数据才回调

三件套对照 libevent/libuv:io_context(事件循环本体)、异步操作 + 完成处理器(handler)、steady_timer / tcp::socket 等对象(挂 executor)。对象构造时挂 executor ------tcp::socket sock(io) 里的 io 就是。绝大多数场景它就是 io_context;并发场景则常用 strand :挂同一 strand 的 handler 保证串行执行,多线程 run() 下共享数据从此免锁------这是 asio 独有的并发原语。

asio 最有辨识度的一笔是 completion token :async_read_some(buf, token) 的第二个参数决定「完成」以什么形态通知你------回调函数、future、还是 co_await。这不是三套 API,是一套操作、多种投递方式 。本篇全部用回调函数 token;co_await 形态是下篇主角。

完成语义还有一档关键区分:async_read_some 尽力读多少算多少;async_read 凑满约定字节数才算完成 。「按长度前缀读消息」用后者,别用 _some 手写补读循环------这正是 Proactor 换来的核心收益,第 5 节对照组会看到不换的话要手写多少。

4. How:asio 框架下的典型写法

配套案例是 TCP 回显服务:客户端发一条消息,服务端原样回显,均含完整源码(examples/31-32,服务端与客户端是两个独立可执行文件,便于理解部署边界)。

4.1 服务端:session 类 + accept 循环

每连接一个 session 对象,enable_shared_from_this + 按值捕获 self 保证跨回调存活------这是回调式 asio 的标准保活姿势:

cpp 复制代码
class session : public std::enable_shared_from_this<session> {
 public:
  explicit session(tcp::socket sock)
      : sock_(std::move(sock)) {}

  void start() { do_read(); }

 private:
  void do_read() {
    auto self = shared_from_this();  // 跨回调保活
    sock_.async_read_some(asio::buffer(buf_),
        [this, self](std::error_code ec, std::size_t n) {
          if (ec) return;  // 对端关闭或出错:self 析构
          printf("server: read %zu bytes\n", n);
          do_write(n);
        });
  }
  void do_write(std::size_t len) {
    auto self = shared_from_this();
    // async_write:写满 len 字节才算完成(含短写补齐)
    asio::async_write(sock_, asio::buffer(buf_, len),
        [this, self](std::error_code ec, std::size_t) {
          if (ec) return;
          printf("server: echoed\n");
          do_read();
        });
  }

  tcp::socket sock_;
  unsigned char buf_[256];
};

接收循环:每完成一次 accept 就再挂一个,连接以 make_shared<session> 接走:

cpp 复制代码
static void do_accept(tcp::acceptor& acc) {
  acc.async_accept(
      [&acc](std::error_code ec, tcp::socket sock) {
        if (ec) return;
        printf("server: accepted\n");
        std::make_shared<session>(std::move(sock))
            ->start();
        do_accept(acc);
      });
}

监听 socket 由 tcp::acceptor 构造时完成 bind + listen;最后 io.run() 驱动全部异步操作。

4.2 客户端:resolve → connect → write → read 回调链

回调式客户端的典型形态是一个小对象,把每一步的参数存成成员,回调链逐步推进:

cpp 复制代码
echo_client(asio::io_context& io,
            const std::string& host, int port)
    : resolver_(io), sock_(io),
      msg_("hello, echo\r\n"), echo_(msg_.size(), '\0') {
  resolver_.async_resolve(
      host, std::to_string(port),
      [this](std::error_code ec,
             tcp::resolver::results_type rs) {
        if (ec) { fail(ec, "resolve"); return; }
        asio::async_connect(sock_, rs,
            [this](std::error_code ec, tcp::endpoint) {
              if (ec) { fail(ec, "connect"); return; }
              printf("client: connected\n");
              do_write();
            });
      });
}
// do_write:async_write(msg_) 后 do_read
// do_read:async_read(echo_) 凑满等长回显后打印

错误处理是回调版的形态:handler 第一个参数永远是 error_code,永不抛异常。实测连接被拒(不启动服务端):

text 复制代码
client failed at connect: code=10061 (由于目标计算机积极拒绝,无法连接。)

10061 即 WSAECONNREFUSED;协程版同一场景的错误走异常,下篇对照。

4.3 运行

bash 复制代码
# 终端 A
./asio_callback_echo serve 18031
# 终端 B
./asio_callback_echo client 127.0.0.1 18031

实测输出(两端合并视角):

text 复制代码
server: listening on 127.0.0.1:18031
client: connected
server: accepted
server: read 13 bytes
client: echoed "hello, echo"
server: echoed

5. 对照组:不借框架的典型写法

同一个回显,用「教科书典型写法」重写一遍(完整源码 bsd_select_echo.cpp,不依赖 asio):客户端是 BSD socket 阻塞式,服务端是 select IO 多路复用单线程。

5.1 BSD socket 阻塞客户端

getaddrinfo → socket → connect 之后,收发各有一个补齐循环 ------send 可能短写、recv 可能短读,内核不保证一次到位:

cpp 复制代码
const char* msg = "hello, echo\r\n";
size_t len = strlen(msg);
// send 可能短写:循环补齐直到发满
size_t sent = 0;
while (sent < len) {
  int n = send(fd, msg + sent, (int)(len - sent), 0);
  if (n <= 0) { /* 出错退出 */ }
  sent += (size_t)n;
}
// recv 可能短读:循环直到凑满等长回显
std::string echo(len, '\0');
size_t got = 0;
while (got < len) {
  int n = recv(fd, &echo[got], (int)(len - got), 0);
  if (n <= 0) { /* 出错或对端关闭 */ }
  got += (size_t)n;
}

这份代码还满是 #ifdef _WIN32:WinSock 要 WSAStartup、SOCKET 类型、closesocket,POSIX 是另一套------跨平台是双份维护。

5.2 select 单线程服务端

单线程服务端用 select 同时照看监听 socket 与全部连接:

cpp 复制代码
for (;;) {
  // 感兴趣集合每轮重建:读始终关注,
  // 写只在有待发字节时关注(否则 select 立即返回可写)
  fd_set rfds, wfds;
  FD_ZERO(&rfds); FD_ZERO(&wfds);
  FD_SET(listener, &rfds);
  sock_t maxfd = listener;
  for (const auto& [fd, c] : conns) {
    FD_SET(fd, &rfds);
    if (!c.send_buf.empty()) FD_SET(fd, &wfds);
    if (fd > maxfd) maxfd = fd;
  }
  int ready = select((int)maxfd + 1, &rfds, &wfds,
                     nullptr, nullptr);
  // ... listener 可读则 accept;
  //     连接可读则 recv 追加进 send_buf;
  //     连接可写则 send 发送缓冲、可能只发一部分
}

每连接状态(send_buf)自己用 unordered_map<fd, conn> 维护;select 只保证「可写」,send 仍可能短写,所以发送缓冲跨轮保留。还有一个隐藏上限:FD_SETSIZE(Windows 默认 64),连接数一多先撞墙------换 epoll/IOCP 就是重写。

5.3 对照:asio 接管了什么

议题 BSD socket / select 手写 asio 回调式
等待就绪 每轮重建 fd_set、FD_ISSET 分发 挂上异步操作,完成才回调
短读 recv 尽力返回,凑满自己循环 async_read 凑满才算完成
短写 send 补齐 + 发送缓冲 + 关注可写 async_write 内含补齐
每连接状态 map<fd, conn> 自己维护 session 对象自管生命周期
跨回调保活 fd 是裸句柄,缓冲自己持有 shared_ptr + 按值捕获 self
连接数上限 FD_SETSIZE(Windows 默认 64) 无此限制(IOCP)
跨平台 每处 #ifdef 双份维护 一套代码
演进到并发 select → epoll/IOCP 重写 多线程 run() + strand

一句话收拢:Reactor 把「等」交给你,Proactor 把「等 + 补齐」都收走------代价是引入对象生命周期管理(保活纪律)与一整套模板接口。三份示例共享同一条 TCP 字节流协议,实测可任意组合互通(BSD 客户端直连协程版服务端照常回显),这也从侧面说明框架改变的只是写法,不是协议。

6. 坑与最佳实践(实测依据)

  1. 忘 io.run() = 程序秒退 :所有 async 只是登记,run() 才驱动。程序毫无动静时第一反应先查它。
  2. Windows 链 ws2_32 mswsock;ConnectEx 等扩展 API 在 mswsock 里。
  3. async_read 与 async_read_some 别混 :前者凑满约定量、后者尽力------「按长度前缀读消息」用 async_read/async_read_until,别用 _some 手写补读循环(第 5 节对照组就是反面教材)。
  4. 回调一代的保活纪律 :跨回调存活的 buffer/socket 用 shared_ptr 按值捕获(4.1 的 self)------悬垂是回调式 asio 的头号崩溃源。
  5. 多线程 run() = 内置线程池 :N 个线程同时 io.run() 即可并行;handler 会并发执行,共享状态要么锁、要么 strand 串行化(挂 make_strand(io))。
  6. accept 循环记得续挂 :async_accept 的回调里再调一次自己,否则只能收一个连接。

7. 参考资料

  • 官方文档 chriskohlhoff.github.io/asio:overview 与教程(timer、socket、acceptor)是必读路径;
  • 完整构建与运行入口:examples/31-32/README;
  • 下篇(第 32 篇):同一回显的 C++20 协程版------co_await 如何把回调链拉直、错误如何从 error_code 变异常。

参考 :chriskohlhoff/asio asio-1-38-2(BSL-1.0,独立发行;Boost.Asio 同源)。本篇输出、10061 错误路径、链接参数均为本机实测(MSVC v145/VS2026,Windows;MinGW g++ 16.2 编译通过)。

相关推荐
沐欣工作室_lvyiyi1 小时前
家庭水资源节能系统设计(论文+源码)
c++·python·云平台·单片机毕业设计·单片机仿真·电子信息工程毕业设计·机械电子工程
神仙别闹1 小时前
基于C语言实现 TINY+ 编译器
c语言·开发语言·数据库
Wang's Blog1 小时前
Java 项目实战: 外卖平台优化-从库Slave配置与主从复制验证
java·开发语言
福大大架构师每日一题1 小时前
pion/webrtc v4.2.22 最新发布:TCP mux 写缓冲区调整至 4 MB,补齐缺失媒体方向处理
tcp/ip·webrtc·媒体
Yyyyyy~1 小时前
[c++]数组
c++
知识分享小能手1 小时前
C++ 学习教程,从入门到精通,C++内存模型和名称空间 — 详细知识点总结(9)
开发语言·c++·学习
2601_962218612 小时前
c++中多重继承与虚继承的实现
开发语言·c++
沫璃染墨2 小时前
《Qt从零入门系列(十一):Qt事件机制详解——从QEvent到鼠标、键盘与定时器事件》
c++·qt·ui·硬件工程·交互·个人开发·qt5
我是小白呀2 小时前
17-企业Workflow引擎怎么选:定时、队列、n8n、Temporal与Camunda
java·开发语言·人工智能·dubbo·workflow