Edited at

TCPサーバーの非同期処理はけっきょくどの手法がいいのか?

More than 3 years have passed since last update.

昔Unix系システムではselect()でIO処理を非同期にして、ファイルディスクリプタの状態に変化があった場合のみ処理を行うことができた。これはメモリやサーバーリソースが極端に少なかった時代に有効な手法だった(らしい)。90年代後半、00年代前半にはマルチスレッドが主流になり、サーバーサイドではAcceptして受け入れたクライアントのソケットをスレッドに渡して送受信を行うことでコネクションの多重化を行った。(もちろん目的に応じて非同期も活躍していた)

00年代後半からC10K問題が騒がれ、node.jsやnginxが登場した。大量のスレッドを生成してリソースやメモリを消費する方法よりも、再びソケットをノンブロッキングにしてディスクリプタの状態を監視する非同期処理が注目されるようになる。そして最近ではjavascript promise、RX、Reactive ExtensionなどクライアントサイドのUIに近い場所で非同期処理を効率的に記述できるライブラリが登場している。

ただし、サーバーサイドで非同期処理を考えた場合に、シングルスレッドで非同期IOを処理するだけではマルチコア、マルチCPUをあまり活かせない。なので、Accept()で受けた後にソケットのレディ状態を非同期で待ち、ソケットがレディ状態になったらスレッドで実際の送受信を行う手法もある。非同期処理自体もselect()、poll、epollなど複数の手法がある。また、処理の多重化にはスレッドを使う方法とプロセスをforkする方法がある。さらにあらかじめスレッドやプロセスを生成しておいて、それらにデータを受け渡す手法もある。

手法
メリット
デメリット

select
メモリやリソース消費が抑えられる
扱えるディスクリプタに制限がある
一つのソケットの送受信処理の間に他のソケットを扱えない

poll
メモリやリソース消費が抑えられる
ディスクリプタ制限なし
一つのソケットの送受信処理の間に他のソケットを扱えない

EPOLL
pollの高速化版
ディスクリプタ制限なし
一つのソケットの送受信処理の間に他のソケットを扱えない

fork
処理待ちソケットがあまり発生しない
プロセス生成コストが大きい

thread
処理待ちソケットがあまり発生しない
スレッドの生成コストとコネクション分のスタック領域を消費する

pre-fork
処理待ちソケットがあまり発生しない
プロセス生成コストが起動時しかない
データの受け渡しとロック処理、プロセスの管理の手間

pre-thread
処理待ちソケットがあまり発生しない
スレッド生成コストが起動時しかない
データの受け渡しとロック処理、スレッドの管理の手間

EPOLL+thread
メモリとリソース消費を抑えつつ、待ち時間を最大限に低くできる
スレッド生成コスト。データの受け渡しとスレッド管理の手間

EPOLL+pre thread
メモリとリソース消費を抑えつつ、待ち時間を最大限に低くできる
データの受け渡しとスレッド管理の手間がより複雑だが、うまく実装すれば完璧なパフォーマンスを出すはず

この辺りは『Linuxネットワークプログラミングバイブル』が詳しい。

個人的には「EPOLL+thread」が良いと思うが、上記の本に載っているベンチマークによると、接続時間が最も速いのは「EPOLL+thread」だが、送受信が最も速かったのは「fork」らしい。接続時間と合わせた総合的な順でいえば「pre-thread」が一番高速という結果になっていた。送受信に限って言えば、「pre-〜」も含めてforkやスレッドなど特別なことをするよりも普通に「poll」や「EPOLL」を使用した方が速いという結果になっている(本当かな??)。ちなみに「EPOLL+thread」は総合4位で「select」や「poll」や「EPOLL」よりも遅いと書かれているが、ちょっとこの結果も信じられない。

ベンチマークの内容は50回の送受信で接続する処理をさらに50回繰り返すスレッドを1000スレッド起動して、サーバーはディアルコアで、スレッド化やfork化するのはsend()の部分のみという実験らしい。

けっきょくスレッドやforkは事前生成してもデータの受け渡しや共有データのロックなどでパフォーマンスが悪くなるのだろうか。ディアルコアという点が影響している可能性もけっこうあると思う(書籍にもサーバーサイドのリソース監視はしてない単純比較と書いてある)。

「EPOLL+pre-thread」が紹介されていないので、おそらくマルチコア環境でスレッド数を適切に抑えればこれが最も速いかもしれない。自分で書くときは「EPOLL+pre-thread」でスレッド数を抑えてやろうと考えている。

ちなみにAccept自体をスレッドで複数処理するという方法も書籍には紹介されていたが、けっきょくロックしてどれか一つが受け口になるため、この方法の是非は自分にはわからない。