TT Lab
はじめる
学ぶ 学習パス コース

TCPの荷物がばらばらに届いた

消えたメッセージの境界を探せ

TT Labで続きを見る

一言でいうと

TCPはバイトの順序を守りますが、皆さんが決めたメッセージの境界までは運びません。

なぜ必要なのか

荷物サーバーに「猫のおやつ」と「充電ケーブル」の2つの注文を送ったのに、1つは半分しか見えず、2つはくっついて見えます。ネットワークがバイトを失ったと決めつけやすいところです。しかし、送信側のsend 2回と受信側のrecv 2回は、1対1に対応しません。オペレーティングシステムのバッファのその瞬間の状態によって、同じデータが別の塊で届くことがあります。短いローカルの試験でたまたま一度に受け取れたという経験は、メッセージの契約の証拠ではありません。

このコースの荷物は、教育用のバイトメッセージです。実際の注文・決済システムや認証済みのサービスではなく、外部のネットワークを開く必要もありません。作るのは、長さヘッダー付きのフレームと、そのフレームを復元する小さなプログラムです。文字列・クラス・ループ・例外を知っているPython学習者に合っています。

どう動くのか

封筒の先頭の4バイトが、本文の長さをunsigned big-endianで示します。b"cat"は、00 00 00 03の後に63 61 74が続きます。フレーム全体は7バイトですが、ヘッダーの値は3です。韓国語の文字列は、まずUTF-8に変換してからlenを計算します。文字が2つであることと、送るバイトが2つであることは同じではありません。受信側のパーサーは、文字を早すぎる段階で解釈せず、bytesのまま保持します。UTF-8の文字の真ん中でchunkが切れても、本文全体を集めてから解釈すれば足ります。

Decoderは3つの質問を繰り返します。ヘッダーの4バイトがあるか。そのヘッダーが要求した本文まであるか。完成品を取り出した後、次のフレームもあるか。最初の質問の答えが「いいえ」なら、残りを保管して戻ります。2つ目も同じです。3つ目では、1回だけ検査するifではなく、繰り返しが必要です。1つのchunkに、完成したフレームが2つと、3つ目のヘッダーの半分が入っていることもあります。

feed 1: [길이 헤더 앞 2바이트]             → []
feed 2: [헤더 뒤 2바이트][본문 앞부분]      → []
feed 3: [나머지 본문][다음 프레임 전체]    → [본문1, 본문2]

空のリストは、まだ完成したメッセージがないという意味です。[b""]は、長さ0のメッセージを1つ完成したという意味です。この2つを同じものとして扱うと、ハートビートのような空のメッセージを失います。feed(b"")も「今回は入力なし」にすぎず、接続の終了とは定義しません。ネットワークでrecvがb""を返したときに初めてEOFを知ったことになり、その出来事をfinishに伝えます。

ヘッダーが半分残ったまま接続が終わったなら、次の注文がない正常な終了ではありません。相手が注文を1つ始めて、完成できませんでした。本文8バイトを約束して5バイトしか送らなかった場合も同じです。残りがない終了と未完成の終了を区別すれば、アプリケーションが不完全な注文を処理することを防げます。ただし、プロトコルのパースの成功が、注文そのものの妥当性の検証の代わりになるわけではありません。

パーサーは接続ごとに1つです。異なるクライアントの残りをグローバルなbufferに集めると、Aのヘッダーの後にBの本文がくっつきます。ファイルのグローバル変数よりも、インスタンスの属性にする理由です。逆に、同じ接続で毎回新しいインスタンスを作ると、まだ完成していないヘッダーが、呼び出しのたびに消えます。状態をどこに置くかが、そのままデータの所有権です。

現場での姿

RPC・機器通信・ゲームサーバーは、それぞれ異なるフレーム形式を使いますが、「一部だけ受け取った」という問題は共通です。HTTPとWebSocketは、すでにそれぞれのフレーミングのルールを持っているので、このコースの4バイトヘッダーを必ず付け足す必要はありません。ライブラリがメッセージ単位のAPIを提供するなら、どの層が境界を処理するかを先に確認してください。TCPに信頼性があるからといって、アプリケーションのメッセージ境界まで自動でできると考えることが、落とし穴です。

長さ1のAと長さ2のBCをつないだバイト列は、00 00 00 01 41 00 00 00 02 42 43です。最初のfeedに前の6バイトだけを渡すと、Aを1つ返して、次のヘッダーの最初のバイト00を保管します。後ろの5バイトを渡すと、残りのヘッダー3バイトと本文BCが完成します。同じバイト列を一度に渡すと、[b"A", b"BC"]になる必要があります。分ける位置だけを変えたのに結果が変わるなら、ネットワークよりも先に、パーサーの状態遷移を疑ってください。

接続AとBのDecoderを2つ作り、Aにはヘッダーの前半だけを、Bには完全なフレームを与える試験も有用です。Bの完了がAの残りを変えるなら、状態が共有されています。このような交差した入力は、並行実行がなくても、所有権のエラーを明らかにします。試験の目的は、オペレーティングシステムのスケジューラの偶然を再現することではなく、コードが許す入力の順序を体系的に確認することです。

この教育用の実装は、bytearrayの先頭を消す単純な方式です。大きなトラフィックでは、コピーと移動のコストが重要になるので、読み取りカーソル・リングバッファのような代替案を検討できます。まず正確なメッセージの復元を証明し、その後でプロファイリングして最適化してください。長さの上限1つが、全体の接続数や、一度に与えられるchunkの大きさまで制限するわけではないことも、区別する必要があります。

次の確認ですること

すぐ後のクイズで、境界・空のメッセージ・EOFを区別します。最後のモジュールのラボでは、1バイトずつ区切って入れる決定的な試験と、複数のフレームをまとめて入れる試験を通します。実際のTCPで、特定の分割がたまたま現れるのを待ちません。同じ原文なら、どう分けて与えても、復元したメッセージのリストが同じである必要があります。