その他
    ホーム技術発信DoRubyRuby黒魔術【callcc】でFiberを実装してみた

    Ruby黒魔術【callcc】でFiberを実装してみた

    この記事はアピリッツの技術ブログ「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_methodmethod_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 と違いユーザレベルスレッドとして実装されています。

    引用 : Ruby2.5.0 Fiberクラスリファレンス

    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.yieldf.yieldにするという実装もあったのですが、コードとして見たときにパッと見未生成に見えるオブジェクトを触っているのが気持ち悪いので、Fiber側に合わせる方向にしました。
    実際渡してるブロックが評価されるのはブロックがcallされた時(つまりこのコードだと最初にresumeが呼ばれた時)で、mainのオブジェクトのBindingから変数fを引っ張ってくるはずなので動くと思うんですが気持ち悪いものは気持ち悪いです。
    オブジェクトの生成とブロックの登録を分ければ多少気持ち悪さが解消される気がします。

    終わりに

    ざっくりとですが、callccとFiberの勉強ができていい機会になったなって思います。
    特にFiberですが、Threadよりコードに寄ってくれるので個人的にはかなり楽に書けるなと印象でした。
    マルチスレッドプログラミングじゃなくてただのコルーチンと化した使い方しかしていないのでそりゃそうですけど。
    一度ちゃんとマルチスレッドプログラミング勉強しないとなぁ……

    モバイルバージョンを終了