0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルだと一回も失敗しないんですよ」と言っていた後輩が、TCPのReceiveはメッセージを返さないと知った話

0
Posted at

導入

私はSasaki。Windowsクライアントの設計レビューと改修を長くやっている、少し口うるさい先輩だ。

ある日の朝、後輩の Go(22歳)が、浮かない顔で席に来た。

「Sasakiさん、装置連携のツールなんですけど、検証環境でたまに JSON のパースに失敗するんです。
ローカルだと一回も失敗しないんですよ。ネットワークが不安定だと思うので、リトライ処理を入れようかと」

聞けば、Windows アプリと装置側サーバーを TCP でつないで、JSON コマンドをやり取りするツールらしい。

ローカルでは動く。検証環境ではたまに壊れる。
この組み合わせを聞いた時点で、容疑者はだいたい決まっている。

TCP というやつは、開発機の上ではいつも行儀がいい。
その行儀のよさが、本番までついて来てくれないだけだ。

Goが書いていた受信コード

コードを見せてもらった。受信部分は、要するにこうだった。

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 コマンドにつき 1 回 Send していて、末尾に改行を付けているという。

「1回送ったら、1回で受かるじゃないですか。ローカルだとずっとそうでしたし」

そこが間違っている。
このコードは「1 回の ReadAsync で 1 メッセージが取れる」ことを前提にしているが、TCP はそんな約束を一度もしていない。

TCPはメッセージではなくバイト列を運ぶ

送信側がこう送ったとする。

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

受信側で同じ 3 回に分かれて読める保証はない。実際には、どれも起こり得る。

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

そして、これらはどちらも TCP としては正常 だ。

TCP が保証するのは「送ったバイト列が、順序を保って、重複なく、欠落なく届くこと」まで。
Send した単位が Receive の単位として残ること」は保証していない。

送信側の呼び出し 受信側の見え方の例
Send("ABC"), Send("DEF") Receive() 1 回で "ABCDEF"
Send("ABCDEF") Receive() 2 回で "AB", "CDEF"
マルチバイトの UTF-8 文字 文字の途中で分割されることもある

「たまに受信データが欠ける」「複数メッセージがくっつく」「文字化けする」。
不具合に見えるこれらの多くは、TCP の異常ではなく、受信側が TCP をメッセージ単位で扱ってしまっている設計ミスだ。

なぜローカルでは動いてしまうのか

この誤解がなくならないのは、開発環境では たまたま 期待どおりに見えるからだ。

  • クライアントとサーバーが同じマシンか、すぐ近くにいる
  • データが小さい
  • Send の直後に Receive している
  • タイミングの揺らぎが少ない

一方、本番では条件が変わる。

  • OS の送受信バッファにたまる
  • 小さな送信がまとめられ、大きな送信が分割される
  • TLS、プロキシ、ロードバランサー、VPN などの層が入る
  • 遅延や輻輳、Nagle アルゴリズムの影響を受ける

その結果が「開発では動いたのに、本番でたまに壊れる」になる。
ネットワーク処理では、この「たまたま動く」が一番危険だ。

そこで読んでもらった記事

私は Go に、次の記事を送った。

TCPでSendした単位ごとにReceiveできるという誤解 ── バイトストリームとして扱うための受信設計
https://comcomponent.com/blog/2026/06/09/001-tcp-send-receive-message-framing/

「リトライを書く前にこれを読め。
どう直すか の前に、何を誤解していたか がそのまま書いてある」

昼休みのあと、Go が椅子を回しながら言った。

「……Receive って、メッセージを返す API じゃないんですね。
バイト列の一部を返すだけで、どこからどこまでが 1 メッセージかは、こっちが決めないといけなかった」

会話が、ようやく設計の話になった。

正しい考え方は「受信」と「解釈」を分けること

受信処理は、2 つに分けて考えると設計しやすい。

受信: TCPから届いたバイト列を読み、バッファに積む
解釈: バッファから、アプリケーション上の1メッセージを切り出す

「1 メッセージがどこで終わるか」を決めるのは TCP ではなく、アプリケーションプロトコルの フレーミング だ。代表的な方式は 4 つある。

方式 内容 向いている用途
固定長 常に決まったバイト数を 1 メッセージとする レガシー機器、バイナリ電文、制御系
区切り文字 \n など特定のバイト列までを 1 メッセージとする コマンド、ログ、NDJSON
長さプレフィックス 先頭に本文長を置き、そのバイト数だけ読む バイナリ、JSON、MessagePack など
自己記述形式 HTTP の Content-Length のように形式内で終端を表す 既存プロトコル、拡張性が必要な通信

独自プロトコルを作るなら、まずは 長さプレフィックス方式 を検討するのがおすすめだ。
本文に改行やバイナリを含められて、受信側の実装が明確で、最大サイズ制限も入れやすい。

長さプレフィックスの最小実装

[4バイトの本文長][本文] という形式を読む実装は、こうなる。

using System.Buffers.Binary;

