【C++三方组件】Asio 下篇:C++20 协程版 TCP 客户端与服务端

【C++三方组件】Asio 下篇:C++20 协程版 TCP 客户端与服务端

【摘要】:下篇只讲协程一代。同一个回显服务,session 与客户端全部改写为 awaitable 协程:回调嵌套变直线,每连接一个协程天然并发,栈上缓冲靠协程帧保活------上篇的 shared_ptr 纪律整段消失。错误从 handler 的 error_code 变成异常,实测 10061 与 End of file 两条路径。completion token 换成 use_awaitable,其余概念原样复用。附 libevent/libuv/asio 三家终局对照,网络篇至此收官。

【关键词】:Asio、C++20 协程、co_await、awaitable、co_spawn、use_awaitable

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

1. What:协程改写的是什么

上篇把概念全部立好:io_context 驱动、Proactor 完成语义、completion token。协程一代没有引入任何新机制------只是把 token 从回调函数换成 asio::use_awaitable :同一个 async_read_some,token 是回调函数时操作按回调投递,是 use_awaitable 时操作挂起协程、完成时恢复。新名字只有三个:

  • asio::awaitable<void>:协程的返回类型,「一个将来会完成、可能抛异常的异步操作」;
  • co_await:挂起点------发起操作、让出执行权,完成时从这里恢复;
  • asio::co_spawn(io, coro, token):把协程挂到 executor 上跑。

体感差异一眼可见:上篇客户端的 resolve → connect → write → read 是四层嵌套回调加一个存参数的类;协程版是一条直线 。写法变了,驱动没变------最后仍是 io.run()。

2. How:协程版服务端

完整源码 asio_coroutine_echo.cpp,与上篇回调版同一个回显协议,实测可与上篇任意一端互通。

2.1 每连接一个协程

cpp 复制代码
asio::awaitable<void> session(tcp::socket sock) {
  try {
    char data[256];  // 协程帧保活:挂起期间不会消失
    for (;;) {
      std::size_t n = co_await sock.async_read_some(
          asio::buffer(data), asio::use_awaitable);
      printf("server: read %zu bytes\n", n);
      co_await asio::async_write(sock,
          asio::buffer(data, n), asio::use_awaitable);
      printf("server: echoed\n");
    }
  } catch (const std::exception& e) {
    printf("session end: %s\n", e.what());
  }
}

对照上篇同职责的 session 类:do_read/do_write 两个互调的成员函数,变成一个 for 循环 ;shared_from_this + 按值捕获 self 的保活纪律整段消失------data 是栈上数组,但协程帧存活到协程结束,挂起期间挂在读写上的缓冲天然有效。回调版要 shared_ptr / enable_shared_from_this 伺候的事,协程一行不用管。

2.2 接收循环

cpp 复制代码
asio::awaitable<void> listener(unsigned short port) {
  auto exec = co_await asio::this_coro::executor;
  tcp::acceptor acc(exec,
      tcp::endpoint(asio::ip::make_address("127.0.0.1"),
                    port));
  printf("server: listening on 127.0.0.1:%u\n",
         (unsigned)port);
  for (;;) {
    tcp::socket sock(exec);
    co_await acc.async_accept(sock, asio::use_awaitable);
    printf("server: accepted\n");
    // 新连接交给独立协程,本协程继续接收
    asio::co_spawn(exec, session(std::move(sock)),
                   asio::detached);
  }
}

两个细节点破:co_await asio::this_coro::executor 拿到当前协程所在执行器 ,用它构造 acceptor/socket,对象天然活在正确的上下文里;每个新连接 co_spawn 出独立协程------并发模型从「每连接一个对象」变成「每连接一个协程」,回调版的 accept 续挂变成最普通的循环。

3. How:协程版客户端

