2018年4月にngx_mrubyのノンブロッキングHTTPクライアントとノンブロッキングsleep相当のメソッドに対応させてngx_mruby v2.0.0をリリースするという目標を立てた
— 松本 亮介 / まつもとりー (@matsumotory) 2018年2月13日
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でうまく行き来するために、こういった複雑な方式にしています。その理由については一旦省略します。
ちょっと何いってるかわからないと思いますので、それを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は色々と機能や書きやすさ等が向上する予定ですので、是非ご期待ください。