所讀版本:機械工業出版社
這一篇在做什麼
前面已經有了連接、異步回調與消息分發,但只要開始真的傳送多條消息,就會遇到 TCP 最常見的一組實戰問題:
- TCP 傳的是字節流,不是天然分隔好的消息
- 接收端可能一次收到多條消息,也可能只收到半條
- 發送端也不保證一次就把全部數據真正送完
這一篇就是在處理這些「真正開始寫網絡程序時一定會踩到」的坑。
TCP 數據流先要怎麼理解
前面說過,Send 只是把數據寫入作業系統的發送緩衝區,真正發送由 OS 負責;相對地,Receive 也是從接收緩衝區中取出目前可讀的字節。
也就是說,TCP 對程序呈現的是一條連續的字節流,而不是一條一條天然分隔好的消息。

這個認知非常重要,因為後面的粘包與半包,本質上都是從這裡來的。
粘包與半包是怎麼來的
粘包
如果發送端連續發兩條消息,而接收端沒有剛好一條一條地讀,那麼兩條消息就可能在接收緩衝區裡黏在一起。
//連續發送兩條消息
string msg1 = "Msg1";
byte[] b1 = System.Text.Encoding.UTF8.GetBytes(msg1);
string msg2 = "Msg2";
byte[] b2 = System.Text.Encoding.UTF8.GetBytes(msg2);
socket.Send(b1);
socket.Send(b2);
理想上我們希望接收端看到兩條獨立消息:
Msg1Msg2
但實際上,很可能一次 Receive 就拿到:
Msg1Msg2
因為這兩次 Send 實際上都只是快速寫入發送緩衝區,最後由系統批量送到接收端。
半包
反過來,如果一條消息太長,或當前網絡狀況導致資料還沒完整到齊,接收端也可能只讀到前半段。也就是說,一次 Receive 讀到的內容,不一定剛好是一整條完整消息。
最常見的解法:長度信息法
解決方案有很多,但最常見、也最實用的一種,是每條消息前面先加上「消息體長度」。
接收端收到數據後,就不再靠猜測判斷消息邊界,而是:
- 先讀長度字段
- 再根據長度判斷後面是否已經湊夠完整消息
- 不夠就繼續等下一次接收
發送端如何封包
public void Send()
{
//長度信息法
string sendStr = reqField.text;
byte[] bodyByte = System.Text.Encoding.UTF8.GetBytes(sendStr);
Int16 len = (Int16)bodyByte.Length;
byte[] lenByte = BitConverter.GetBytes(len);
byte[] sendByte = lenByte.Concat(bodyByte).ToArray();
socket.Send(sendByte);
}
這段邏輯的意思是:
- 先把正文轉成字節
- 算出正文長度
- 把長度也轉成字節
- 把「長度字節 + 正文字節」拼起來再發送
一個直觀例子
假設要送的是:
HelloWorld
正文長度是 10,那麼發送出去的實際數據不再只是正文,而會變成:
- 前面兩個字節表示長度
- 後面才是真正的消息內容
這樣接收端才知道自己什麼時候算收齊一條完整消息。
接收端要保存「未處理完的殘留數據」
既然一次 Receive 不一定剛好讀滿一條消息,那接收端就不能每次都從頭覆蓋緩衝區。它需要保留前一次還沒處理完的資料。
最基本的做法是:
byte[] readBuff = new byte[1024];
int buffCount = 0;
其中:
readBuff:整個接收緩衝區buffCount:目前有效數據長度
下一次接收數據時,要把新字節寫到有效數據後面,而不是從 0 開始蓋掉。
socket.BeginReceive(readBuff, buffCount, 1024 - buffCount, 0 ,ReceiveCallback, socket);
接收完成後,再把有效長度往後推。
public void ReceiveCallback(IAsyncResult ar)
{
Socket socket = (Socket) ar.AsyncState;
int count = socket.EndReceive(ar);
buffCount += count;
}

