CRubyのGVLとビジーループ

mrubyのVMのマルチスレッド対応がgithubにて議論されています。

multi-thread support on the RiteVM #1657

自分もthread-safeなVMが欲しいなぁと思っています。STM32F4DiscoveryにFreeRTOS載せて使ってみたい。

並行・並列処理の戦国時代?

さて、マルチコアが普通になったし、WebアプリのC10K問題があるので並行・並列処理は昨今のトピックです(多分)。

ただマルチスレッドプログラミングは難しすぎ!というは昔から言われていたことです。
で、もうちょい並行・並列処理を書きやすく出来ないのかよ?ということでErlang,go,Scala(Actor),EventMachine,Thread pool ,node.js,deferred,future,java.util.concurrent色々出てきました (言語とライブラリ、書き方のスタイルごちゃまぜですが)。全部見てたら頭おかしくなりそう・・・。

勝手にまとめると

まず、

A「どうせボトルネックはファイル読み書きとかネットワーク、つまりI/Oじゃん。その辺は全部ノンブロッキング/非同期API使おうぜ!イベントドリブン最強!」

っていうのがありますね。node.jsなんかはシングルスレッドですが、パフォーマンスも良いみたいだし一定の支持を得てる。ただコールバック地獄になったりするんで、いい感じに書けるようにDeferredとかasyncみたいなスタイルも併用。EventMachineもチラッとしか見てないけど似たような感じ? 排他を気にしなくて良いのは嬉しい。

B「俺のマシンはマルチコアなのになんで一つだけ100%で他のやつは暇してるんだ。納得出来ない! 派」

無意味にスレッド増やしてもコア数はそんな多くないので重い計算はいい感じに書くと勝手に並列化してくれると嬉しい。C#のParallelとかJavajava.util.Concurrentとか?並列コンテナってやつ。スレッドプールで使いまわしてコストも低め。

C「forkすればいいじゃん。派」

一番簡単にマルチコア使えますね。parallel gemなんか使うとforkした子プロセスから計算結果返すのも余裕。cygwinみたいに死ぬ気でやればWindowsもOK?

結局どうなのよ?

実際にはAとB(とC)は常に完全に別れた要求でもなくて、要するに
「わけわかんねぇけど俺はいい感じに書きたいだけ!I/Oは使えるならノンブロッキング使うとか、重い計算はマルチコア使いまくりでよきにはからってよ!あ、コンテキストスイッチに時間使うとか勘弁ね。あと俺は普通のマルチスレッドとMutexで排他するスタイルではもう書かないって決めてるから。後もう一回言うけどいい感じに書きたいから」
ってことですかね。

アクターモデルってのがあります。Erlangで有名になって、Scalaも売り(の一つ)にしてるし、goではgoroutine、あとRubiniusもActor持ってます。
「Don't communicate by sharing memory; share memory by communicating. 」とか優等生っぽい。ただ馴染めるのかまだよくわかりません。ある程度使ってみないとなぁ。goの本積みっぱなし。

そうそう、並行と並列って言葉ですが、こんな感じ?
並行(concurrent):処理を別々に分けて書くこと(同時に実行されるとは限らない)。ふつうのマルチスレッドで書く場合
並列(parallel) : 複数の計算が同時に実行されること。マルチコアが同時に動いてるイメージ

忘れたら"並行 並列 違い"でググればOK.

CRubyのGVL(GIL)

CRubyのThreadクラスは1.8.xまではグリーンスレッドで実装されていました。で、1.9.0ではインタプリタからVMになって、ThreadクラスもNative Thread(OSが提供してるスレッド)で実装されるようになったというのは有名な話。

ただNative Threadになったけど、マルチコアを(ほとんど)活かせない。なぜならVMが動く際、基本的に一つの巨大な排他をしてるから。この排他というかロックをGVL(Giant VM Lock)またはGIL(Giant Interpreter Lock)と呼ぶ。Native ThreadだからVMが検知できないタイミングでスレッドは切り替わろうとするけど、ロックされてるからまた元のスレッドに戻ってくる(正確にはロックを待っている方のスレッドはOSのスケジューラのキューに入らない)。

