Chienomi

RubyのRactorで並列AVIF圧縮

開発::util

Ractorの概要

RactorはActorモデルのRuby実装。 以前からRevactorというのがあったけど、それとはだいぶ違う。

Ractorは現状experimentalな組み込みライブラリで、doc.ruby-lang.orgのドキュメントはまだない。

要点は次のとおり

  • Ractor.new { ... }でRactor生成。ブロック内が実行される。引数を渡すとそのままブロック引数になる (ブロック外のオブジェクトへの直接アクセスは不可)
  • Ractor内でRactor.yieldしたら、Ractor#takeで取り出せる
  • Ractor#sendしたのをRactor内でRactor.receiveで取り出せる
  • sendは非同期で常時即時リターン。 takeは同期なのでブロッキングされる
  • Ractor.selectすると複数のRactorからどれかが終わるのを待てる。終わったRactorオブジェクトと、ブロックの値が返る
  • 回収はしなくても良いが、メインが終わるとRactorは打ち切られてしまうので、メインが終わるタイプは待ち合わせが必要

キューを作る場合はRactor.yieldするRactorを作って他のRactorに渡すのがよさそう。

q = (1..20).to_a
rs = []

qer = Ractor.new q do |q|
  loop do
    puts "QIN"
    Ractor.yield q.shift
  end
end


4.times do |i|
  rs.push(Ractor.new(qer, i) {|qer, i|
    item = qer.take
    puts "R#{i} received #{item}"
    sleep i
    item
  })
end

loop do
  break if rs.empty?
  r, msg = Ractor.select(*rs)
  rs.delete(r)
  if msg

  rs.push(Ractor.new(qer, 5) {|qer, i|
    item = qer.take
    puts "R#{i} received #{item}"
    sleep i
    item
  })
  end
end

複数のどれかにsendすることができないので、双方向マルチワーカーは難しい。 方法としては、司令塔になるRactorに対して自分が準備できたことを知らせるために「自分自身を」sendするというものがある。

通常、sendしたオブジェクトはmoved objectという扱いになって、sendした側では扱えないのだが、selfをsendする分には問題ないようだ。

qs = (1..30).to_a
workers = []

master = Ractor.new qs do |qs|
  loop do
    r = Ractor.receive
    r.send(qs.shift)
  end
end

3.times do |i|
  workers.push(Ractor.new(master) do |master|
    loop do
      master.send self
      v = Ractor.receive
      break unless v
      puts "#{self.object_id} got #{v}"
    end
  end)
end

until workers.empty?
  r, msg = Ractor.select(*workers)
  workers.delete(r)
end

AVIF圧縮を並列化してみる

キューがあればだいたいの並列事案は解決する。

photocompress.zshの並列化をやってみよう。 AVIF圧縮はちょっと時間がかかり、なおかつシングルスレッドで動くので並列化したいという気持ちは割とあった。 ImageMagickとかもいい材料かも。

実はphotocompress.zshは動作に少し問題がある1のでアプローチを変更する。

作業ディレクトリ上の画像ファイルを変換したものをアルバムへ、サムネイルをサムネイルへ出力。

Ractorを中心にRactorの流れをまとめるとこうなる。

setup

master = Ractor.new files, SETTINGS, STDERR do |files, settings, stderr|
  loop do
    i = files.shift
    unless i
      Ractor.yield [nil, nil]
      next
    end

    # ...Classifying...
    Ractor.yield [i, type]
  end
end

rs = []

workers.times do
  rs.push(Ractor.new(master, SETTINGS, CONFIG) do |master, settings, config|
    loop do
      item = master.take
      break nil unless item[0]

      # ...Compress...
    end
  end)
end

until rs.empty?
  r, msg = Ractor.select(*rs)
  rs.delete(r)
end

# ...JPEG Optimization...

先の例の1つ目に近い形だが、先の例では1処理ごとにRactorは終わる形だったが、ループして最後まで取って、取りきったら終わる形にして待ち合わせた。

Ractorの感想

Rubyの並列処理の中では感触はかなり良いほうで、Rubyで投げっぱなし並列処理を書きやすい。

ただ、プロセス自体が終わってしまう場合は結局待ち合わせを必要とするため、完全に投げっぱなしにはできず、使い勝手はそこまでよくない。

というのも、JavaScriptのasync/awaitのような感覚で使うことも可能2なのだけど、最終的に全部待たないといけないというのはなんとも微妙な書き方になる。

どちらかというとスレッド間通信がメインになると思うんだけども、ThreadFiberと比べれば筋が良い。 いままでだとThreadRevActorみたいな感じだったけれど、正直いまいちだった。 というのも、Threadは結局マルチスレッドにならないし、一方Fiberはコントロールが難しい。 制限はあるものの利用環境が整っているRactorはかなり使いやすいと感じた。

一方で、最大の難点のその制限。 STDERRにすらアクセスできないため、Ractorのブロックで書ける内容は非常に限定的で、小さいものにならざるをえない。 また、呼び出したメソッドの内部でSTDERRにアクセスしていてもエラーになるし、そうしたエラーは結局、Ractorを使うことで「本来隠蔽されているはずの内部事情を意識させる」ものになるから、すごく筋の悪い状態になってしまう。 Experimentalを外す段階でもこの制限はそのままなのだろうか。

今回のプログラムはsystemを呼んでいるためThreadでもマルチスレッドを活用できるが、マルチコアパワーを活用するのに新たな選択肢と考えても良さそうだ。

ただし。ただし、である。 マルチプロセスのほうが良い。間違いない。