Twisted vs Tornado vs Go で非同期Webサーバー対決
昨日の takada-at の記事で「サーバー側では単純に100ms待ってからレスポンスを返すだけのページを用意しておき、」とあったのですが、今日はそのサーバー側の話をします。
もともとこのサーバーを作った動機は、takada-at が作成中の負荷試験システムがちゃんと並列に負荷をかけられるかどうかを検証するためでした。 すぐにレスポンスを返してしまうと、負荷試験スクリプトがきちんと並列に負荷をかけられなくても PV/sec が出てしまいます。 そこで、 epoll を使って高速に並列接続を扱えるTwistedフレームワークを使って、100msの遅延をしつつ数千PV/secに耐えるWebサーバーを作ってみました。 さらに、同じく epoll を使っている Tornado や Go にも興味があったので、こちらでも同じものを作成し、パフォーマンスを比較してみました。
コード
まずは、コードを見てみましょう。
Twisted Python:
from twisted.internet import epollreactor epollreactor.install() from twisted.web import server, resource from twisted.internet import reactor class Lazy(resource.Resource): isLeaf = True def __init__(self, delay=0.1): self._delay = delay def render_GET(self, request): def after(): request.write('Hello, World!') request.finish() reactor.callLater(self._delay, after) return server.NOT_DONE_YET if __name__ == '__main__': import sys delay = 0.1 try: delay = float(sys.argv[1]) / 1000 except Exception: pass site = server.Site(Lazy(delay)) reactor.listenTCP(8000, site) reactor.run()
次は、Tornadoです。
from tornado import httpserver, ioloop, web from time import time DELAY = 0.1 class MainHandler(web.RequestHandler): @web.asynchronous def get(self): def _hello(): self.write("Hello, World!") self.finish() ioloop.IOLoop.instance().add_timeout(time() + DELAY, _hello) application = web.Application([ (r"/", MainHandler), ]) if __name__ == "__main__": import sys try: DELAY = float(sys.argv[1]) / 1000 except Exception: pass http_server = httpserver.HTTPServer(application) http_server.listen(8001) ioloop.IOLoop.instance().start()
最後に、Goです。
package main import ( "flag" "http" "io" "log" "time" ) var addr = flag.String("addr", ":9000", "http service address") var delay = flag.Int64("delay", 100, "response delay[ms]") func main() { flag.Int64Var(delay, "d", 100, "response delay[ms]") flag.Parse() http.Handle("/", http.HandlerFunc(hello)) err := http.ListenAndServe(*addr, nil) if err != nil { log.Exit("ListenAndServe:", err) } } func hello(c *http.Conn, req *http.Request) { time.Sleep(int64(1000000) * int64(*delay)) io.WriteString(c, "Hello, World!\n") }
TwistedやTornadoは、ループに後で呼び出す関数を登録していていて明らかにイベントドリブンフレームワークという感じがします。 それに対してGoは Sleep() を使っていて、一見普通のスレッドべースの並列処理に見えます。 しかし、Goはネイティブスレッドではなくgoroutineという軽量なユーザーランドスレッドを利用しており、そのスケジューラは(Linuxの場合)epollを利用しています。通常のスレッドを使うよりも効率的に動作するはずです。
ベンチマーク
それでは、この3つにそれぞれ負荷をかけてみましょう。といっても、100msの遅延だと負荷をかけるのが大変なので、10msの遅延で実行し、ab -c100 -n10000で実験してみます。
Twisted: Requests per second: 2620.31 [#/sec] (mean) Time per request: 38.163 [ms] (mean) Time per request: 0.382 [ms] (mean, across all concurrent requests) Percentage of the requests served within a certain time (ms) 50% 31 66% 32 75% 33 80% 37 90% 38 95% 38 98% 40 99% 44 100% 3082 (longest request) Tornado: Requests per second: 5299.04 [#/sec] (mean) Time per request: 18.871 [ms] (mean) Time per request: 0.189 [ms] (mean, across all concurrent requests) Percentage of the requests served within a certain time (ms) 50% 19 66% 19 75% 19 80% 19 90% 19 95% 19 98% 21 99% 22 100% 33 (longest request) Go: Requests per second: 4712.63 [#/sec] (mean) Time per request: 21.220 [ms] (mean) Time per request: 0.212 [ms] (mean, across all concurrent requests) Percentage of the requests served within a certain time (ms) 50% 19 66% 20 75% 20 80% 21 90% 32 95% 40 98% 42 99% 42 100% 48 (longest request)
TornadoやGoに比べると、Twistedが残念な結果になっています。 これは、Twistedが比較的汎用的で高機能なフレームワークということもありますが、遅延実行する関数をリストに格納していて、実行する関数があるかどうかをリストを全部チェックしているのが大きそうです。(Tornadoはソート済みリストを利用していてます)
Goも、コンパイラ型なのにTornadoに負けてるのが残念ですが、epollから直接イベントドリブンしているTornadoに比べると goroutine という抽象化レイヤを1枚はさんでいるので仕方ないかもしれません。
さらに高速化
まずはTornadoのJITによる高速化に挑戦してみます。PyPy-1.2が一番よさそうですが、セットアップが面倒なのでお手軽なpsycoを使ってみました。もとのソースコードに、次の2行を追加します。
import psyco psyco.full()
結果はこうなりました。
Requests per second: 7503.16 [#/sec] (mean) Time per request: 13.328 [ms] (mean) Time per request: 0.133 [ms] (mean, across all concurrent requests) Percentage of the requests served within a certain time (ms) 50% 13 66% 13 75% 14 80% 14 90% 14 95% 14 98% 15 99% 15 100% 23 (longest request)
5299req/sec => 7503req/sec で、20%以上高速化できました。topで見ている限り、メモリ使用量(RES)も1MB以下です。Tornado+psycoは非常に優秀ですね。
次に、Goでマルチスレッド化を試してみます。goroutineは複数のネイティブスレッドを利用して並列に動作することができます。しかし、現時点ではまだスケジューラがよくないらしく、デフォルトではマルチスレッドを使わないようになっています。環境変数 GOMAXPROCS に並列数を指定して実験してみました。
GOMAXPROCS=6 の場合: Requests per second: 5760.33 [#/sec] (mean) Time per request: 17.360 [ms] (mean) Time per request: 0.174 [ms] (mean, across all concurrent requests) Percentage of the requests served within a certain time (ms) 50% 16 66% 17 75% 17 80% 18 90% 28 95% 33 98% 37 99% 40 100% 53 (longest request)
実験マシンはCore2 Duoで2コアしか載っていない(Hyper Threadingもなし)なのですが、2を指定してもあまり性能が上がらず、そこから1つずつ数字を上げていったところ6までは少しずつ性能が向上していきました。しかも、topを見ている限りCPU使用率は最大の200%には全く届かず120%以下しか利用できていません。 CPUを原因は判りませんが、今回のようなイベントループをヘビーに使う用途にはスケジューラが最適化されておらず、もっとサーバー側でCPUを利用する計算を行う処理をしないとマルチコアを使いきれないのかもしれません。
Goは今回のケースではTornadoに負けてしまいましたが、IO多重化をイベントドリブンよりシンプルに記述でき、コンパイル型言語なので複雑な計算をPythonより圧倒的に高速に計算できそうなので、非常に期待しています。