2008-09-20

MySQLPlus と NeverBlock

前に Rails がマルチスレッドになっても MySQL のドライバとかがブロックしたらダメじゃないの? という話をちらっと書いた. やっぱりダメというのが結論らしい. MySQLplus は そんな問題に対処する ruby の MySQL ドライバ拡張だというので眺めてみた. MySQL の API がブロッキングで困るだなんて, まったく他人事には思えない.

MySQL ドライバの API は基本的にマルチスレッド+ブロッキングを前提とした設計をしており, 刺さりそうな場所は多い. 中でも一番困りそうなのは mysql_query()mysql_real_query() だろう. ばしっとクエリーを投げて結果を受けとるこれらの API は, MySQL から返事が戻ってくるまでデータを待ち続ける. MySQL/Ruby もこの API を使っている.

普通に考えるとお手上げに見えけど, MySQLPlus ではどう解決するのだろう. 該当個所 を見てみる:

class Mysql
  def async_query(sql)
    send_query(sql)
    select [ (@sockets ||= {})[socket] ||= IO.new(socket) ], nil, nil, nil
    get_result
  end
end

send_query() なんてのがあるのか。C 側を見てみると...:

/* send_query */
static VALUE send_query(VALUE obj, VALUE sql)
{
   MYSQL* m = GetHandler(obj);
   Check_Type(sql, T_STRING);
   if (GetMysqlStruct(obj)->connection == Qfalse) {
     rb_raise(eMysql, "query: not connected");
   }
  if (mysql_send_query(m, RSTRING_PTR(sql), RSTRING_LEN(sql)) != 0)
  mysql_raise(m);
  return Qnil;
}

mysql_send_query() を呼んでいる. どんな API なのかとぐぐってみるがほとんどヒットしない. MySQL のドキュメントにも載っていない. でも mysql.h にはエントリがある. プチ裏口 API というところだろうか. get_result() の中では mysql_read_query_result() を呼んでいる. これもプチ裏口. つまり MySQLPlus はクエリの送受信を 送信(mysql_send_query()) と 受信 (mysql_read_query_result()) に分割し, 送受信の間はソケットを select() して待つことでブロックを回避している. この select() は Ruby 処理系を経由するので, スレッド同期用のジャイアントロックは解除される. おかげで刺さらない.

結果として, MySQL.async_query() は C で書かれた mysql_real_query() の処理を ruby 側に移した感じになっている. (MySQL では select() しないけど.)

/* client.c */
int STDCALL
mysql_real_query(MYSQL *mysql, const char *query, ulong length)
{
 ...
  if (mysql_send_query(mysql,query,length))
    DBUG_RETURN(1);
  DBUG_RETURN((int) (*mysql->methods->read_query_result)(mysql));
}

不思議なのは select() の引数に渡している socket 変数だ。これはどこから取ってきたの?

static VALUE socket(VALUE obj)
{
  MYSQL* m = GetHandler(obj);
  return INT2NUM(vio_fd(m->net.vio));
}

わーおい. こんなのよく見付けたなあ. 抽象化がダダ漏れだ...おかげでブロックせずに済むんだから, 文句は言えない.

C++ でもこれを真似すればいいかと思ったけれど, 不安が残る. 受信の開始を待たないおかげで一番長いブロックは避けられるものの, 他にも怪しいところがある. たとえば mysql_read_query_result() に続く 結果取得の API は相変わらずブロックするだろう. "select * from hello" みたいに大きな結果を受信するクエリーが刺さることはありそうだ. mysql_real_connect() なんかも不安. やっぱりスレッドをわけて接続プールにした方が確実そうだなあ... シングルスレッド/ノンブロッキングの代表格 Twisted も データベースの接続はスレッドプールに逃げていたから, こればかりは仕方がないのだろう.

NeverBlock

と, 憂鬱な話はここまで.

MySQLPlus の作者は NeverBlock というライブラリも作っている. 世間的にはこっちが注目を集めているようだ. NeverBlock は Fiber を 使ったノンブロッキングな並列コードをユーザ透過な方法で実現する仕組みらしい...よくわからない...

Fiber は ruby-1.9.x が提供する協調型マルチスレッドの仕組で, いわゆるコルーチンの一種. スタックのコピーなんかをしているところを見ると, 1.8.x のスレッドからプリエンプティブを取ったようなものらしい. (開発者本人の紹介にもそう書いてあった.) NeverBlock は Thread のかわりに Fiber を使って色々やろうとする. 具体的には Rails を multi-threading するかわりに mult-fibering する. (NeverBlock のフレームワークは Rails 専用ではないけれど, 主な用途は Rails だとおもう.)

NeverBlock は MySQLPlus もフレームワークに組み込んでいる. たとえば select() で Thread を譲りわたしていたオリジナルの MySQLPlus に対し, NeverBlock 版は Fiber.yield() する.