cpp 复制代码
asio::awaitable<void> client(const std::string& host,
                             unsigned short port) {
  try {
    auto exec = co_await asio::this_coro::executor;
    tcp::resolver r(exec);
    auto endpoints = co_await r.async_resolve(
        host, std::to_string(port), asio::use_awaitable);
    tcp::socket sock(exec);
    co_await asio::async_connect(sock, endpoints,
                                 asio::use_awaitable);
    printf("client: connected\n");

    const char* msg = "hello, echo\r\n";
    size_t len = strlen(msg);
    co_await asio::async_write(sock,
        asio::buffer(msg, len), asio::use_awaitable);

    char buf[256];
    // async_read:凑满 len 字节才算完成
    std::size_t n = co_await asio::async_read(sock,
        asio::buffer(buf, len), asio::use_awaitable);
    printf("client: echoed \"%.*s\"\n",
           (int)(n - 2), buf);
  } catch (const std::system_error& e) {
    printf("client failed: code=%d (%s)\n",
           e.code().value(), e.what());
  }
}

上篇同一个客户端:一个类、四个成员、三层嵌套、跨回调的参数全靠 this 存活。协程版从 resolve 到读满回显一口气写完,每一步的错误都被同一个 try/catch 接住。

4. 两代对照:同一个回显,输出不变

实测运行(协程版服务端 + 协程版客户端,与上篇回调版输出逐行一致):

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

最后一行是协程版特有的:客户端完成任务退出、连接关闭,session 协程下一次 async_read_some 以 eof 失败------协程版错误以 std::exception 浮出 ,被 2.1 的 catch 打印后协程正常结束。回调版同一场景是 handler 收到 error_code 后静默 return(上篇 4.1 的 if (ec) return;)。

回调一代(上篇) 协程一代(本篇)
completion token 回调函数 use_awaitable
步骤衔接 嵌套 lambda / 成员函数互调 直线 co_await
每连接形态 session 对象(shared_ptr) 一个协程(协程帧保活)
错误形态 handler 首参 error_code 异常(system_error)
保活纪律 按值捕获 self、buffer 挂堆 栈上变量天然有效
语言门槛 C++11/17 C++20

5. 错误形态:error_code 与异常双轨

同一个异步操作,token 决定通知形态与错误形态。实测连接被拒(不启动服务端):

text 复制代码
# 回调版(上篇)
client failed at connect: code=10061 (由于目标计算机积极拒绝,无法连接。)
# 协程版(本篇)
client failed: code=10061 (由于目标计算机积极拒绝,无法连接。)

信息一样、通道不同:回调版的错误是 handler 的 error_code 参数,永不抛异常 ;协程版的错误以 std::system_error 异常抛出,try 块覆盖到哪一步、哪一步的错误就归你。一个差别要留意:回调版 fail() 知道自己挂在哪一步(resolve/connect/write/read 各自的 handler);协程版异常不自带环节名,要么在 catch 里统一处理,要么分段 try。

第三种常见错误是取消 :timer.cancel() 或连接销毁会让未决操作以 operation_aborted 收场------协程里同样是异常,优雅退出(停 acceptor、逐个停 session)全靠它。

asio::detached 要慎用 :co_spawn 的第二个 token 决定协程结果去哪,detached 表示一概弃置------协程异常穿透 detached 直接 terminate。本篇示例每个协程体自己 try/catch 收尾,异常不出协程,这是最省心的纪律;正式代码也可以给 co_spawn 传带完成回调的 token,让每个协程都有归宿。

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

  1. 每个协程体一个 try/catch 收尾 :落日志再退出------异常穿透 detached 直接 terminate(第 5 节)。
  2. 协程内先拿 executor :co_await asio::this_coro::executor 再构造 acceptor/socket/timer,对象天然活在正确的上下文里(本篇示例均如此)。
  3. 栈上 buffer 是协程版的福利 :协程帧存活到结束,挂起期间挂在读写上的缓冲天然有效------回调版要 shared_ptr 伺候的事这里消失;反过来,跨协程共享的缓冲仍要自己管生命周期,协程不是免死金牌。
  4. 别在协程里做长阻塞 :协程跑在 io_context 的线程上,一个阻塞的同步调用会拖住同线程所有协程------重活交给线程池(asio::thread_pool)或上篇的 strand 思路隔离。
  5. 多线程 run() 仍然要 strand :协程只是写法变了,handler/协程恢复仍会在多个 run() 线程并发执行,共享状态挂 make_strand(io)------上篇坑 5 原样适用。
  6. 回调版并没有过时:读老代码、维护存量、与 C API 交互时回调形态仍是日常;两代可以在同一个 io_context 里混用------completion token 本来就是逐调用选择的。

