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

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

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

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

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