この記事はアピリッツの技術ブログ「DoRuby」から移行した記事です。情報が古い可能性がありますのでご注意ください。
以前rubyの大域脱出を紹介した際にrubyには継続渡しがあると話をしたので、簡単に解説してみた上で継続渡しとFiberの理解のためcallccを使ったEvilFiberを実装してみました。
はじめに
17新卒エンジニアくろすです。
使ってたBluetoothイヤホンが断線したり、代わりに通販で買ったイヤホンが初期不良で電源入らなかったりと本厄の洗礼が激しく、お祓いに行こうか真剣に悩んでます。
誰かオススメの厄払いがある方いらっしゃいましたら連絡をください。
今回は楽しい黒魔術の話だけ書いてパパッと終わらせちゃうぞ、と思っていました。
思っていました。
callccからFiberの話なんか始めちゃったのが間違いでした。
環境等
環境 : ruby-2.4.2
callccを使おうとcontinuation
をrequireすると
warning: callcc is obsolete; use Fiber instead
と怒られます。
処理の中断、再開として使うなら Fiber
大域脱出として使うなら throw..catch
があるのでそちらを使いましょう。
黄泉還りを楽しみたい方は遊びの範囲内で使いましょう。
黒魔術
メタプログラミングによる動的なコード生成や加工が黒魔術と呼ばれることが多いです。
Rubyで有名なのは
与えられた文字列をrubyのコードとして解釈するようなeval属
動的なメソッドを生成、またはそのメソッドが存在するかのように見せかけるdefine_method
やmethod_missing
によるゴーストメソッド
などがあります。
今回はRuby黒魔術の中でも黄泉還りが危険すぎてrequireしないと使えないようになってしまっているcallcc
について軽く触れていきたいと思います。
Rubyの継続
require 'continuation'
x = 1
if x == 1
y = x * callcc{|cont| @a = cont; x}
puts y
end
if y < 5
x += 1
@a.call(x)
end
callccを呼ぶことでcallcc以降のコンテキスト(継続)
を@a
に保存して、@a.call(x)
でcallcc{|cont| @a = cont; x}
の返り値に、call時のxの値を代入して5行目に戻ります。
パッと見、yにはx2 が入りそうな感じがしますが、Ruby的には最初に5行目を評価した時点で、xの値も評価し終わっているようで結果は以下のようになります。
$ ruby black_magic.rb
1
2
3
4
5
一歩間違えば無限ループ入りまっしぐらなのが見てわかるかと。
ちなみに、5行目を以下のように変更すると
y = callcc{|cont| @a = cont; x} * x
結果はこうなります。
$ ruby black_magic.rb
1
4
9
ざっくり継続の感じが掴めましたでしょうか。
Fiber
話が少し飛びますが、そもそもFiberとはなんぞ?という疑問がありそうなので軽く引用します。
ノンプリエンプティブな軽量スレッド(以下ファイバーと呼ぶ)を提供します。 他の言語では coroutine あるいは semicoroutine と呼ばれることもあります。 Thread と違いユーザレベルスレッドとして実装されています。
Rubyはそもそもの言語仕様として同時に実行されるネイティブスレッドは常にひとつ
だったりするんで、あくまでRubyVM内でノンプリエンプティブだって話ですが……
参考 : Ruby2.5.0 Threadクラスリファレンス
もう完全にコルーチンなんですが、要するにrubyのコードレベルで明示的にコンテキストを変更できる軽量なスレッド(スレッドではない)です。
Ruby的にわかりやすく言うならば処理の中断再開が可能なProcオブジェクト的な感じです。
以下のようにfiber自体は同一スレッド内でコンテキストを変更して動きますし、別スレッドからresume
しようとするとFiberError
って怒られます。
require 'fiber'
fiber =
Fiber.new do
loop do
Fiber.yield Thread.current.object_id
end
end
puts <<-EOS
===========================
parent : #{Thread.current.object_id}
child : #{fiber.resume}
===========================
EOS
#=>
# ===========================
# parent : 70103219828660
# child : 70103219828660
# ===========================
EvilFiber
callcc
を使い実装した邪悪なFiberです。
このブログのcounterを参考にさらに抽象度を上げてFiberに近づけた形になります。
元記事でも言われてますが、stopとresumeのスパゲッティ具合がとにかく邪悪です。
限界までFiberとfiber(コルーチンライクに使うための標準ライブラリ)に合わせようと思いましたが、#transferとスレッドセーフを実装する気力はなかったです……
require 'continuation'
class EvilFiber
@@fibers_hash = {}
@@current = nil
@@prev = nil
class << self
def yield(*args)
@@current.stop(*args)
end
def current
@@current
end
def prev
@@prev
end
private
def fibers_hash
@@fibers_hash
end
end
def initialize
@@fibers_hash[self.object_id] = self
@resume =
proc do
yield
@dead = true
reset_registered_fiber
nil
end
end
def stop(*args)
reset_registered_fiber
callcc do |cont|
@resume = cont
@return.call(*args)
end
end
def resume
raise 'THIS FIBER DIED!!!' if self.dead?
@@current = @@fibers_hash[self.object_id]
callcc do |ret|
@return = ret
@resume.call
end
end
def alive?
not @dead
end
def dead?
!!@dead
end
private
def reset_registered_fiber
@@prev, @@current = @@current, nil
end
end
[1] pry(main)> require_relative 'evil_fiber'
warning: callcc is obsolete; use Fiber instead
=> true
[2] pry(main)> f = EvilFiber.new{ puts '小烏丸'; EvilFiber.yield; puts '蜥蜴丸'; EvilFiber.yield; puts '小狐丸' }
=> #<EvilFiber:0x00007fc61a0e7dc8 @resume=#<Proc:0x00007fc61a0e7d50>>
[3] pry(main)> f.resume
小烏丸
[4] pry(main)> f.resume
蜥蜴丸
[5] pry(main)> f.resume
小狐丸
[6] pry(main)> f.resume
RuntimeError: THIS FIBER DIED!!!
new時に渡しているブロック内のEvilFiber.yield
をf.yield
にするという実装もあったのですが、コードとして見たときにパッと見未生成に見えるオブジェクトを触っているのが気持ち悪いので、Fiber側に合わせる方向にしました。
実際渡してるブロックが評価されるのはブロックがcallされた時(つまりこのコードだと最初にresumeが呼ばれた時)で、mainのオブジェクトのBindingから変数fを引っ張ってくるはずなので動くと思うんですが気持ち悪いものは気持ち悪いです。
オブジェクトの生成とブロックの登録を分ければ多少気持ち悪さが解消される気がします。
終わりに
ざっくりとですが、callccとFiberの勉強ができていい機会になったなって思います。
特にFiberですが、Threadよりコードに寄ってくれるので個人的にはかなり楽に書けるなと印象でした。
マルチスレッドプログラミングじゃなくてただのコルーチンと化した使い方しかしていないのでそりゃそうですけど。
一度ちゃんとマルチスレッドプログラミング勉強しないとなぁ……