Asio异步读写——简单服务器和客户端异步通信

文章目录

首先定义一个基础的 Session 类。在网络服务器中, Session 通常作为管理类,负责维护和处理某一个特定客户端连接的生命周期、状态以及数据收发。

cpp 复制代码
class Session {
public:
    Session(std::shared_ptr<asio::ip::tcp::socket> socket);
    void Connect(const asio::ip::tcp::endpoint& ep);
private:
    std::shared_ptr<asio::ip::tcp::socket> _socket; // 负责对端连接读写的 Socket 成员
};

void Session::Connect(const asio::ip::tcp::endpoint &ep) {
    _socket->connect(ep); // 封装建立连接的基础操作
}

回调函数

回调函数就像你留给系统的"售后服务电话"。你发起了一个任务,但不想坐在那死等结果,于是把任务交给系统,并留下一个函数(电话)。你转头去干别的事,一旦系统把任务做完了,就会主动拨通这个电话,执行你写在函数里的后续步骤。

在网络异步读写中,回调函数承担着三大核心作用:

  • 第一,它是接收"完工通知"的唯一通道。 异步操作(如 async_write_some)是"转头就走"的非阻塞调用,函数返回时数据根本没发完。回调函数的触发,就是操作系统发给应用层的"挂号信",明确宣告:"你之前交代的网络数据搬运任务,现在已经安全完成了。"
  • 第二,它是维系网络监听的"多米诺骨牌"。 网络接收数据需要持续不断地监听。在异步读中,当你发起读取后函数立刻返回;一旦网卡收到数据,读回调函数被触发。你在这个回调函数内部处理完数据后,会再次调用异步读函数 。正是通过这种在回调内部不断投递下一次请求的机制,连接才能像多米诺骨牌一样,持续不断地接收网络流。
  • 第三,它是错误处理的第一现场。 网络传输极易出现异常(如对端断网、拔网线)。Asio 框架在网络出错时同样会触发回调函数,并把错误码(error_code)传给它。回调函数成了你第一时间捕捉网络灾难、执行关闭 Socket 和释放内存等资源回收工作的安全闸门。

前置:封装数据管理节点

在网络传输中,异步操作意味着"投递请求"与"实际完成"是分离的。为了保证系统安全、避免内存越界,必须确保被发送或接收的数据缓冲区在整个异步操作完成前保持有效。为此,我们封装一个 MsgNode 结构。它不仅管理要发送/接收的数据域首地址,还记录了数据的总长度以及当前已处理的字节数(已读或已写长度)。

cpp 复制代码
// 最大报文接收大小
const int RECVSIZE = 1024;

class MsgNode {
public:
    // 构造函数1:两个参数,负责拷贝并构造【写节点】
    MsgNode(const char* msg, int total_len) : _total_len(total_len), _cur_len(0) {
        _msg = new char[total_len];
        memcpy(_msg, msg, total_len);
    }

    // 构造函数2:一个参数,负责开辟指定大小的非初始化空间,用于【读节点】
    MsgNode(int total_len) : _total_len(total_len), _cur_len(0) {
        _msg = new char[total_len];
    }

    // 析构函数:释放内存,防止内存泄漏
    ~MsgNode() {
        delete[] _msg;
    }

    char* _msg;       // 消息缓冲区的首地址
    int _total_len;   // 需要处理的数据总长度
    int _cur_len;     // 当前已经处理完成的长度
};

异步写操作

错误的单节点异步写尝试(Err 版本)

我们先来看一种初学者常犯的、无法投入实际生产的异步写实现。首先为 Session 扩展对应的成员:

cpp 复制代码
/**
 * @class Session
 * @brief 客户端连接的管理类(错误示范版本)
 * 
 * @note 本类设计仅用于演示 boost::asio 异步回调的基本参数绑定与生命周期控制。
 *       由于采用了单节点变量 `_send_node`,在高并发连续调用时会引发严重的数据乱序与越界崩溃,
 *       实际生产环境请严格参考后续的"队列(Queue)版本"。
 */
class Session {
public:
    /**
     * @brief 异步写操作完成后的【底层回调函数】
     * 
     * @param ec                底层的错误码。若发送成功,其 value() 为 0;若对端断开或网络异常,会携带对应的错误信息。
     * @param bytes_transferred 本次异步操作中,底层 TCP 发送缓冲区实际成功接收并发送的字节数。
     * @param msg_node          【核心】通过值传递引入的智能指针。利用引用计数机制,强行延长发送节点的生命周期,
     *                          确保在异步回调触发前,该节点对应的内存缓冲区不会被提前析构释放。
     */
    void WriteCallBackErr(
        const boost::system::error_code& ec, 
        std::size_t bytes_transferred, 
        std::shared_ptr<MsgNode> msg_node
    );

    /**
     * @brief 应用层调用的【异步发送接口】
     * 
     * @param buf 业务层想要发送的原始字符串数据。
     * 
     * @details 该函数会率先将外部数据拷贝至专门的 `MsgNode` 中,随后调用 `async_write_some` 
     *          向操作系统投递一个非阻塞的写请求。由于是异步行为,该函数会立即返回,不会发生网络阻塞。
     */
    void WriteToSocketErr(const std::string& buf);

private:
    /**
     * @brief 负责维护当前正在发送的数据节点(智能指针)
     * 
     * @warning 【致命缺陷】:作为类成员变量,它同时只能维护一个发送节点。
     *          如果在第一次异步写未完全结束(回调未触发)时,外部再次调用了 `WriteToSocketErr`,
     *          该指针会被无情指向新开辟的节点。这会导致:
     *          1. 前一个未发完的数据节点引用计数减少,可能直接导致底层内存被销毁,引发系统越界崩溃(Wild Pointer)。
     *          2. 导致底层多次投递的异步写操作数据流严重交织、乱序。
     */
    std::shared_ptr<MsgNode> _send_node; 
};

深入 Asio 源码看回调参数:

为什么回调函数需要特定的参数?我们可以看一眼 Boost.Asio 中 async_write_some 的底层签名:

async_write_some 接收一个 WriteToken(回调函数对象),其标准签名要求返回值为 void,参数为 (error_code, size_t)

我们声明的 WriteCallBackErr 前两个参数与之完全契合,而额外的第三个参数 std::shared_ptr<MsgNode> 则是为了**通过智能指针的引用计数机制,延长 Node 的生命周期**,防止回调触发前数据被提前销毁。

