ストリームとフレーミング — 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 杯目の水」の境目が残らないのと同じです。
send の切れ目(上段)は recv の切れ目(下段)に受け継がれません。バイトの内容と順序だけが約束されます。「1 send = 1 recv」が成立するのは偶然です。
切れ目が変わる 2 つの現場
切れ目はどこで変わるのでしょうか。代表的な現場が 2 つあります。
どちらも TCP にとっては正しい最適化です。開発環境(ローカル、低遅延、小さなデータ)では偶然「1 send = 1 recv」が成立し続けるため、この前提のバグは本番でだけ、たまに発火します。「再現しない不具合」の名産地です。
小問 6-1 — バイトストリームという性質
「たまに受信データが欠ける・くっつく」— 実務の定番バグの正体を確かめます。
Q1. TCP のコネクションで、送信側が send() を 2 回(300 バイトと 200 バイト)呼びました。受信側の recv() の結果としてありえるものはどれですか。
TCP が約束するのは「同じバイト列が同じ順序で届く」ことだけで、send の切れ目は保存されません。経路の都合で 2 回分が 1 セグメントにまとまることも、1 回分が複数に割れることもあります。500 バイトの順序は必ず保たれるので、後から送った分が先に届くことはありません。切れ目だけが信用できないのです。
Q2. 「recv() が返したデータを 1 つの JSON メッセージとして parse する」コードが、開発環境では動くのに本番でたまに失敗します。最も可能性の高い原因はどれですか。
定番中の定番バグです。開発環境はローカルで低遅延・メッセージも小さく、たまたま 1 send = 1 recv が成立してしまいます。本番では遅延・分割・結合の条件が変わり、半端な JSON(parse エラー)や 2 通連結(後半が消えたように見える)が発生します。データの破損なら TCP のチェックサムと再送が防ぎ、順序も常に保証されます。壊れているのはデータではなく切れ目の前提です。
Q3. 小さな send を連続して呼ぶと、TCP がそれらを 1 つのセグメントにまとめて送ることがあります(Nagle アルゴリズム)。この動作の目的と副作用の説明として正しいものはどれですか。
1 バイト送るにも TCP/IP ヘッダで約 40 バイト付きます。Nagle アルゴリズムは「ACK 待ちの小さなデータがあるうちは、次の小さな送信をまとめ待ちする」ことでこの無駄を抑えます。代償は対話的な小さい書き込みの遅延で、リアルタイム性が要る場面では TCP_NODELAY で無効化します。ここでも大事なのは、TCP が送った単位を平気で組み替えるという事実です。
フレーミング — 境界はアプリが自分で作る
メッセージの境界が必要なら、境界の情報をバイト列そのものの中に埋め込むしかありません。これをフレーミングと呼びます。定番は 2 つです。
どちらの方式でも、受信側のコードは同じ型になります — recv で届いた分をバッファに追記し、完成したメッセージだけを切り出し、余りは次に持ち越す。recv の回数や切れ目には一切依存しないループです。逆に言えば、このループさえ正しければ、TCP がどんな切れ目で届けようとアプリは壊れません。
覚え方: TCP に「1 通」という概念はありません。「1 通」を発明するのは、いつもアプリ側の仕事です。既製のプロトコルやライブラリを使うときも、その下でこのフレーミングが動いています。
小問 6-2 — 境界は自分で作る(フレーミング)
切れ目が保存されないなら、切れ目の情報をデータの中に入れるしかありません。
Q4. 長さプレフィックス方式(各メッセージの先頭に本文の長さを付けて送る)の受信側の正しい実装はどれですか。
受信側は「バイトの流れから自分で切り出す」ループを書きます — (1) 長さフィールドがそろうまで貯める、(2) 長さを読む、(3) 本文がその長さ分そろうまで貯める、(4) 切り出して処理し、余りは次に回す。recv の回数や切れ目には一切依存しません。時間で区切る方式は遅延次第で壊れ、アプリ層からの再送要求という仕組みは TCP にはありません。
Q5. 改行文字などの区切り文字でメッセージ境界を作る方式(例: 1 行 = 1 メッセージ)を使うときに、必ず設計しておくべきことはどれですか。
本文に区切り文字そのものが現れると、受信側はそこを偽の境界として切ってしまいます。だから区切り文字方式には「本文に区切り文字を含めない」保証 — エスケープ(例: 改行を \n と書く)や Base64 のようなエンコード — が必須です。JSON Lines が「JSON 内の改行は必ず \n にエスケープされる」性質に乗っているのが好例です。固定長化やランダム化は方式自体を別物にする話で、対策として噛み合いません。
Q6. 長さプレフィックス 4 バイト + 本文 300 バイト(計 304 バイト)のメッセージを 2 通連続で送りました(合計 608 バイト)。受信側の最初の recv() は 500 バイトを返しました。2 通目のメッセージを完成させるには、あと何バイト受信する必要がありますか。
1 通目はバイト 1〜304 で、500 バイトの中に丸ごと入っています。残る 500 − 304 = 196 バイトは 2 通目の先頭部分(プレフィックス 4 バイト + 本文 192 バイト)。2 通目は全体で 304 バイトなので、あと 304 − 196 = 108 バイトです。受信バッファに 196 バイトを保持したまま次の recv を待つ — 小問 6-2 第 1 問のループの動きそのものです。
この章で持ち帰ること
- TCP はバイトストリーム — 約束は「同じバイト列を同じ順序で」。send の切れ目は保存されない
- 切れ目は結合(Nagle)と分割(MSS)で日常的に変わる。「1 send = 1 recv」は開発環境の偶然
- 境界が要るならフレーミング — 長さプレフィックスか区切り文字。区切り文字にはエスケープの設計が必須
- 受信側の型は「貯めて、そろった分だけ切り出し、余りは持ち越す」。recv の切れ目に依存しない
これで部品はすべて揃いました。最終章では、実務風の 8 つの障害ケースを「どの章の話か」に翻訳しながら切り分けます。