コラム: Lazy Client 問題
このサーバには 「メッセージをまったく読みに来ない人がいても、サーバは止まらないこと」 という、ちょっと意地悪な要件があります。受信トレイを開かない同僚に Slack の通知を全部溜め込まれつつも、他のチームメイトには普通にメッセージを届け続けないといけない、という設定です。一見小さな注釈ですが、ネットワーク・サーバ設計の本質的な落とし穴を突いています。
1. 何が「lazy」なのか
サーバが send(fd, buf, len, 0) を呼んだとき、
- クライアント側の TCP 受信バッファ にコピーされる
- クライアントが
recvで読み取って受信バッファを空ける
通常はこの 1 → 2 がすぐ起こる。でも 2 をやらないクライアント (フリーズした、recv を呼んでない、ネットワークが遅い) がいると、受信バッファがどんどん溜まり、TCP のフロー制御 が働いて、最終的にサーバ側の send が ブロック します。
ブロッキング send の動作:
| 状態 | send の挙動 |
|---|---|
| 受信バッファに余裕あり | 即座にコピーしてリターン |
| 受信バッファ満杯 | 空くまでブロック (秒〜無限) |
サーバが 1 つの lazy client への send で止まると、他の全クライアントへの応答も止まる。これがまさに「サーバを巻き込む」状態。
2. mini_serv の対策: writefds で事前判定
select の writefds (= 書き込み可な fd 集合) を使い、「今 send してもブロックしない fd」だけ に書きに行きます:
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 人が抜けただけでサーバが落ちる のは最悪。
通常の対策:
signal(SIGPIPE, SIG_IGN); // プロセスとしては無視
// send は -1 / EPIPE を返すだけになるただし mini_serv では使える関数のリストに signal が入っていません。なので別の戦略でカバーしています:
recvが0を返したら、その fd は切断検知できる →disconnect_clientで集合から外す。disconnect_clientの前にsend_allで他人に通知する間、writefdsのチェックがあるのでブロックは起きにくい。- 万一
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) には別途タイムアウト設計が必要。