cpp 复制代码
/**
 * @brief 检查并限定 Completion Token(完成令牌)合法性的宏
 * 
 * @details 
 * 1. 该宏用于编译期断言(Static Assert)或概念检查(Concepts)。
 * 2. 它严格限制了用户传入的异步回调对象(WriteToken)必须具备指定的函数签名。
 * 3. 这里的签名限定为:void(boost::system::error_code, std::size_t)。
 *    也就是说,无论你传入的是 Lambda、std::bind 还是自定义函数对象,
 *    底层触发回调时,必须且只能接收"错误码"和"传输字节数"这两个参数。
 */
BOOST_ASIO_COMPLETION_TOKEN_FOR(void (boost::system::error_code, std::size_t)) WriteToken

/**
 * @brief 指定默认 Completion Token 类型的宏
 * 
 * @details 
 * 1. 如果用户在调用 `async_write_some` 时没有传递第二个参数(token),
 *    编译器就会使用该宏指定的默认类型(通常与当前的 `executor_type` 绑定,例如 `asio::use_future` 或 C++20 协程的 `asio::use_awaitable`)。
 * 2. 这里的 `>` 符号说明整个宏处于模板参数列表 `template <...>` 的末尾。
 */
  BOOST_ASIO_DEFAULT_COMPLETION_TOKEN_TYPE(executor_type)>

/**
 * @brief 自动推导并定义异步函数返回值的宏(前缀部分)
 * 
 * @details 
 * 1. 这是 Asio 最精妙的设计之一:异步函数的返回值不是写死的 void。
 * 2. 它的返回值完全由你传入的 `WriteToken` 决定!
 *    - 如果你传普通回调函数或 Lambda,该宏推导出的返回值就是 void。
 *    - 如果你传 `asio::use_future`,该宏推导出的返回值就是一个 `std::future<std::size_t>`。
 *    - 如果你传 `asio::use_awaitable`(用于 C++20 协程),返回值则是满足 `co_await` 的协程对象。
 * 3. 括号中的 `void (boost::system::error_code, std::size_t)` 则是告诉推导机制,
 *    底层真正的异步完成通知(Completion Handler)的标准签名是什么。
 */
BOOST_ASIO_INITFN_AUTO_RESULT_TYPE_PREFIX(WriteToken, void (boost::system::error_code, std::size_t))

/**
 * @brief 成员函数:底层的异步非阻塞写操作
 * 
 * @param buffers 符合 Asio 常量缓冲区序列要求的对象(如 `asio::buffer(_msg, len)`)。
 *                它指向应用层开辟的、即将写入网络流的数据源内存块。
 * 
 * @param token   用户传入的异步令牌/回调对象。
 *                - 使用 `BOOST_ASIO_MOVE_ARG` 宏包裹是为了在支持 C++11 及更高版本的编译器中,
 *                  强行通过右值引用(`std::move`)来减少回调对象拷贝带来的性能开销。
 * 
 * @return 具体的返回值类型由上面的 `BOOST_ASIO_INITFN_AUTO_RESULT_TYPE_PREFIX` 动态决定。
 * 
 * @note **async_write_some 的底层行为特征:**
 *       该函数是"非阻塞"的,调用后立即返回。它仅仅是将写请求和缓冲区投递给底层的多路复用器(如 epoll/IOCP)。
 *       一旦底层 TCP 发送缓冲区有空闲空间,哪怕**只写入了一个字节**,它也会立即宣告完成,
 *       并由 Asio 的事件循环(io_context)驱动并触发 `token` 绑定的业务层回调。
 */
async_write_some(
    const ConstBufferSequence& buffers, 
    BOOST_ASIO_MOVE_ARG(WriteToken) token
);
cpp 复制代码
/**
 * @brief 应用层调用的异步发送接口(缺陷演示版本)
 * 
 * @param buf 业务层传入的、想要发送的原始字符串数据。
 * 
 * @note 缺陷核心:连续调用此函数会导致类成员变量 `_send_node` 被强行覆盖,
 *       引发前一个未发完节点的生存期断裂或内存数据流错乱。
 */
void Session::WriteToSocketErr(const std::string& buf) {
    // 1. 根据传入字符串的长度和内容,动态开辟一个新的数据管理节点。
    //    将智能指针赋值给类成员变量 `_send_node`。
    _send_node = make_shared<MsgNode>(buf.c_str(), buf.length());
    
    // 2. 投递非阻塞的异步写请求。
    //    asio::buffer 会将我们的 char* 数组包装成 Asio 底层识别的 ConstBufferSequence。
    this->_socket->async_write_some(
        asio::buffer(_send_node->_msg, _send_node->_total_len),
        
        // 3. 使用 std::bind 进行函数签名的适配:
        //    - async_write_some 期望的回调签名是: void(error_code, size_t)
        //    - 我们的 WriteCallBackErr 签名是:   void(error_code, size_t, shared_ptr<MsgNode>)
        //    
        //    【占位符解析】:
        //    - `this`                      : 成员函数调用必须隐式绑定的类对象指针。
        //    - `std::placeholders::_1`     : 映射底层触发回调时传回的 boost::system::error_code。
        //    - `std::placeholders::_2`     : 映射底层触发回调时传回的 std::size_t (实际发送字节数)。
        //    - `_send_node`                : 【生命周期守护】将当前的智能指针以"值拷贝"的形式塞入 bind 包装成的函数对象中。
        //                                    只要这个异步请求还在底层排队,bind 对象就不销毁,从而保持 MsgNode 引用计数至少为 1。
        std::bind(&Session::WriteCallBackErr, this, std::placeholders::_1, std::placeholders::_2, _send_node)
    );
}

/**
 * @brief 异步写操作完成后的底层回调函数(缺陷演示版本)
 * 
 * @param ec                底层返回的错误码。
 * @param bytes_transferred 本次单次异步发送,操作系统实际成功塞进 TCP 缓冲区的字节数。
 * @param msg_node          由 std::bind 延长了生命周期的当前数据节点。
 * 
 * @warning 此函数中存在一处极其隐蔽且致命的 bug:混用了局部变量 `msg_node` 和类成员变量 `_send_node`!
 */
