Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: はじめてRactorを触ってみた学習メモ(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

参考: アクターモデル - Wikipedia

Ruby: はじめてRactorを触ってみた学習メモ(翻訳)

本記事は私の学習メモです。Ractorを使うと以下の警告が英文で表示されることをお忘れなく。

警告: Ractorは実験的機能であり、今後のRubyバージョンで変更される可能性があります。また、実装上の問題も多数あります。

警告を終えたので、それでは始めましょう。

🔗 アクターを作成する

シンプルなRactorは以下のように作れます。

simple = Ractor.new do
  # 何かする
end

アクターのイニシャライザを使うと、作成されるアクターに引数を渡せるようになります。このときにアクターの名前も設定できます。

Ractor.new()に渡される引数は、指定されたブロックのブロックパラメータになります。ただし、インタープリタはパラメータオブジェクトへの参照を渡すのではなく、メッセージとして送信します

obj = Object.new
val = "I'm a value"

simple = Ractor.new(obj, val, name: "Simple actor") do |obj, val|
  "#{name} has: obj: #{obj.object_id}, val: #{val.object_id}"
end

puts "Created here: obj: #{obj.object_id}, val: #{val.object_id}"
puts simple.take

上の実行結果は以下のようになります。

Created here: obj: 167328, val: 167336
Simple actor has: obj: 167344, val: 167352

引数に渡した数値は、作成された数値と明らかに異なっています(参考)。

もう1つ知っておくべきことがあります。

> simple
=> #<Ractor:#5 Simple actor (irb):57 terminated>

このシンプルなアクターは、結果を生成した後で終了しています(terminated)。終了したアクターはもうメッセージを受け取らないので、以下を実行するとエラーになります。

> simple.send "anything"
<internal:ractor>:600:in 'Ractor#send': The incoming-port is already closed (Ractor::ClosedError)

また、結果は出力ボックス(outbox)から既に取得済みなので、同じ結果を再度取り出すことはできません。

> simple.take
<internal:ractor>:711:in 'Ractor#take': The outgoing-port is already closed (Ractor::ClosedError)

アクターは、「メッセージを繰り返し生成するもの」と定義できます。
以下のup_to_3_timesに対してtakeを実行するたびに、yieldが1個ずつ「消費」されます。

up_to_3_times = Ractor.new do
  Ractor.yield 3
  Ractor.yield 2
  Ractor.yield 1
end

ここで興味深いのは、up_to_3_timesアクターは値を4つ生成することです。

> up_to_3_times.take
=> 3
> up_to_3_times.take
=> 2
> up_to_3_times.take
=> 1
> up_to_3_times.take
=> nil
> up_to_3_times.take
<internal:ractor>:711:in 'Ractor#take': The outgoing-port is already closed (Ractor::ClosedError)

最初の321は予想通りの結果です。
しかし4つ目のtakeではnil値が返されます(ブロックの戻り値もアクターによってyieldされるため)。

今度はアクターを以下のように更新します。

up_to_3_times = Ractor.new do
  Ractor.yield 3
  Ractor.yield 2
  Ractor.yield 1
  "finished here"
end

この場合の結果は以下のようになります。

> loop { puts up_to_3_times.take }
3
2
1
finished here
=> nil

これを避けるには、以下のようにclose_outgoingメソッドを使えます。

up_to_3_times = Ractor.new do
  Ractor.yield 3
  Ractor.yield 2
  Ractor.yield 1
  close_outgoing
end

上の結果は以下のようになります。

> loop { puts up_to_3_times.take }
3
2
1
=> nil

上のサンプルでは既にアクターの通信メカニズムが使われていますが、今度はそれを詳しく調べてメッセージを送受信してみましょう。

🔗 メッセージを送受信する

アクターはメッセージを送受信する形で他の部分とやりとりします。以下は、テキスト文字列を送信して大文字で表示するアクターの例です。

upcase = Ractor.new do
  msg = Ractor.receive
  Ractor.yield msg.upcase
end

このアクターはメッセージを待ち受けます。
これにメッセージを送信するには、以下のようにsendを使います。

> upcase.send "abc"
=> #<Ractor:#14 (irb):123 running>

アクターはメッセージを受信して処理し、結果を出力ボックスに送信します。結果を調べるには、そのアクターの出力ボックスからtakeで結果を取り出す必要があります。

> upcase.take
=> "ABC"

このアクターは短命であることを覚えておいてください。
次はアクターをもう少し長生きさせてみましょう。

🔗 長時間実行されるアクター

アクターにもっと長生きしてもらって複数のメッセージを処理するための実装は、以下のような感じになります。

upcase = Ractor.new do
  loop do
    Ractor.yield receive.upcase
  end
end

これで、渡した文字列をすべて大文字にする処理を何度も実行できるようになります。

upcase.send "aaa"
upcase.send "bbb"
upcase.send "ccc"

アクターにメッセージを送信すると、メッセージは受信ボックス(inbox)に到達します。
receiveメソッドは、受信ボックスからメッセージを取り出します。続いて、アクターはこのメッセージを処理します。
最後に、処理済みのメッセージをアクターの出力ボックスに置きます。

これで、以下の結果を得られるようになります。

> loop { puts upcase.take }
AAA
BBB
CCC

しかし待ってください、loopはまだ終了していないので無限ループになってしまいました。
このupcaseアクターは次のメッセージを待っており、receiveメソッドはアクターの受信ボックスにメッセージが届くのを待っています。メインスレッドは無限ループになっていて、アクターの出力ボックスに新しいメッセージが置かれるのを待ち構えています。
このときtakeメソッドを実行すると、次のメッセージが出力ボックスに置かれるまで実行がブロックされてしまいます。

アクターをこの例のような方法で利用すると、こういう結果になります。

🔗 アクターによる通信方法の種別

アクターは以下の2通りの方法で通信を行えます。

🔗 プッシュ型

  • sendメソッドとreceiveメソッドをペアで利用する
  • 送信側は、メッセージの送信先を認識している
  • 受信側は、送信側が誰なのかを知らないので、すべてのメッセージを受け取る
  • sendメソッドは、受信側の無限受信ボックスのキューに到達するが、実行をブロックしない
  • sendメソッドはノンブロッキングであり、receiveメソッドはアクターの受信ボックスに届くあらゆるメッセージを待ち受ける
  • アクターベースの言語は、ほとんどの場合このプッシュ型モデルを採用している

🔗 プル型

  • yieldメソッドとtakeメソッドをペアで利用する
  • 送信側はメッセージをyieldで生成するが、どこに到達するかは認識しない
  • 受信側は、誰が送信側であるかを認識しており、送信側の出力ボックスからtakeでメッセージを取得する
  • 受信側がメッセージをtakeで取り出せない場合、受信側の実行はブロックされる
  • yieldtakeもブロッキング実行である
    出力ボックスは1度に1個のメッセージしか置けず、yieldメソッドはアクターの出力ボックスから直前のメッセージが取り出されるまで待機する。takeメソッドは実行をブロックし、アクターの出力ボックスからメッセージを取得可能になるまで待機する。

🔗 アクターのライフサイクル

以下の例では、アクターは受信ポートがオープンされている限り生き続けます。

r = Ractor.new do
  loop do
    close_incoming if receive == 0
  end
  "I'm done"
end

この振る舞いは以下で確認できます。

> r
=> #<Ractor:#18 (irb):93 running>

このアクターは生きていて、メッセージの到着を待ち続けています。

> r.send 123
=> #<Ractor:#18 (irb):93 running>

アクターはメッセージを受信すると、メッセージを処理して引き続き実行し続けます。

> r.send 0
=> #<Ractor:#18 (irb):93 terminated>

このシステムが終了メッセージ(ここでは0)を受信すると、受信ポートをクローズして(つまり受信メッセージを受け取らなくなります)、アクターをterminatedとマーキングします。
終了したアクターに別のメッセージを送信しても以下のようにエラーになります。

> r.send 42
<internal:ractor>:600:in 'Ractor#send': The incoming-port is already closed (Ractor::ClosedError)

しかし、エラーが表示された後でもアクターの出力ボックスにtakeでメッセージを送信することは引き続き可能です。

irb(main):102> r.take
=> "I'm done"

この"I'm done"(=もう無理)というメッセージが出力ボックスに置かれていたのは、アクターが次のメッセージを処理するのを諦めたためです1。アクターの無限ループがclose_incomingメソッドで中断されたこともわかります。

アクターの出力ボックスからもう一度takeでメッセージを送信しようとすると、エラーが発生します。

> r.take
<internal:ractor>:711:in 'Ractor#take': The outgoing-port is already closed (Ractor::ClosedError)

close_incomingの代わりにclose_outgoingメソッド(アクターの出力ポートをクローズする)を使って同じテストを実行してみると、興味深い結果を得られます。

r = Ractor.new do
  loop do
    close_outgoing if receive == 0
  end
  "I'm done"
end

最初のうち、アクターはこれまでと同様に動き続けています。

> r
=> #<Ractor:#19 (irb):105 running>

アクターがメッセージを受け取るのも同じです。

> r.send 123
=> #<Ractor:#19 (irb):105 running>

しかしsend 0で終了メッセージを送信しても、アクターのステートはrunningのまま変わりません

> r.send 0
=> #<Ractor:#19 (irb):105 running>

アクターは引き続きメッセージを受け取ります。

> r.send 42
=> #<Ractor:#19 (irb):105 running>

しかしアクターでtakeを実行しても、出力ボックスから結果を取り出せません。

> r.take
<internal:ractor>:711:in 'Ractor#take': The outgoing-port is already closed (Ractor::ClosedError)

しかしエラー後もアクターは引き続き動き続けています。

irb(main):116> r
=> #<Ractor:#19 (irb):105 running>

以下のようにアクターの実行ブロック内でエラーをraiseするように変更すると、振る舞いが少し変わってきます。

r = Ractor.new do
  loop do
    raise "Error" if receive == 0
  end
  "I'm done"
end

最初のうち、アクターはこれまでと同様に受信メッセージを読み取り続けます。

irb(main):123> r.send 123
=> #<Ractor:#20 (irb):117 running>

send 0で終了メッセージを送信すると、エラーがスローされてアクターが終了します。

irb(main):124> r.send 0
#<Thread:0x000000011fd64000 run> terminated with exception (report_on_exception is true):
(irb):119:in 'block (2 levels) in <top (required)>': Error (RuntimeError)

アクターは終了状態(terminated)になり、メッセージを受け付けなくなります。

> r
=> #<Ractor:#20 (irb):117 terminated>
> r.send 42
<internal:ractor>:600:in 'Ractor#send': The incoming-port is already closed (Ractor::ClosedError)

アクターの出力ボックスからtakeでメッセージを取り出すこともできません。

> r.take
<internal:ractor>:711:in 'Ractor#take': thrown by remote Ractor. (Ractor::RemoteError)

🔗 まとめ

"one actor is no actor, they come in systems"2
大意: アクターは1個だけではアクターにならない。システム間で協調動作してこそ意味がある。

何しろこれはアクターの始まりに過ぎません。

🔗 関連資料

関連記事

RubyのRactorとは一体何なのか(翻訳)

Ruby 3: FiberやRactorでHTTPサーバーを手作りする(翻訳)


  1. 訳注: 原文"because the actor was having difficulty thinking of new lines"は、「この俳優(actor)はもう新しいセリフを思いつけなくなった」というシャレでもあります。 
  2. 訳注: これはアクターの概念でよく引き合いに出される言葉です。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。