合同会社小村ソフト
第 6 章

ストリームとフレーミング — TCP の send と recv はずれる

TCP が届けるのはメッセージの列ではなくバイトの流れ。send の切れ目は recv に残らない — 境界が要るならアプリが自分で作る。定番バグを軸に学ぶ。

TCP は正しく動いているのに、データが「壊れる」

ここまでの 5 章で、TCP は 3 つの約束を見事に守ることを見てきました。ところが実務では、TCP の上で「たまに受信データが欠ける」「2 つのメッセージがくっついて届く」という不具合が頻発します。パケットキャプチャを見ても再送もロスもない。TCP は完全に正常。それでもアプリのデータは壊れて見える — なぜでしょうか。

答えは、TCP が届けるものの正体にあります。TCP が約束したのは「同じバイト列を、同じ順序で」であって、「同じ切れ目で」ではありません。第 3 章で見たとおり、TCP の主役はセグメントではなくバイトです。send() を 300 バイト・200 バイトの 2 回に分けて呼んでも、ネットワークを旅するうちに 1 つのセグメントにまとまるかもしれないし、120 + 380 に切り直されるかもしれません。受信側の recv() が返す塊の切れ目は、送信側の send の切れ目と無関係なのです。

この性質をバイトストリーム(メッセージ境界を保存しない)と呼びます。ソケットは手紙の束を運ぶ郵便屋ではなく、切れ目のない水道管です。水道管に「1 杯目の水」と「2 杯目の水」の境目が残らないのと同じです。

送信側の 2 回の send の切れ目と、受信側の recv が返す塊の切れ目が一致せず、メッセージが途中で切れたり連結したりして見える様子の図

send の切れ目(上段)は recv の切れ目(下段)に受け継がれません。バイトの内容と順序だけが約束されます。「1 send = 1 recv」が成立するのは偶然です。

切れ目が変わる 2 つの現場

切れ目はどこで変わるのでしょうか。代表的な現場が 2 つあります。

結合 — 小さな send がまとめられる(Nagle アルゴリズム)
1 バイトのデータにも約 40 バイトのヘッダが付く。小さな送信を細かく流す無駄を防ぐため、ACK 待ちの間は次の小さなデータをまとめ待ちしてから 1 セグメントで送る。対話的な通信で遅延が問題になるときは TCP_NODELAY で無効化できる
分割 — 大きな send が切り分けられる(MSS)
1 セグメントで運べるデータには上限(MSS: 多くの環境で 1460 バイト前後)がある。10 KB の send は必ず複数セグメントに割られ、受信側には数回の recv に分かれて見えることがある

どちらも TCP にとっては正しい最適化です。開発環境(ローカル、低遅延、小さなデータ)では偶然「1 send = 1 recv」が成立し続けるため、この前提のバグは本番でだけ、たまに発火します。「再現しない不具合」の名産地です。

小問 6-1 — バイトストリームという性質

「たまに受信データが欠ける・くっつく」— 実務の定番バグの正体を確かめます。

Q1. TCP のコネクションで、送信側が send() を 2 回(300 バイトと 200 バイト)呼びました。受信側の recv() の結果としてありえるものはどれですか。

Q2. 「recv() が返したデータを 1 つの JSON メッセージとして parse する」コードが、開発環境では動くのに本番でたまに失敗します。最も可能性の高い原因はどれですか。

Q3. 小さな send を連続して呼ぶと、TCP がそれらを 1 つのセグメントにまとめて送ることがあります(Nagle アルゴリズム)。この動作の目的と副作用の説明として正しいものはどれですか。

フレーミング — 境界はアプリが自分で作る

メッセージの境界が必要なら、境界の情報をバイト列そのものの中に埋め込むしかありません。これをフレーミングと呼びます。定番は 2 つです。

長さプレフィックス方式
各メッセージの先頭に「本文は何バイトか」を固定長(例: 4 バイト)で付ける。受信側は長さ分そろうまでバッファに貯めてから切り出す。バイナリに強く、HTTP/2 や gRPC の下回りもこの系統
区切り文字方式
メッセージの終わりに改行などの目印を置く(1 行 = 1 メッセージ)。人間に読みやすくデバッグしやすい。ただし本文に区切り文字が現れない保証(エスケープやエンコード)が必須。HTTP/1.1 のヘッダや Redis のプロトコルがこの系統

どちらの方式でも、受信側のコードは同じ型になります — recv で届いた分をバッファに追記し、完成したメッセージだけを切り出し、余りは次に持ち越す。recv の回数や切れ目には一切依存しないループです。逆に言えば、このループさえ正しければ、TCP がどんな切れ目で届けようとアプリは壊れません。

覚え方: TCP に「1 通」という概念はありません。「1 通」を発明するのは、いつもアプリ側の仕事です。既製のプロトコルやライブラリを使うときも、その下でこのフレーミングが動いています。

小問 6-2 — 境界は自分で作る(フレーミング)

切れ目が保存されないなら、切れ目の情報をデータの中に入れるしかありません。

Q4. 長さプレフィックス方式(各メッセージの先頭に本文の長さを付けて送る)の受信側の正しい実装はどれですか。

Q5. 改行文字などの区切り文字でメッセージ境界を作る方式(例: 1 行 = 1 メッセージ)を使うときに、必ず設計しておくべきことはどれですか。

Q6. 長さプレフィックス 4 バイト + 本文 300 バイト(計 304 バイト)のメッセージを 2 通連続で送りました(合計 608 バイト)。受信側の最初の recv() は 500 バイトを返しました。2 通目のメッセージを完成させるには、あと何バイト受信する必要がありますか。

バイト

この章で持ち帰ること

  • TCP はバイトストリーム — 約束は「同じバイト列を同じ順序で」。send の切れ目は保存されない
  • 切れ目は結合(Nagle)と分割(MSS)で日常的に変わる。「1 send = 1 recv」は開発環境の偶然
  • 境界が要るならフレーミング — 長さプレフィックスか区切り文字。区切り文字にはエスケープの設計が必須
  • 受信側の型は「貯めて、そろった分だけ切り出し、余りは持ち越す」。recv の切れ目に依存しない

これで部品はすべて揃いました。最終章では、実務風の 8 つの障害ケースを「どの章の話か」に翻訳しながら切り分けます。