void Session::WriteCallBackErr(const boost::system::error_code& ec, std::size_t bytes_transferred, std::shared_ptr<MsgNode> msg_node) {
    
    // 1. 如果有网络层面的硬错误(比如对端崩溃、连接被重置),应在此处及时拦截并进行资源释放
    if (ec) {
        // 实际工业代码中此处需要增加错误处理逻辑,如释放 session、记录日志等
        return;
    }

    // 2. 边界判定:单次发送的字节数 + 该节点历史已发送字节数 < 节点需要发送的总长度
    if (bytes_transferred + msg_node->_cur_len < msg_node->_total_len) {
        
        // 3. 【致命 BUG 位置】:这里修改的是类成员变量 `_send_node` 的内部计数,而不是形参 `msg_node`!
        //    如果外部在此期间连续调用了 `WriteToSocketErr`,导致类成员 `_send_node` 已经指向了全新的第 2 个节点,
        //    那么这行代码将会错误地去累加第 2 个新节点的计数,直接导致第 1 个节点的数据补发被死锁,第 2 个节点计数错乱。
        _send_node->_cur_len += bytes_transferred;
        
        // 4. 指针算术运算:重新计算接下来补发的内存起始地址与剩余长度
        //    - 起始地址:首地址 `_msg` + 已经发完的偏移量 `_cur_len`
        //    - 剩余长度:总长度 `_total_len` - 已经发完的长度 `_cur_len`
        this->_socket->async_write_some(
            asio::buffer(_send_node->_msg + _send_node->_cur_len, _send_node->_total_len - _send_node->_cur_len),
            
            // 5. 再次通过 bind 包装,并将当前节点的控制权继续往下传递,直到全量发完为止
            std::bind(&Session::WriteCallBackErr, this, std::placeholders::_1, std::placeholders::_2, _send_node)
        );
    }
}

为什么该版本(Err)不能投入实际应用?

async_write_some 极其依赖底层的 TCP 缓冲区状态。假设底层 TCP 发送缓冲区总大小为 8 字节,此时已经有 3 字节积压未发,那么当前剩余可用空间仅剩 5 字节。

若应用层此时调用 async_write_some 试图发送 "hello world!",底层实际只能一口气塞入 5 字节(即只发出了 "hello"),剩下的 "world!" 必须等待回调触发后再继续补发。

而在实际开发中,业务层对底层的缓冲区状态是无感知的。如果用户连续、循环调用 WriteToSocketErr:

WriteToSocketErr("Hello World!"); // 第一次调用
WriteToSocketErr("Hello World!"); // 第二次连续调用

由于 Boost.Asio 底层是基于 epoll/IOCP 等多路复用模型的非阻塞并发,发送行为会直接按照 async_write_some 的调用顺序进行投递。这就导致:第一次的 "Hello" 刚进缓冲区,还没来得及触发回调去补发 " World!",第二次调用的整个 "Hello World!" 就已经被强行塞进了网络流。

最终对端收到的错乱数据极有可能是:"HelloHello World! World!"。

基于发送队列的异步写(`async_write_some`) 为了彻底解决连续调用导致的数据交织穿插问题,我们需要在应用层通过 **队列(Queue)** 来强行保证数据的发送顺序。

cpp 复制代码
class Session {
public:
    /**
     * @brief 发送数据接口(应用层调用)
     * 
     * 核心逻辑通常是:
     * 1. 将待发送的数据(buf)打包成 MsgNode 并放入 _send_queue 队列。
     * 2. 检查 _send_pending 标记。
     * 3. 如果当前没有正在进行的异步发送(_send_pending == false):
     *    - 将 _send_pending 设为 true。
     *    - 取出队头节点,调用 asio::async_write 发送数据,并将 WriteCallBack 注册为回调。
     * 4. 如果当前已有发送在进行(_send_pending == true),则只需排队,什么都不做,等回调函数去处理。
     * 
     * @param buf 待发送的字符串数据
     */
    void WriteToSocket(const std::string &buf);

    /**
     * @brief 异步发送完成后的底层回调函数
     * 
     * 核心逻辑通常是:
     * 1. 检查错误码(ec)。如果有严重错误,通常会关闭 Session。
     * 2. 如果发送成功,从 _send_queue 中弹出(pop)刚才已经发送完的队头节点。
     * 3. 检查队列是否还有剩余数据:
     *    - 如果队列为空:将 _send_pending 重置为 false,表示当前闲置。
     *    - 如果队列不为空:说明在发送期间应用层又塞入了新数据,此时直接取出下一个队头节点,
     *      继续调用 asio::async_write,保持 _send_pending 为 true。
     * 
     * @param ec 错误码,用于判断发送是否成功、对端是否断开等
     * @param bytes_transferred 本次异步操作实际传输的字节数
     */
    void WriteCallBack(const boost::system::error_code& ec, std::size_t bytes_transferred);

private:
    /**
     * @brief 应用层发送队列
     * 
     * 作用:由于底层网络发送是异步的,当应用层频繁调用 WriteToSocket 时,
     * 必须将这些消息暂时缓存在队列中。
     * 使用 shared_ptr 保证即使在异步发送期间,消息节点的生命周期也是安全的,不会被提前销毁。
     */
    std::queue<std::shared_ptr<MsgNode>> _send_queue; 

    /**
     * @brief TCP 智能指针套接字
     * 
     * 代表与客户端建立的 TCP 连接。
     * 使用 shared_ptr 是为了配合 Asio 的伪闭包机制(例如 shared_from_this),
     * 确保在异步读写回调触发前,Session 和 Socket 对象不会被销毁。
     */
    std::shared_ptr<asio::ip::tcp::socket> _socket;

