コラム: select(2) の I/O 多重化
mini_serv の心臓部である select(2)。「使えるけど、よくわからないまま動いている」状態から脱するために、API の意味論と、なぜそれで複数クライアントが捌けるのかを言語化します。
1. select がやっていること
select は 「N 個の fd のうち、どれかが I/O 可能になるまでブロックする」 syscall です。
int select(int nfds,
fd_set *readfds, // 「読める」を待ちたい fd 集合
fd_set *writefds, // 「書ける」を待ちたい fd 集合
fd_set *exceptfds, // 通常 NULL
struct timeval *timeout);- 戻り値
> 0: その数だけ準備のできた fd がある (どれかはFD_ISSETで調べる) - 戻り値
0: タイムアウト - 戻り値
< 0: エラー (EINTRならシグナルで起こされた)
重要: select は引数の fd_set を 書き換えます。呼び出し前は「監視したい集合」、呼び出し後は「準備のできた fd の集合」になります。だから mini_serv は毎回:
readfds = writefds = activefds;
select(...);と マスタ集合 activefds から複製してから渡す わけです。これを忘れると 2 回目以降の select が壊れます。
2. nfds = max_fd + 1 の意味
select の第 1 引数は 「監視したい fd の最大値 + 1」。これはカーネル側のループ上限を伝えるための情報で、
for (i = 0; i < nfds; i++) {
if (FD_ISSET(i, &readfds)) check(i);
...
}のような走査が内部で走ります。max_fd を 0 にすると nfds = 1 になり listen_fd 0 番だけ見る、というわけ。max_fd を雑に大きく取ると毎回無駄なビット走査が走るので、「現状アクティブな最大 fd」を正確に追う のが効率的です。
mini_serv が g_maxfd を大事に保持しているのはこのため。
3. EINTR で抜けたら何をすべきか
select 中に シグナルが来る と、-1 / EINTR で戻ります。たとえば SIGPIPE / SIGCHLD / SIGINT などが該当。
mini_serv の対応:
if (select(maxfd + 1, &readfds, &writefds, NULL, NULL) < 0)
continue; // EINTR 等は単に再試行これだけでよい (失敗の意味を細かく分けない) のは、ここでは使える関数が絞られていて errno を直接読まない縛りがあるためです。本来は:
if (select(...) < 0) {
if (errno == EINTR) continue;
fatal();
}と書きたいところ。
4. select の限界 — C10K 問題
select には決定的な制約が 2 つあります。
FD_SETSIZE : fd_set のビット幅は固定 (Linux glibc では 1024)。それ以上の fd を扱おうとすると未定義動作。
走査コストが O(N) : 毎回 0..max_fd まで全部走査する。クライアントが 10000 人いたら、1 回の select 呼び出しごとに 10000 ビットを舐める。
これが有名な 「C10K 問題」 (1 万接続の壁) の根っこ。21 世紀初頭、これを越えるために各 OS が独自 API を出しました:
| API | OS | 仕組み | 計算量 |
|---|---|---|---|
select | POSIX | 毎回 fd 集合を全部渡す | O(N) |
poll | POSIX | pollfd 配列を毎回渡す。FD_SETSIZE 制約なし。 | O(N) |
epoll | Linux | カーネル側に「監視対象」を登録しておき、変化のあった fd だけ通知 | O(変化数) |
kqueue | BSD / macOS | epoll とほぼ同等。fd 以外のイベント (タイマ・シグナル) も統一 API | O(変化数) |
io_uring | Linux 5.1+ | システムコール自体を非同期キューに | O(変化数) |
mini_serv が select で良いのは、想定接続数が 1024 未満で、レイテンシ要件もシビアでないからです。本番チャットなら epoll か kqueue、最近なら io_uring を選ぶでしょう。
5. readfds と writefds を両方使う理由
readfds は理解しやすい (新規接続 / クライアントからの受信を待つ) ですが、writefds は何のため?
→ 「相手が受信バッファを空けてくれているか」を判定するため。
send(fd, buf, len, 0) は、相手の TCP 受信バッファが満杯だとブロックします (デフォルトのブロッキング fd の場合)。1 人がフリーズしたら全員が止まる。これを避けるため、FD_ISSET(fd, &writefds) (= カーネルが「今 send しても多分すぐ返るよ」と保証してくれている) の fd にだけ send する のが mini_serv の lazy-client 戦略です。
詳細は Lazy client 問題 で。
まとめ
selectは 「複数 fd のうちどれかが I/O 可能になるまでブロック」 する syscall。- 呼び出し前後で
fd_setが 書き換わる。マスタ集合は別に保持して毎回コピーする。 nfds = max_fd + 1。最大 fd を正確に追うのが効率の鍵。EINTRは単に再試行する。FD_SETSIZE = 1024、走査が O(N) — これが C10K 問題。- 大規模化したら
epoll/kqueue/io_uringに乗り換える。