人間とウェブの未来

「ウェブの歴史は人類の歴史の繰り返し」という観点から色々勉強しています。

HTTPリクエスト単位でmrubyのバイトコードをProcとFiberで包みなおして実行した場合の性能とv2について

ngx_mrubyのv2の4月リリースに向けて、HTTPリクエスト単位で実行されるRubyのコードを、FiberとProcで包んだオブジェクト経由で実行する実行方式に実装しなおしています。これまでのngx_mrubyのv1系は、Rubyのコードをnginx起動時にstruct RPocにコンパイルしておき、リクエスト毎にそのバイトコードを実行していました。

一方v2では、nginx起動時にコンパイルされたstruct RProcを、HTTPリクエスト時にprocオブジェクトに変換した上で、そのprocオブジェクトをcallする処理をfiberで包み、そのfiberオブジェクトをresumeする処理をさらにprocで包んで、procをcallで実行するようにしました。それをC側とRubyのコードを行き来しながらうまいことnginxとmruby間のイベントループの上に乗るようにします。今のところはCとRubyの世界のコンテキストを現状のmrubyでうまく行き来するために、こういった複雑な方式にしています。その理由については一旦省略します。

github.com

ちょっと何いってるかわからないと思いますので、それをRubyだけで擬似コードにすると、以下のような処理をやりたいわけです。

# p1がnginx.confに書くRubyコードのバイトコードをprocオブジェクトにしたものとする(C側)
p1 = Proc.new do 
  %w(hoge fuga foo).each do |s|
    puts s
    Fiber.yield
  end
end

# p1をcallする処理をfiberで包む(Ruby側)
f = Fiber.new do
  p1.call
end

# fiberをresumeする処理をprocで包む(Ruby側)
p2 = Proc.new do
  r = f.resume
  f.alive?
end

# C側のイベントループの中でp2をいい感じでcallする(C側側)
p p2.call
p p2.call
p p2.call
p p2.call

これを実行すると以下のようになるわけですね。

$ ./mruby/bin/mruby loop.rb
hoge
true
fuga
true
foo
true
false

ただ、この動きはCRubyでは動かないので、mrubyのみで実行できる処理になっています。(今issueで問い合わせ中)

実際に、この方式を利用して既存のハンドラを全てfiberで処理しつつ、nginxのタイマーとイベントループを活用することにより、ノンブロッキングのsleepを既に実装済みです。また、単一のコードの中で、ブロッキングのような書き方で何度でも実装できるようにもしています。ということで、まず簡単にノンブロッキング版sleepを紹介します。

ノンブロッキング版sleepの動き

以下のように従来のプロセスをブロックするsleepの動きと比較します。 そのために、nginxのworkerを1つで起動させるようにします。

従来のsleep

worker_processes  1;
events {
    worker_connections  200;
}

daemon off;
master_process off;

(snip)

location /async_sleep {
    mruby_rewrite_handler_code '
      sleep 3
      Nginx.rputs "body"
      Nginx.return Nginx::HTTP_OK
    ';
}

そして、ほぼ同時に3つリクエストを投げてみます。

[ubuntu@ubuntu-xenial:~/DEV/ngx_mruby]$ for i in `seq 1 3`; do time curl localhost:58080/async_sleep & done
[1] 25513
[2] 25515
[3] 25516
[ubuntu@ubuntu-xenial:~/DEV/ngx_mruby]$ body
real    0m3.018s
user    0m0.004s
sys     0m0.000s
body
real    0m6.020s
user    0m0.004s
sys     0m0.000s
body
real    0m9.023s
user    0m0.000s
sys     0m0.004s

[1]   Done                    time curl localhost:58080/async_sleep
[2]-  Done                    time curl localhost:58080/async_sleep
[3]+  Done                    time curl localhost:58080/async_sleep

すると、このようにngx_mrubyのv1では、ひとつのプロセスでsleepを実行すると、シーケンシャルにsleep実行し、その完了後に次のリクエストのsleepを実行しており、大量にアクセスが集中すると処理が待たされる問題がありました。現に3つめのリクエストは9秒待たされています。

async sleep

続いて、ノンブロッキングのsleepを見てみましょう。

worker_processes  1;
events {
    worker_connections  200;
}

daemon off;
master_process off;

(snip)

location /async_sleep {
    mruby_rewrite_handler_code '
      Nginx::Async.sleep 3000
      Nginx.rputs "body"
      Nginx.return Nginx::HTTP_OK
    ';
}

同様に3つのリクエストを投げてみます。

[ubuntu@ubuntu-xenial:~/DEV/ngx_mruby]$ for i in `seq 1 3`; do time curl localhost:58080/async_sleep & done
[1] 25436
[2] 25437
[3] 25439
[ubuntu@ubuntu-xenial:~/DEV/ngx_mruby]$ body
real    0m3.015s
user    0m0.004s
sys     0m0.000s
body
real    0m3.014s
user    0m0.004s
sys     0m0.000s
body
real    0m3.013s
user    0m0.004s
sys     0m0.000s

