TCPのバイトストリームとフレーミング設計の知識マップ(知識グラフ)
TCP通信で、送信側がSend/Writeした単位ごとに受信側でReceive/Readできるという思い込みが、分割・結合・文字化け・プロトコル破損を引き起こすことを扱う。TCPが運ぶのは順序付きのバイトストリームであり、メッセージ境界は保証されないため、受信側はアプリケーションプロトコルとしてフレーミング(固定長・区切り文字・長さプレフィックス・自己記述形式)でメッセージ境界を決める必要がある。Socket.NoDelayやDataAvailable、TLSのレコード単位はいずれもメッセージ境界の代わりにはならない。実装では、Read/Receiveの戻り値のバイト数だけを信用し、必要バイト数を読み切るまでループする設計と、本番でだけ壊れる不具合をWiresharkやpktmonで観測する手順が実務の要点になる。
TCPのバイトストリーム性
TCPが保証するのは送ったバイト列が順序を保って重複・欠落なく届くことだけであり、送信側のSend単位を受信側のReceive単位として保存することは保証しないという性質である。
TCPは順序付きのバイトストリーム
アプリケーションプロトコルのフレーミング
受信側が、受け取ったバイト列のどこからどこまでが1メッセージなのかを判断する、アプリケーションプロトコル側の仕組みである。
フレーミング
メッセージ境界の決定
長さプレフィックス方式
メッセージの先頭に本文長を置き、そのバイト数だけ本文を読むフレーミング方式で、任意のバイナリを含められ最大サイズ制限も入れやすい。
長さプレフィックス
送信区切りと受信区切りのずれ(分割・結合)
TCPの送信単位と受信単位が一致しないために、1メッセージが複数回の受信に分割されたり、複数メッセージが1回の受信に結合されたりする現象である。
分割・結合
フレーミングの不具合
固定長方式
常に決まったバイト数を1メッセージとするフレーミング方式で、境界は明確だが可変長データを扱いにくい。
固定長電文
区切り文字方式
改行など特定のバイト列までを1メッセージとするフレーミング方式である。
改行区切り
自己記述形式
HTTPのContent-Lengthやchunkedのように、形式そのものの中に長さや終端を表す情報を持たせるフレーミング方式である。
Nagleアルゴリズム(Socket.NoDelay)
小さな送信をまとめて送るかどうかを調整するアルゴリズムで、Socket.NoDelayをtrueにすると無効化できる送信遅延・効率に関する設定である。
NetworkStream.DataAvailable
その瞬間にローカルの受信バッファに読み取り可能なデータがあるかどうかだけを示す、.NETの NetworkStreamのプロパティである。
TLSレコード
TLSが暗号化通信を運ぶ際に使う内部的な伝送単位で、アプリケーションメッセージの境界とは一致しない。
並行Writeによるアプリケーションレベルの混線
同じTCP接続に対して複数のタスクが同時にフレームを書き込むと、ヘッダーと本文の順序が混ざってしまう問題である。
並行書き込みの混線
必要バイト数まで読み切る/書き切るループ
Read/ReceiveやSend/Writeの戻り値が要求したサイズより少ないことがあるため、目的のバイト数に達するまで繰り返し呼び出す実装パターンである。
非同期I/O
要求を発行した呼び出しがI/Oの完了を待たずにすぐ戻り、完了を別途の通知(イベント・APC・I/O完了ポートなど)で受け取るI/Oの形態。
オーバーラップI/O
Wireshark
キャプチャしたパケットをパケット詳細ペインとバイト列ペインで対応づけて表示できる、パケット解析ツール。
Packet Monitor(pktmon)
Windows標準でパケットキャプチャができるツールで、取得したETLはpcapngに変換してWiresharkで開ける。
Npcap
Windowsでループバック(127.0.0.1)のトラフィックをキャプチャするために必要なパケットキャプチャドライバーである。
TCPは送ったバイト列の順序・重複なし・欠落なしの到達だけを保証し、Sendした単位がReceiveの単位として保存されることは保証しないため、分割や結合が起こり得る
受信側でメッセージ境界を判断する仕組み(フレーミング)を設ければ、分割・結合によるメッセージ境界のずれに正しく対応できる
固定長方式は、常に決まったバイト数を1メッセージとすることでフレーミングを実現する
区切り文字方式は、改行など特定のバイト列までを1メッセージとすることでフレーミングを実現する
長さプレフィックス方式は、先頭に本文長を置きそのバイト数だけ本文を読むことでフレーミングを実現する
自己記述形式は、HTTPのContent-Lengthのように形式内に長さや終端の情報を持たせることでフレーミングを実現する
独自プロトコルを作るなら、本文に任意のバイナリを含められ、受信側の実装が明確で、最大サイズ制限も入れやすい長さプレフィックス方式がまず検討候補になる
Socket.NoDelay(Nagleアルゴリズムの無効化)は送信の遅延・効率の調整であり、メッセージ境界を保存する設定ではないため、フレーミングの代わりにはならない
DataAvailableはその瞬間にローカル受信バッファに読み取り可能なデータがあるかを示すだけで、アプリケーション上の1メッセージが完了したことを意味しない
TLSレコードは内部的な伝送単位であり、アプリケーションメッセージの境界ではないため、TLS化してもアプリケーション層でのフレーミング設計は必要になる
同じTCP接続に複数タスクが同時にフレームを書き込むと、ヘッダーと本文が混ざるアプリケーションレベルの混線が起き、受信側のフレーム復元が壊れる
Read/Receiveの戻り値のバイト数だけを信用し、必要なバイト数を読み切るまでループすることで、途中で切れた受信を1メッセージとして誤処理しない
ReadAsync/WriteAsyncのような非同期I/Oを使う場合でも、要求サイズに届くまで読み書きを繰り返すという考え方は同じである
Follow > TCP Streamで復元したバイト列を見て、送信側のフレーム組み立てと受信側の復元処理のどちらが壊れているかを切り分けられる
Windows標準のpktmonでキャプチャしたETLをpcapngに変換し、Wiresharkで開いて同様に確認できる
4バイトヘッダーも本文も分割され得るため、ヘッダーと本文それぞれを読み切るまでのループが前提になる
固定長方式でも、1回のReadAsyncで指定バイト数がすべて返るとは限らないため、読み切るまでのループが必要になる
改行が来るまでバイトを蓄積し、1行分そろってからデコードするなど、区切りが確認できるまで読み続ける実装が必要になる
Windowsでループバック(127.0.0.1)を見るにはNpcapが必要になる