Skip to content

コラム: メッセージ・フレーミング

「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\n

recv で来たバイト列を fd 毎のバッファに積み、\n を見つけるたびに 1 行を切り出す。実装は短くて済む:

c
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 行ずつ切り出します。

c
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 は バイトストリームrecv 1 回 = 1 メッセージは保証されない。
  • メッセージの境界はアプリ層で決める = フレーミング。
  • 主要 3 方式: デリミタ / 長さプレフィックス / 固定長
  • mini_serv は \n デリミタ。実装が薄く、telnet でデバッグしやすい。
  • 本格運用では「行の最大長」「DoS タイムアウト」「バイナリを入れたいなら長さプレフィックスへ移行」を順に検討する。