TCP 中「Send 單位=Receive 單位」的誤解 ── 把 TCP 視為位元組串流來設計接收邏輯

· · TCP, Socket, Network, .NET, CSharp, ProtocolDesign, 維運, 既有資產活用

1. 首先要掌握的重點

在 TCP 通訊的實作中,有一個相當常見的誤解。

那就是:

傳送端呼叫 Send / Write 的每一個單位,接收端也能一一對應地透過 Receive / Read 收到

假設傳送端是這樣送出資料的:

Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")

於是就以為接收端會像下面這樣分 3 次收到:

Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"

但 TCP 並不保證會這樣。

實際上,下面這些情況都可能發生:

Receive() => "LOGIN\nGET /items\nQUIT\n"
Receive() => "LOG"
Receive() => "IN\nGET /ite"
Receive() => "ms\nQUIT\n"
Receive() => "LOGIN\nGET /items\n"
Receive() => "QUIT"
Receive() => "\n"

以上這些對 TCP 來說都是正常的。

TCP 保證的,粗略來說是「送出的位元組序列會依照順序、不重複、不遺漏地送達」。它不保證的是「應用程式呼叫 Send 的單位,會被保留成接收端 Receive 的單位」。

因此,使用 TCP 的應用程式,接收端需要有一套機制,用來判斷「現在收到的位元組序列,從哪裡到哪裡是 1 則訊息」。

這個機制,就稱為應用程式協定的訊框化(framing)

本文整理 TCP 的 SendReceive 之間容易產生的誤解,以及在 .NET / C# 中正確處理的方式。

另外,本文出現的程式碼,已整理成一套可建置、可執行的範例(函式庫、迴圈測試用的 TCP demo、重現分割・合併・中途斷線的單元測試),公開在 GitHub 上。

tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)

2. TCP 傳輸的不是「訊息」,而是「位元組序列」

首先,很重要的一點是不要把 TCP 想成訊息佇列(message queue)。

TCP 會把應用程式傳入的資料,當作連續的位元組序列來處理。

舉例來說,即使傳送端呼叫了 3 次 Send

Send("ABC")
Send("DEF")
Send("GHI")

從 TCP 的角度來看,最終只是下面這樣一段 9 位元組的資料流:

ABCDEFGHI

裡面並不會保留

ABC | DEF | GHI

這種屬於應用程式層面的邊界。

接收端會在某個時間點,讀取「目前接收緩衝區裡的資料」。因此,接收結果可能是下面這樣:

傳送端的呼叫 接收端可能看到的結果
Send("ABC"), Send("DEF") 1 次 Receive() 就收到 "ABCDEF"
Send("ABCDEF") 分 2 次 Receive() 收到 "AB""CDEF"
Send("ABC"), Send("DEF"), Send("GHI") 分 3 次 Receive() 收到 "A""BCDEFG""HI"
Send("\u3042") 這樣的 UTF-8 字元 也可能在多位元組字元的中途被切斷

重點是,這裡面沒有任何「異常」。

「有時候接收資料好像缺了一部分」「多則訊息黏在一起」「出現亂碼」,這些看起來像是不正常的現象,多數並不是 TCP 本身出了問題,而是接收端把 TCP 當成訊息單位在處理,屬於設計上的錯誤。

3. 為什麼看起來好像可以按 Send 單位收到

這個誤解之所以難以消除,是因為在本機環境或資料量小的情況下,經常「碰巧」看起來符合預期。

在開發環境中,很容易同時滿足以下條件:

  • 用戶端與伺服器在同一台機器上,或位於相近的網路環境
  • 資料量很小
  • 通訊對象會立即讀取資料
  • CPU 與網路都很寬裕
  • 測試是手動進行的,時間上的抖動很小
  • 呼叫 Send 之後馬上呼叫 Receive

在這種條件下,1 次 Send 對應 1 次 Receive 就能讀到完整資料,看起來是理所當然的。

但在正式環境中,條件會不一樣:

  • 作業系統的傳送 / 接收緩衝區會累積資料
  • 多次小型傳送可能被合併
  • 大型傳送可能因為 TCP 分段或接收緩衝區的限制而被拆開
  • 接收端執行緒的排程可能延遲
  • 中間會經過 TLS、Proxy、負載平衡器、VPN 等層級
  • 可能發生網路延遲或壅塞
  • 會受到 Nagle 演算法或延遲 ACK 的影響

