SevenZipRuby 作成メモ 3 - マルチスレッド
7z ファイルを読み書きする Ruby gem ライブラリである seven_zip_ruby の作成メモその 3 です。
ひとまずこれでメモは終了し、久し振りに mruby の世界へ戻ろうと思います。
最後のメモはマルチスレッドや GVL (Global VM Lock) についてです。
概要
SevenZipRuby では、LZMA や BZIP2 圧縮の 7z ファイルを作る場合、デフォルトでマルチスレッドによる圧縮をサポートしています。
今回は、このマルチスレッドへの対応について、メモしておきます。
また rb_thread_call_without_gvl
の使い方についても書いておきます。
ただし、(他の記事もそうですが) 今回の記事はあまり正しいかどうか、あまり自信がありません。
Ruby のマルチスレッド処理と GVL (Global VM Lock)
Ruby には Thread クラスがあり、手軽にマルチスレッドプログラミングを楽しむことができます。
ただし、MRI (CRuby) では複数の CPU Core がある環境で複数のスレッドを作成したとしても、同時に実行されるスレッドは高々一つになります。
これは GVL というグローバルな Mutex があり、これを取得しているスレッドしか動作しないように実装されているためです。
CPython でも同様な実装らしく GIL (Global Interpreter Lock) というものがあります。
詳しくは Wikipedia の GIL の項目などを見てください。
さて、GVL は、現状スレッドセーフに書かれていない CRuby の各関数や SevenZipRuby のような拡張機能が、マルチスレッド環境下で (そこそこに) 動くように守ってくれています。
GVL があることで、C の拡張機能を書いたときに、特定の Ruby の C API を呼んだときなど、こちらが明示的に指定した箇所でしかスレッドの切り替えが起こらないようになっています。
例えば、以下のような C 拡張機能のコードでは、スレッドの切り替えが起こりません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // C 拡張機能 static VALUE test(VALUE self) { // 実行に 1s ぐらいかかる処理など // e.g. 複雑な数値計算 SleepEx(1000, FALSE); // 例としてとりあえず 1s 待つ return Qnil; } void Init_hoge( void ) { VALUE cls = rb_define_class( "Hoge" ); rb_define_method(cls, "test" , RUBY_METHOD_FUNC(test), 0); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | # Ruby スクリプト require( "hoge" ) # 別スレッドで 0.1s ごとに "check" を表示 Thread .start do loop do sleep 0 . 1 puts "check" end end a = Hoge. new # 以下の test メソッド実行中は、 # スレッドの切り替えが起こらない # => "check" の出力は止まる a.test |
この例の場合、Hoge#test
の実行中は、"check"
の出力が止まります。
これは、Ruby の C 拡張関数の実行中は GVL は取得されたままで解放されることがないため、他のスレッドが実行されたりしないためです。
ところで、VC++ 2010 などで試す場合、上の test
関数を以下のように定義すると、"check"
が 0.1s ごとに出力されます。
1 2 3 4 5 | static VALUE test(VALUE self) { Sleep(1000); return Qnil; } |
これは、Ruby 側に細工があり、上記の例では Sleep
関数は Windows API ではなく、適切に GVL を解放してから待ちに入る rb_w32_Sleep
が代わりに呼ばれるためです。
SleepEx
であれば、このようなことは起こらないので、試す場合はこちらを使うとよいでしょう。
rb_thread_call_without_gvl
による GVL の解放
先に挙げた SleepEx
の例で、"check"
が 0.1s ごとに出力されるようにするには、rb_thread_call_without_gvl
を利用します。
この関数は、引数で指定した関数を、GVL を解放した状態で呼んでくれます。
そのため、上述した test
関数を以下のように書くと、"check"
が 0.1s ごとに出力されるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | static void *slow_func( void *data) { // この関数は GVL を解放した状態で実行される // => 他の Ruby スレッドをブロックしない SleepEx(1000, FALSE); return NULL; } static void cancel_func( void *data) { // ここでは、slow_func を中断するための処理を書く。 // 例えば slow_func 内で定期的に「中断用フラグ」を // チェックするようにしておき、この関数内でその // フラグを true にする。 // Ruby 処理系は、slow_func を強制終了させたいときに、 // cancel_func を呼んでくる。 } static VALUE test(VALUE self) { // slow_func を GVL を解放した状態で呼ぶ。 rb_thread_call_without_gvl(slow_func, NULL, cancel_func, NULL); return Qnil; } |
このように書くことで、slow_func
は GVL を解放した状態で実行されます。
ただし、注意点として、GVL を解放した状態では、ほぼすべての Ruby 関係の API を呼ぶことはできない というものがあります。
上記の slow_func
の中では、例えば rb_gv_get
でグローバル変数にアクセスしたりはできません。そのため、重い数値計算など、時間がかかる処理に限定して行うとよいでしょう。
ちなみに、Windows では Sleep
は rb_w32_Sleep
になるので、上の例で SleepEx
の代わりに Sleep
を呼んでしまうと、GVL の扱いがおかしくなり、SEGV することになります。
7z.dll の呼び出し
以前に書いたように、7z.dll は内部で独自にスレッドを作成します。
そのため、以下のような方針で拡張機能を作ることにりました。
- 7z.dll が作ったスレッド内で呼び出される Ruby のコールバック関数は、そこで直接処理しない
- その処理をするために、別途 Ruby のスレッドを作り、イベントループのような動作をさせる
- Ruby スレッドが 7z.dll のイベント待ちをする際は、条件変数で CPU を消費しないようにして待つ
イベントループの作成
概念的には、以下のようなループで 7z.dll のコールバック処理を行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | VALUE runProtectedRubyAction(VALUE p) { typedef std::function< void ()> func_type; func_type *action = reinterpret_cast <func_type *>(p); (*action)(); return Qnil; } void rubyEventLoop() { g_action_mutex.lock(); while (g_event_loop_running){ g_action_mutex.unlock(); // 7z.dll からのイベント登録を待つ std::function< void ()> *action = wait_for_event(); int status = 0; // 登録された関数を実行する rb_protect(runProtectedRubyAction, reinterpret_cast <VALUE>(action), &status); g_action_mutex.lock(); g_action = nullptr; g_action_cond_var.broadcast(); } g_action_mutex.unlock(); } |
この rubyEventLoop
は Ruby のスレッドとして rb_thread_create
から実行されます。
この中では、g_action_mutex
で g_action
をガードしながら、g_action_cond_var
の知らせを wait_for_event
で待ちます。
イベント登録側は以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | template < typename T> void runRubyAction(T t) { g_action_mutex.lock(); // イベントが空くまで待つ while (g_action){ g_action_cond_var.wait(&g_action_mutex); } std::function< void ()> action = t; // 空いたので登録 g_action = &action; g_action_cond_var.broadcast(); // 処理されるまで待つ while (g_action == &action){ g_action_cond_var.wait(&g_action_mutex); } g_action_mutex.unlock(); } // 以下のように使う void hoge() { // 関数 hoge 自体は 7z.dll から呼ばれる // そのため、7z.dll が作った独自スレッドから呼ばれるかもしれない runRubyAction([&](){ // この中では Ruby の関数を好きなように呼べる // これらの処理は、Ruby のスレッドの中から呼ばれることになる VALUE value = rb_funcall(gHoge, rb_intern( "new" ), 0); }); } |
実際にはエラー処理などがありますが、概要としては以上のように Ruby 用のスレッドを用意して 7z.dll の処理を行っています。
イベント待ち
イベント待ちの部分では、適切に GVL を解放した上で Mutex の wait を呼ぶ必要があります。
上記の例の wait_for_event
では、以下のように待つ必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | void *wait_for_event_without_gvl( void *ptr) { while (!g_action){ g_action_cond_var.wait(&g_action_mutex); } return nullptr; } std::function< void ()> *wait_for_event() { g_action_mutex.lock(); rb_thread_call_without_gvl(wait_for_event_without_gvl, nullptr, nullptr, nullptr); std::function< void ()> *action = g_action; g_action_mutex.unlock(); return action; } |
このように GVL を解放して Mutex の wait を呼ばないと、wait を呼んだ途端に他の Ruby スレッドが GVL を取得することができなくなってしまうので、ハングします。
というわけで、マルチスレッドの場合は GVL をきちんと考慮して書く必要があります。
私自身もあまり深く分かっているわけではないですが、半年後ぐらいには seven_zip_ruby の実装を忘れてしまいそうなので、メモしておきました。