[1]   Done                    time curl localhost:58080/async_sleep
[2]-  Done                    time curl localhost:58080/async_sleep
[3]+  Done                    time curl localhost:58080/async_sleep

ノンブロッキング版のsleepでは、このようにプロセスが一つであるにも関わらず、リクエスト単位でsleepが他のリクエストを邪魔することなくnonblockingに実行されていることがわかります。それぞれのリクエストはブロックしながらも、他の処理の邪魔をしないような動きです。そのため、Rubyのコード上ではまるでブロッキングするような処理として記述することができます。

また、以下のようにNginx::Async.sleepを複数回使うことも可能です。

location /async_sleep_loop {
    mruby_rewrite_handler_code '
      5.times do |s|
        Nginx::Async.sleep 500
        Nginx.rputs s
      end     
      Nginx.return Nginx::HTTP_OK
    ';
}

リクエストを投げてみましょう。

$ time curl http://127.0.0.1:58080/async_sleep_loop &
[1] 3022
$ time curl http://127.0.0.1:58080/async_sleep_loop &
[2] 3024
$
01234
real    0m2.545s
user    0m0.004s
sys     0m0.000s
01234
real    0m2.513s
user    0m0.000s
sys     0m0.004s

上記のように、ブロッキングぽくコードは書けつつも、他のリクエストをブロックすることなくスリープできていますね。いい感じです。

FiberとProcでバイトコードをリクエスト単位で包む影響

で、本題はこのsleepの紹介ではなくて、実際にこういうFiberとProcを経由して実行する処理をHTTPリクエスト単位で行うと、どれぐらい性能が低下するかを測定してみたという話です。

そこで、v1系のstruct RProcを直接実行する処理と、v2でのHTTPリクエスト単位でFiberとProcで包んで実行する処理の性能比較を行いました。

検証環境

  • Vagrant ubuntu 16
    • CPUはマックのコアを2つcorei7
    • メモリは8GB割り当て
    • build.shでビルド
  • nginx.confの設定

一番方式自体のオーバヘッドが顕著になるようにRubyのコードはコストが軽量なhello world程度のものにしておきます。

server {
    listen       58080;
    server_name  localhost;

(snip)

    location /mruby {
            mruby_content_handler_code 'Nginx.echo "hello mruby #{Nginx.module_version}"';
    }
}
  • 起動コマンド
./build/nginx/sbin/nginx -c conf/nginx.conf
  • ベンチコマンド
$ ab -c 100 -n 100000 -l -k http://127.0.0.1:58080/mruby

v1の性能

$ curl http://127.0.0.1:58080/mruby
hello mruby 1.20.2
Server Hostname:        127.0.0.1
Server Port:            58080

Document Path:          /mruby
Document Length:        Variable

Concurrency Level:      100
Time taken for tests:   2.578 seconds
Complete requests:      100000
Failed requests:        0
Keep-Alive requests:    99049
Total transferred:      14095245 bytes
HTML transferred:       1900000 bytes
Requests per second:    38788.47 [#/sec] (mean)
Time per request:       2.578 [ms] (mean)
Time per request:       0.026 [ms] (mean, across all concurrent requests)
Transfer rate:          5339.19 [Kbytes/sec] received

v2の性能

$ curl http://127.0.0.1:58080/mruby
hello mruby 2.0.0-dev
Server Software:        nginx/1.13.8
Server Hostname:        127.0.0.1
Server Port:            58080

Document Path:          /mruby
Document Length:        Variable

Concurrency Level:      100
Time taken for tests:   3.317 seconds
Complete requests:      100000
Failed requests:        0
Keep-Alive requests:    99049
Total transferred:      14395245 bytes
HTML transferred:       2200000 bytes
Requests per second:    30147.40 [#/sec] (mean)
Time per request:       3.317 [ms] (mean)
Time per request:       0.033 [ms] (mean, across all concurrent requests)
Transfer rate:          4238.08 [Kbytes/sec] received

結果

Fiberで包んだノンブロッキング対応のmruby実行方式によって、Requests per secondがv1の38788.47 [#/sec]から30147.40 [#/sec]になりました。約2割の性能劣化です。個人的にはもっと差がでるのではないかと思っていたましたが、そこまで遅くならないようです。

hello worldレベルの処理でこの程度の性能差なので、もう少し実用的な処理であれば、Rubyハンドラの処理コストが支配的となり、v1とv2の処理速度の差はそれほど問題にならないようにも思います。

とはいえ、もう少しこの差が埋まるように色々最適化していきたいと思っています。

まとめ

ということで、mrubyのFiber、色々CとRubyの世界を行き来しながら使いやすいし結構速いので便利ですねというお話でした。また、ProcとFiberで包むことにより、v1ではできなかったRubyコード内での任意の箇所でのreturnもできるようになるので、よりngx_mrubyのコードが書きやすくなるのではないかと思います。

location /enable_return {
    mruby_content_handler_code '
      Nginx.rputs"hoge"
      return if true 
      Nginx.rputs "foo"
    ';
}
$ curl http://127.0.0.1:58080/enable_return
hoge

ということで、v2は色々と機能や書きやすさ等が向上する予定ですので、是非ご期待ください。