Skip to content

コラム: Lazy Client 問題

このサーバには 「メッセージをまったく読みに来ない人がいても、サーバは止まらないこと」 という、ちょっと意地悪な要件があります。受信トレイを開かない同僚に Slack の通知を全部溜め込まれつつも、他のチームメイトには普通にメッセージを届け続けないといけない、という設定です。一見小さな注釈ですが、ネットワーク・サーバ設計の本質的な落とし穴を突いています。

1. 何が「lazy」なのか

サーバが send(fd, buf, len, 0) を呼んだとき、

  1. クライアント側の TCP 受信バッファ にコピーされる
  2. クライアントが recv で読み取って受信バッファを空ける

通常はこの 1 → 2 がすぐ起こる。でも 2 をやらないクライアント (フリーズした、recv を呼んでない、ネットワークが遅い) がいると、受信バッファがどんどん溜まり、TCP のフロー制御 が働いて、最終的にサーバ側の sendブロック します。

ブロッキング send の動作:

状態send の挙動
受信バッファに余裕あり即座にコピーしてリターン
受信バッファ満杯空くまでブロック (秒〜無限)

サーバが 1 つの lazy client への send で止まると、他の全クライアントへの応答も止まる。これがまさに「サーバを巻き込む」状態。

2. mini_serv の対策: writefds で事前判定

selectwritefds (= 書き込み可な fd 集合) を使い、「今 send してもブロックしない fd」だけ に書きに行きます:

c
static void send_all(int except) {
    int len = strlen(g_buf_write);
    for (int fd = 0; fd <= g_maxfd; fd++) {
        if (fd == g_listen_fd || fd == except) continue;
        if (FD_ISSET(fd, &g_writefds))           // ← lazy client はここで弾かれる
            send(fd, g_buf_write, len, 0);
    }
}

writefds に立っていない fd は このイテレーションでスキップ。次回の select で、その fd の受信バッファが空けば再びレディになります。

副作用として、lazy client は メッセージを取り損ねます (メッセージはバッファに保存されない)。本物のチャットサーバなら「fd 毎の出力キュー」を持って EWOULDBLOCK で再送するのが筋ですが、mini_serv のスコープ外。

3. SIGPIPE とどう向き合うか

クライアントが切断した直後の fd に send すると、TCP は ACK 代わりに RST を返し、サーバ側に SIGPIPE が飛んできます。デフォルトでは SIGPIPE はプロセスごと終了させる致命的シグナル。1 人が抜けただけでサーバが落ちる のは最悪。

通常の対策:

c
signal(SIGPIPE, SIG_IGN);    // プロセスとしては無視
                             // send は -1 / EPIPE を返すだけになる

ただし mini_serv では使える関数のリストに signal が入っていません。なので別の戦略でカバーしています:

  1. recv0 を返したら、その fd は切断検知できる → disconnect_client で集合から外す。
  2. disconnect_client の前に send_all で他人に通知する間、writefds のチェックがあるのでブロックは起きにくい。
  3. 万一 send が EPIPE になっても、サーバ全体には影響しない (writefds の判定でほとんど来ないし、戻り値を無視)。

完璧ではないけれど、この実装の制約下では妥当な落としどころ。実プロダクトでは必ず signal(SIGPIPE, SIG_IGN) を入れる べきです。

4. ノンブロッキング fd (O_NONBLOCK) との比較

別解として、全 fd を fcntl(fd, F_SETFL, O_NONBLOCK) でノンブロッキングにする方法があります。すると:

  • send は満杯なら -1 / EWOULDBLOCK で戻る (ブロックしない)
  • アプリ側で「送れなかった分」を一旦キューに積んで、writefds がレディになった時点で再送

これが本物の event-driven サーバの定石 (libevent / nginx / Node.js)。writefds チェックは「最初のヒント」になり、EWOULDBLOCK 経由のリトライがフォールバックになります。

mini_serv では fcntl が許可されていないので、writefds だけで近似する形になっています。

5. 似て非なる問題: slow-loris

「lazy client」は受信が遅い人ですが、似た攻撃に slow-loris という「送信が遅い (\n を永遠に送らない)」クライアントが居ます。

名称何が遅いサーバへの影響
Lazy client受信が遅い該当 fd への send がブロック
Slow-loris送信が遅い該当 fd の蓄積バッファが伸びる

mini_serv では「リクエストはせいぜい 1000 文字以内」という前提があるので、slow-loris のリスクは小さめ。本番なら アイドル fd の自動切断 (select の timeout) を入れます。

まとめ

  • send は受信側が満杯だとブロックする。1 人が止まると全員が止まる。
  • mini_serv は writefds チェック で「今 send してブロックしない fd」だけに書く。
  • SIGPIPE は本来 SIG_IGN で無視するのが定石 (mini_serv は使える関数の制約で無いまま動かす)。
  • 真の event-driven 化は O_NONBLOCK + 出力キュー + EWOULDBLOCK リトライ。
  • 「送信が遅い」攻撃 (slow-loris) には別途タイムアウト設計が必要。