    /**
     * @brief 状态标记:原子/串行发送状态锁
     * 
     * true  表示当前底层已经有一个 `async_write` 正在执行,尚未触发 WriteCallBack。
     * false 表示当前底层套接字处于闲置状态,可以立刻发起新的发送。
     * 
     * 它是防止多个 async_write 并发调用、导致数据包在网络层发生交织错乱的关键。
     */
    bool _send_pending;                               
};
cpp 复制代码
void Session::WriteToSocket(const std::string& buf){
    // 1. 将新数据无脑压入队列尾部
    // 【注意细节】:这里使用了 emplace 和 new,由于类定义中队列装的是 std::shared_ptr<MsgNode>,
    // 这里隐式调用了 shared_ptr 的构造函数来托管这个 new 出来的隐式节点。
    _send_queue.emplace(new MsgNode(buf.c_str(), buf.length()));
    
    // 2. 如果 pending 为 true,说明前序节点正在发送中。
    //    根据"前序节点完工后会自动触发后续发送"的原则,本次调用直接 return 即可。
    if (_send_pending) {
        return;
    }
    
    // 3. 如果当前处于空闲状态,则代表队列此前为空,立刻开启第一次异步投递
    // 【致命隐患 1】:这里传入的是 asio::buffer(buf)。
    // buf 是一个局部引用的 const std::string&。当 WriteToSocket 函数执行完毕返回后,
    // 外部传入的临时字符串可能已经被销毁了!而底层 async_write_some 只是记录了内存指针,
    // 当真正触发网络发送时,访问的将是已经释放的脏内存(悬空指针),会导致数据乱码或崩溃。
    // 正确的做法应该是绑定刚才压入队列的、生命周期安全的 send_queue.front()->_msg。
    this->_socket->async_write_some(
        asio::buffer(buf), 
        std::bind(&Session::WriteCallBack, this, std::placeholders::_1, std::placeholders::_2)
    );
    _send_pending = true;
}

void Session::WriteCallBack(const boost::system::error_code & ec, std::size_t bytes_transferred){
    // 检查 Asio 底层是否有错误(如对端关闭连接、超时等)
    if (ec.value() != 0) {
        std::cout << "Error , code is " << ec.value() << " . Message is " << ec.message();
        // 【致命隐患 2】:如果发生错误直接 return,会导致 _send_pending 永远保持为 true。
        // 这意味着该 Session 以后再也无法发送任何数据(死锁)。并且队列中的节点无法释放。
        // 通常在这里需要清空队列、重置 pending 标志,或者直接关闭/销毁当前 Session。
        return;
    }
    
    // 取出当前正在发送的队首元素(使用引用避免拷贝开销)
    auto & send_data = _send_queue.front();
    
    // 更新当前节点的已发送字节偏移量。
    // 因为 async_write_some 是"尽力而为"的发送,可能只发送了数据的一部分(短写/Partial Write)
    send_data->_cur_len += bytes_transferred;
    
    // 情况 A:当前队首节点的数据仍未发送完毕,继续对其执行"补发"
    if (send_data->_cur_len < send_data->_total_len) {
        // 计算剩余未发送的数据首地址:首地址 + 已发送偏移量
        // 计算剩余需要发送的长度:总长度 - 已发送长度
        this->_socket->async_write_some(
            asio::buffer(send_data->_msg + send_data->_cur_len, send_data->_total_len - send_data->_cur_len),
            std::bind(&Session::WriteCallBack, this, std::placeholders::_1, std::placeholders::_2)
        );
        return; // 补发任务已投递,退出当前回调,等待下一次 WriteCallBack
    }

    // 情况 B:当前队首节点已经完美发送完毕
    _send_queue.pop(); // 将该节点从队列中弹出,shared_ptr 计数减归零,自动释放 MsgNode 内存

    // 如果队列变空,重置发送状态为闲置
    if (_send_queue.empty()) {
        _send_pending = false;
    }
    
    // 如果队列中还有后续排队的数据(说明在刚才发送期间,应用层又并发调用了 WriteToSocket 塞入了新数据)
    // 立刻驱动下一个节点的发送,从而实现串行、连贯的异步发送管道
    if (!_send_queue.empty()) {
        // 获取新的队首节点
        auto& next_send_data = _send_queue.front();
        // 投递新节点的发送任务(同样考虑到了可能存在历史遗留的 _cur_len 偏移,逻辑非常严密)
        this->_socket->async_write_some(
            asio::buffer(next_send_data->_msg + next_send_data->_cur_len, next_send_data->_total_len - next_send_data->_cur_len),
            std::bind(&Session::WriteCallBack, this, std::placeholders::_1, std::placeholders::_2)
        );
    }
}

使用 async_send 简化发送

每次都要在回调函数里判定 _cur_len < _total_len 显得代码非常冗长。为此,Asio 提供了更高级的包裹函数 async_send(其内部原理是帮我们包裹并循环调用了 async_write_some)。

async_send 能够向我们保证:只有当下层要求的长度全部发送完毕,或者发生网络错误时,才会触发回调。 因而回调被触发时,该节点必定已经是"完工"或"夭折"状态。

⚠️ 重要铁律: <font style="color:#DF2A3F;">async_send</font><font style="color:#DF2A3F;">async_write_some</font> 内部行为冲突,在同一个 socket 上绝对不能混合使用

cpp 复制代码
void Session::WriteAllToSocket(const std::string& buf) {
    // 1. 将新数据无脑压入队列尾部
    // 使用包装好的 MsgNode 独占一份堆内存,确保数据在整个异步发送生命周期内是安全的。
    _send_queue.emplace(new MsgNode(buf.c_str(), buf.length()));
    
    // 2. 如果 pending 为 true,说明底层已有发送任务在"死磕",直接排队返回
    if (_send_pending) {
        return;
    }
    
    // 3. 核心机制改变:
    // async_send 会在数据不完整时在底层死磕,直到发完或报错才回调。
    //
    // 【致命隐患依然存在】:这里传入的依然是 `buf`(局部引用的临时的 string)。
    // 尽管底层机制变了,但 async_send 依然是异步的!一旦此函数执行完毕,外面的 buf 随时可能析构。
    // 当底层真正去发送数据时,读取的依然可能是已经死掉的栈内存,导致发送乱码。
    // 应该使用刚才压入队列的:_send_queue.front()->_msg 
    this->_socket->async_send(
        asio::buffer(buf), 
        std::bind(&Session::WriteAllCallBack, this, std::placeholders::_1, std::placeholders::_2)
    );
    _send_pending = true;
}