結果就變成「開發環境沒問題,但正式環境偶爾會壞掉」這種棘手的問題。

在網路處理中,這種「碰巧能動」是最危險的狀況。

4. 常見的脆弱接收程式碼

舉例來說,下面這樣的程式碼是危險的:

byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);

if (read == 0)
{
    // 對方已正常斷線
    return;
}

string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);

這段程式碼的前提是「1 次 ReadAsync 就能取得 1 則完整訊息」,但這個前提在 TCP 中並不成立。

主要問題有 3 個。

第 1 個問題是,1 則訊息可能被拆開。

傳送: {"command":"login","user":"komura"}\n
接收1: {"command":"login",
接收2: "user":"komura"}\n

在這種情況下,如果只把接收1當作 JSON 來解析,就會失敗。

第 2 個問題是,多則訊息可能被合併。

傳送1: {"command":"login"}\n
傳送2: {"command":"get"}\n
接收: {"command":"login"}\n{"command":"get"}\n

在這種情況下,如果把它當成 1 個 JSON 來解析,也會失敗。

第 3 個問題是,可能在字元編碼的邊界處被切開。

在 UTF-8 中,1 個字元有時會由多個位元組組成。ReadAsync 的邊界並不保證與字元的邊界一致。

因此,如果每次收到位元組序列後,就立刻呼叫 Encoding.UTF8.GetString 轉成字串,一旦在多位元組字元的中途被切斷,就有可能出錯。

基本原則不是「收到就馬上轉成字串」,而是「先把資料當作位元組累積起來,直到確認訊息邊界為止,等一則訊息的內容完整收齊後再解碼」。

5. 不能用 DataAvailable 來判斷訊息的結尾

以下這種程式碼也很常見:

var ms = new MemoryStream();
byte[] buffer = new byte[4096];

while (stream.DataAvailable)
{
    int read = await stream.ReadAsync(buffer, cancellationToken);
    if (read == 0)
    {
        break;
    }

    ms.Write(buffer, 0, read);
}

byte[] message = ms.ToArray();

這種寫法也很危險。DataAvailable 表示的是「在這一瞬間,本機接收緩衝區裡是否還有可讀取的資料」,並不代表應用程式層面的 1 則訊息已經收完。

舉例來說,假設 1 則訊息長度為 100 位元組,當只有最前面 40 位元組抵達的那一瞬間,DataAvailable 可能是 true;讀完這 40 位元組後,DataAvailable 可能暫時變成 false。剩下的 60 位元組可能稍後才會抵達。

這時候,如果把 DataAvailable == false 解讀成「訊息已經結束」,就會把還沒收完的資料當成 1 則完整訊息來處理。

DataAvailable 可以用在讀取迴圈的最佳化,或做一些非阻塞式的確認,但不建議用它來判斷協定上的訊息邊界。

6. 正確的思考方式:把「接收」與「解讀」分開

在設計 TCP 的接收處理時,把下面兩件事分開思考,會比較容易設計:

接收: 讀取從 TCP 送來的位元組序列,累積進緩衝區
解讀: 從緩衝區中,切出應用程式層面的 1 則訊息

Receive / Read 終究只是「讀取位元組」的處理。至於「1 則訊息在哪裡結束」,則需要由應用程式協定來決定。

代表性的方法有下面 4 種:

方式 內容 適合的用途
固定長度 永遠以固定的位元組數作為 1 則訊息 舊型設備、二進位電文、控制系統
分隔符號 \n 等特定位元組序列作為 1 則訊息的結尾 指令、日誌、NDJSON、簡易協定
長度前綴 在開頭放上內容長度,再讀取對應位元組數的內容 二進位、JSON、MessagePack、Protocol Buffers 等
自我描述格式 像 HTTP 的 Content-Length 或 chunked 那樣,在格式內部自行表達長度或結尾 既有協定、需要擴充性的通訊

就個人而言,如果要設計自訂協定,會先考慮長度前綴方式。理由是內容可以包含換行或任意二進位資料,接收端的實作也很明確,也容易加上最大長度限制。

