コンピューターサイエンス:アルゴリズム / OS / ネットワーク / DB
イベントループと epoll
1 万本の接続のうち、どれに返事が来たのか
前回、1 スレッドのまま数万件を抱えられる、と書きました。ではその 1 スレッドは、どの接続に返事が来たのかをどうやって知るのでしょうか。
普通の read は、データが届くまでその場で待ちます。接続 1 番に対して read を呼んだらそこで止まってしまい、たまたま先にデータが届いた 5,000 番には手が回りません。1 スレッドで多数を見張るには、「今読める接続はどれか」をまとめて教えてもらう別の仕組みが要ります。
全員に聞いて回る方式は、数が増えると潰れる
古くからあるのが select と poll です。監視したい接続の一覧をカーネルへ渡し、この中で読めるようになったものはあるかと尋ねます。
素直な作りですが、呼ぶたびに一覧を丸ごと渡し、カーネルは全部を確認し、戻ってきたアプリ側も全部を走査してどれが立ったのかを調べます。接続が 10 個なら誰も気にしません。10 万個になると、実際に届いたのが 100 件でも、毎回 10 万件を確認することになります。1990 年代末に同時 1 万接続が壁として語られた C10K 問題は、ここが原因でした。
先に登録しておいて、起きた分だけ受け取る
Linux の epoll は順序を入れ替えます。監視したい接続は最初に 1 回だけ登録し、以降は「何か起きたものだけ」を受け取ります。
c
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev); // 登録は接続ごとに 1 回だけ
while (1) {
int n = epoll_wait(epfd, events, 100, -1); // 起きたものだけ n 件返る
for (int i = 0; i < n; i++)
handle(events[i].data.fd);
}10 万接続のうち動いているのが 100 件なら、ループは 100 回しか回りません。1 周の重さが監視数ではなく発生件数に比例するので、接続をいくら増やしても重くなりません。
同じ発想の仕組みは各 OS にあり、macOS と BSD では kqueue、Windows では IOCP と呼ばれます。前回の asyncio も、Node.js も nginx も、内側ではこの登録と受け取りを回し続けているだけです。
1 周が長くなると、全員が待たされる
この構造は、ループを止めずに回し続けられることが前提です。取り出した 1 件の処理に 500 ミリ秒かかると、その間は次の受け取りに戻れず、残りの接続は放置されます。
だからイベントループの上には、重い計算も、返るまで待つ形のライブラリ呼び出しも置けません。前回の「別スレッドへ逃がす」がここでも効いてきます。1 件あたりの処理が短いことがそのまま全体のスループットになる、という制約と引き換えに、1 スレッドで 1 万接続が成り立っています。