接收端處理消息時要做什麼
當前緩衝區裡的有效數據可能出現三種情況:
- 連長度字段都還沒收齊
- 長度字段收齊了,但正文還沒收完
- 至少收齊一條,甚至收齊多條完整消息
因此,處理流程通常是:
- 如果有效數據還不夠表示一條完整消息,就先不動
- 如果已經足夠,就取出一條消息
- 取完後再繼續檢查,看看後面是否還有下一條完整消息
而當消息被處理掉之後,緩衝區剩下的尾部殘留數據還要被整理。
常見做法有兩種:
Option A:直接把後面的數據往前搬
用 Array.Copy 把剩下未處理的字節複製到開頭。這種寫法直觀,但如果消息很多、搬移頻繁,成本會變高。
Option B:維護讀寫指針
保留一個「讀取起點」和「寫入終點」,先用指針描述目前有效範圍,只有在剩餘空間真的不夠時,才做一次整體搬移。
這就是後面 ByteArray 類要解決的事情。
大端小端問題
長度字段通常會用 BitConverter 在字節與整數之間轉換,但這裡有個平台層面的細節:不同機器可能使用大端或小端字節序。
例如:
BitConverter.ToInt16(buffer, offset)
底層轉換方式會依賴當前平台的端序。
大端與小端可以簡化理解成:
- 大端:高位字節在前
- 小端:低位字節在前

怎麼兼容大小端
發送時可以先判斷當前平台是否為小端,如果不是,就手動調整長度字節順序。
public void Send()
{
string sendStr = reqField.text;
byte[] bodyByte = System.Text.Encoding.UTF8.GetBytes(sendStr);
Int16 len = (Int16)bodyByte.Length;
byte[] lenByte = BitConverter.GetBytes(len);
if (!BitConverter.IsLittleEndian)
{
//大端轉小端
lenByte.Reverse();
}
byte[] sendByte = lenByte.Concat(bodyByte).ToArray();
socket.Send(sendByte);
}
也可以完全手動解析長度字段。
16 位長度
Int16 bodyLength = (short)((readBuff[1] << 8) | readBuff[0]);
32 位長度
Int32 bodyLength = (short)((readBuff[3] << 24) |
readBuff[2] << 16 |
readBuff[1] << 8 |
readBuff[0]);
這裡的 | 可以理解成按位組裝數值,而左移 << 則是把不同字節放到正確的權重位置上。
發送不完整問題
除了接收端可能拿到半包,發送端其實也可能「一次沒有送完整」。
這通常發生在:
- 網絡擁擠
- 發送緩衝區壓力大
- 一次送出的數據較大