7. 長度前綴方式的基本概念

長度前綴方式,會把訊息設計成下面的格式:

[4 位元組的內容長度][內容]

舉例來說,假設內容是 UTF-8 的 JSON,內容長度為 31 位元組,就會像下面這樣傳送:

00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
  內容長度                內容

接收端會依照下面的順序處理:

  1. 先把 4 位元組讀取完整
  2. 從這 4 位元組中取出內容長度
  3. 驗證內容長度是否合理
  4. 讀取指定長度的內容
  5. 把讀取完的內容當作 1 則訊息來處理
  6. 讀取下一個訊框

這裡要注意的重點是「即使是 4 位元組的標頭,也可能被拆開」。

接收1: 00 00
接收2: 00 1F 7B 22 63 ...

因此,即使是標頭,也不能保證 1 次 Read 就能取得完整的 4 位元組。

內容部分也是一樣。Read 的回傳值小於要求的大小是很平常的事。如果已經確定需要的位元組數,就需要寫一個「讀到足夠位元組數為止」的迴圈。

8. .NET 中的接收實作範例:長度前綴方式

以下是在 .NET / C# 中,讀取長度前綴方式訊框的範例。

這裡把最前面的 4 位元組,當作 big-endian 的 int 來表示內容長度。

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocol
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024; // 1 MiB。依用途決定

    public static async ValueTask<byte[]?> ReadFrameAsync(
        Stream stream,
        CancellationToken cancellationToken)
    {
        byte[] header = new byte[HeaderSize];

        int headerBytes = await ReadUntilFullOrEndAsync(
            stream,
            header,
            cancellationToken);

        if (headerBytes == 0)
        {
            // 不是在訊框途中,而是在下一個訊框開始前,對方就正常結束了
            return null;
        }

        if (headerBytes != HeaderSize)
        {
            throw new EndOfStreamException("Frame header was truncated.");
        }

        int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);

        if (payloadLength < 0 || payloadLength > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Invalid payload length: {payloadLength} bytes.");
        }

        byte[] payload = new byte[payloadLength];

        int payloadBytes = await ReadUntilFullOrEndAsync(
            stream,
            payload,
            cancellationToken);

        if (payloadBytes != payloadLength)
        {
            throw new EndOfStreamException("Frame payload was truncated.");
        }

        return payload;
    }

    private static async ValueTask<int> ReadUntilFullOrEndAsync(
        Stream stream,
        Memory<byte> buffer,
        CancellationToken cancellationToken)
    {
        int totalRead = 0;

        while (totalRead < buffer.Length)
        {
            int read = await stream.ReadAsync(
                buffer[totalRead..],
                cancellationToken);

            if (read == 0)
            {
                break;
            }

            totalRead += read;
        }

        return totalRead;
    }
}

呼叫端的寫法如下:

while (true)
{
    byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
        stream,
        cancellationToken);

    if (payload is null)
    {
        // 對方在訊框邊界上乾淨地斷線了
        break;
    }

    await HandleMessageAsync(payload, cancellationToken);
}

在這個實作中,無論 ReadAsync 每次回傳多少位元組都沒有問題。即使每次只回傳 1 位元組,也會持續迴圈,直到讀完標頭與內容為止。

反過來說,即使作業系統的接收緩衝區裡已經累積了多則訊息的資料,這段程式碼也只會依照內容長度切出最前面那個訊框,剩下的部分留到下一次迴圈再讀取下一個訊框。

另外,在較新版本的 .NET 中,也可以使用 Stream.ReadExactly / ReadExactlyAsync。如果使用這些 API,就可以把「讀到所需位元組數為止」的處理交給標準 API。不過,連線結束時的處理、訊框開始前的正常結束、以及訊框中途斷線的異常結束該如何區分,仍然需要由應用程式自行設計。

9. 傳送端的實作範例

傳送端也需要依照相同的訊框格式來傳送資料。

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocolWriter
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024;

    public static async ValueTask WriteFrameAsync(
        Stream stream,
        ReadOnlyMemory<byte> payload,
        CancellationToken cancellationToken)
    {
        if (payload.Length > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Payload is too large: {payload.Length} bytes.");
        }

        byte[] header = new byte[HeaderSize];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        await stream.WriteAsync(header, cancellationToken);
        await stream.WriteAsync(payload, cancellationToken);
    }
}