void Session::WriteAllCallBack(const boost::system::error_code& ec, std::size_t bytes_transferred){
    // 1. 检查底层网络错误(如对端断开连接等)
    if (ec.value() != 0) {
        std::cout << "Error occured! Error code = " << ec.value() << ". Message: " << ec.message();
        // 【逻辑死锁隐患】:发生错误直接 return,会导致 _send_pending 永远为 true。
        // 该 Session 后续将无法再发送任何消息。通常需要在这里清理队列、关闭 Socket。
        return;
    }
    
    // 2. 触发此回调说明整个节点已经 100% 发送完毕,直接弹出队首
    // 因为底层 `async_send` 保证了如果不发完整个 buffer 或者报错,是绝对不会进这个回调的。
    // 所以这里不需要再像之前那样去判断 `_cur_len < _total_len` 并手动补发了。
    _send_queue.pop();

    // 3. 如果队列变空,说明积压的消息全部消化完毕,将状态重置为闲置
    if (_send_queue.empty()) {
        _send_pending = false;
    }
    
    // 4. 若有积压节点,由于它是一整个全新的节点,直接将其全量再次投递即可
    // (注:虽然这里写了 `+ send_data->_cur_len`,但由于每个节点都是全量发送才会被 pop,
    // 此时新队首节点的 `_cur_len` 必然是 0,逻辑上等同于从头全量发送该节点)。
    if (!_send_queue.empty()) {
        auto& send_data = _send_queue.front();
        this->_socket->async_send(
            asio::buffer(send_data->_msg + send_data->_cur_len, send_data->_total_len - send_data->_cur_len),
            std::bind(&Session::WriteAllCallBack, this, std::placeholders::_1, std::placeholders::_2)
        );
    }
}

异步读操作

异步读操作与写操作的内在逻辑完全对等:

  • async_read_some:只要底层网卡读到一点点数据就会触发回调,获取的长度可能小于你期望的总长度。
  • async_receive:内部不断帮我们调用 read_some,直到读满了指定的缓冲区才会触发应用层回调。

基于 async_read_some 封装读取

我们先在 Session 类中引入对应的读控制变量:

cpp 复制代码
class Session {
public:
    /**
     * @brief 启动/触发异步接收数据的接口
     * 
     * 核心逻辑通常是:
     * 1. 检查 _recv_pending 标记。如果已经是 true,说明底层已经挂起了一个异步接收请求,直接返回,避免并发接收导致数据错乱。
     * 2. 如果是 false,则将其置为 true。
     * 3. 准备好存放数据的缓冲区(如分配 _recv_node 的内存)。
     * 4. 调用 _socket->async_read_some 或 async_read,并将 ReadCallBack 注册为底层完成后的回调。
     */
    void ReadFromSocket();

    /**
     * @brief 异步接收完成后的底层回调函数
     * 
     * 核心逻辑通常是:
     * 1. 检查错误码(ec)。如果对端关闭了连接(boost::asio::error::eof)或者发生其他错误,执行清理并关闭 Session。
     * 2. 如果接收成功,bytes_transferred 表示这次实际收到了多少字节。
     * 3. 提取 _recv_node 缓冲区中的数据,进行业务层解包(例如处理粘包、切包,解析出完整的协议报文投递给上层)。
     * 4. 处理完当前数据后,将 _recv_pending 重置为 false(或者直接在下一步继续推进)。
     * 5. 【关键点】:在网络编程中,接收是一个持续不断的循环。因此在回调函数的最后,通常会再次调用 ReadFromSocket(),重新挂起下一个异步接收请求,从而形成"循环接收"的闭环。
     * 
     * @param ec 错误码,用于判断网络读取是否正常、对端是否断开
     * @param bytes_transferred 本次异步操作实际接收到的字节数
     */
    void ReadCallBack(const boost::system::error_code& ec, std::size_t bytes_transferred);

private:
    /**
     * @brief TCP 智能指针套接字
     * 
     * 代表与客户端建立的 TCP 连接,负责底层的异步数据接收。
     */
    std::shared_ptr<asio::ip::tcp::socket> _socket;

    /**
     * @brief 缓存当前正在接收的数据节点(接收缓冲区)
     * 
     * 作用:TCP 是面向字节流的,应用层发来的一个完整大包,在网络层可能会被拆成多次小包到达(拆包/粘包)。
     * 该指针指向的结构体(MsgNode)用来在多次异步接收回调之间保持状态,一点一点地把碎片数据拼凑成一个完整的业务包。
     * 使用 shared_ptr 确保在异步读取的生命周期内,缓冲区内存绝对安全可用。
     */
    std::shared_ptr<MsgNode> _recv_node; // 缓存当前正在接收的数据节点

    /**
     * @brief 状态标记:异步接收状态锁
     * 
     * true  表示当前已经向 Asio 底层投递了一个 async_read/async_read_some 请求,底层正在拼命等待网络数据的到来。
     * false 表示当前没有挂起的接收任务,处于闲置状态。
     * 
     * 作用:严格防止上层多次重复调用 ReadFromSocket(),导致底层的同一个 Socket 同时存在多个读操作(Asio 严禁对同一 Socket 并发调用异步读)。
     */
    bool _recv_pending;                  // 状态标记:true 表示当前节点正在拼命接收中
};
cpp 复制代码
void Session::ReadFromSocket() {
    // 1. 防御性检查:如果 pending 为 true,说明底层已经挂起了一个异步接收任务。
    //    为了防止并发调用 async_read_some 导致数据交织错乱,这里直接拦截返回。
    if (_recv_pending) {
        return; 
    }
    
    // 2. 初始化接收缓冲区:
    // 使用 make_shared 动态分配一块大小为 RECVSIZE 的接收节点内存。
    // 【原作者提示】:可以用 make_shared 直接构造投递,但千万不可用已构造好的 std::unique_ptr 智能指针直接强行赋值
    _recv_node = std::make_shared<MsgNode>(RECVSIZE);
    
    // 3. 投递第一次异步接收请求
    // 传入的 buffer 指向 _recv_node 内部的字符数组首地址,安全长度为 _total_len。
    // 此时数据生命周期牢牢绑定在类成员 _recv_node 中,即使本函数执行完毕,内存依然安全。
    _socket->async_read_some(
        asio::buffer(_recv_node->_msg, _recv_node->_total_len), 
        std::bind(&Session::ReadCallBack, this, std::placeholders::_1, std::placeholders::_2)
    );
    
    // 4. 将标记设为 true,表示当前正在等待底层网络数据
    _recv_pending = true;
}

