第十五章:网络编程:Boost.Asio 入门,TCP/UDP 服务器框架,异步 I/O 与协程(C++20 Coroutines)
网络编程,说白了就是让两台机器能互相说话。我刚开始接触这块时,总觉得它很神秘——数据怎么就从一台电脑飞到另一台了呢?后来做多了才发现,核心无非就是 socket、bind、listen、accept 那几板斧。但真正让网络编程变得优雅的,是异步 I/O 和协程。
这一章,我们聊聊 Boost.Asio。它是 C++ 网络编程的事实标准。C++20 标准库虽然有了 std::net 的提案,但还没完全落地。所以,目前做生产级项目,Boost.Asio 还是首选。
核心要点:Boost.Asio 的核心思想是“异步操作 + 事件循环”。你发起一个操作,然后去干别的事,等操作完成,回调函数会被调用。C++20 协程让这种异步代码写起来像同步代码一样直观。
15.1 为什么是 Boost.Asio?
我见过不少团队自己封装 socket 库。说实话,大部分都踩了同样的坑:线程安全、跨平台、内存泄漏。Boost.Asio 把这些坑都填平了。
- 跨平台:Windows 上用 IOCP,Linux 上用 epoll,macOS 上用 kqueue。你写一套代码,到处编译。
- 高性能:基于 Proactor 设计模式,I/O 操作由操作系统异步完成,不阻塞线程。
- 可扩展:从单线程到线程池,从 TCP 到 UDP,从同步到异步,一套 API 全搞定。
嗯,这里要注意:Boost.Asio 不是 Boost 里最轻量的库。编译起来有点慢,但运行时性能绝对对得起编译时间。
15.2 同步 vs 异步:一个简单的对比
先看同步方式。代码很直观,但线程会卡住:
// 同步 TCP 客户端
boost::asio::io_context io_context;
tcp::socket socket(io_context);
socket.connect(tcp::endpoint(
boost::asio::ip::address::from_string("127.0.0.1"), 8080));
char data[1024];
boost::system::error_code error;
size_t length = socket.read_some(
boost::asio::buffer(data), error);
这段代码,read_some 会一直等,直到有数据或出错。如果网络延迟高,线程就白白浪费了。
再看异步方式:
// 异步 TCP 客户端
void handle_read(const boost::system::error_code& ec,
size_t bytes_transferred) {
if (!ec) {
// 处理数据
}
}
socket.async_read_some(
boost::asio::buffer(data),
handle_read);
io_context.run(); // 事件循环开始
异步方式不会阻塞线程。你发起读操作,然后去处理其他请求。等数据到了,handle_read 被自动调用。
我个人习惯用异步方式写服务器。同步方式只适合写简单的测试工具。
15.3 TCP 服务器框架:从零搭建
一个典型的 TCP 服务器,需要处理多个客户端连接。每个连接独立收发数据。我分享一个我常用的框架:
class tcp_server {
public:
tcp_server(boost::asio::io_context& io_context, short port)
: acceptor_(io_context, tcp::endpoint(tcp::v4(), port)) {
do_accept();
}
private:
void do_accept() {
acceptor_.async_accept(
[this](boost::system::error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<session>(std::move(socket))->start();
}
do_accept(); // 继续等待下一个连接
});
}
tcp::acceptor acceptor_;
};
这里有个关键点:do_accept() 在回调里再次调用自己。这叫“链式异步调用”。每次 accept 完成后,立即发起下一次 accept。这样服务器就能持续接收新连接。
小技巧:每个客户端连接用 std::shared_ptr<session> 管理。session 对象在连接断开时自动销毁,不会内存泄漏。我在项目中用过裸指针,调试起来很痛苦。
15.4 UDP 服务器:无连接的通信
UDP 比 TCP 简单。没有连接的概念,你只管发数据包。但要注意:UDP 不保证数据到达,也不保证顺序。
class udp_server {
public:
udp_server(boost::asio::io_context& io_context, short port)
: socket_(io_context, udp::endpoint(udp::v4(), port)) {
do_receive();
}
private:
void do_receive() {
socket_.async_receive_from(
boost::asio::buffer(data_, max_length),
sender_endpoint_,
[this](boost::system::error_code ec, std::size_t bytes_recvd) {
if (!ec && bytes_recvd > 0) {
// 处理收到的数据
do_receive(); // 继续接收
}
});
}
udp::socket socket_;
udp::endpoint sender_endpoint_;
enum { max_length = 1024 };
char data_[max_length];
};
UDP 服务器不需要 accept。直接 async_receive_from 就行。谁发来的数据,sender_endpoint_ 里就有谁的地址。
我曾经在做一个游戏服务器时,用 UDP 传输玩家位置信息。丢几个包没关系,但延迟必须低。TCP 的重传机制反而成了累赘。
15.5 异步 I/O 的核心:io_context 与 strand
io_context 是 Boost.Asio 的心脏。它管理所有异步操作,并在合适的时机调用回调函数。
单线程场景下,io_context.run() 在一个线程里循环处理事件。多线程场景下,可以多个线程同时调用 run(),实现并行处理。
但多线程会带来一个问题:多个回调可能同时访问同一个数据。这时候就需要 strand。
boost::asio::io_context io_context;
boost::asio::strand<boost::asio::io_context::executor_type>
strand_(io_context.get_executor());
// 通过 strand 投递任务,保证顺序执行
boost::asio::post(strand_, []() {
// 这个回调不会和其他 strand 上的回调并发执行
});
说白了,strand 就是一个串行化执行器。它保证投递到同一个 strand 上的回调不会并发执行。你想想看,这比用 mutex 优雅多了。
15.6 C++20 协程:让异步代码像同步一样写
这是我最喜欢的部分。C++20 协程和 Boost.Asio 结合,写出来的代码既高效又清晰。
先看一个传统异步读写的例子:
void start_read() {
socket_.async_read_some(
boost::asio::buffer(data_),
[this](error_code ec, size_t len) {
if (!ec) {
process_data(data_, len);
start_read(); // 继续读
}
});
}
回调嵌套回调,逻辑一复杂,代码就变成“回调地狱”。
用协程改写:
boost::asio::awaitable<void> session::reader() {
while (true) {
auto [ec, len] = co_await socket_.async_read_some(
boost::asio::buffer(data_),
boost::asio::use_awaitable);
if (ec) break;
process_data(data_, len);
}
}
看到 co_await 了吗?它让异步操作看起来像同步调用。程序执行到 co_await 时,会挂起当前协程,不阻塞线程。等数据读完了,协程自动恢复执行。
注意:协程不是线程。协程挂起时不占用线程资源。一个线程可以跑成千上万个协程。但协程内部不能有阻塞操作,否则会阻塞整个线程。
要使用协程,需要包含头文件:
#include <boost/asio/experimental/awaitable_operators.hpp>
#include <boost/asio/use_awaitable.hpp>
然后在 io_context 里用 co_spawn 启动协程:
boost::asio::co_spawn(io_context,
session::reader(),
boost::asio::detached);
15.7 协程实战:一个完整的 Echo 服务器
把前面学的串起来,写一个基于协程的 Echo 服务器:
class echo_session : public std::enable_shared_from_this<echo_session> {
public:
echo_session(tcp::socket socket) : socket_(std::move(socket)) {}
void start() {
co_spawn(socket_.get_executor(),
[self = shared_from_this()] { return self->echo(); },
boost::asio::detached);
}
private:
boost::asio::awaitable<void> echo() {
char data[1024];
try {
while (true) {
size_t n = co_await socket_.async_read_some(
boost::asio::buffer(data),
boost::asio::use_awaitable);
co_await async_write(socket_,
boost::asio::buffer(data, n),
boost::asio::use_awaitable);
}
} catch (std::exception& e) {
// 连接断开或出错
}
}
tcp::socket socket_;
};
这段代码,读数据、写数据,都在一个函数里顺序执行。没有回调,没有状态机。逻辑一目了然。
我在生产环境里用这种模式写过网关服务。一个协程处理一个连接,代码量比回调版本少了 40%,而且 bug 明显减少。
15.8 避坑指南
最后,分享几个我踩过的坑:
- 不要在回调里抛异常:异步回调里抛异常,很难被捕获。建议用
error_code处理错误。 - 注意 io_context 的生命周期:
io_context销毁后,所有异步操作都会取消。确保io_context比所有 socket 活得久。 - 协程里的阻塞操作:协程挂起时不占线程,但如果你在协程里调用了
std::this_thread::sleep_for,整个线程都会卡住。用boost::asio::steady_timer代替。 - 我曾经在协程里直接调用了
std::mutex::lock(),结果死锁了。协程切换时,锁的状态可能不符合预期。用strand代替锁。
15.9 知识体系总览
下面这张图,概括了本章的核心内容:
从图中可以看出,io_context 是基石。往上,你可以选择同步、异步回调或协程三种编程模型。每种模型都支持 TCP 和 UDP。再往上,是各自的关键概念。最终目标是一致的:写出高性能、可维护的网络服务。
我个人推荐新项目直接用协程。虽然编译器支持还不算完美(需要 C++20 和较新的 Boost),但带来的开发效率提升是实实在在的。