戰(zhàn):長(zhǎng)度字段與緩沖區(qū)方案)
簡(jiǎn)介這是一份面向C網(wǎng)絡(luò)編程學(xué)習(xí)者的實(shí)戰(zhàn)源碼資源聚焦TCP/IP通信中粘包與丟包這一棘手問題適合已掌握C基礎(chǔ)、希望深入理解網(wǎng)絡(luò)消息收發(fā)機(jī)制的開發(fā)者。資源通過服務(wù)器與客戶端兩端的完整實(shí)現(xiàn)逐步演示網(wǎng)絡(luò)編程的完整流程并將底層收發(fā)細(xì)節(jié)封裝為設(shè)計(jì)良好的函數(shù)應(yīng)用層只需定義協(xié)議頭、消息結(jié)構(gòu)體與回調(diào)函數(shù)即可無需關(guān)心數(shù)據(jù)如何被接收和發(fā)送。壓縮包共36個(gè)文件包含16個(gè)h頭文件、8個(gè)cpp源文件以及dsw、dsp工程文件、rc資源腳本、lib靜態(tài)庫(kù)和ico圖標(biāo)等整體約49KB工程結(jié)構(gòu)清晰可直接編譯運(yùn)行。目前已有1649人學(xué)習(xí)下載。讀者可從中獲得一套可復(fù)用的粘包處理方案、協(xié)議與消息結(jié)構(gòu)設(shè)計(jì)范例、客戶端與服務(wù)器通信的完整代碼框架以及便于二次開發(fā)的目錄組織方式是理解并解決TCP粘包問題的實(shí)用參考。1. TCP 粘包不是 bug是字節(jié)流在替你背鍋很多人第一次寫 C TCP 服務(wù)端都會(huì)遇到一個(gè)詭異現(xiàn)象客戶端明明分三次send每次發(fā)一條 JSON服務(wù)端recv一次卻把三條消息全讀出來了或者一條消息被劈成兩半。新手第一反應(yīng)是「TCP 有 bug」老手會(huì)告訴你這叫粘包和拆包。它根本不是錯(cuò)誤而是 TCP 作為字節(jié)流協(xié)議的天性——它只保證字節(jié)順序和可靠到達(dá)不保證消息邊界。你在應(yīng)用層不劃邊界內(nèi)核就按自己的節(jié)奏把字節(jié)塞給你。這個(gè)標(biāo)題要解決的就是這件事用一套能編譯運(yùn)行的 C 源代碼把「長(zhǎng)度字段 緩沖區(qū)」這套最穩(wěn)的拆包方案講透。適合兩類人一是剛學(xué)完 socket 編程、被粘包折磨到懷疑人生的新手二是想找一個(gè)可以直接抄進(jìn)項(xiàng)目的工業(yè)級(jí)收發(fā)框架的熟手。下面從協(xié)議設(shè)計(jì)一路寫到編譯運(yùn)行代碼全部可復(fù)現(xiàn)不玩虛的。2. 先定協(xié)議再寫代碼長(zhǎng)度字段為什么比分隔符靠譜2.1 粘包拆包的本質(zhì)與三種主流方案對(duì)比TCP 是面向字節(jié)流的發(fā)送端調(diào)用send只是把數(shù)據(jù)交給內(nèi)核發(fā)送緩沖區(qū)內(nèi)核怎么分段、什么時(shí)候發(fā)取決于 MSS、Nagle 算法和當(dāng)前擁塞窗口。接收端recv返回的是「當(dāng)前內(nèi)核緩沖區(qū)里已有的字節(jié)」可能多可能少。所以「一條消息」這個(gè)概念TCP 層根本不存在必須由應(yīng)用層協(xié)議自己定義。常見三種劃邊界方案方案做法優(yōu)點(diǎn)致命缺點(diǎn)固定長(zhǎng)度每條消息定長(zhǎng)實(shí)現(xiàn)最簡(jiǎn)單變長(zhǎng)業(yè)務(wù)直接廢掉分隔符用\n或\0分隔可讀性好消息體含分隔符就翻車需轉(zhuǎn)義長(zhǎng)度字段頭部寫 body 長(zhǎng)度二進(jìn)制安全、效率高需要處理頭部本身拆包我一般直接選長(zhǎng)度字段因?yàn)樗嵌M(jìn)制安全的圖片、protobuf、壓縮數(shù)據(jù)都能塞。分隔符方案在文本協(xié)議里能用但一旦業(yè)務(wù)數(shù)據(jù)里出現(xiàn)\n就是血淚經(jīng)驗(yàn)現(xiàn)場(chǎng)。長(zhǎng)度字段唯一的麻煩是「頭部本身也可能被拆開」這個(gè)后面用讀緩沖解決。協(xié)議格式我定成這樣簡(jiǎn)單到不會(huì)記錯(cuò)-------------------------------- | 4 字節(jié) | 4 字節(jié) | N 字節(jié) | | magic | length | body | --------------------------------magic固定值用于快速校驗(yàn)length表示 body 字節(jié)數(shù)網(wǎng)絡(luò)字節(jié)序。接收端先攢夠 8 字節(jié)頭部讀出 length再攢夠 length 字節(jié) body一條完整消息才算到齊。2.2 用環(huán)形緩沖區(qū)還是 std::vector 攢數(shù)據(jù)攢數(shù)據(jù)這一步是核心。最樸素的做法是每次recv后append到一個(gè)std::vectorchar解析時(shí)從頭掃解析完把已消費(fèi)部分erase掉。這個(gè)方案能跑但erase會(huì)搬移內(nèi)存高頻小包場(chǎng)景下性能很難看。我一般用「讀偏移 定期壓縮」的折中方案vector 只增不刪維護(hù)一個(gè)readPos解析時(shí)從readPos開始看消費(fèi)后readPos前移。當(dāng)readPos超過某個(gè)閾值比如 64KB且超過容量一半時(shí)才做一次erase壓縮。這樣絕大多數(shù)情況下沒有內(nèi)存搬移實(shí)現(xiàn)又比環(huán)形緩沖區(qū)簡(jiǎn)單得多不容易寫出越界 bug。下面這段是緩沖區(qū)骨架先看結(jié)構(gòu)完整代碼在下一章給全class RecvBuffer { public: void append(const char* data, size_t len) { buf_.insert(buf_.end(), data, data len); } // 可讀字節(jié)數(shù) size_t readable() const { return buf_.size() - readPos_; } // 可讀起始指針 const char* peek() const { return buf_.data() readPos_; } // 消費(fèi) n 字節(jié) void consume(size_t n) { readPos_ n; // 超過閾值才壓縮避免頻繁搬移 if (readPos_ 64 * 1024 readPos_ * 2 buf_.size()) { buf_.erase(buf_.begin(), buf_.begin() readPos_); readPos_ 0; } } private: std::vectorchar buf_; size_t readPos_ 0; };append負(fù)責(zé)把新收到的字節(jié)追加進(jìn)來peek返回當(dāng)前未消費(fèi)數(shù)據(jù)的起點(diǎn)consume前移讀指針。壓縮條件readPos_ 64KB readPos_*2 size的意思是浪費(fèi)的空間既絕對(duì)值夠大、又占比夠高才值得搬一次。這個(gè)閾值可以按業(yè)務(wù)調(diào)小消息密集就調(diào)小大消息為主就調(diào)大。提示peek返回的指針在下次append后可能失效vector 擴(kuò)容所以解析時(shí)要么先把頭部拷出來要么保證解析期間不再 append。3. 完整可編譯的 C 收發(fā)框架從頭部解析到消息分發(fā)3.1 項(xiàng)目文件結(jié)構(gòu)與編譯命令整個(gè)項(xiàng)目就三個(gè)文件不依賴任何第三方庫(kù)g 直接編tcp_demo/ ├── protocol.h // 協(xié)議常量與消息結(jié)構(gòu) ├── connection.h // 連接類收發(fā)與拆包 ├── connection.cpp └── main.cpp // 服務(wù)端 客戶端演示編譯命令Linux 和 macOS 通用g -stdc17 -O2 -Wall -pthread main.cpp connection.cpp -o tcp_demoWindows 上用 MinGW 或 MSVC 也行把-pthread去掉socket 相關(guān)頭文件換成winsock2.h初始化時(shí)調(diào)一次WSAStartup。下面代碼以 POSIX 為準(zhǔn)Windows 差異我會(huì)在注釋里點(diǎn)出來。3.2 協(xié)議頭定義與字節(jié)序處理網(wǎng)絡(luò)字節(jié)序是大端x86 是小端所以 length 字段必須轉(zhuǎn)換。別自己手寫移位用htonl/ntohl最穩(wěn)// protocol.h #pragma once #include cstdint #include arpa/inet.h // Windows: winsock2.h constexpr uint32_t kMagic 0x54435031; // TCP1 constexpr size_t kHeaderLen 8; // magic(4) length(4) constexpr uint32_t kMaxBody 16 * 1024 * 1024; // 單條消息上限 16MB struct Header { uint32_t magic; uint32_t length; }; // 把 8 字節(jié)頭部解析成 Header調(diào)用前保證有 kHeaderLen 字節(jié) inline Header parseHeader(const char* p) { Header h; uint32_t netMagic, netLen; std::memcpy(netMagic, p, 4); std::memcpy(netLen, p 4, 4); h.magic ntohl(netMagic); h.length ntohl(netLen); return h; } // 把 Header 序列化成 8 字節(jié) inline void writeHeader(char* p, uint32_t length) { uint32_t netMagic htonl(kMagic); uint32_t netLen htonl(length); std::memcpy(p, netMagic, 4); std::memcpy(p 4, netLen, 4); }kMaxBody是必須的防御如果對(duì)端發(fā)來一個(gè) length 是 4GB 的頭部你不校驗(yàn)就直接分配內(nèi)存瞬間被打爆。這是最常見的攻擊面之一。parseHeader用memcpy而不是強(qiáng)制類型轉(zhuǎn)換是因?yàn)閜可能不對(duì)齊直接reinterpret_cast在某些 ARM 平臺(tái)上會(huì)崩。3.3 拆包主循環(huán)一個(gè) while 把粘包拆干凈這是整個(gè)方案的心臟。核心邏輯只要緩沖區(qū)里夠一個(gè)完整包就取出來循環(huán)直到不夠// connection.h #pragma once #include protocol.h #include vector #include functional #include string class Connection { public: using MessageCallback std::functionvoid(const std::string); void onMessage(MessageCallback cb) { cb_ std::move(cb); } // 收到原始字節(jié)后調(diào)用 void feed(const char* data, size_t len) { buf_.append(data, len); // 循環(huán)拆包直到剩余不足一個(gè)完整包 while (true) { if (buf_.readable() kHeaderLen) break; Header h parseHeader(buf_.peek()); if (h.magic ! kMagic) { // 協(xié)議錯(cuò)亂直接斷開別硬撐 onError(bad magic); return; } if (h.length kMaxBody) { onError(body too large); return; } if (buf_.readable() kHeaderLen h.length) break; // 完整包到齊取出 body std::string body(buf_.peek() kHeaderLen, h.length); buf_.consume(kHeaderLen h.length); if (cb_) cb_(body); } } void onError(std::functionvoid(const std::string) cb) { errCb_ std::move(cb); } private: RecvBuffer buf_; MessageCallback cb_; std::functionvoid(const std::string) errCb_; };逐段說邏輯。feed先把新字節(jié)追加進(jìn)緩沖區(qū)然后進(jìn)入while。第一個(gè)break條件連 8 字節(jié)頭部都不夠等下次數(shù)據(jù)。第二個(gè)break頭部夠但 body 沒到齊也等。只有readable kHeaderLen h.length時(shí)才真正取出一條消息consume掉回調(diào)出去然后繼續(xù)循環(huán)——這一步就是「拆粘包」如果一次recv收到三條消息這個(gè) while 會(huì)連續(xù)回調(diào)三次。magic校驗(yàn)和kMaxBody校驗(yàn)是兩道防線任何一道不過直接走錯(cuò)誤回調(diào)斷開連接。生產(chǎn)環(huán)境里協(xié)議錯(cuò)亂繼續(xù)讀下去只會(huì)讀到更多垃圾早斷早干凈。3.4 發(fā)送端一次 send 未必發(fā)完必須循環(huán)發(fā)送比接收更容易被忽視。send返回值可能小于請(qǐng)求長(zhǎng)度尤其是大包或發(fā)送緩沖區(qū)滿時(shí)。必須循環(huán)發(fā)// 發(fā)送一條完整消息返回是否全部發(fā)出 bool sendMessage(int fd, const std::string body) { std::string packet; packet.resize(kHeaderLen body.size()); writeHeader(packet[0], static_castuint32_t(body.size())); std::memcpy(packet[kHeaderLen], body.data(), body.size()); size_t sent 0; while (sent packet.size()) { ssize_t n ::send(fd, packet.data() sent, packet.size() - sent, 0); if (n 0) { if (errno EINTR) continue; // 被信號(hào)打斷重試 if (errno EAGAIN || errno EWOULDBLOCK) { // 非阻塞模式下緩沖區(qū)滿實(shí)際項(xiàng)目應(yīng)注冊(cè)可寫事件 // 這里簡(jiǎn)化處理短暫等待后重試 continue; } return false; } sent static_castsize_t(n); } return true; }關(guān)鍵點(diǎn)send返回EINTR要重試返回EAGAIN說明發(fā)送緩沖區(qū)滿。真實(shí)項(xiàng)目里非阻塞 socket 應(yīng)該把剩余數(shù)據(jù)掛到可寫事件上等epoll通知可寫再繼續(xù)發(fā)。這里為了演示拆包用忙等簡(jiǎn)化但你要知道生產(chǎn)環(huán)境不能這么干否則 CPU 空轉(zhuǎn)。注意packet用std::string存二進(jìn)制沒問題但別用strlen或c_str()去算長(zhǎng)度二進(jìn)制里可能有\(zhòng)0一律用size()。4. 編譯運(yùn)行與聯(lián)調(diào)把粘包場(chǎng)景親手復(fù)現(xiàn)一遍4.1 服務(wù)端與客戶端最小可運(yùn)行代碼main.cpp里放一個(gè)服務(wù)端和一個(gè)客戶端服務(wù)端收消息打印客戶端故意用「一次發(fā)三條」和「一條拆兩次發(fā)」兩種方式制造粘包和拆包// main.cpp #include connection.h #include sys/socket.h #include netinet/in.h #include unistd.h #include cstring #include cstdio #include thread int makeServer(uint16_t port) { int fd ::socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr INADDR_ANY; addr.sin_port htons(port); bind(fd, (sockaddr*)addr, sizeof(addr)); listen(fd, 8); return fd; } int main() { int srv makeServer(9000); printf(server listening on 9000\n); std::thread client([]{ std::this_thread::sleep_for(std::chrono::milliseconds(200)); int fd ::socket(AF_INET, SOCK_STREAM, 0); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_port htons(9000); inet_pton(AF_INET, 127.0.0.1, addr.sin_addr); ::connect(fd, (sockaddr*)addr, sizeof(addr)); // 場(chǎng)景一連續(xù)發(fā)三條制造粘包 sendMessage(fd, hello-1); sendMessage(fd, hello-2); sendMessage(fd, hello-3); // 場(chǎng)景二一條消息拆成兩次發(fā)制造拆包 std::string body split-me; std::string packet; packet.resize(kHeaderLen body.size()); writeHeader(packet[0], (uint32_t)body.size()); std::memcpy(packet[kHeaderLen], body.data(), body.size()); ::send(fd, packet.data(), 5, 0); // 先發(fā)半個(gè)頭 std::this_thread::sleep_for(std::chrono::milliseconds(50)); ::send(fd, packet.data() 5, packet.size() - 5, 0); // 再發(fā)剩下的 ::close(fd); }); int conn accept(srv, nullptr, nullptr); Connection c; c.onMessage([](const std::string msg){ printf(recv message: %s\n, msg.c_str()); }); c.onError([](const std::string e){ printf(error: %s\n, e.c_str()); }); char tmp[4096]; while (true) { ssize_t n ::recv(conn, tmp, sizeof(tmp), 0); if (n 0) break; c.feed(tmp, (size_t)n); } client.join(); ::close(conn); ::close(srv); return 0; }4.2 運(yùn)行結(jié)果與拆包驗(yàn)證編譯后運(yùn)行你應(yīng)該看到server listening on 9000 recv message: hello-1 recv message: hello-2 recv message: hello-3 recv message: split-me四條消息一條不多一條不少。第一條recv很可能一次性收到三條 hello 的全部字節(jié)但feed里的 while 循環(huán)把它們拆成了三次回調(diào)這就是粘包被正確處理。split-me那條先發(fā)了 5 字節(jié)半個(gè)頭部服務(wù)端第一次feed時(shí)readable 8直接 break 等待50ms 后剩余字節(jié)到達(dá)第二次feed才湊齊頭部和 body回調(diào)一次。這就是拆包被正確處理。想更直觀地看粘包可以在服務(wù)端feed入口加一行打印n你會(huì)看到n經(jīng)常大于單條消息長(zhǎng)度。這不是 bug是 TCP 在正常合并小包Nagle 算法 延遲確認(rèn)。你的拆包邏輯只要正確n多大都無所謂。4.3 用 tcpdump 觀察真實(shí)字節(jié)流想徹底搞明白抓包看最直接sudo tcpdump -i lo -X tcp port 9000 -c 20-i lo抓本地回環(huán)-X以十六進(jìn)制加 ASCII 顯示。你會(huì)看到54 43 50 31就是 TCP1 的十六進(jìn)制后面跟著長(zhǎng)度字段三條 hello 的頭部和 body 可能擠在同一個(gè) TCP 段里。這一步能幫你建立「應(yīng)用層消息」和「TCP 段」是兩回事的直覺以后遇到粘包就不會(huì)慌。5. 避坑與排查這五個(gè)坑我全踩過5.1 現(xiàn)象收到消息后程序偶發(fā)崩潰原因peek()返回的指針在append觸發(fā) vector 擴(kuò)容后失效解析時(shí)還拿著舊指針讀。解決解析頭部時(shí)先把 8 字節(jié)memcpy到局部變量再解析或者保證解析期間不 append。我現(xiàn)在的習(xí)慣是parseHeader內(nèi)部直接memcpy從根上杜絕懸空指針。5.2 現(xiàn)象大消息收一半就斷了原因kMaxBody設(shè)太小或者對(duì)端 length 字段沒做字節(jié)序轉(zhuǎn)換讀出來是個(gè)天文數(shù)字。解決發(fā)送端和接收端統(tǒng)一用htonl/ntohl并且把kMaxBody設(shè)成業(yè)務(wù)真實(shí)上限的 2 倍。調(diào)試時(shí)打印一下解析出的 length 原始值一眼就能看出是不是字節(jié)序問題。5.3 現(xiàn)象客戶端發(fā)完就關(guān)服務(wù)端最后一條消息丟了原因客戶端close后內(nèi)核可能還有數(shù)據(jù)沒發(fā)完或者服務(wù)端recv返回 0 時(shí)緩沖區(qū)里還有未解析的完整包。解決服務(wù)端recv返回 0 后不要立刻退出循環(huán)先調(diào)一次feed把緩沖區(qū)里剩余數(shù)據(jù)解析完再關(guān)閉??蛻舳巳绻谝饪煽啃詂lose前用shutdown(fd, SHUT_WR)半關(guān)閉等服務(wù)端確認(rèn)。5.4 現(xiàn)象非阻塞 socket 下 CPU 跑滿原因send返回EAGAIN后用continue忙等或者recv返回EAGAIN后沒掛起直接重試。解決非阻塞模式下必須配合epoll/selectEAGAIN時(shí)把 fd 注冊(cè)到可讀/可寫事件等通知再操作。演示代碼里的忙等只是為了簡(jiǎn)化生產(chǎn)環(huán)境這么寫會(huì)被運(yùn)維找上門。5.5 現(xiàn)象多線程下消息順序錯(cuò)亂原因多個(gè)線程同時(shí)對(duì)一個(gè)連接調(diào)feed緩沖區(qū)被并發(fā)修改。解決一個(gè)連接綁定一個(gè)線程或者給feed加鎖。我一般用「one loop per thread」模型每個(gè)連接的所有讀寫都在同一個(gè)事件循環(huán)線程里天然無鎖。如果非要跨線程發(fā)消息用任務(wù)隊(duì)列把發(fā)送請(qǐng)求投遞到連接所屬線程別直接調(diào)send。6. 進(jìn)階把拆包框架接進(jìn) epoll 事件循環(huán)上面演示用的是阻塞 socket 忙等真實(shí)項(xiàng)目必須換成 epoll 非阻塞。核心改動(dòng)就三處socket 設(shè)O_NONBLOCK用epoll_wait驅(qū)動(dòng)recv返回EAGAIN時(shí)停止讀、等下次可讀事件。拆包邏輯一行不用改feed照常調(diào)。// epoll 事件循環(huán)骨架 int ep epoll_create1(0); epoll_event ev{}; ev.events EPOLLIN | EPOLLET; // 邊緣觸發(fā) ev.data.fd conn; epoll_ctl(ep, EPOLL_CTL_ADD, conn, ev); epoll_event events[64]; while (true) { int n epoll_wait(ep, events, 64, -1); for (int i 0; i n; i) { int fd events[i].data.fd; // 邊緣觸發(fā)必須一次讀到 EAGAIN否則事件會(huì)丟 while (true) { char tmp[8192]; ssize_t r ::recv(fd, tmp, sizeof(tmp), 0); if (r 0) { connMap[fd].feed(tmp, (size_t)r); } else if (r 0) { // 對(duì)端關(guān)閉先解析完剩余數(shù)據(jù)再清理 connMap[fd].feed(nullptr, 0); closeConn(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno EINTR) continue; closeConn(fd); break; } } } }邊緣觸發(fā)EPOLLET下必須循環(huán)讀到EAGAIN否則內(nèi)核不會(huì)再通知你剩下的數(shù)據(jù)就爛在緩沖區(qū)里了。這是從阻塞模型切到 epoll 最容易翻車的地方我當(dāng)年在這上面浪費(fèi)了一整個(gè)下午。發(fā)送側(cè)同理把sendMessage里沒發(fā)完的剩余數(shù)據(jù)存到連接的發(fā)送緩沖區(qū)注冊(cè)EPOLLOUT可寫時(shí)繼續(xù)發(fā)。驗(yàn)證方法很簡(jiǎn)單把演示代碼的客戶端改成循環(huán)發(fā) 10 萬條小消息服務(wù)端用 epoll 版本接收統(tǒng)計(jì)收到條數(shù)是否正好 10 萬。如果少了八成是邊緣觸發(fā)沒讀到EAGAIN或者feed里漏了循環(huán)拆包。我現(xiàn)在的習(xí)慣是任何新寫的拆包代碼先跑一遍「10 萬條小消息 1 條 10MB 大消息」的混合壓測(cè)兩個(gè)都過才算穩(wěn)。這套長(zhǎng)度字段加緩沖區(qū)的方案我從 demo 一路用到線上服務(wù)沒出過粘包相關(guān)的線上事故希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取