這段程式碼把標頭與內容分別呼叫 WriteAsync。這裡又容易產生一個誤解:傳送端把標頭與內容分成 2 次呼叫 WriteAsync,並不代表接收端就會分成 2 次讀到。

接收端看到的結果,可能是下面這樣:

Read() => [4 位元組標頭 + 部分內容]
Read() => [剩下的內容]

也可能是這樣:

Read() => [標頭的前 2 位元組]
Read() => [標頭的後 2 位元組 + 全部內容 + 下一個訊框的標頭]

正因為如此,接收端要依據的不是「呼叫了幾次 Read」,而是「依照訊框格式,讀到了多少位元組」。

10. 直接使用 Socket.Send 時,傳送端也要檢查回傳值

如果使用 NetworkStream.Write / WriteAsync,基本上可以把它當成「會寫入指定範圍」的 API 來處理。

但另一方面,如果直接使用 Socket.Send,就要特別注意它的回傳值。

Socket.Send 回傳的是「實際成功送出的位元組數」。尤其是在非阻塞式 socket 等情境下,實際送出的位元組數可能會比要求的位元組數少。

因此,如果要直接使用 Socket.Send,傳送端也需要一段「持續送到全部送完為止」的處理。

using System.Net.Sockets;

public static async ValueTask SendAllAsync(
    Socket socket,
    ReadOnlyMemory<byte> buffer,
    CancellationToken cancellationToken)
{
    while (!buffer.IsEmpty)
    {
        int sent = await socket.SendAsync(
            buffer,
            SocketFlags.None,
            cancellationToken);

        if (sent == 0)
        {
            throw new IOException("Socket was closed while sending data.");
        }

        buffer = buffer[sent..];
    }
}

不過,這裡所說的「送出成功」,並不代表「對方的應用程式已經處理完這則訊息」。傳送 API 的成功,跟應用程式協定上的成功回應,是兩件不同的事。

舉例來說,如果業務上需要確認「訂單已受理」「檔案已儲存」「指令已執行」,就不能只靠 TCP 傳送成功來判斷,而必須把對方應用程式回傳的 ACK 或回應訊息,定義成協定的一部分。

11. 使用分隔符號方式時的注意事項

在文字協定中,有時會使用換行作為分隔符號。

LOGIN komura secret\n
GET item-001\n
QUIT\n

這種方式容易理解,也很適合日誌或指令格式。

不過,仍然有幾點需要注意:

  • 決定分隔符號出現在內容中時的跳脫規則
  • 決定 \r\n\n 的處理方式
  • 決定單行的最大長度
  • 在遇到分隔符號之前,不要無限制地累積記憶體
  • 確保 UTF-8 的多位元組字元即使被拆開也不會出錯

尤其要避免下面這樣的程式碼:

int read = await stream.ReadAsync(buffer, cancellationToken);
string text = Encoding.UTF8.GetString(buffer, 0, read);

foreach (string line in text.Split('\n'))
{
    await HandleLineAsync(line, cancellationToken);
}

這段程式碼沒有考慮到:收到的這段資料,最後可能剛好停在某一行的中途;也沒有考慮到 UTF-8 字元可能在中途被切開的可能性。

如果要使用換行分隔,至少要做到「先把位元組累積起來,找出換行位元組,等湊齊 1 行之後才解碼」,或者使用像 StreamReader.ReadLineAsync 這種可以在串流上逐行讀取的 API。

不過,即使使用 StreamReader.ReadLineAsync,最大行長度、逾時、取消、連線結束時的處理,仍然需要自行設計。

12. 使用固定長度方式時的注意事項

在固定長度電文中,會事先約定「一定以 128 位元組作為 1 則訊息」之類的規則。這種方式常見於老舊的業務系統、控制系統、機器連動等場景。

固定長度的思考方式也是一樣的。

1 則訊息 = 128 位元組

如果是這樣約定,接收端就需要持續迴圈,直到讀完 128 位元組為止。