void Session::ReadCallBack(const boost::system::error_code& ec, std::size_t bytes_transferred){
    // 【致命逻辑漏洞】:这里居然完全没有检查错误码 `ec`!
    // 如果客户端突然拔掉网线、遭遇断网、或者主动关闭了连接,`ec` 会返回非 0 值(如 boost::asio::error::eof)。
    // 此时 `bytes_transferred` 会是 0。如果不做检查,代码会直接向下执行:
    // 进入下面的 if 判断,导致程序针对一个已经断开的 socket 死循环不断地投递 `async_read_some`,
    // 陷入死循环并疯狂空转 CPU,或者引发后续的空指针/野指针崩溃。
    // 正确的做法应该是:在函数第一行判断 `if(ec) { 处理断开连接; return; }`

    // 累加本次实际接收到的字节数到当前节点的偏移量中
    _recv_node->_cur_len += bytes_transferred;
    
    // 情况 A:如果还没读够预期的总大小(TCP 字节流的拆包现象,数据没有一次性到齐)
    // 则重新计算剩余数据的写入偏移量和剩余长度,继续死磕接收
    if (_recv_node->_cur_len < _recv_node->_total_len) {
        // 缓冲区首地址向后偏移已接收的长度:_msg + _recv_node->_cur_len
        // 期待接收的剩余长度相应递减:_total_len - _recv_node->_cur_len
        _socket->async_read_some(
            asio::buffer(_recv_node->_msg + _recv_node->_cur_len, _recv_node->_total_len - _recv_node->_cur_len), 
            std::bind(&Session::ReadCallBack, this, std::placeholders::_1, std::placeholders::_2)
        );
        return; // 补录任务已投递,直接退出当前回调,等待下一次数据拼凑
    }
    
    // 情况 B:说明已经完美读完一个完整节点
    // 【此处略去】:将数据投递到专门的逻辑线程队列中处理。
    
    // 接收完毕后的收尾工作:
    _recv_pending = false; // 1. 接收状态重置为闲置,允许下一次 ReadFromSocket 的调用
    _recv_node = nullptr;  // 2. 及时将指针置空,释放对当前 MsgNode 的智能指针引用计数,防止内存泄漏
}

使用async_receive 简化读取

类似写操作,使用 async_receive 同样可以让代码变得极其干练。由于它本身保证了"不读满不回调",因此回调函数不需要再写任何累加判断逻辑。

cpp 复制代码
void Session::ReadAllFromSocket(const std::string& buf) {
    // 1. 防御性检查:如果 pending 为 true,说明底层已经挂起了一个异步接收任务。
    //    为了防止并发读操作导致数据错乱,这里直接拦截返回。
    // 【代码细节提示】:虽然函数声明里接收了一个 `const std::string& buf`,但函数体内完全
    // 没有用到它,实际接收的数据是直接存入下面分配的 `_recv_node` 缓冲区中的。
    if (_recv_pending) {
        return;
    }
    
    // 2. 初始化接收缓冲区:
    // 使用 make_shared 动态分配一块大小为 RECVSIZE 的接收节点内存,用于存放即将到来的全量数据。
    _recv_node = std::make_shared<MsgNode>(RECVSIZE);
    
    // 3. 异步投递,只有填满 RECVSIZE 大小或者出错,才会触发 ReadAllCallBack
    // 相比于 `async_read_some` 的"来多少读多少",这里的底层策略是"不读满不回头"。
    // 数据生命周期牢牢绑定在类成员 _recv_node 中,即便本函数执行完毕,内存依然安全。
    _socket->async_receive(
        asio::buffer(_recv_node->_msg, _recv_node->_total_len), 
        std::bind(&Session::ReadAllCallBack, this, std::placeholders::_1, std::placeholders::_2)
    );
    
    // 4. 将标记设为 true,代表当前套接字正处于全量接收的等待状态中
    _recv_pending = true;
}

void Session::ReadAllCallBack(const boost::system::error_code& ec, std::size_t bytes_transferred) {
    // 【严重安全隐患】:这里依然缺乏对错误码 `ec` 的检查!
    // 如果对端突然断开连接(如触发 eof 或 connection_reset),底层异步操作会强行中断并回调此处。
    // 此时 `ec` 会携带错误,而 `bytes_transferred` 通常会小于 RECVSIZE(甚至为 0)。
    // 如果不加 `if(ec)` 判断,代码会无视网络错误,强行把一个"残缺的"甚至"空的"节点投递到后端业务队列,
    // 从而引发后端业务逻辑解析发生空指针崩溃、断言失败或数据错乱。
    // 规范做法:在函数首行加上 `if (ec) { 处理断连/报错; return; }`

    // 触发此回调,说明 bytes_transferred 必定等于 RECVSIZE(除非网络报错夭折)
    // 累加本次实际接收到的字节数到当前节点的长度标记中
    _recv_node->_cur_len += bytes_transferred;
    
    // 【此处略去】:将完整的数据节点无脑投递到后端的逻辑线程队列
    // 此时数据已经 100% 接收完毕,业务层可以安全地按照固定长度(RECVSIZE)进行反序列化或协议解析。
    
    // 接收完毕后的收尾工作:
    _recv_pending = false; // 1. 接收状态重置为闲置,允许下一次 ReadAllFromSocket 的调用
    _recv_node = nullptr;  // 2. 及时将指针置空,释放对当前 MsgNode 的智能指针引用计数,防止内存泄漏
}

异步读写 API 对比

核心 API 回调触发时机 适用场景 核心缺陷/注意事项
async_write_some 只要底层缓冲区塞进去一点数据就触发 细粒度控制网络吞吐、流控 必须搭配应用层 _send_queue 队列,否则连续调用会导致多客户端数据穿插错乱。
async_send 必须全量发送完毕或网络彻底报错才触发 常规无乱序需求的顺序发送 代码极其简洁,但绝对不可与 async_write_some 混合使用
async_read_some 只要网卡收到一点数据就触发 定长接收、流式分包解析 需要自写 _cur_len 累加逻辑,不断计算偏移量重复投递。
async_receive 必须彻底填满制定的 buffer 块才触发 严格的定长协议块读取 绝对不可与 async_read_some 混合使用,否则会出现极其诡异的底层逻辑灾难。

异步通信的服务器和客户端

服务器

Session 类是主要负责处理单个客户端消息收发的会话类。为了简单起见,我们暂不考虑网络粘包问题,也不支持应用层手动、任意时刻调用发送接口。它只以串行应答的方式,接收并发送固定长度(最大 1024 字节)的数据。

cpp 复制代码
/**
 * @brief Session 类:代表一个已经建立的 TCP 客户端连接会话
 * @details 采用典型的"伪本振/自销毁"模式,当连接断开时,通过 delete this 释放自身内存。
 */
