コラム: メッセージ・フレーミング
「TCP は recv を呼べば 1 メッセージ届く」 — これが多くのバグの根源です。TCP はバイトストリームしか保証しません。1 メッセージの境界は アプリケーション層 で決める必要があります。
このコラムでは、フレーミング (framing) の 3 大手法を比較し、なぜ mini_serv が \n 区切りを採ったのかを整理します。
1. 何が起こるのか — TCP のバイトストリーム性
クライアントが send(fd, "hello\nworld\n", 12, 0) と一気に送っても、サーバ側の recv は:
| パターン | recv 結果 |
|---|---|
| パターン A | "hello\nworld\n" (12 バイト) ← 一発で全部届く |
| パターン B | "hello\n" (6 バイト) → "world\n" (6 バイト) ← 行毎に分かれる |
| パターン C | "hel" (3) → "lo\nw" (4) → "orld\n" (5) ← 任意の位置で切れる |
| パターン D | "hi\n" + 別の人の "hello\nworld\n" ← (これは別 fd なので mini_serv では混ざらない) |
ネットワークの状態 (MSS / Nagle / TCP セグメント分割) や OS のバッファリング次第で、A〜C のどれにでもなります。呼び出し側は受信タイミングと境界をコントロールできません。
2. フレーミングの 3 大手法
| 方式 | 例 | 長所 | 短所 |
|---|---|---|---|
| デリミタ (区切り文字) | hello\n | 実装が薄い、人間が読める | 区切り文字を本文に含められない |
| 長さプレフィックス | [uint32 = 5]hello | 任意のバイト列を運べる、サイズが事前にわかる | バイナリでデバッグしにくい、エンディアン注意 |
| 固定長メッセージ | [64 byte で必ず固定] | パースが単純、メモリ確保も静的可 | 可変長を扱えない、無駄なパディング |
2.1 デリミタ方式 (mini_serv の選択)
hello\n
world\nrecv で来たバイト列を fd 毎のバッファに積み、\n を見つけるたびに 1 行を切り出す。実装は短くて済む:
while (extract_message(&g_msgs[fd], &msg)) {
/* 1 行届いた */
}代表的なプロトコル: SMTP / HTTP/1.x ヘッダ / IRC / Redis Inline / mini_serv。
2.2 長さプレフィックス方式
最初に「次に来るバイト数」を送る:
[5]hello
[5]worldサーバ側のロジック:
1. 4 バイト読む → 長さ N が判明
2. N バイト揃うまで読む
3. 1 メッセージ完成任意のバイト列 (改行を含む文字列、画像のチャンク、等) を運べるのが圧倒的な強み。代表例: HTTP/2 (フレーム形式)、Redis RESP の Bulk String、gRPC、ほぼ全てのバイナリ系。
2.3 固定長方式
「全メッセージは 64 バイト」など、サイズを 1 つに固定する。古い金融ネット (FIX 以前)、軽量センサ通信、暗号通信のチャンク等で使われる。一般用途では柔軟性に欠ける。
3. mini_serv のバッファリング実装
mini_serv は per-fd のバッファ g_msgs[fd] を \n が来るまで成長させ、見つけたら 1 行ずつ切り出します。
static int extract_message(char **buf, char **msg) {
*msg = NULL;
if (!*buf) return 0;
for (int i = 0; (*buf)[i]; i++) {
if ((*buf)[i] == '\n') {
char *newbuf = calloc(strlen(*buf + i + 1) + 1, sizeof(char));
if (!newbuf) fatal();
strcpy(newbuf, *buf + i + 1); // '\n' の次以降を新バッファに
*msg = *buf;
(*msg)[i + 1] = '\0'; // 旧バッファを '\n' まででカット
*buf = newbuf; // 残りを今後のバッファに
return 1;
}
}
return 0;
}ポイント:
\nを含めて切り出す ので、ブロードキャスト時に改めて改行を足す必要がない (sprintf("client %d: %s", id, msg))。- 改行が見つからなければバッファを成長させてリターン。次回の
recvを待つ。 - 1 回の
recvに複数行が含まれていても、while (extract_message(...))ループで順に取り出せる。
4. デリミタ方式の落とし穴
本文に区切り文字を入れたい
「メッセージに \n を含めたい」と言われた瞬間に詰みます。エスケープ (\\n) するか、引用符でくくるか、最終的には長さプレフィックスに移行する以外にない。
行が無限に伸びる (DoS)
\n を送らずに 'A' を 1GB 流し続けるクライアントが現れたら、g_msgs[fd] が 1GB に成長してプロセスがメモリを食い潰します。本番なら 「1 行の最大長を決めて超えたら切断」 というガードが要ります。ここでは「リクエストはせいぜい 1000 文字以内」という前提があるので mini_serv は省略しています。
ASCII 互換でないデータが来たら
UTF-8 (multibyte) は \n (0x0A) と衝突しないため通常は問題ありませんが、UTF-16 や生バイナリだと \n を本文にバラ撒く可能性があります。「テキスト前提」のデリミタ方式は テキストにしか使えない と割り切ります。
まとめ
- TCP は バイトストリーム。
recv1 回 = 1 メッセージは保証されない。 - メッセージの境界はアプリ層で決める = フレーミング。
- 主要 3 方式: デリミタ / 長さプレフィックス / 固定長。
- mini_serv は
\nデリミタ。実装が薄く、telnetでデバッグしやすい。 - 本格運用では「行の最大長」「DoS タイムアウト」「バイナリを入れたいなら長さプレフィックスへ移行」を順に検討する。