因此,EndSend 返回的已發送長度,不一定等於你原本打算發送的總長度。
最直接的補發方法
做法是保存:
- 總數據
- 目前已發送到哪裡
- 還剩多少沒發
int readIdx = 0; //緩沖區偏移值
int length = 0; //緩沖區剩餘長度
public void Send()
{
//sendBytes = 要發送的數據
length = sendBytes.Length;
readIdx = 0;
socket.BeginSend(sendBytes, 0, length, 0, SendCallback, socket);
}
public void SendCallback(IASyncResult ar)
{
Socket socket = (Socket)ar.AsyncState;
int count = socket.EndSend(ar); //返回已發送數據的長度
readIdx += count; //將readIdx向後移動至已發送數據長度的末尾
length -= count;
if(length > 0) //如果全部數據正常發送,length - count應該 == 0
{
//如果>0,代表有數據需要重新發送
socket.BeginSend(sendBytes, readIdx, length, 0, SendCallback, socket);
}
}
這樣即使某次只送出去一部分,回調也能接著把剩下的部分補完。
但這個做法還不夠穩
問題在於,如果 SendCallback 還沒回來,你又再次調用了 Send,那麼:
readIdxlength
這兩個共享變量就可能被新消息覆蓋。結果就是原本那條還沒送完的消息狀態被破壞掉。
所以接下來需要兩個東西:
- 一個把「一條待發消息的狀態」封裝起來的結構
- 一個隊列,把多條待發消息排隊處理
ByteArray 的角色
ByteArray 就是在做這件事。它把:
- 實際字節數據
- 讀寫索引
- 緩衝容量
都包成一個獨立對象。
using System;
public class ByteArray
{
//默認大小
const int DEFAULT_SIZE = 1024;
//初始大小
int initSize = 0;
//緩沖區
public byte[] bytes;
//讀寫位置
public int readIdx = 0;
public int writeIdx = 0;
//容量
private int capicity = 0;
//剩餘空間
public int remain { get { return capicity - writeIdx; } }
//數據長度
public int length { get { return writeIdx - readIdx; } }
public ByteArray(int size = DEFAULT_SIZE)
{
bytes = new byte[size];
capicity = size;
initSize = size;
readIdx = 0;
writeIdx = 0;
}
public ByteArray(byte[] defaultBytes)
{
bytes = defaultBytes;
capicity = defaultBytes.Length;
initSize = defaultBytes.Length;
readIdx = 0;
writeIdx = defaultBytes.Length;
}
//重設尺寸
public void ReSize(int size)
{
if (size < length) { return; }
if (size < initSize) { return; }
int n = 1;
while(n < size) //計算新數組的尺寸, 為避免頻繁重設,一般直接翻倍
{
n *= 2;
}
capicity = n;
//申請新的byte數組並把原數據複製過去
byte[] newBytes = new byte[capicity];
Array.Copy(bytes, readIdx, newBytes, 0, writeIdx - readIdx);
bytes = newBytes;
//重設讀寫索引
writeIdx = length;
readIdx = 0;
}
//檢查並移動數據
public void CheckAndMoveBytes()
{
if(length < 8)
{
MoveBytes();
}
}
//移動數據
public void MoveBytes()
{
if(length > 0)
{
Array.Copy(bytes, readIdx, bytes, 0, length);
}
writeIdx = length;
readIdx = 0;
}
//寫入數據
public int Write(byte[] bs, int offset, int count)
{
if(remain < count)
{
//檢查緩沖區剩餘空間
ReSize(length + count);
}
Array.Copy(bs, offset, bytes, writeIdx, count); //從offset位置寫入
writeIdx += count;
return count; //返回新的有效數據長度
}
//讀取數據
public int Read(byte[] bs, int offset, int count)
{
count = Math.Min(count, length);
Array.Copy(bytes, readIdx, bs, offset, count); //把數據從bytes的readIdx讀取,保存在bs的offset到count的空間中
readIdx += count; //根據已讀取的數據量更新readIdx
CheckAndMoveBytes(); //如果剩餘的有效數據很小,可以將數據前移
return count;
}
}
ByteArray 解決了什麼
這個類主要在處理兩件事:
一,緩衝區不是固定死板的
如果剩餘空間不夠,就自動擴容,避免每次都重新創建新數組。
二,讀與寫有獨立索引
不需要每次讀完一點點資料就立刻把整塊數據往前搬,而是用 readIdx / writeIdx 記錄目前有效區間。只有在有需要時,才調用 MoveBytes 重新整理。
把 ByteArray 放進發送隊列
真正用在發送流程時,會再配合一個 Queue<ByteArray>。
public void Send()
{
//byte[] sendByte = 發送的數據字符串
ByteArray ba = new ByteArray(sendByte);
int count = 0;
//BeginSend和回調函數可能於不同線程執行,可能出現多個線程同時操作writeQueue的情況,因此需要加鎖
lock (writeQueue)
{
writeQueue.Enqueue(ba);
count = writeQueue.Count;
}
if(count == 1)
{
socket.BeginSend(sendByte, 0, sendByte.Length, 0, SendCallback, socket);
}
}
public void SendCallback(IAsyncResult result)
{
Socket socket = (Socket)result.AsyncState;
int count = socket.EndSend(result);
ByteArray ba;
//BeginSend和回調函數可能於不同線程執行,可能出現多個線程同時操作writeQueue的情況,因此需要加鎖
lock (writeQueue)
{
ba = writeQueue.First();
}
ba.readIdx += count;
if(ba.length == 0) //ba.length = ba.writeIdx - ba.readIdx
{
//Send Finished
//BeginSend和回調函數可能於不同線程執行,可能出現多個線程同時操作writeQueue的情況,因此需要加鎖
lock (writeQueue)
{
writeQueue.Dequeue();
ba = writeQueue.First(); //Get Next Send ByteArray
}
}
if(ba != null)
{
//Send Remain ByteArray
socket.BeginSend(ba.bytes, ba.readIdx, ba.length, 0, SendCallback, socket);
}
}
為什麼這裡要加鎖
BeginSend 與 SendCallback 可能跑在不同線程上,因此隊列是共享資源。只要:
- 一邊在入隊
- 另一邊在取隊首 / 出隊
就有機會出現競態條件,所以這裡必須用 lock 做最基本的保護。
這一篇先記住的重點
這一篇最核心的幾件事其實非常實戰:
- TCP 是字節流,不是消息流
- 一次
Receive可能拿到多條,也可能只拿到半條 - 最常見的拆包方案,是在消息前面加長度字段
- 發送端也不保證一次把所有數據送完
- 真正穩定的收發流程,通常需要可重用的緩衝結構與隊列管理
如果前幾篇是在搭網絡程序的骨架,那這一篇就是開始補真正能在實戰裡站得住的收發細節。