ngx_mrubyのv2リリースに向けて、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でうまく行き来するために、こういった複雑な方式にしています。その理由については一旦省略します。
ちょっと何いってるかわからないと思いますので、それをRubyだけで擬似コードにすると、以下のような処理をやりたいわけです。
# p1がnginx.confに書くRubyコードのバイトコードをprocオブジェクトにしたものとする p1 = Proc.new do %w(hoge fuga foo).each do |s| puts s Fiber.yield end end # p1をcallする処理をfiberで包む f = Fiber.new do p1.call end # fiberをresumeする処理をprocで包む p2 = Proc.new do r = f.resume f.alive? end # C側のイベントループの中でp2をいい感じでcallする 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
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 '; }
[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を実行しており、大量にアクセスが集中すると処理が待たされる問題がありました。
async 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 '; }
一方、ノンブロッキング版のsleepでは、このようにプロセスが一つにも関わらずリクエスト単位でsleepが他のリクエストを邪魔することなくnonblockingに実行されていることがわかります。
[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
また、以下のように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でstruct RProcをリクエスト単位で包む影響
で、本題はこの仕様の話ではなくて、実際にこういうfiberとprocを経由して実行する処理をHTTPリクエスト単位でやると、どれぐらい性能が低下するかを測定してみました。
そこで、v1系のstruct RProcを直接実行する処理と、v2でのHTTPリクエスト単位でfiberとprocで包んで実行する処理の性能比較を行いました。
検証環境
Vagrant ubuntu 16
- CPUはマックのコアを2つcorei7
- メモリは8GB割り当て
build.sh
でビルド
nginx.confの設定
一番方式のオーバヘッドが顕著になるようにRubyのコードはコストが軽量なものにしておきます。
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レベルの処理でこの程度の性能差なので、もう少し実用的な処理であれば、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は色々と機能や書きやすさ等が向上する予定ですので、是非ご期待ください。