epoll 化にあたって stone 2.3 のコードの全面的な見直しを 行っていたところ、性能低下を引き起こす問題点を発見。
stone 2.3 において、TCP 接続を accept し、 中継先へ connect する部分の構造は、 次のようになっている(説明のため大幅に単純化している)。
fd_set rin; main() { .... for (;;) repeater(); } void repeater(void) { .... rout = rin; ret = select(FD_SETSIZE, &rout, &wout, &eout, timeout); .... if (FD_ISSET(stone->sd, &rout)) { FD_CLR(stone->sd, &rin); .... ASYNC(asyncAccept, stone); } } void asyncAccept(Stone *stone) { .... nsd = accept(stonep->sd, from, &fromlen); FD_SET(stonep->sd, &rin); .... }
つまり、select(2) によって listen しているポート (stone->sd) が accept 可能であることが判明すると、 まず rin の該当ビットをクリア(FD_CLR)し、 accept 処理を行う子スレッドを立てる(asyncAccept)。 このスレッドは accept(2) を呼び出し、 rin の該当ビットを再セット(FD_SET)する。
ところが、子スレッドと親スレッドのどちらが先に処理されるか、 という問題がある。 子スレッドが先に実行されるなら、 rin の該当ビットが再セットされた後、 親スレッドの select(2) が実行されるので問題は起きないが、 普通のスレッド実装では、 親スレッドの実行が優先されるようだ。
つまり、親スレッドの処理が先に実行されると、 rin の該当ビットがクリアされたままで、 親スレッドが select に突入してしまう。 親スレッドが select 待ちになってから、 子スレッドが rin を再セットするが、 時すでに遅し、 親スレッドが呼び出した select では、 listen しているポート (stone->sd) は 監視しない状態になってしまっている。
このため、この select が timeout して、 次回の select 待ちに入るまで、 接続を受け付けない状態が続いてしまう。 timeout は 0.1秒なので、 秒あたり高々10回の接続しか受け付けられなくなってしまう。
この問題を回避するには、 子スレッドで accept するのではなく、 親スレッドで accept すればよい。 この場合、rin の該当ビットをクリアする必要もなくなる。
で、なぜ epoll 化したことによって高速化されたかというと、 epoll の場合、rin に相当するものがカーネル空間にあるわけで、 子スレッドで EPOLL_CTL_MOD (FD_SET に相当) すると、 即それが epoll_wait 待ち (select 待ちに相当) している 親スレッドに反映するためだろう。