【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. 坑与最佳实践(实测依据)
- 忘
io.run()= 程序秒退 :所有 async 只是登记,run()才驱动。程序毫无动静时第一反应先查它。 - Windows 链
ws2_32 mswsock;ConnectEx 等扩展 API 在 mswsock 里。 async_read与async_read_some别混 :前者凑满约定量、后者尽力------「按长度前缀读消息」用async_read/async_read_until,别用_some手写补读循环(第 5 节对照组就是反面教材)。- 回调一代的保活纪律 :跨回调存活的 buffer/socket 用
shared_ptr按值捕获(4.1 的self)------悬垂是回调式 asio 的头号崩溃源。 - 多线程
run()= 内置线程池 :N 个线程同时io.run()即可并行;handler 会并发执行,共享状态要么锁、要么strand串行化(挂make_strand(io))。 - 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 编译通过)。