byte[] message = new byte[128];
int read = await ReadUntilFullOrEndAsync(stream, message, cancellationToken);

if (read != message.Length)
{
    throw new EndOfStreamException("Fixed-length message was truncated.");
}

await HandleMessageAsync(message, cancellationToken);

這裡同樣要注意,1 次 ReadAsync 不保證能讀到 128 位元組。

固定長度方式的優點是邊界明確、容易實作;缺點則是難以處理可變長度的資料、將來擴充較困難、多餘空白的處理較麻煩,而且字元編碼轉換後位元組數可能會改變。

13. 停用 Nagle 演算法無法解決訊息邊界的問題

如果想立即送出小量資料,有時會考慮把 Socket.NoDelay 設為 true。這是用來停用 Nagle 演算法的設定。

不過,NoDelay 只是關於「小型傳送要如何整併」的傳送延遲與效率設定,並不是「把 Send 的單位保留成 Receive 單位」的設定。

也就是說,即使把 NoDelay 設為 true,下面這些問題依然無法解決:

  • 1 次 Send 被拆成多次 Receive
  • 多次 Send 被合併成 1 次 Receive
  • 在字元中途被切斷
  • 接收端無法判斷訊息邊界

NoDelay 對於調整延遲確實有意義,但無法取代訊框化(framing)。

14. 即使使用 TLS 或 SslStream,思考方式也相同

即使透過 SslStream 進行 TLS 加密,從應用程式的角度來看,基本的處理方式也是一樣的。

TLS 內部有所謂的 TLS 記錄(TLS record)這個單位,但那並不是應用程式訊息的邊界。

即使是 SslStream.ReadAsync,也不保證應用程式期望的 1 則訊息能在 1 次呼叫中完整取得。

因此,無論是否使用 TLS,都需要在應用層設計下面其中一種方式:

  • 長度前綴
  • 換行等分隔符號
  • 固定長度
  • 既有的協定格式

TLS 只是加密與驗證的層級,並不會自動幫你劃出訊息邊界。

15. 接收迴圈中要注意的錯誤處理

在 TCP 的接收處理中,除了正常流程,也要明確處理斷線或中途結束的情況,這一點很重要。

Read / Receive 的回傳值為 0 時,一般代表對方已經正常結束傳送。

不過,在應用程式協定的層面,需要區分下面兩種情況:

狀態 處理方式
在讀取下一個訊框之前,收到 0 位元組而結束 有些情況可以當作正常結束來處理
在訊框標頭或內容途中結束 屬於不完整的電文,應當作異常處理

以長度前綴方式為例,可以這樣思考:

在訊框邊界處斷線:
  可以視為正常結束

4 位元組標頭只收到 2 位元組就斷線:
  屬於協定錯誤

內容長度應為 100 位元組卻只收到 60 位元組就斷線:
  屬於協定錯誤

事先做好這個區分,之後查日誌時會輕鬆很多。

比起只留下「對方斷線了」這樣的訊息,如果能輸出像下面這樣的內容:

Frame payload was truncated. expected=100 actual=60

就更容易懷疑是對方異常結束、逾時,還是協定不一致所造成的問題。

16. 一定要設定最大長度上限

在長度前綴方式中,開頭會放置內容長度。

這裡有一個危險之處:如果對方指定了一個異常巨大的長度值。

FF FF FF FF

如果直接拿這個值去配置陣列,就會嘗試配置大量記憶體,導致應用程式不穩定。

因此,接收端一定要設定最大長度上限。

private const int MaxPayloadSize = 1024 * 1024;

if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
    throw new InvalidDataException(
        $"Invalid payload length: {payloadLength} bytes.");
}

最大長度上限要依業務需求決定。如果是指令類的訊息,64 KiB 可能就已經足夠;如果要傳送圖片或檔案,或許應該考慮其他的傳輸方式或串流方式。重點在於,不要把系統設計成「理論上可以無限接受」的架構。

17. 字串協定看的是「位元組數」,不是「字元數」

TCP 傳輸的並不是字串,而是位元組序列。

因此,在長度前綴方式中放入內容長度時,通常應該放入的是「位元組數」,而不是「字元數」。

舉例來說,假設要把下面這段文字轉成 UTF-8:

こんにちは

