Hatena::ブログ(Diary)

Alone Like a Rhinoceros Horn

2010-05-08

wxRuby におけるスレッドの扱い

実行に時間のかかる処理を GUI をブロックせずに行いたい場合、通常はタイマスレッドを使ってバックグラウンドで処理を実行します。wxRuby でも Wx::Timer.every を使って擬似的な並列処理をさせることが可能ですが、この場合プログラマタイマイベントにあわせて処理を分割しなければならず、面倒です。

そこでスレッドを使うことになるわけですが、

require 'rubygems'
require 'wx'

Wx::App.run do
  frame = Wx::Frame.new(nil, :title => "Thread.new #{RUBY_VERSION}")
  frame.set_sizer(Wx::VBoxSizer.new)
  frame.get_sizer.add_item(text = Wx::TextCtrl.new(frame))
  
  Thread.new(0) do |counter|
    loop do
      counter += 1
      text.change_value(counter.to_s)
      frame.refresh
      frame.update
      break if counter == 1000
    end
  end 
    
  frame.centre
  frame.show
end

こんな風に書いてもスレッドは期待した通りに動いてくれません。上に挙げたサンプルの場合、(ウィンドウ上でマウスを動かしたりしない限り*1)カウンタが全然回らず、スレッドの切り替えが起こらないことがわかります。

これは Ruby 1.8 と 1.9、どちらでも同じです。

理由 for 1.8

なぜスレッドの切り替えが起こらないのか。その理由は Ruby 1.8 については明解で、Rubyスレッドグリーンスレッド、すなわちユーザーレベルで実装されたスレッドだからです。スレッドの切り替えを行うのはカーネルではなくインタプリタなので、インタプリタになかなか制御を戻さない関数があった場合、その関数を実行中はスレッドの切り替えが起こりません。*2

GUIツールキットのイベントループというのがまさにその「インタプリタに制御を戻さない関数」なので、イベントループのあるメインスレッドから他のスレッドへのスレッド切り替えが起こらないということになります。

理由 for 1.9

Ruby 1.9 からスレッドの実装がネイティブスレッドになり、スレッドスケジューリングカーネルが行うようになりましたが、C のレベルでプリエンプティブになったわけではありません。

RubyMRI)はスレッドセーフでない C のコード(C の標準ライブラリを利用している部分をはじめ、既存の拡張ライブラリなども)を実装に多数抱えているため、個々のクリティカルセクションにおいて排他制御*3をしようと思うと、かなりのコード修正が必要となります。なので、現在は GVL(Global VM Lock) という、仮想マシンごとに設けられた大きなロックによる排他制御*4でこの問題に対処しています。すなわち、

Ruby 1.9.1 リファレンスマニュアル - スレッド

ネイティブスレッドを用いて実装されていますが、現在の実装では Ruby VM は Giant VM lock (GVL) を有しており、同時に実行されるネイティブスレッドは常にひとつです。ただし、IO 関連のブロックする可能性があるシステムコールを行う場合には GVL を解放します。その場合にはスレッドは同時に実行され得ます。また拡張ライブラリから GVL を操作できるので、複数のスレッドを同時に実行するような拡張ライブラリは作成可能です。

ということなので、メインスレッドが GVL を保持したままイベントループを回し続ける限り、やっぱりスレッド切り替えは起こらない、ということになります。

メインスレッドからのスレッド切り替えが起こらない理由は以上です。

Thread.pass

では、どうすれば自分の作ったスレッドを動かせるのか。答えは簡単で、放っておいてもスレッド切り替えが起こらないなら、自分から起こす、です。具体的には、イベントループのあるメインスレッドにおいて、定期的に Thread.pass を呼び出します。

require 'rubygems'
require 'wx'

Wx::App.run do
  frame = Wx::Frame.new(nil, :title => "Thread.pass #{RUBY_VERSION}")
  frame.set_sizer(Wx::VBoxSizer.new)
  frame.get_sizer.add_item(text = Wx::TextCtrl.new(frame))

  Thread.new(0) do |counter|
    loop do
      counter += 1
      text.change_value(counter.to_s)
      frame.refresh
      frame.update
      break if counter == 1000
    end
  end

  Wx::Timer.every(100) do
    Thread.pass
  end

  frame.centre
  frame.show
end

これでカウンタが回るようになります。

sleep

定期的に Thread.pass することでスレッドの切り替えが起こるようになり、カウンタが回るようになりますが、Ruby 1.9 ではどうもスレッドの働きが芳しくありません。カウンタの動きを見ればわかりますが、非常にぎこちなく、十分な処理時間をもらえていない印象です。

そこでもうひと工夫。メインスレッドにおいて単に Thread.pass するのではなく、sleep するようにします。sleep はスレッド切り替えの契機となるので、メインスレッドが sleep すれば必然的に制御が別スレッドへ移ります。しかも、指定した時間メインスレッドは活動を停止するため、その分の時間を確実に別スレッドの処理にあてることができます。*5

require 'rubygems'
require 'wx'

Wx::App.run do
  frame = Wx::Frame.new(nil, :title => "main thread sleep #{RUBY_VERSION}")
  frame.set_sizer(Wx::VBoxSizer.new)
  frame.get_sizer.add_item(text = Wx::TextCtrl.new(frame))

  Thread.new(0) do |counter|
    loop do
      counter += 1
      text.change_value(counter.to_s)
      frame.refresh
      frame.update
      break if counter == 1000
    end
  end

  Wx::Timer.every(100) do
    sleep 0.05
  end

  frame.centre
  frame.show
end

これで Ruby 1.9 においてもカウンタがスムーズに回るようになります。Timer.every に渡すインターバルや sleep する時間などは各自で最適なものを探ってみて下さい。

