Thundering herd 対策の本命、 EPOLLEXCLUSIVE を試してみた
epoll を使った prefork 型アプリケーションサーバーにおける Thundering herd 対策の決定版として注目されていた EPOLLEXCLUSIVE が、 3/13 にリリースされた Linux 4.5 で導入されました。
昨年 SO_REUSEPORT というソケットオプションが登場して、 Thundering herd 対策として話題になったものの、ワーカーごとに listen キューが作られるため graceful restart するときに listen キューに入ってるリクエストを取りこぼす可能性があり利用するのが難しい状況でした。
参考: epoll の thundering herd 問題について解説しているサイト
- http://tech.geniee.co.jp/entry/so_reuseport
- http://uwsgi-docs.readthedocs.org/en/latest/articles/SerializingAccept.html
一方、 EPOLLEXCLUSIVE
は、 listen キューは一本のまま、その listen キューを待っている複数プロセスの epoll_wait()
を全部起こすのではなく、1つ以上(1つとは限らない)だけ起こすという機能です。
graceful shutdown 中は epoll_wait()
をしていないはずなので、 listen キューに来たリクエストは他のワーカープロセスの epoll_wait()
に通知され、取りこぼしがありません。
http://man7.org/linux/man-pages/man2/epoll_ctl.2.html
EPOLLEXCLUSIVE (since Linux 4.5) Sets an exclusive wakeup mode for the epoll file descriptor that is being attached to the target file descriptor, fd. When a wakeup event occurs and multiple epoll file descriptors are attached to the same target file using EPOLLEXCLUSIVE, one or more of the epoll file descriptors will receive an event with epoll_wait(2). The default in this scenario (when EPOLLEXCLUSIVE is not set) is for all epoll file descriptors to receive an event. EPOLLEXCLUSIVE is thus useful for avoiding thundering herd problems in certain scenarios.
使い方は、 EPOLL_CTL_ADD
するときに、 events で EPOLLIN
などと bitwise OR で組み合わせて利用します。
struct epoll_event ev = {EPOLLIN | EPOLLEXCLUSIVE, NULL}; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
環境準備
Ubuntu 16.04 LTS xenial xerus や Amazon Linux 2016.03 といった最新(あるいはもうすぐリリースされる)のディストリビューションでも、採用しているのは Linux 4.4 なので、そのままでは試すことができません。 (Fedora 24 は Linux 4.5 らしいですが、普段使わないので除外しました)
そこで、 Ubuntu 15.10 を元に、 https://wiki.ubuntu.com/KernelTeam/GitKernelBuild にある手順に従い Linux 4.5 をソースからビルドして再起動することにしました。 書いてある手順通りで問題ないのですが、私の場合 Ubuntu 15.10 の公式 AMI のデフォルトディスクサイズ (8GB) で容量が足らずに一度ビルドに失敗して32GBに拡張したので、同じくAWSを使われる方は注意してください。
カーネルのインストールが終わって再起動したら、Linux 4.5 カーネルを利用していることを確認します。
また、 <sys/epoll.h>
にはまだ EPOLLEXCLUSIVE
が定義されていないので、定数の値を確認しておきます。
$ uname -a Linux ip-10-0-1-221 4.5.0-custom #1 SMP Thu Mar 31 07:35:29 UTC 2016 x86_64 x86_64 x86_64 GNU/Linux ~/linux-4.5/include$ ag EPOLLEXC uapi/linux/eventpoll.h 30:#define EPOLLEXCLUSIVE (1 << 28)
EPOLLEXCLUSIVE を使った場合にどれくらい性能に影響がでるか、簡単なベンチマークをしたいので、 TIME_WAIT で詰まる問題を回避するおまじないをしておきます。
$ echo 'net.ipv4.tcp_tw_reuse = 1' | sudo tee -a /etc/sysctl.conf $ sudo sysctl -p net.ipv4.tcp_tw_reuse = 1
meinheld
meinheld は奥一穂さんの picoev を利用した Python アプリケーション用の HTTP サーバーです。
listen ソケット以外に、ワーカー間で共有しつつ epoll を利用する fd はないので、 EPOLL_CTL_ADD
しているところで無条件に EPOLLEXCLUSIVE
してしまいます。
diff --git a/meinheld/server/picoev_epoll.c b/meinheld/server/picoev_epoll.c index 06e1dbb..773cf3c 100644 --- a/meinheld/server/picoev_epoll.c +++ b/meinheld/server/picoev_epoll.c @@ -115,6 +115,7 @@ int picoev_update_events_internal(picoev_loop* _loop, int fd, int events) SET(EPOLL_CTL_MOD, 0); if (epoll_ret != 0) { assert(errno == ENOENT); + ev.events |= (1 << 28); // EPOLLEXCLUSIVE (from linux 4.5) SET(EPOLL_CTL_ADD, 1); } } @@ -124,7 +125,12 @@ int picoev_update_events_internal(picoev_loop* _loop, int fd, int events) if ((events & PICOEV_READWRITE) == 0) { SET(EPOLL_CTL_DEL, 1); } else { - SET(target->events == 0 ? EPOLL_CTL_ADD : EPOLL_CTL_MOD, 1); + if (target->events == 0) { + ev.events |= (1 << 28); // EPOLLEXCLUSIVE (from linux 4.5) + SET(EPOLL_CTL_ADD, 1); + } else { + SET(EPOLL_CTL_MOD, 1); + } } #endif
試験に使っていたのは AWS の c4.xlarge で、4コアなので、8ワーカー起動し、 "hello, world" を返すだけのアプリに ab で4並列で負荷をかけてみました。 (wrk ではなく ab を使ったのは、今回は keep-alive しない試験だったからです。)
結果は、改造前がだいたい 15k req/sec で、 改造後がだいたい 20k req/sec を超えるくらいでした。
uWSGI
Python だけでなくいろんな言語に対応している uWSGI というサーバーでも試してみました。
epoll に追加する関数が read と write で別れているので、accept で利用する read 側にだけ EPOLLEXCLUSIVE
を追加します。
diff --git a/core/event.c b/core/event.c index 36751a6..32a1934 100644 --- a/core/event.c +++ b/core/event.c @@ -514,7 +514,7 @@ int event_queue_add_fd_read(int eq, int fd) { struct epoll_event ee; memset(&ee, 0, sizeof(struct epoll_event)); - ee.events = EPOLLIN; + ee.events = EPOLLIN | (1 << 28); ee.data.fd = fd; if (epoll_ctl(eq, EPOLL_CTL_ADD, fd, &ee)) {
こちらもだいたい meinheld と同じような数値 (15k req/sec -> 20+k req/sec) で性能向上を確認できました。
また、1並列で ab を実行したときの accept の回数を、 strace で確認してみました。
ubuntu:~/app$ strace -f ~/.local/bin/uwsgi --master --workers=8 --http-socket :8080 --wsgi hello 2>trace.log ubuntu:~$ ab -c1 -n1000 http://localhost:8080/ # 別セッションから ubuntu:~/app$ grep -c '] accept4' trace.log # 改造前 6884 ubuntu:~/app$ grep -c '] accept4' trace.log # 改造後 1557
改造前は 6884 回ですが、これは 8 プロセスのうち accept に成功した 1 プロセスが、レスポンスを返してから epoll し直している間に、他のプロセスが次のリクエストを accept しているから、7000回弱になっているのでしょう。
一方改造後は 1557 回になっていて、リクエストの回数の約 1.5 倍になっています。 man に書いてあるとおり、必ずしも1つのイベントに対して1つの epoll だけが起こされるわけではなく、複数の epoll が起こされる場合があるのだと思います。
雑感
meinheld を strace していて気づいたのですが、 thundering herd 問題が起きている状況で multi accept (1度 epoll が起きたときに、 accept を1度だけでなく、エラーが出るまで複数回実行する) をすると、「起こされたのにすることがない」状況が発生しやすくなります。
keep-alive に対応しないシンプルなアプリケーション・サーバーなら、レスポンスを返したあと次にすることがなので、「自分(レスポンスを返したばかりのプロセス)が寝るか、他人(起こされたばかり)が寝るか」の違いでしかありません。自分で実行したほうが、コンテキストスイッチを待たずに処理を始められるので、ベンチマークスコアは上がると思います。 一方、 keep-alive に対応していたり、 nginx のようにいろんな処理を行う場合は、 multi accept を無効化した方が綺麗にコネクションを分散させることができて良いかもしれません。
あと、今回はかなり雑な感じに EPOLLEXCLUSIVE
対応したのですが、古いディストリビューションを使いつつもカーネルをアップグレードすることはよくあるので、 #ifdef EPOLLEXCLUSIVE
せずに、今回のような強引な方法で対応するのは意外と現実的な気がします。
実際、 xenial の標準のカーネル (Linux 4.4) で EPOLLEXCLUSIVE
を有効にしても、 epoll_ctl
がエラーを返すこともなく普通に動作しました。
@methane