public static class LengthPrefixedProtocol
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024; // 用途に合わせて決める

    public static async ValueTask<byte[]?> ReadFrameAsync(
        Stream stream, CancellationToken ct)
    {
        byte[] header = new byte[HeaderSize];
        int headerBytes = await ReadUntilFullOrEndAsync(stream, header, ct);

        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, ct);

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

        return payload;
    }

    private static async ValueTask<int> ReadUntilFullOrEndAsync(
        Stream stream, Memory<byte> buffer, CancellationToken ct)
    {
        int totalRead = 0;
        while (totalRead < buffer.Length)
        {
            int read = await stream.ReadAsync(buffer[totalRead..], ct);
            if (read == 0) break;
            totalRead += read;
        }
        return totalRead;
    }
}

ポイントは 3 つある。

  1. ヘッダーも分割され得る。 4 バイトだからといって 1 回の Read で取れる保証はない。必要なバイト数が決まっているなら、読み切るまでループする。
  2. 最大サイズを必ず検証する。 相手から FF FF FF FF のような巨大な長さを指定されたとき、そのまま配列確保に使うとアプリが不安定になる。「理論上いくらでも受け付ける」設計にしない。
  3. フレーム境界での切断と、途中での切断を区別する。 前者は正常終了として扱えるが、後者はプロトコルエラーだ。この区別をログに出せると、障害調査が桁違いに楽になる。

なお、現行の .NET なら Stream.ReadExactlyAsync で読み切り処理を標準 API に任せられる。ただし、正常終了と途中切断の区別はアプリ側で設計が要る。

Goがハマりかけた罠

直しながら、Go はいくつかの「それっぽい解決策」も口にした。全部、罠だ。

DataAvailable が false になったら 1 メッセージの終わりですよね?」
違う。DataAvailable は「その瞬間にローカルの受信バッファにデータがあるか」を表すだけで、メッセージの完了とは無関係だ。100 バイトのメッセージのうち 40 バイトだけ届いた瞬間にも true になり、読んだ直後に false になり得る。境界判定には使わない。

NoDelay = true にすれば 1 Send 1 Receive になりません?」
ならない。NoDelay は Nagle アルゴリズムを無効にする送信遅延の設定であって、メッセージ境界を保存する設定ではない。レイテンシ調整には意味があるが、フレーミングの代わりにはならない。

「受け取ったらすぐ GetString してました」
UTF-8 の 1 文字は複数バイトになることがあり、Read の境界が文字の境界と一致する保証はない。「メッセージの境界が分かるまでバイトとして蓄積し、1 メッセージ分そろってからデコードする」が基本だ。

「送信側は WriteAsync してるから大丈夫ですよね」
Socket.Send を直接使うなら戻り値を見ること。要求より少ないバイト数で成功することがある。さらに、同じ接続へ複数タスクが並行 Write すると、A のヘッダーと B のヘッダーがアプリレベルで混線してプロトコルが壊れる。1 接続への書き込みは SemaphoreSlim などで直列化する。

Goがコードをどう直したか

  • 「1 Read = 1 メッセージ」前提を捨て、長さプレフィックス方式に切り替えた
  • ヘッダーも本文も、必要バイト数を読み切るループにした
  • 本文長に上限を入れ、不正な長さは InvalidDataException で即落とすようにした
  • 途中切断のログに expected=100 actual=60 のようにバイト数を出すようにした
  • テストでは「1 バイトずつしか返さない Stream」を差し込み、わざと分割・結合させて受信パーサーを検証した

「リトライを入れる前に、まず壊れない読み方にするんですね。
あのまま再送処理を足してたら、壊れたまま頑張るツールができるところでした」

そう。
ネットワーク処理の品質は「普通に送ったら動く」ではなく、「分割されても、結合されても、途中で切れても、想定どおり振る舞う」で判断する。

以後、通信コードのレビューで最初に見る表

それ以来、うちでは TCP まわりのレビューで、最初にこの表を埋めるようになった。

観点 確認すること
受信単位 1 回の Read / Receive を 1 メッセージとして扱っていないか
蓄積 メッセージがそろうまでバイトを蓄積しているか
境界 固定長 / 区切り文字 / 長さプレフィックスなどのルールがあるか
文字コード メッセージ完成前に文字列化していないか
最大長 長さや行長に上限があるか
切断 フレーム境界での切断と途中切断を区別しているか
送信 Socket.Send の戻り値を無視していないか
並行性 同じ接続への複数タスクの書き込みが混ざらないか
テスト 分割、結合、途中切断のテストがあるか

まとめ

  • TCP はメッセージではなく、順序付きのバイトストリームを提供する
  • Send の呼び出し単位は、受信側の Receive 単位として保存されない
  • 受信側は、アプリケーションプロトコルとしてメッセージ境界を決める必要がある
  • 独自プロトコルなら長さプレフィックス方式が扱いやすいことが多い
  • NoDelayDataAvailable は、メッセージ境界の代わりにはならない

Receive はメッセージを返さない。バイト列の一部を返すだけだ。
メッセージにする責任は、アプリケーション側のプロトコル設計にある。

区切り文字方式・固定長方式それぞれの注意点、送信側の実装例、テスト観点の全リストまで含めた詳細版はこちら。

https://comcomponent.com/blog/2026/06/09/001-tcp-send-receive-message-framing/

(了)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?