所讀版本:機械工業出版社

這一篇在做什麼

前面已經有了連接、異步回調與消息分發,但只要開始真的傳送多條消息,就會遇到 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);

理想上我們希望接收端看到兩條獨立消息:

  • Msg1
  • Msg2

但實際上,很可能一次 Receive 就拿到:

Msg1Msg2

因為這兩次 Send 實際上都只是快速寫入發送緩衝區,最後由系統批量送到接收端。

半包

反過來,如果一條消息太長,或當前網絡狀況導致資料還沒完整到齊,接收端也可能只讀到前半段。也就是說,一次 Receive 讀到的內容,不一定剛好是一整條完整消息。

最常見的解法:長度信息法

解決方案有很多,但最常見、也最實用的一種,是每條消息前面先加上「消息體長度」。

接收端收到數據後,就不再靠猜測判斷消息邊界,而是:

  1. 先讀長度字段
  2. 再根據長度判斷後面是否已經湊夠完整消息
  3. 不夠就繼續等下一次接收

發送端如何封包

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);
}

這段邏輯的意思是:

  1. 先把正文轉成字節
  2. 算出正文長度
  3. 把長度也轉成字節
  4. 把「長度字節 + 正文字節」拼起來再發送

一個直觀例子

假設要送的是:

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;
}

接收端處理消息時要做什麼

當前緩衝區裡的有效數據可能出現三種情況:

  1. 連長度字段都還沒收齊
  2. 長度字段收齊了,但正文還沒收完
  3. 至少收齊一條,甚至收齊多條完整消息

因此,處理流程通常是:

  • 如果有效數據還不夠表示一條完整消息,就先不動
  • 如果已經足夠,就取出一條消息
  • 取完後再繼續檢查,看看後面是否還有下一條完整消息

而當消息被處理掉之後,緩衝區剩下的尾部殘留數據還要被整理。

常見做法有兩種:

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,那麼:

  • readIdx
  • length

這兩個共享變量就可能被新消息覆蓋。結果就是原本那條還沒送完的消息狀態被破壞掉。

所以接下來需要兩個東西:

  1. 一個把「一條待發消息的狀態」封裝起來的結構
  2. 一個隊列,把多條待發消息排隊處理

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);
    }
}

為什麼這裡要加鎖

BeginSendSendCallback 可能跑在不同線程上,因此隊列是共享資源。只要:

  • 一邊在入隊
  • 另一邊在取隊首 / 出隊

就有機會出現競態條件,所以這裡必須用 lock 做最基本的保護。

這一篇先記住的重點

這一篇最核心的幾件事其實非常實戰:

  • TCP 是字節流,不是消息流
  • 一次 Receive 可能拿到多條,也可能只拿到半條
  • 最常見的拆包方案,是在消息前面加長度字段
  • 發送端也不保證一次把所有數據送完
  • 真正穩定的收發流程,通常需要可重用的緩衝結構與隊列管理

如果前幾篇是在搭網絡程序的骨架,那這一篇就是開始補真正能在實戰裡站得住的收發細節。