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