class Session {
private:
    boost::asio::ip::tcp::socket socket_; // 负责与当前客户端进行数据通信的通信套接字
    boost::asio::streambuf buf;           // 流式缓冲区:由 Asio 自动管理的多段动态内存,用于缓存接收到的数据

    /**
     * @brief 异步读取操作完成后的回调函数(读处理器)
     * @param error 框架传回的错误码,用于判断读取是否成功,或对端是否断开连接
     * @param bytes_transferred 本次异步读取操作实际从内核套接字缓冲区拷贝到 buf 中的字节数
     */
    void read_handler(const boost::system::error_code &error, size_t bytes_transferred)
    {
        // 判定异步操作是否出错
        if (error) {
            std::cerr << "read_handler error: " << error.message() << std::endl;
            // 【核心生命周期管理】:客户端断开连接(如触发 EOF)或发生网络错误时,
            // 必须手动释放通过 new 创建的 Session 裸指针,防止内存泄漏。
            delete this; 
            return;
        }

        // 1. 获取当前 streambuf 的可读数据缓冲区序列(指向当前未消费的连续/不连续数据段)
        auto const_buffers = buf.data();
        
        // 2. 这里的 bytes_transferred 是本次读取到的字节数,用来精准截取字符串
        // 使用 buffers_begin 迭代器从 const_buffers 中,安全地提取并构造出我们需要的完整 std::string
        std::string s(boost::asio::buffers_begin(const_buffers),
                      boost::asio::buffers_begin(const_buffers) + bytes_transferred);
        
        // 将读取到的内容打印输出到控制台
        std::cout << "read:" << s << std::endl;

        // 3. 全局异步写函数(完美支持 streambuf)
        // 将此时 buf 中缓存的数据回显发送回客户端。
        // 注意:Lambda 表达式 [this] 通过值捕获了当前对象的 explicit 指针,确保回调触发时能找到 write_handler
        boost::asio::async_write(socket_, buf, 
            [this](const boost::system::error_code &ec, size_t length) {
                // 转发给成员函数处理写完成事件
                this->write_handler(ec);
            });
    }

    /**
     * @brief 异步写入/回显操作完成后的回调函数(写处理器)
     * @param error 框架传回的错误码,用于判断数据发送是否成功
     */
    void write_handler(const boost::system::error_code &error)
    {
        // 判定异步写入是否出错
        if (error) {
            std::cerr << "write_handler error: " << error.message() << std::endl;
            // 发送失败(例如写入过程中客户端强行拔掉网线),同样需要销毁会话,回收内存
            delete this;
            return;
        }

        // 提示:async_write 完成后会自动消费掉发出的数据,不需要手动 buf.consume()。

        // 4. 核心修正点:使用全局 async_read,并配合 transfer_at_least(1)
        // 这样既能完美传入 streambuf,又能实现"收到任意数据就立刻回调"的 some 效果
        // 重新投递异步读请求,让会话持续等待接收客户端的下一包数据,保持交互
        //transfer_at_least(1):在内核的网络缓冲区里,只要抓到至少 1 个字节的数据,你就立刻收工,并触发我的回调函数
        boost::asio::async_read(socket_, buf, boost::asio::transfer_at_least(1),
            [this](const boost::system::error_code &ec, size_t bytes_transferred) {
                // 读取完成后,继续把数据送入读处理器解析
                this->read_handler(ec, bytes_transferred);
            });
    }

public:
    /**
     * @brief Session 构造函数
     * @param ioc 全局上下文 io_context 的引用,用于初始化底层的通信 socket_
     */
    Session(boost::asio::io_context &ioc) : socket_(ioc) {}

    /**
     * @brief 获取底层 Socket 对象的引用
     * @return 返回当前套接字对象的引用,暴露给 Server 类以便在 async_accept 中进行绑定
     */
    boost::asio::ip::tcp::socket &Socket()
    {
        return socket_;
    }

    /**
     * @brief 开启会话的业务生命周期
     * @details 当 Server 成功接收一个客户端连接后,会显式调用此函数启动第一轮异步监听。
     */
    void start()
    {
        // 5. 初始化读取同样修改为全局 async_read + transfer_at_least(1)
        // 投递首个异步读操作。一旦客户端有任何字节发送过来,就会激活底层事件并触发 read_handler
        boost::asio::async_read(socket_, buf, boost::asio::transfer_at_least(1),
            [this](const boost::system::error_code &ec, size_t bytes_transferred) {
                this->read_handler(ec, bytes_transferred);
            });
    }
};

Server 类为服务器接收连接的管理类,负责全局端口监听与新连接的建立。

cpp 复制代码
/**
 * @brief Server 类:负责管理服务器的端口监听、接受连接以及全局调度
 */
class Server {
public:
    // 构造函数:初始化 io_context 引用和监听器 acceptor
    // 绑定本地 IPv4 任意地址 (any) 以及指定端口 (port),并使其进入 listen 监听状态
    Server(boost::asio::io_context& ioc, short port):ioc_(ioc)
    ,acceptor_(ioc_,asio::ip::tcp::endpoint(asio::ip::address_v4::any(),port)){
        // 构造完成后,立刻投递第一个异步 accept 监听请求
        start_accept();
    }

private:
    // 投递异步连接接收请求的函数
    //将要接收连接的 acceptor 绑定到服务上
    //其内部就是将 acceptor 对应的 socket 描述符绑定到 epoll 或 iocp 模型上,实现事件驱动。
    void start_accept(){
        // 在堆上动态实例化一个新的 Session,用来代表即将连入的"未知"客户端
        Session* session = new Session(ioc_);
        
        // 显式采用 [this, session] 值捕获。
        // 绝不能使用 [&],否则当 start_accept 执行完毕退出时,栈上的 session 指针变量被销毁,
        // 延迟触发的 Lambda 回调里就会读到悬空的无用内存,导致服务器崩溃或提前关闭。
        acceptor_.async_accept(session->Socket(), [this, session](const boost::system::error_code& error){
            // 将接受到的套接字和错误状态提交给 handle_accept 处理
            this->handle_accept(session, error);
        });
    }
    
    // 异步连接接收完成后、为新连接到来后触发的回调函数
    void handle_accept(Session* new_session, const boost::system::error_code& error){
        // 检查异步接收过程中是否发生底层网络错误
        if(error.value() != 0){
            std::cerr << "handle accept error:" << error.message() << std::endl;
            // 接收失败,该客户端并没有成功建立连接,需要将之前 new 出来的 Session 内存安全释放
            delete new_session;
        }else{
            // 接收成功,客户端顺利接入,调用 start 激活该 Session 的网络数据读写循环
            new_session->start();
        }
    }
    