# fibered_mysql_connection.rb
  class FiberedMysqlConnection < Mysql
    ...
    def query(sql)
       begin
       ...
          send_query sql
          @fiber = Fiber.current
          Fiber.yield # これ
          get_result
       ...
      end
    end
  ...
  end

yield() した fiber は, どこかで resume() しないと眠り続けてしまう. NeverBlock は EventMachine というノンブロッキング IO の フレームワーク (select や epoll などのラッパ) をイベントループの基盤に使っており, この EventMachine から通知を受け取って Fiber を呼び起こす.

通知を受け取るための登録は初期化の途中で済ませている.

# fibered_mysql_connection.rb
  class FiberedMysqlConnection < Mysql
    ...
    def reconnect
      unregister_from_event_loop
      super
      init_descriptor
      register_with_event_loop(@loop)  # ここで登録
    end
    ...
    def register_with_event_loop(loop)
      ...
        @em_connection = EM::attach(@io,EMConnectionHandler,self)
      ...
    end
    ...
  end

EM::attach() の引数にわたす @io 変数には MySQL Driver のソケット記述子がバインドされている. leaky abstraction から拾いだしたやつね. これのおかげでデータが届いたソケットの通知を EventMachine に任せることができる.

  class FiberedMysqlConnection < Mysql
    ...
    def resume_command
      @fiber.resume # 無事復帰
    end
  ...
  end
  ...
  module EMConnectionHandler
    def initialize connection
      @connection = connection
    end
    def notify_readable # これが呼ばれる
      @connection.resume_command
    end
  end

通知を処理する EMConnectionHandler を経て Fiber が resume() されている. めでたしめでたし.

...といいたいところだけど, EventMachine のようなノンブロック IO フレームワークは 誰かがイベントループを回してやる必要がある. ところが Rails (の動いているウェブサーバ)は自分で制御ループを持っている. それが Fiber フレンドリーとは限らない. むしろ Rails がマルチスレッドに対応するなら, マルチスレッド(1 接続 1 スレッド)で作る方が自然に思える. その方針は Fiber 化と衝突する.

NeverBlock がどうしているかというと, 実行時にウェブサーバをぱちっている. 今のところ MongrelThin に対応しているようだ.

Mongrel だと...

# lib/never_block/servers/mongrel.rb
module Mongrel
  class MongrelProtocol < EventMachine::Connection
  ...
  class HttpServer
  ...
  class HttpRequest
  ...
  class HttpResponse
  ...
  class Configurator
  ...
end

と軒並書き換える. Mongrel が合計 4-5000 行に対し, この patch は 250 行. 多いのか少いのか...

Thin はもともと EventMachine を使っているため, ずっとあっさり統合されている:

# lib/never_block/servers/thin.rb
module Thin
  ...
  class Connection < EventMachine::Connection
    def process
      @request.threaded = false
      @backend.server.fiber_pool.spawn{post_process(pre_process)}
    end
  end # Connection

end # Thin

ちなみに Thin の元コードはこんなの:

# connection.rb
module Thin
  ...
  class Connection < EventMachine::Connection
    ...
    def process
      if threaded?
        @request.threaded = true
        EventMachine.defer(method(:pre_process), method(:post_process))
      else
        @request.threaded = false
        post_process(pre_process)
      end
    end
    ...
  end
  ...
end

比べてみると, <中で yield() しそうな処理の呼出し元を FiberPool#spawn() でくるむ> という NeverBlock 化の手口がよくわかる.

Fiber vs. Thread

NeverBlock はただ Fiber を使うだけでなく, FiberPool のようなちょっと凝った機構も用意している. スレッドと張り合う意欲が溢れている. Ruby はシングルコアのマルチスレッドなので, 理屈の上では Thread でも Fiber でも性能に大差はない. NeverBlock の blog にあった ベンチマーク結果 も均衡している. 性能差がないのになぜ Thread ではなく Fiber が良いのか. NeverBlock のページによれば

というマルチスレッドの欠点を乗り越えるためだという. ほんとにスレッドのオーバーヘッドなんてあるのかよという疑問もあるけれどそこは火薬庫なので近付かないとして, Fiber にも欠点はある. 現実的に一番苦労しそうなのは Fiber.resume()/FiberPool.spawn() や Fiber.yield() を呼んでやらなければいけないところだろう. resume() を呼ぶには Mongrel や Thin のようにアプリケーションサーバを書き換える必要がある. 同様に, yield() を呼ぶには MySQLPlus のようにライブラリを書き換える必要がある. Web API を使うなら HTTP を, ファイルを読むならファイルを, ブロックしうる入出力を片っぱしから直していかないとゴールは達成できない. けっこうな茨の道に見える. 一方で NeverBlock 作者の馬力を見ていると, なんとかしてしまいそうな気もする. File と Socket を書き換えるくらいのことはやりかねないオーラがあるからね... Twisted だって根性で非同期スタイルの API を揃えまくったわけだし, 体力と根性で片付く問題領域の広さを侮るなかれ, とおもう.

今日の結論: 根性があれば Fiber, なければ Thread.