Skip to content

コラム: select(2) の I/O 多重化

mini_serv の心臓部である select(2)。「使えるけど、よくわからないまま動いている」状態から脱するために、API の意味論と、なぜそれで複数クライアントが捌けるのかを言語化します。

1. select がやっていること

select「N 個の fd のうち、どれかが I/O 可能になるまでブロックする」 syscall です。

c
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 は毎回:

c
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 の対応:

c
if (select(maxfd + 1, &readfds, &writefds, NULL, NULL) < 0)
    continue;             // EINTR 等は単に再試行

これだけでよい (失敗の意味を細かく分けない) のは、ここでは使える関数が絞られていて errno を直接読まない縛りがあるためです。本来は:

c
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 を出しました:

APIOS仕組み計算量
selectPOSIX毎回 fd 集合を全部渡すO(N)
pollPOSIXpollfd 配列を毎回渡す。FD_SETSIZE 制約なし。O(N)
epollLinuxカーネル側に「監視対象」を登録しておき、変化のあった fd だけ通知O(変化数)
kqueueBSD / macOSepoll とほぼ同等。fd 以外のイベント (タイマ・シグナル) も統一 APIO(変化数)
io_uringLinux 5.1+システムコール自体を非同期キューにO(変化数)

mini_serv が select で良いのは、想定接続数が 1024 未満で、レイテンシ要件もシビアでないからです。本番チャットなら epollkqueue、最近なら io_uring を選ぶでしょう。

5. readfdswritefds を両方使う理由

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 に乗り換える。