GVLはブロッキングするようなC関数(典型的には各OSのSleep()。write()とかも?)呼び出し中には解放される。というかCで書かれたライブラリ中でそういう風に作ってる。なのでその際他のThreadクラスのインスタンスも動作できる。この時だけはマルチコアが同時に動く(可能性がある)。
C拡張は基本的にGVLがかかった状態で呼び出される。これをしないと、C拡張を常に注意深くスレッドセーフにする必要があるし、そういうことをすべてのC拡張開発者に求めるのはキツかろう、という判断みたい。

ただ、C拡張の中でブロッキング関数を呼び出したり、純粋な重い処理をやるときにGVLを解放してやることはできる。その為にrb_thread_blocking_region()というのが用意されている。よってC拡張の中でrb_thread_blocking_region()が使われている場合はマルチコアが同時に動く(可能性がある)。

GVLはCRubyの実装を単純に保ち、かつシングルスレッド性能を落とさないためには今の所まぁしょうがないよね〜。forkでも使えば〜。っていうところらしい。あとは複数のVMを使う(MVM:multi VM)というのも今後CRubyではあり得る感じ。今はCRubyでサクッとMVMするのは難しいけど、mrubyは簡単にできる。mruby-threadというmrbgemはスレッドごとにVMを分けてる。

で、それで満足できない場合は、JRuby、Rubinius、RubyMotionあたりに逃げちゃう手がある。これらにはGVLがない。内部的には所々排他処理してるけど、GVLよりもっと細かく、複数のMutexをつかってやるらしい。これをfine grained lockなどと呼んでGVLと区別してる。

JRubyJavaだから置いておくとして、RubiniusはC拡張の安全性はどうすんの?って思ったら、前までC拡張にはGVLでロックをかけていたけど、最近はデフォルトでそれもdisableにしたらしい。それでも「みんなフツーに使えてるよ」とのことらしいけど、まぁ楽観的な考えなのかな。CRubyとはユーザベースも違うとは思うけど。

Thread内でのビジーループ

で、そこまで調べて気になったのが、じゃぁ例えば2つのスレッドが(内部でGVLを開放するようなメソッドを一切呼ばないで)ひたすらループしてたらどうなんの?ってこと。片方がGVLをロックしっぱなしだと、もう一方のスレッドは動作するチャンスがなくなっちゃうのでは?

結論からいうと、そんなケースでも時々スレッドは切り替わる。以下の凄まじく長いGVL(=GIL)に関する記事によると、タイマースレッドというのが裏で動いていて、時々フラグを立てるらしい。タイマースレッドはCレベルで書かれていて、もちろんGVLと関係なく動く。そんなこともしてたのか・・。

Nobody understands the GIL - Part 2: Implementation

本当なのか以下のコードで実験してみた。2つのスレッドを作って、aという変数を書き換えるだけ。各スレッドはaの値が書き換わっていたら、その回数を記録する。

a = 1
 
thread_a_preempted_count = 0
thread_b_preempted_count = 0

t1 = Thread.new do 
  10000000.times do|i|
    if (a != 1)
      thread_a_preempted_count += 1
    end
    a = 1
  end
end

t2 = Thread.new do
  10000000.times do |i|
    if (a != 2)
      thread_b_preempted_count +=1
    end
    a = 2
  end
end
 
t1.join
t2.join
 
p thread_a_preempted_count
p thread_b_preempted_count

Windowsでの実行結果:

C:\home\work\ruby_concurrent>ruby --version
ruby 2.0.0p353 (2013-11-22) [i386-mingw32]

C:\home\work\ruby_concurrent>ruby threadatomic.rb
30
34


Mac OSXでの実行結果

[koji@macbookpro:~/work/ruby_concurrent]$ ruby --version
ruby 2.0.0p247 (2013-06-27 revision 41674) [universal.x86_64-darwin13]
[koji@macbookpro:~/work/ruby_concurrent]$ ruby thread_preempt_busy_loop.rb 
1
6

ということで確かにビジーループでも時々スレッドは切り替わる。Native Threadがプリエンプションでちゃんと切り替わるのと同じ感覚で使える。

あと、確かにタイマースレッドが存在することも確認できた。Rubyコード上ではスレッドは3つだけど、プロセスレベルでは4つスレッドがある。