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 的 Send 與 Receive 之間容易產生的誤解,以及在 .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 ...
^---------^ ^------------------------------^
內容長度 內容
接收端會依照下面的順序處理:
- 先把 4 位元組讀取完整
- 從這 4 位元組中取出內容長度
- 驗證內容長度是否合理
- 讀取指定長度的內容
- 把讀取完的內容當作 1 則訊息來處理
- 讀取下一個訊框
這裡要注意的重點是「即使是 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 次接收
- 接收端需要以應用程式協定的方式,自行決定訊息邊界
- 在自訂協定中,長度前綴方式通常比較容易處理
- 設計時要包含:讀到所需位元組數為止的迴圈、最大長度上限、中途斷線、字元編碼、並行寫入
NoDelay與DataAvailable都無法取代訊息邊界的判斷
只看正常情況時,網路處理看起來很簡單。但實際上,唯有先決定好「在哪裡切分」「不夠時如何等待」「太多時如何保留」「中途斷線時如何處理」,通訊才會真正穩定下來。
如果要使用 TCP,就要記住:Receive 回傳的不是訊息,只是位元組序列的一部分。把它組裝成訊息的責任,在於應用程式端的協定設計。
參考
- 本文的範例程式碼一式(函式庫、demo、單元測試) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/tcp-send-receive-message-framing
- RFC 9293: Transmission Control Protocol (TCP) https://www.rfc-editor.org/rfc/rfc9293.html
- Microsoft Learn:
Socket.Receivehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-10.0 - Microsoft Learn:
NetworkStream.Readhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.read?view=net-10.0 - Microsoft Learn:
Socket.Sendhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.send?view=net-10.0 - Microsoft Learn:
NetworkStream.Writehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.write?view=net-10.0 - Microsoft Learn:
Stream.ReadExactly/ReadExactlyAsynchttps://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactly?view=net-10.0 https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactlyasync?view=net-10.0 - Microsoft Learn:
Socket.NoDelayhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.nodelay?view=net-10.0
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
PDB(程式資料庫)是什麼 ── 理解偵錯資訊、符號與 Source Link
整理 PDB(Program Database / 程式資料庫)是什麼、裡面有什麼、沒有什麼,並從實務角度說明 Debug / Release、Portable PDB、Source Link、符號伺服器、傾印分析之間的關係。
把OSI參考模型看得明明白白 ── 解剖一個HTTP請求的七層結構
不靠死記硬背來理解OSI參考模型,而是看真實的東西。本文用C#組出一個承載HTTP GET請求的Ethernet訊框並加以解剖,透過十六進位傾印和Wireshark確認L2〜L7如何以位元組序列的形式一層層物理地嵌套在一起。文章從實務角度整理各層與.NET API(Http...
什麼是 Roslyn?── 用編譯器的視角讀懂、修正、產生 C# 程式碼
整理 Roslyn(.NET Compiler Platform)的概觀,包括 Syntax Tree、SemanticModel、Workspace、Analyzer、Source Generator,以及在實務上的使用場合與注意事項。
正確處理 Windows 偽裝權杖 - 執行緒層級的權限借用與安全還原方式
本文整理 Windows 偽裝權杖的實務安全處理方式,涵蓋存取權杖、主要權杖、執行緒權杖、偽裝層級、RevertToSelf,以及 .NET 的 WindowsIdentity.RunImpersonated。
在 .NET 中區分 GC 延遲與記憶體洩漏 ── 觀測、比較、證明記憶體增長的實務步驟
本文整理如何用 dotnet-counters、dotnet-gcdump、dotnet-dump 來判斷 .NET 應用程式的記憶體增加,究竟是等待垂圾回收(GC)造成的,還是真正的記憶體洩漏。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
故障調查 & 長期運行故障
整理間歇性故障、通訊診斷、長期運行當機、失敗路徑測試基礎的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
既有資產活用 & 遷移支援
在持續活用 COM / ActiveX / OCX 資產、原生程式碼與 32 位元相依的同時,協助規劃階段性的遷移。
常見問題
整理諮詢這個主題時常見的問題。
- 為什麼 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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。