@m_seki の

I like ruby tooから引っ越し

どうでもいいような仕事をする

dRubyのアプリケーションを雑に作るため、別プロセスに貸しだしたオブジェクトを一定時間、GCから保護するTimerIdConvっていうのがあります。*1

保護するのは簡単なんだけど、保護するのをやめるのがめんどくさい。とりあえずスレッドを使って時々忘れさせるようにしておきました。想定される使い方では、dRubyでサービスを提供するプロセスは終了までそのまま提供するのでそうなってます。
でもできればこういうスレッドなくしてみたい。
で、どうでもいい仕事(この場合は忘れる処理)をするトリガーとしてGC使えないだろうかと考えました。

module OnGC
  module_function
  def on_gc(&blk)
    ObjectSpace.define_finalizer(Object.new, &blk)
  end
end

Object.newしたオブジェクトがGCされたとき、ブロックを呼び出すことができます。

def do_gc
  p [:do_GC, Thread.main, Thread.current]
end

OnGC.on_gc { do_gc }
GC.start

puts "end."

実行する。

$ ruby on_gc.rb
end.
[:do_GC, #<Thread:0x007fccf207cd00 dead>, #<Thread:0x007fccf207cd00 dead>]

あれ?そこか。GC二回してみる。

def do_gc
  p [:do_GC, Thread.main, Thread.current]
end

OnGC.on_gc { do_gc }

GC.start
GC.start

puts "end."

end.の前にログがでた。GCがいつ行われるのか、あてにできないってことだよね。

$ ruby on_gc.rb
[:do_GC, #<Thread:0x007fed14080d00 run>, #<Thread:0x007fed14080d00 run>]
end.

ループしてみる

定期的な処理をしたいので、ループさせてみる。

def do_gc
  p [:do_GC, Thread.main, Thread.current]
  OnGC.on_gc { do_gc }
end

と、無限ループしてしまう。プロセスの終わりにGCが走るからじゃなくてファイナライザが働くので無限ループに入ってしまう。
中田さん情報でThread.main.alive?がfalseになるっぽいので終端条件をいれよう。

module OnGC
  module_function
  def on_gc(&blk)
    return unless Thread.main.alive?
    ObjectSpace.define_finalizer(Object.new, &blk)
  end
end

どのスレッドで動くのか

なんとなくGC用のスレッドがあるのかと思ったけど、そうではない。どのスレッドでもGCが発生する可能性がある(と思う)。この実験では明示的なGC.startだけど、いつでも起きるよね。

def do_gc
  p [:do_GC, Thread.current]
  OnGC.on_gc { do_gc }
end

OnGC.on_gc { do_gc }
th = Thread.new do
  10.times do
    GC.start
    sleep(rand)
  end
end

10.times do
  GC.start
  sleep(rand)
end

th.join
puts "end."

できそうにないことを確認する

Threadは作れない?

def do_gc
  p [:do_GC, Thread.current]
  begin
    Thread.new { puts 'helo' }
  rescue
    p $!
  end
  OnGC.on_gc { do_gc }
end

OnGC.on_gc { do_gc }
th = Thread.new do
  10.times do
    GC.start
    sleep(rand)
  end
end

10.times do
  GC.start
  sleep(rand)
end

th.join
puts "end."

なんとつくれた。Thread.main.alive?がfalseのときはThreadErrorになる。
なるほどー。
Thread通信のしかけも動くかな。Queueで試してみる。

require 'thread'
$queue = Queue.new

def do_gc
  p [:do_GC, Thread.current]
  begin
    puts $queue.pop
  rescue
    p $!
    raise($!)
  end
  OnGC.on_gc { do_gc }
end

OnGC.on_gc { do_gc }
th = Thread.new do
  10.times do |n|
    GC.start
    $queue.push(n)
    sleep(rand)
  end
end

10.times do |n|
  GC.start
  sleep(rand)
end

th.join
puts "end."

動く(よびだせる)。でもこう書くと最後にはデッドロックで終わってしまう。だいたいどのスレッドでGCが働くかわからないのに、スレッドの待ち合わせするのはだめだよな。その手の操作は別スレッドを作ってやるべきだな。そうする。

def do_gc
  p [:do_GC, Thread.current]
  Thread.new do
    begin
      puts $queue.pop
    rescue
      p $!
      raise($!)
    end
  end
  OnGC.on_gc { do_gc }
end

だいたい定期的な処理

do_gcで時刻を調べて、期限を過ぎていたら特別な処理をするようにすれば定期的な処理が書けそう。

$expires = Time.now + 2

def do_gc
  if $expires < Time.now
    p [:do_gc, Time.now]
    $expires = Time.now + 2
  end
  OnGC.on_gc { do_gc }
end

OnGC.on_gc { do_gc }
th = Thread.new do
  30.times do |n|
    GC.start
    sleep(rand)
  end
end

30.times do |n|
  GC.start
  sleep(rand)
end

th.join
puts "end."

大団円

TimerIdConvのケースでは、こんな風に使おうと思います。

  • keeperスレッドをやめる。(これはすでにやめている)
  • @expiresをon_gcのループの制御に使う。次にrotateする時刻を入れる。nilなら停止。
  • addのケースにだけ、on_gcのループの開始する
      def invoke_keeper
        return if @expires
        @expires = Time.now + @keeping
        on_gc
      end

ループは同時にただ一つだけにしておきたいので、停止中の場合にだけ、開始する。

      def on_gc
        return unless Thread.main.alive?
        return if @expires.nil?
        Thread.new { rotate } if @expires < Time.now
        ObjectSpace.define_finalizer(Object.new) {on_gc}          
      end

まず、プロセス終了中、ループ終了状態をチェックする。次に、rotate時刻を過ぎていたら、別スレッドでrotateする。次回に備えて、on_gcのループの準備をする。ファイナライザーの登録以外、なにも状態を変えないのがミソ。rotateは別スレッドで実行され、状態の変更はsynchronizeの内側で行います。

      def rotate
        synchronize do
          if @expires &.< Time.now
            @gc = @renew      # GCed
            @renew = {}
            @expires = @gc.empty? ? nil : Time.now + @keeping
          end
        end
      end

rotateでは、@expiresを過ぎていたら、GCから保護している箱(Hash)を入れ替え、次のrotateの時刻を設定する。箱がからならnilにして、on_gcのループをやめる。

空になってon_gcのループが終わっても、つぎのaddのときにはinvoke_keeperされて再開される(と期待してる)。commitしていいかなあ。

あわせて読みたい

dRubyによる分散オブジェクトプログラミング

dRubyによる分散オブジェクトプログラミング

The dRuby Book: Distributed and Parallel Computing with Ruby

The dRuby Book: Distributed and Parallel Computing with Ruby

dRubyによる分散・Webプログラミング

dRubyによる分散・Webプログラミング

*1:慣れてくるとこれしなくてもうまく書けるので、自分は使ってません