ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Boost.Asio实战:两种简易方式解决TCP粘包

Boost.Asio实战:两种简易方式解决TCP粘包 TCP编程里粘包这个东西几乎是每个写网络应用的人都会撞上的墙。我早期用原生socket写的时候被它折腾得够呛后来切到asio发现只要换个思路这个问题其实有不少相当省事的解法。这篇是系列的第12篇前几篇咱们把socket生命周期、读写流程、strand调度这些基础过了一遍今天专门把粘包这块好好捋一捋——用asio提供的现成机制给出两个我实测下来最实用的简易方式。1. 先还原一下粘包现场TCP本来就不知道消息是什么先从一个最典型的场景说起。假设你写了一个echo服务端客户端连着发了三条消息hello、world、hello asio。你要是简单地在上层做循环读取很可能看到的是这样的结果received: helloworldhello asio三条消息黏在一起了。更烦人的是有时候还会出现另外两种形态半包一条消息没读完被拆成了两截比如hel和lo分两次到达。粘包与半包混合一次读到hellowor下一次读ldhello asio。这些现象本身太容易吓到新手了但本质原因其实很简单TCP是字节流协议不是消息协议。它只保证你发送的字节按顺序到达对端但完全不保证字节的分组方式和你write时的分组方式一致。打个比方你往邮筒里投了三封信邮局不会因为你是分三次投的就一定分三次送到收件人手里。运送途中可能三封信被塞进同一个大信封一起送达也可能一封信在路上被拆开分两批送。收件人拿到手的是一堆字节至于哪几个字节算一封信TCP不管得应用层自己定规矩。具体到TCP实现层面粘包的出现有几方面推手Nagle算法发送方连续write几个小数据块时内核可能把它们合并成一个TCP段再发出减少网络往返。MSS/MTU限制一个TCP段能承载的应用数据有限通常1460字节左右超过就得拆分这就是半包的来源。接收缓冲区对端read时一次可能从内核缓冲区里捞出多个TCP段的数据应用层的概念边界在这里完全消失。所以处理粘包的第一步不是急着写代码而是建立一个认知你收到的所有数据都只是一串连续的字节必须自己定义一条完整消息从哪里开始、到哪里结束。这就是所谓的消息边界。那怎么定义消息边界最简单实用的就两条路要么约定一个分隔符要么约定一个固定头部带上长度信息。下面两个小节分别展开。2. 最省事的方案分隔符协议 async_read_until如果你的协议是自己定的而且消息体是文本比如JSON、命令行、日志行那么用分隔符切包绝对是最快上手的方案。asio里对应的现成接口是async_read_until。2.1 为什么是 async_read_until而不是 read_some先看一段最朴素的read写法会碰到什么问题void do_read() { socket_.async_read_some(asio::buffer(buf_), [this](std::error_code ec, std::size_t len) { // 处理 buf_[0..len) do_read(); }); }async_read_some的语义是至少读1个字节就返回具体一次能读多少完全看内核心情。你以它为循环去读一个消息可能读到一半就被回调了你得自己把剩下的字节拼完——这就落入手动拼半包的泥潭很容易写得又长又容易错。async_read_until则完全不同。它的签名长这样templatetypename AsyncReadStream, typename DynamicBuffer_v1, typename ReadHandler void async_read_until( AsyncReadStream s, DynamicBuffer_v1 buffers, const std::string delim, // 分隔符 ReadHandler handler);它的内部逻辑是反复从socket读数据进缓冲区直到缓冲区里出现指定的分隔符才回调。如果一次没凑齐它会继续读如果分隔符跨越了两次底层读取它内部也会正确处理。这相当于把等一条完整消息这个状态机封装好了上层根本不用关心半包长什么样。2.2 一个可直接运行的服务器例子下面是一个完整的示例客户端发送以换行符\n结尾的文本行服务端读出一行后原样回复。#include boost/asio.hpp #include memory #include iostream using boost::asio::ip::tcp; class Session : public std::enable_shared_from_thisSession { public: Session(tcp::socket socket) : socket_(std::move(socket)) {} void start() { do_read_line(); } private: void do_read_line() { auto self shared_from_this(); asio::async_read_until(socket_, streambuf_, \n, [this, self](std::error_code ec, std::size_t /*bytes_transferred*/) { if (ec) { // 连接关闭或出错清理资源 return; } // 从 streambuf 中取出一行 std::istream is(streambuf_); std::string line; std::getline(is, line); // 如果协议用 \r\n 结尾把行尾的 \r 去掉 if (!line.empty() line.back() \r) { line.pop_back(); } std::cout recv: line std::endl; // 回显 do_write(line \n); do_read_line(); // 继续读下一行 }); } void do_write(const std::string data) { auto self shared_from_this(); auto buf std::make_sharedstd::string(data); asio::async_write(socket_, asio::buffer(*buf), [this, self, buf](std::error_code ec, std::size_t) { if (ec) { return; } }); } tcp::socket socket_; asio::streambuf streambuf_; }; // server 主逻辑accept 后创建 Session class Server { public: Server(asio::io_context io, short port) : acceptor_(io, tcp::endpoint(tcp::v4(), port)) { do_accept(); } private: void do_accept() { acceptor_.async_accept( [this](std::error_code ec, tcp::socket socket) { if (!ec) { std::make_sharedSession(std::move(socket))-start(); } do_accept(); }); } tcp::acceptor acceptor_; }; int main() { asio::io_context io; Server server(io, 12345); io.run(); return 0; }这段代码的核心就一个动作async_read_until(socket_, streambuf_, \n, handler)。每次回调时streambuf里一定已经躺着至少一条以\n结尾的完整数据。你就放心用std::getline把它取出来然后继续发起下一次读取。实测下来这个方案对付95%以上的文本协议足够了。客户端连续发100行服务端就能收到完整的100行无论是粘包还是半包async_read_until都会在内部处理好。2.3 分隔符方案的两个关键细节细节一之后一定要让streambuf的数据被消费掉。我用的是std::istream is(streambuf_); std::getline(is, line);getline会从streambuf里把一行数据加上换行符整体读走。这样streambuf里剩下的才是下一行或下一行的一部分。如果只是查看streambuf内容而不消费下一次async_read_until会从头扫描同一批旧数据导致同一行被反复处理——这个问题后面第4节还会细说。细节二必须防一行无限长。async_read_until有一个重载可以传最大匹配长度asio::async_read_until(socket_, streambuf_, \n, max_size, handler);max_size除了能防内存被恶意撑爆还能约束单条消息体积。如果客户端半天不发\n而缓冲区数据已经超过max_size这个操作会以错误结束你可以借此断开连接。不要裸奔用无限长版本尤其在公网环境。分隔符方案唯一的软肋消息体里不能出现分隔符。如果协议里的业务文本可能包含\n你就得选一个几乎不会出现的分隔符比如\r\n\r\n、\0、或者一些自定义的不可见字符组合或者干脆转义。要是你的协议承载的是二进制数据那下面这个方案更靠谱。3. 更通用的方案定长头部 变长包体用asio组合读写处理二进制协议最常见的消息格式是固定长度的头部头部里写明包体长度然后是包体。比方说| 4字节大端整数body长度 | body内容长度由头部决定 |这个格式朴素、好实现、跨语言都通用也是业界用了很多年的经典做法。3.1 为什么不自己拼字节而是交给asio的 async_read用最原始的方式实现这个格式你得这么干攒数据直到够4字节。解析出长度N。继续攒直到总长达到4N。取出完整包处理然后从头再来。这个攒数据的逻辑最容易写崩尤其是一边RTP一边还要考虑粘包、半包和缓冲区剩余数据。asio提供的async_read帮我们把等到读够N字节这件事封装成了组合操作它会循环内部读取直到满足条件。配合completion condition可以精确表达我要读够这么多字节。于是整个读取流程可以拆成两步状态机void read_header() { asio::async_read(socket_, asio::buffer(header_buf_), [this, self](std::error_code ec, std::size_t) { if (ec) { /* 处理错误 */ return; } // 解析长度 uint32_t net_len; std::memcpy(net_len, header_buf_, sizeof(net_len)); body_len_ ntohl(net_len); // 合法性检查防止恶意长度 if (body_len_ max_payload_) { // 关闭连接 return; } body_buf_.resize(body_len_); read_body(); }); } void read_body() { asio::async_read(socket_, asio::buffer(body_buf_.data(), body_len_), [this, self](std::error_code ec, std::size_t) { if (ec) { return; } // 到这里一个完整的 body 已经就绪 process_packet(body_buf_); // 继续读下一个包头 read_header(); }); }这个写法的妙处在于async_read自己处理了一次读不够数的情况。read_header发起的异步读只有当4字节头部全部到达后才回调read_body发起的读只有当整个body都齐了才回调。中途收到的多余字节比如客户端连续发了几个包asio会继续留在socket的内核缓冲区里下次read_header再读时首先读到的就是上一个包剩下的头部数据。数据不会丢也不会乱。3.2 完整代码骨架与字节序处理我贴一段整理过的完整骨架带注释#include boost/asio.hpp #include cstring #include vector #include memory using boost::asio::ip::tcp; class PacketSession : public std::enable_shared_from_thisPacketSession { public: PacketSession(tcp::socket socket) : socket_(std::move(socket)), header_buf_(4), body_buf_() {} void start() { read_header(); } private: static constexpr uint32_t kMaxPayload 1024 * 1024; // 1MB 上限 void read_header() { auto self shared_from_this(); asio::async_read(socket_, asio::buffer(header_buf_.data(), header_buf_.size()), [this, self](std::error_code ec, std::size_t) { if (ec) { if (ec ! asio::error::eof) { std::cerr read header error: ec.message() std::endl; } return; } uint32_t net_len; std::memcpy(net_len, header_buf_.data(), sizeof(net_len)); body_len_ ntohl(net_len); // 网络字节序 - 主机字节序 if (body_len_ 0 || body_len_ kMaxPayload) { // 校验收到的长度不合法就直接断开防止恶意包 socket_.close(); return; } body_buf_.resize(body_len_); read_body(); }); } void read_body() { auto self shared_from_this(); asio::async_read(socket_, asio::buffer(body_buf_.data(), body_buf_.size()), [this, self](std::error_code ec, std::size_t) { if (ec) { std::cerr read body error: ec.message() std::endl; return; } process_packet(body_buf_); read_header(); // 继续读下一个包 }); } void process_packet(const std::vectorchar body) { std::cout process packet, size body.size() std::endl; // 这里做业务逻辑 } tcp::socket socket_; std::vectorchar header_buf_; std::vectorchar body_buf_; uint32_t body_len_ 0; };有几个地方值得停下来提醒一下。字节序不能省。头部用uint32_t表示长度的时候发送方和接收方必须约定统一字节序。上面代码用ntohl把网络序转主机序发送方应该用htonl。我见过不少项目本地测试没问题一换成跨平台环境就出鬼十有八九是字节序没处理。长度校验是安全底线。如果不检查body_len_就直接resize(body_len_)一个恶意客户端可以发个4字节的0xFFFFFFFF服务端瞬间尝试分配4GB内存直接OOM崩溃。所以kMaxPayload这个上限必须设超出直接断连。无论分隔符方案还是这个方案这一点都是铁律。大包体的内存策略。body_buf_.resize(body_len_)每包都做一次。如果消息频率很高可以做一个够用就复用的优化只有当前capacity()不够时才重新分配。这一点后面第4节专门说。3.3 为什么说这是简易方式对比自己拼buffer的做法可能有人觉得这个方案还是要写两步读哪里简易了我说一个对比你就明白了。不用asio组合操作的话原始写法大概率长这样void do_some_read() { socket_.async_read_some(asio::buffer(tmp_buf_), [this](std::error_code ec, std::size_t len) { recv_buf_.append(tmp_buf_, len); // 先存起来 while (true) { if (recv_buf_.size() 4) break; // 头部没凑齐 uint32_t body_len parse_len(recv_buf_); // 解析长度 if (recv_buf_.size() 4 body_len) break; // body没凑齐 std::string packet recv_buf_.substr(4, body_len); // 摘出包 recv_buf_.erase(0, 4 body_len); // 删除已消费数据 process(packet); } }); }这段代码本身不算难但问题在于你得维护一个半成品缓冲区负责把多次读取的数据拼在一起。recv_buf_.erase(0, n)在std::string身上是O(n)的高频场景会有性能损耗。边界条件多size() 4、size() 4 body_len稍微不小心就越界。每次append和erase都在破坏迭代器/指针的稳定性容易写出隐蔽bug。用async_readasync_read_until这些底层攒数据的活全被封装掉了。你只需要关心什么时候该读头部、什么时候该读body状态机清晰不容易出错。这才是简易二字的真正含义。4. 处理半包的关键动态缓冲区与合理的内存管理粘包处理里最容易被忽视的是缓冲区这一层。asio的streambuf、dynamic_buffer这些概念不搞清楚的话即使是用了上面的方案也早晚会踩坑。4.1 streambuf 到底是什么、为什么它不用你管扩容在async_read_until的例子中缓冲区类型是asio::streambuf。这个名字容易让人联想到C标准库的streambuf但这里的streambuf本质是一个可自动扩容的字节容器专门配合组合读操作使用。它内部维护了一段连续或近似连续的内存对外暴露两部分逻辑输入区从socket读进来的原始数据堆积在这里。输出区你消费掉的数据所占的空间可以通过consume(n)标记为可回收。async_read_until往streambuf里写数据时如果当前容量不够它会自动增长写完后你通过istream或手动的consume(n)来释放已处理数据。因为扩容是自动的所以你在业务代码里基本不需要计算我应该预分配多少——默认从某个较小值起步不够再长。这一点对快速验证想法特别友好。但自动扩容也有代价。streambuf的增长策略是按倍数扩展的如果一个连接上长期跑着1KB左右的包第一次增长到2KB、4KB……之后容量稳定在一个较大值这没问题。但如果你的消息大小跨度很大一个10字节下一个10MB缓冲区就会频繁发生较大的重分配这在高吞吐场景下会拖后腿。4.2 一个让我记忆犹新的bug忘记consume同一包数据处理了两次说个真实栽过的跟头。早期我用async_read_until偷懒没走istream消费而是直接在回调里这么干std::string line(streambuf_.data(), streambuf_.size());然后没调用consume接着就发起了下一次async_read_until。结果那条line被处理了两次而且第二次处理时里面还混杂着下一包的数据碎片。排查了很久最后翻文档才反应过来streambuf_.data()只是看一眼缓冲区数据本身还在里面。async_read_until判断分隔符时是从现有缓冲区内容开始找的旧数据不清掉它会一直看到那些已经被处理过的内容。正确做法是处理完务必让streambuf瘦身// 方式一用 istream 读走数据推荐直观 std::istream is(streambuf_); std::string line; std::getline(is, line); // 自动消费到换行符为止 // 方式二手动计算并 consume size_t n ...; // 分隔符在缓冲区中的偏移 分隔符长度 streambuf_.consume(n);这两种方式选哪种都行但必须二选一不能只看不消费。consume这个词要记住它负责把已经处理的字节从缓冲区头部移除为后续数据腾出空间。4.3 vector缓冲区的resize陷阱别每次读之前都清零分隔符方案有streambuf兜底但headerbody方案里我用的是std::vectorchar这也有个新手高频坑以为每次读取前都要把buffer清空。错误示范std::vectorchar buf(1024); void do_read() { buf.clear(); // 或 buf.assign(1024, 0) socket_.async_read_some(asio::buffer(buf.data(), buf.size()), [this](std::error_code ec, std::size_t len) { handle(buf.data(), len); do_read(); }); }问题在哪如果这次底层只读到了半个包你下次do_read把buf清空了上一次读到的半个包的数据就没了。这在read_some自拼接缓冲区的模式里是致命的。所以凡是需要跨多次读取累积数据的场景缓冲区的生命周期必须比单次读取长而且已收到的数据要小心翼翼地保留。在headerbody方案里因为用的是async_read它内部已经处理了读满指定长度所以每次读取前不需要考虑上一轮的剩余数据asio已经把剩余留在了socket的内核缓冲区等下次读自然能拿到。body_buf_.resize(body_len_)只需要保证容量够不需要清空重来——反正process_packet只会读取body_buf_.data()[0..body_len_)这段刚读进来的内容长度由异步读取保证完全填满。4.4 自己管理缓冲的优化思路顺带一提如果需要追求极致性能不想让streambuf反复增长可以用一块预分配环状缓冲区或大块连续内存。思路是recv_buf预先开成8KB或64KB维护read_pos_和write_pos_两个下标数据攒进来、处理掉两个下标跟着走。当数据不足时把剩余未处理数据搬到头部腾出尾部空间继续读。这算是进阶玩法了对于绝大多数业务场景用asio的streambuf或头部body两步读已经够稳够快。没必要为了省那几次realloc把自己逼进手动管理内存的深坑。5. 粘包处理中我踩过的坑和最后的取舍建议最后一个部分我把这些年做网络服务时积累的经验集中倒一倒尤其是那些不那么容易在文档里看到的点。5.1 别对同一个socket发起两个并发的异步读asio文档明确要求同一个socket上的异步读操作不能并发发起。意思是如果当前已有一个async_read_until或async_read在等数据你不能再同时发起另一个读。为什么这条和粘包有关因为我在处理读完一个包后继续读的时候如果图省事在某个定时器里也触发了一次读就违反了这条约束。常见的后果是你的两个读handler可能同时收到数据或者一个读操作把另一个读到一半的数据抢走——行为未定义排查起来极其痛苦。牢记每条连接上读状态机必须严格串行。read完一个包再发起下一个read或者始终只维护一个在读的异步链。5.2 Nagle算法与延迟确认导致的诡异粘包有些项目在局域网里跑得好好的一到公网测试粘包现象突然变频繁了。这大概率是Nagle算法和TCP延迟确认Delayed ACK在捣鬼。发送方的小包被合并接收方收到的大包自然就黏得多。处理办法如果你的协议对实时性要求高、消息普遍偏小可以在socket上关闭Nagletcp::no_delay option(true); socket_.set_option(option);asio里直接set_option(tcp::no_delay(true))即可。这会牺牲一点点带宽效率但能显著降低小包被合并的概率。注意这只是降低粘包概率不是根治——协议层该定义的消息边界还是得定义。5.3 不要只在测试环境验证粘包逻辑粘包这东西本质上是网络时序概率事件。在loopback本机回环地址上测试数据几乎都是即时到达粘包概率很低你模拟不出来的半包场景到了真实网络里分分钟出现。所以我建议本地测试时把发送方改成连续多次write不flush人为制造粘包。或者用一个代理工具把TCP流量切小模拟拆包。压力测试时用多个客户端并发发包让服务端承受合并和乱序TCP有序但并发小包特别容易黏。这些手段能提前暴露问题别等上线了才被线上脏数据打脸。5.4 一张表总结两个方案的适用场景与取舍方案适用协议优点注意点分隔符方案async_read_until文本行、JSON行、命令协议实现极简、代码易懂、天然处理半包消息体不能含分隔符要设置max_size防超长头部body方案async_read 两步读二进制协议、任意payload通用性强、长度精确、适合跨语言要处理字节序、要做长度合法校验两个方案我都认为是简易方式关键看你的协议形态。如果协议是自己定义的文本行用分隔符如果是二进制或者别人定死的协议用头部body。别在文本协议上硬上二进制封包也不要在二进制协议里傻傻地找特殊字节——那是给自己找麻烦。5.5 我现在的做法以我现在手头维护的一个游戏网关项目来说消息全部走4字节包头包体格式服务端入口就是第3节那个两步读。用asio的async_read组合操作代码比最初手写拼buffer的版本短了一半出错率肉眼可见地下降了。文本调试通道单独开一个端口用换行分隔符方便用telnet或nc直接手敲命令排障。两者并存互不干扰。粘包处理说穿了就两件事定义消息边界以及把攒数据和找边界这件事交给成熟可靠的组件。asio的async_read_until和async_read正是为此设计的。下次写网络程序再遇到收到一坨不明所以的字节先别急着自己写贪心循环拼buffer想想这两把现成的钥匙能省下不少头发。
返回列表