這段文字有 5 個字元,但轉成 UTF-8 之後是 15 個位元組。

如果協定上的長度直接用 5 來表示,接收端就只會讀取 5 個位元組,結果會在字元的中途被切斷。

因此,傳送端一定要以編碼後的位元組陣列作為長度計算的基準。

string json = "{\"message\":\"こんにちは\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);

await LengthPrefixedProtocolWriter.WriteFrameAsync(
    stream,
    payload,
    cancellationToken);

接收端則要先把訊框內容以位元組的形式讀取完整,之後再轉換回字串。

byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
    stream,
    cancellationToken);

if (payload is not null)
{
    string json = Encoding.UTF8.GetString(payload);
    await HandleJsonAsync(json, cancellationToken);
}

按照這個順序處理,即使 Read 恰好在 UTF-8 字元的中途被拆開,也不會造成問題。

18. 也要注意並行寫入導致的應用程式層混線

另外一個容易被忽略的重點,是並行寫入(concurrent write)。

舉例來說,假設有多個工作(task)同時對同一個 TCP 連線寫入訊框:

_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);

如果不加以控制,就可能在應用程式層面發生下面這種混線:

A 的標頭
B 的標頭
A 的內容
B 的內容

接收端在讀完 A 的標頭之後,會預期接下來會收到 A 的內容。如果這時候插入了 B 的標頭,協定就會被破壞。

因此,對於同一個連線的寫入,最安全的做法是將其序列化(serialize)。舉例來說,可以使用 SemaphoreSlim 或傳送用的佇列,避免不同訊框的寫入互相混雜。

private readonly SemaphoreSlim _sendLock = new(1, 1);

public async ValueTask SendFrameSafelyAsync(
    Stream stream,
    byte[] payload,
    CancellationToken cancellationToken)
{
    await _sendLock.WaitAsync(cancellationToken);

    try
    {
        await LengthPrefixedProtocolWriter.WriteFrameAsync(
            stream,
            payload,
            cancellationToken);
    }
    finally
    {
        _sendLock.Release();
    }
}

TCP 會保證位元組的傳送順序,但如果應用程式讓多個工作把位元組序列混在一起寫入,TCP 也只會忠實地把這個「混雜後的順序」送達。

19. 測試時要刻意製造分割與合併

如果只是照一般方式測試 TCP 的接收處理,很容易忽略掉「碰巧能動」的狀態。

因此,測試時要刻意製造下面這些模式:

測試角度 範例
每次只送 1 位元組 標頭與內容都以 1 位元組為單位被 Read
標頭中途斷線 4 位元組標頭只收到 2 位元組就結束
內容中途斷線 內容長度 100 卻只收到 60 位元組就結束
多個訊框被合併 2 個訊框同時放進 1 次內部緩衝區
指定異常巨大的長度 傳送超過最大長度限制的內容長度
0 位元組內容 確認是否允許內容長度為 0
UTF-8 被拆開 日文或表情符號的位元組序列在中途被拆開

在單元測試中,即使不使用真正的 TCP socket,也可以把 Stream 替換成「只能以指定區塊大小讀取的串流」,藉此驗證接收處理。

透過真正的 TCP 連線進行的整合測試同樣不可或缺,但如果先把接收解析器(parser)拆成一個對 Stream 進行純粹處理的元件,測試就會容易得多。

網路處理的品質,不該用「照正常方式送出就能動」來判斷,而該用「即使被拆開、被合併、中途被切斷,也能依照預期運作」來判斷。

20. 修改既有程式碼時的檢查清單

在檢視既有的 TCP 通訊程式碼時,從下面這些角度來看,比較容易找出問題:

檢查角度 確認事項
接收單位 是否把 1 次 Read / Receive 當成 1 則訊息來處理
回傳值 是否確實使用了 Read / Receive 回傳的位元組數
累積 是否把資料累積到湊齊 1 則訊息為止
邊界 是否有固定長度、分隔符號、長度前綴等明確規則
字元編碼 是否在訊息完整收齊之前就轉成字串
最大長度 長度或行長是否有上限
斷線 是否有區分訊框邊界處的斷線與中途斷線
傳送 是否忽略了 Socket.Send 的回傳值
並行性 同一連線是否可能被多個工作同時寫入而混線
日誌 是否能輸出 expected / actual 的位元組數
測試 是否有涵蓋分割、合併、中途斷線的測試