7. 选型终局:三家事件库与网络篇收官

asio libevent libuv
模型 Proactor Reactor Reactor
接口 C++ 模板/协程 C C
Windows IOCP 原生 select 为主 IOCP 原生
C++20 协程 ✅ 一等公民 ❌ ❌
生态 Beast、SSL、TS 蓝本 memcached 系老牌 Node 系全家

收束成一句话:新 C++ 项目默认 asio;维护 C 存量看 libevent/libuv;要 HTTP 语义直接上层(libcurl/cpr/cpp-httplib),要消息语义看 ZeroMQ ------网络库选型决策树到此完整。学习路径的务实建议:asio 前陡后平,executor 与 completion token 两道坎过了,剩下的 API 面按需查即可。别试图先把模板参数全部搞懂再动手------从协程版起手、回调版在读老代码时补课,是当下性价比最高的路径;官方示例的 cpp20 目录是现成阶梯。

回望网络篇九篇:libevent/libuv 立起事件循环底座,libcurl/cpr/cpp-httplib 把 HTTP 客户端与服务端包圆,ZeroMQ 给出消息语义,gRPC 把抽象层级登顶------而 asio 两篇拆开看正好是它的两层价值:上篇证明它能替你写掉 Reactor 时代的全部手工活,下篇证明它搭上 C++20 协程后写法还能再瘦一圈。框架之重(gRPC)与零件之陡(asio)是两种复杂,网络编程至此收官。

8. 参考资料

  • 官方文档 chriskohlhoff.github.io/asio:协程专题(awaitable、co_spawn、this_coro)与 cpp20 示例目录;
  • 上篇(第 31 篇):回调一代典型写法、BSD socket/select 对照、Boost.Asio 与独立版取舍------概念立柱全在上篇;
  • 完整构建与运行入口:examples/31-32/README。

参考 :chriskohlhoff/asio asio-1-38-2(BSL-1.0,独立发行;Boost.Asio 同源)。本篇输出、10061 异常路径、End of file 会话结束路径均为本机实测(MSVC v145/VS2026,Windows;MinGW g++ 16.2 编译通过)。

相关推荐
一只旭宝2 小时前
【C++复习】四种类型转换 + 常见设计模式 + Redis/MySQL(后端面试复习完整版)
c++·redis·笔记·mysql·设计模式
rhett. li2 小时前
用 AI 写 C++ 桌面 UI:TRAE + nim_duilib 实战指南
c++·人工智能·ui
故乡dee云2 小时前
2026年华为云国际账号开通避坑:IP自动跳转中国站,选错地区只能重新注册
服务器·tcp/ip·华为云
ShineWinsu2 小时前
对于Redis:Redis特性以及应用场景的解析
linux·c++·redis·缓存·高并发·分布式系统·key-value
-森屿安年-2 小时前
买卖股票的最佳时机
c++·贪心算法
2601_962218612 小时前
C++ AVL树概念与实现详解
开发语言·c++
炘爚2 小时前
C++(STL)
开发语言·c++
波力海苔夹心脆6752 小时前
C# 海康威视摄像头二次开发入门:HCNetSDK 登录、实时预览、云台控制、录像与抓图(WinForms 实战)
开发语言·windows·经验分享·tcp/ip·c#
2601_962218612 小时前
C++ string 类原理、踩坑与对象语义详解
开发语言·c++