    boost::asio::io_context& ioc_;       // 全局 I/O 上下文服务的引用,整个网络引擎的核心驱动器
    asio::ip::tcp::acceptor acceptor_;   // 底层用于 listen 和 accept 的监听器组件
};

/**
 * @brief 程序入口
 */
int main(){
    // 实例化一个网络事件上下文对象。它是 Asio 机制的动力源,负责与操作系统的内核多路复用(IOCP/epoll)通信
    asio::io_context ioc;
    
    // 初始化服务器实例,在 3333 端口开始监听
    Server server(ioc,3333);
    
    // 【核心驱动】:启动事件循环。
    // 该函数是一个阻塞调用。它会让出主线程控制权去轮询和驱动内核事件。
    // 只要程序中还有未完成的异步任务(例如当前的监听、或者是读写),它就会一直保持阻塞运行。
    ioc.run();
    
    return 0; // 当所有异步任务枯竭或被主动停止后,run 退出,程序正常结束
}

客户端

cpp 复制代码
#include <iostream>
#include <boost/asio.hpp>
#include <string>
#include <thread>

namespace asio = boost::asio;

// 负责异步接收服务端回显的线程函数
void do_read(asio::ip::tcp::socket& socket, asio::streambuf& buf) {
    // 使用全局 async_read + transfer_at_least(1) 保持长连接监听
    asio::async_read(socket, buf, asio::transfer_at_least(1),
        [&socket, &buf](const boost::system::error_code& ec, size_t bytes_transferred) {
            if (!ec) {
                // 打印服务端回显的数据
                auto const_buffers = buf.data();
                std::string msg(asio::buffers_begin(const_buffers),
                                asio::buffers_begin(const_buffers) + bytes_transferred);
                std::cout << "\n[Server Echo]: " << msg << std::endl;
                
                // 自动消耗掉已读数据,并继续监听下一轮服务器响应
                buf.consume(bytes_transferred);
                do_read(socket, buf); 
            } else {
                std::cout << "\n[Client] Connection closed by server. (" << ec.message() << ")" << std::endl;
            }
        });
}

int main() {
    try {
        asio::io_context ioc;
        asio::ip::tcp::socket socket(ioc);
        
        // 1. 连接到你的服务端
        asio::ip::tcp::endpoint endpoint(asio::ip::make_address("127.0.0.1"), 3333);
        socket.connect(endpoint);
        std::cout << "Connected to server! Type your message and press Enter (Type 'exit' to quit):\n" << std::endl;

        // 2. 启动异步读取线程,专门用来接管服务端的异步回显通知
        asio::streambuf receive_buf;
        do_read(socket, receive_buf);
        
        // 让 io_context 在后台线程运行,负责驱动异步读事件
        std::thread io_thread([&ioc]() { ioc.run(); });

        // 3. 主线程进入标准的控制台交互循环(Interactive Loop)
        std::string input_line;
        while (true) {
            std::cout << "Enter text > ";
            if (!std::getline(std::cin, input_line) || input_line == "exit") {
                break; // 输入 exit 退出循环
            }
            
            if (input_line.empty()) continue;

            // 同步发送用户输入的数据给服务端
            boost::system::error_code ec;
            asio::write(socket, asio::buffer(input_line), ec);
            if (ec) {
                std::cerr << "Send failed: " << ec.message() << std::endl;
                break;
            }
            
            // 给异步读取线程留一点点输出打印的时间间隙
            std::this_thread::sleep_for(std::chrono::milliseconds(50));
        }

        // 4. 清理并退出
        socket.close();
        if (io_thread.joinable()) {
            io_thread.join();
        }
    }
    catch (std::exception& e) {
        std::cerr << "Client Exception: " << e.what() << std::endl;
    }
    return 0;
}

运行服务器之后再运行客户端,输入字符串后,就可以收到服务器应答的字符串了。

隐患

该简单异步 demo 示例存在一个严重的内存生命周期隐患

:::color4

⚠️**二次析构与野指针风险****:**

当服务器即将发送数据前(调用 async_write 后、尚未进入 handle_write 期间),如果客户端突然异常中断(断开连接)。

此时在底层会引发两个链式反应:

  1. 发送端因为连接断开导致 async_write 失败,触发 handle_write 回调。由于 ec 非 0,代码执行 delete this 回收了会话。
  2. 但在 TCP 层面,客户端关闭也会向服务器触发一个"读就绪(读到 EOF)"事件,这会导致还在队列里的 async_read_some 读回调函数被推入就绪队列并执行。

当执行读回调 handle_read 时,由于先前写回调已经把 this 释放了,此时再在读回调里判断 ec 非 0 进而执行第二次 delete this,就会造成二次析构(Double Free),或访问已经死去的内存,这是极度危险的。

:::


相关推荐
不昀3 小时前
音频变压器Bourns SM-LP-5001国产替代选型指南
网络·音视频·以太网·网络通信·电子元器件
随身数智备忘录3 小时前
从点检到全生命周期:设备管理体系能解决哪些场景痛点?一套设备管理体系的实战应用
java·网络·数据库
广州灵眸科技有限公司3 小时前
瑞芯微(EASY EAI)RV1126B 千兆以太网电路
服务器·前端·人工智能·python·深度学习
sbjdhjd4 小时前
02 下 | Kubernetes Pod 实战实验完全解析
linux·运维·云原生·kubernetes·podman·kubelet·kubeless
我不是懒洋洋4 小时前
从零实现Transformer:从注意力机制到ChatGPT
c语言·数据结构·c++·经验分享
切糕师学AI4 小时前
Envoy 详解:云原生时代的高性能网络代理
网络·云原生·istio·网络代理·envoy·sidecar·网格服务
H Journey4 小时前
VMware + Linux(Ubuntu) + 桥接网络知识梳理
linux·网络·ubuntu
TechWayfarer4 小时前
街道级IP定位的技术边界:IP精准定位服务在本地生活场景的落地实践
大数据·网络·python·tcp/ip·生活
少年攻城狮4 小时前
阿里云系列---【申请域名并绑定到主机ip】
linux·服务器·tcp/ip·阿里云·云计算