特別危險的程式碼,通常會是下面這種形式:

int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);

這裡的問題有很多:

  • 沒有使用 read 這個回傳值
  • 把整個緩衝區都轉成字串
  • 把 1 次 Receive 當成 1 則訊息來處理
  • 沒有訊息邊界的概念
  • 沒有考慮字元被中途拆開的情況

至少,應該改成下面這種思考方式:

把 Receive 取得的 read 位元組加入接收緩衝區
  ↓
確認能否依照協定,從接收緩衝區中切出 1 個訊框
  ↓
如果可以切出,就進行處理
  ↓
多出來的位元組保留下來,作為下一個訊框的開頭
  ↓
如果不夠,就等待下一次 Receive

21. 總結

在 TCP 通訊中,不能保證按照 Send 的單位就能收到 Receive。這並不是例外行為,而是使用 TCP 時的基本前提。

需要掌握的重點如下:

  • TCP 提供的不是訊息,而是保有順序的位元組串流
  • Send / Write 呼叫的單位,不會被保留成接收端 Receive / Read 的單位
  • 1 次傳送可能被拆成多次接收,多次傳送也可能被合併成 1 次接收
  • 接收端需要以應用程式協定的方式,自行決定訊息邊界
  • 在自訂協定中,長度前綴方式通常比較容易處理
  • 設計時要包含:讀到所需位元組數為止的迴圈、最大長度上限、中途斷線、字元編碼、並行寫入
  • NoDelayDataAvailable 都無法取代訊息邊界的判斷

只看正常情況時,網路處理看起來很簡單。但實際上,唯有先決定好「在哪裡切分」「不夠時如何等待」「太多時如何保留」「中途斷線時如何處理」,通訊才會真正穩定下來。

如果要使用 TCP,就要記住:Receive 回傳的不是訊息,只是位元組序列的一部分。把它組裝成訊息的責任,在於應用程式端的協定設計。

參考

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

為什麼 TCP 無法保證按 Send 的單位進行 Receive?
因為 TCP 保證的只是「送出的位元組序列會依照順序、不重複、不遺漏地送達」,並不保證 Send 呼叫的單位會被保留成接收端 Receive 的單位。TCP 傳輸的是連續的位元組串流而不是訊息,因此一次傳送被拆成多次接收、或多次傳送被合併成一次接收,都是正常行為。接收端必須自行建立判斷訊息邊界的機制(也就是所謂的 framing/訊框化)。
TCP 的 framing(決定訊息邊界的方式)有哪些做法?
代表性的方式有 4 種:固定長度方式(永遠以固定的位元組數作為 1 則訊息)、分隔符號方式(以換行等特定位元組序列作為 1 則訊息的結尾)、長度前綴方式(在開頭放上內容長度)、以及像 HTTP 的 Content-Length 那樣、在格式內部自行表達長度或結尾的自我描述格式。若要設計自訂協定,長度前綴方式通常是第一個考慮對象,因為內容可以包含任意二進位資料,也容易加上最大長度限制。
把 Socket.NoDelay 設為 true,可以解決 TCP 的分割 / 合併問題嗎?
無法解決。NoDelay 是用來停用 Nagle 演算法的設定,調整的是小型傳送的延遲與效率,並不是把 Send 的單位保留成 Receive 單位的設定。即使把 NoDelay 設為 true,一次 Send 被拆成多次 Receive、多次 Send 被合併成一次 Receive、字元在中途被切斷等問題依然會發生。它無法取代 framing。
TCP 接收資料時為什麼會出現亂碼?
因為 UTF-8 中一個字元有時會由多個位元組組成,而 Read 的邊界並不保證與字元的邊界一致。如果每次收到位元組序列後就立刻用 Encoding.UTF8.GetString 轉成字串,一旦在多位元組字元中途被切斷,就會出錯。對策是先把資料當作位元組累積起來,直到確認訊息邊界為止,等一則訊息完整收齊後才解碼。長度前綴中的內容長度,也要以編碼後的位元組數計算,而不是字元數。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