第十五章:网络编程: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 知识体系总览

下面这张图,概括了本章的核心内容:

Boost.Asio 网络编程知识体系 io_context (事件循环) 同步 I/O 异步 I/O (回调) 协程 (C++20) TCP / UDP TCP / UDP TCP / UDP 阻塞 / 非阻塞 Strand / 线程安全 co_await / 挂起恢复 目标:高性能、可维护的网络服务

从图中可以看出,io_context 是基石。往上,你可以选择同步、异步回调或协程三种编程模型。每种模型都支持 TCP 和 UDP。再往上,是各自的关键概念。最终目标是一致的:写出高性能、可维护的网络服务。

我个人推荐新项目直接用协程。虽然编译器支持还不算完美(需要 C++20 和较新的 Boost),但带来的开发效率提升是实实在在的。


公众号:蓝海资料掘金营,微信deep3321