H2Oではバージョン1.5より、mrubyを用い、Rackのインターフェイスに則った形でハンドラを書けるようになっています。
この機能を提供している目的は、正規表現による書き換え等を用いる複雑な設定ファイルではなくプログラミング言語を用いることで、ウェブサーバの設定をより簡潔に拡張しやすくするためです(Apacheのmod_rubyやmod_perlのようにウェブアプリケーションをウェブサーバ内で実行可能にすることではありません)。
とは言っても、現実のウェブサーバの設定においては、外部のデータベース等に問い合わせた結果に基づいたルーティングが必要になることがあります。
H2Oのようなイベントドリブンなウェブサーバ上で動作する、同期モデルを採用するRackインターフェイスを用いて記述されるハンドラ内において、データベースへの問い合わせをどのように実現すれば良いか。問い合わせが同期的だと、その間ウェブサーバの処理が止まってしまうので、Rubyで問い合わせ関数が呼ばれたタイミングで、ウェブサーバ側に処理を戻したいわけです。
そんなこんなで
そのとおりです。mrubyの構造上、Cライブラリ呼出のタイミングで非同期化するの難しそう>「mrubyのコード中でredisへのアクセスが発生した際にh2oがそのスレッドを開放できず、イベントループの恩恵が得られてない」 https://t.co/suTAf8PatM
— Kazuho Oku (@kazuho) November 10, 2015
とツイートしたところ、@kazuho むむむ、どういう改善をすれば「恩恵を得られる」ようになりますかね。改善する意志はあります。
— Yukihiro Matsumoto (@yukihiro_matz) November 10, 2015
@yukihiro_matz @kazuho CからFiberいじれるようにするとかそういう話ですかね。
— MATSUMOTO, Ryosuke (@matsumotory) November 10, 2015
@matsumotory @kazuho yieldとresumeはできますが。後はなにが必要ですか? 作るのはC関数に再入できない問題がありますが、resume同様returnでしか呼べない制限をつければ可能ですね
— Yukihiro Matsumoto (@yukihiro_matz) November 10, 2015
という流れになりました。その後、考えたところ、
@matsumotory @yukihiro_matz rack handlerをfiber内から呼ぶようにすれば、ひょっとしてmrubyに手を入れずに対応できるんじゃないかと考えています
— Kazuho Oku (@kazuho) November 10, 2015
という気がしてきたので、まずはPoCを書いてみることにしました。■Fiberを使って、同期コールを非同期化するPoC
ざっと、以下のような感じになります。Rack ハンドラ自体を Fiber 内に置き、その入出力と、非同期化したい関数(ここでは
DB#query
)が呼ばれたタイミングで Fiber.yield
を呼ぶことで、メインループ(これは実際には C で書くことになる)へ制御を戻しています。# DB class that calls yield class DB def query return Fiber.yield ["db#query"] end end # the application, written as an ordinary Rack handler app = lambda {|env| p "received request to #{env["PATH_INFO"]}" [200, {}, ["hello " + DB.new.query]] } # fiber that runs the app runner = Fiber.new { req = Fiber.yield while 1 resp = app.call(req) req = Fiber.yield ["response", resp] end } runner.resume # the app to be written in C msg = {"PATH_INFO"=> "/abc"} # set request obj while 1 status = runner.resume(msg) if status[0] == "response" resp = status[1] break elsif status[0] == "db#query" # is a database query, return the result msg = "" else raise "unexpected status:#{status[0]}" end end p "response:" + resp[2].join("")
やろうと思えばできることはわかりました。しかし、この手法には制限が2点あります。
- fiber 内からしか呼べない - それでいいのか?
- fiber 内で、Cコードを経由して呼ばれた ruby コードから Fiber.yield できない
■プロトコルバインディングの実装手法
さて、これで行けそうだということは分かったのですが、可能であることと、それが良いアプローチであることが等価であるとは限りません。そもそも、プロトコルバインディングはどのように書かれるべきなのでしょうか。2種類に大別してプロコンを書きたいと思います。
- Cライブラリのラッパーを書く
- Cライブラリが、非同期モデルをサポートしている必要がある
- イベントループ (libuv, libev, ...) 毎に対応が必要
- プロトコルを実装しなくて良い
- rubyでバインディングを書く
- プロトコルを実装する必要がある
- rubyで書ける!
- 各バックエンド (libuv, libev, ngx_mruby, h2o, ...) が同じ ruby API (TCPSocketのサブセットで良いと思う) を提供すれば、イベントループ毎の対応が不要
- Cより遅いかも…
個人的には、rubyでバインディングを書くアプローチが好みです。速度が遅いかも…という点については、Perl IO を用いた HTTP 実装を推進してきた立場から言うと、スクリプト言語のI/Oレイヤの負荷はネットワーク通信を行うプログラムにおいては多くの場合問題にならないと考えます。問題になるとすれば、通信データのパーサですが、ここのみをネイティブコード化するという手法で十分に対応できることは、Plack や Furl に慣れた Perl プログラマであれば納得できる話かと思いますし、(m)ruby においても同等かと思います。
■まとめ
長くなりましたが、H2O (あるいはイベントドリブンなプログラム一般)から、同期的に書かれたネットワーククライアントを呼び出す mruby スクリプトを起動する方法については、
- 同期的に記述されたアプリケーションを Fiber を使ったラッパーで非同期化する
- ホストプログラムは、Fiber を通じて、TCPSocket と互換性のある同期ソケット API を提供する
- プロトコルバインディングは、Rubyで(もしくは、Ruby の TCPSocket と C で書かれたプロトコルパーサを組み合わせて)提供する