参考

*1:なぜウィンドウ上でマウスを動かすとスレッドの切り替えが起こるのか、詳細はわかりませんが、何らかの割り込みが発生して制御がインタプリタ側に戻るからだろうと推測しています。

*2Ruby の組み込みライブラリでは IO がブロックする場合は別スレッドへの切り替えを試みるので、IO待ちですべてのスレッドがブロックされるということはありません、念のため。

*3:thread.c 冒頭にある YARV Thread Desgin における model 3

*4:thread.c 冒頭にある YARV Thread Desgin における model 2。将来的には model 3 へ移行する?

*5:なんという協調的マルチタスクw

トゥイートゥイー 2010/12/14 11:06 まさに欲しかった情報です。スレッドを平行動作させても返答が遅くてどうしようかと思っていました。
GUI 作るのに最初は FXRuby を試したんですが、こちらはスレッドからの Grid の更新時に問題が発生し wxRuby を試し始めたところでした。

h1mesukeh1mesuke 2010/12/14 17:31 コメントありがとうございます。

メインスレッドを sleep させてスレッド切り替える、という部分は、実際に後ろでスレッドが走っていない場合にはまったく無駄な sleep になってしまうので、その辺の制御に工夫が必要でしょうね。

後、メインスレッド(イベントループ)の外側でダイアログを作ったりすると segmentation fault で落ちてしまったりするので注意して下さい。

トゥイートゥイー 2010/12/18 18:43 返信ありがとうございます。問題解決かと思いきや…。裏のスレッドでファイル読込等の IO を走らせると以下のようなメッセージが出て落ちる現象に悩まされています。

[BUG] object allocation during garbage collection phase

どうしたらいいのやら…。というところでテンパっています。コントロールの配置状況によって落ちたり落ちなかったりです。
Notebook を使っていたら落ちたり落ちなかったりなのですが、Listbox を配置しはじめたら確実に落ちるようになりました。
スレッド関連、安定しませんねぇ…。

h1mesukeh1mesuke 2010/12/18 19:18 メッセージを見るに、ガベージコレクション中にオブジェクトを生成しようとして落ちているようですね。

ファイル読み込み → 落ちる という因果関係があるのならそこを GC.disable, GC.enable で囲って、IO の間ガベージコレクションが走らないようにしてみてはどうでしょう。

対症療法ですが、そんな方法しか思い付かないです。
この辺のやり取りが参考になるかも↓

A bug in wxRuby. Segmentation fault in random situations. - Ruby Forum
http://www.ruby-forum.com/topic/142975

トゥイートゥイー 2010/12/19 03:04 参考意見ありがとうございます。

単純なプログラムを作ってみて詳細を調べたのですが IO 自体は大丈夫でした。

IO で取得した文字列を CSV モジュールの parse_line で配列に変換する処理をしていたのですが、それがあるとダメなようです。

Wx::ListBox と CSV.parse_line を同時に利用すると問題が発生することがわかりました。
GC.disable と GC.enable を試したところ問題は解決できました。

が…。この方法だと CSV による解析をさらに並列で処理しようと思ったら問題ですよね?複数のファイルを並列で読ませる処理を考えていたのです。
GC.disable 〜 GC.enable の部分をクリティカルセクションにすれば大丈夫だとは思うのですが…。
グローバルに Mutex インスタンスを1つ作ってクリティカルセクションにする必要があるということですよね?たぶん。
処理性能が悪くなりそうですけど他になさそうな気がします。後で試してみようと思います。
Ruby のスレッドはなかなか厳しいですねー。

トゥイートゥイー 2011/05/29 13:07 wxRuby ではなく本家 wxWidgets ではメインスレッド以外のスレッドで GUI の更新を安全に行うために wxMutexGuiEnter と wxMutexGuiLeave という関数を使って制御するようです。

http://docs.wxwidgets.org/trunk/group__group__funcmacro__thread.html

wxRuby でこれに該当する機能を探しているのですが見つかっていません。
もし無いとしたらスレッドでの GUI 操作は安全ではないということになりますが…、はてさて。

このような問題は別の GUI ライブラリでもあるようで、Android OS ではスレッドでの GUI 操作をキューに追加するのみにし、メインスレッドでその操作を実行するという方法が取られているようです。

h1mesukeh1mesuke 2011/05/29 13:44 コメントありがとうございます。
ちょっと調べてみたところ、wxPython にはそれらの関数があるようです。

wxRuby には見切りをつけて wxPython へ移行する、というのが現実的な解決かも知れません。(ドキュメントがないだけで wxRuby の方でも実は使えたりする可能性もありますが)

Ruby と Python という言語の違いはあるものの、API自体は同じなので、その気になれば移行は難しくないと思います。(経験者談)

h1mesukeh1mesuke 2011/05/29 13:51 移行は難しくないなんて軽々しく書いてしまいましたが、既存のアプリケーションを書き直すのはやっぱ大変ですよね……
うーむ。

トゥイートゥイー 2011/05/31 21:08 じゃぁ、wxRuby のプロジェクトに参加して修正を加えよう!!
とか軽々しく言ってみたり…。英語、お強いですか?ちなみに僕は読めますが、しゃべれないし、書けないです^^;

トゥイートゥイー 2011/05/31 21:11 ちなみに 2010/12/19 03:04 に僕が発言した「グローバルに Mutex インスタンスを1つ作ってGC.disable 〜 GC.enable の部分をクリティカルセクションにする」という方法を取れば、簡易的な処置としては機能することを当時確認しました。別の理由があって使うのを止めましたが…(aui を使おうと思ったけど、かなりの頻度でいきなり落ちる=別の理由)。

トラックバック - http://d.hatena.ne.jp/h1mesuke/20100508/p1