はじめに

今回はRuby1.9の例外処理を以下の手順で読解したいと思います。

サンプルスクリプト

例外処理だけに注目するということでサンプルはとても作為的です。

def exc_func
  begin
    raise "Exception"
  ensure
    puts "ensure in exc_func"
  end
end

begin
  exc_func
rescue
  puts $!.backtrace
ensure
  puts "ensure in top"
end

見るのは以下の項目です。

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

$ ruby exc.rb
ensure in exc_func
exc.rb:3:in `raise'
exc.rb:3:in `exc_func'
exc.rb:10:in `<main>'                       
ensure in top

ん?Ruby1.8と違いますね。Ruby1.8だと以下のようになります。まあ見比べはしませんが。

$ ruby exc.rb
ensure in exc_func
exc.rb:3:in `exc_func'
exc.rb:10
ensure in top

NODEツリーへの変換

いろいろな要素がどういうNODEツリーに変換されるかはスクリプト解析を読むを参照してください。ここでは目新しい項目だけを取り上げます。なお、raiseは予約語ではなく関数なのでNODE_FCALLになります。

primary(keyword_begin)

beginが見つかるとprimary規則のkeyword_beginがひっかかります。

nd_type = NODE_BEGIN
u1.value = 0
u2.value = bodystmt
u3.value = 0

bodystmt

次に、beginの中身とrescue以下がbodystmt規則にひっかかります。トップレベルの方ではelseは付けていないので以下のNODEが構築されます。

NODE_ENSURE
  NODE_RESCUE
    compstmt
    opt_rescue
    0(opt_else)
  opt_ensure

opt_rescue

opt_rescue規則です。補足する例外クラスは指定しておらず他のrescueもないので以下のNODEが構築されます。

nd_type = NODE_RESBODY
u1.value = 0(opt_rescue)
u2.value = compstmt
u3.value = 0(exc_list)

opt_ensure

opt_ensure規則ではensureに続くcompstmtがそのままNODEになります。今回の場合はともにNODE_FCALL(puts)です。

変換結果

というわけで変換結果です。 fileexc.node.txt

YARVコードへの変換

それでは次にNODEからYARVコードへの変換です。全体的な流れについてはYARVコードへのコンパイルを読むを参照してください。

NODE_ENSURE

NODE_BEGINは子ノードをCOMPILE_マクロにかけるだけです。で、その子ノードであるNODE_ENSUREについて見てみましょう。

まず、ensureの本体がNEW_CHILD_ISEQVALマクロにかけられています。iseq_compile関数に行ってISEQ_TYPE_ENSUREを見るとiseq_set_exception_local_table関数が呼び出されてlocal_tableが設定されています。

local_table = ID("#$!")
local_table_size = 1
local_size = 1

ん・・・、$!ではなくて#$!ですか。まあそのうち意味がわかるでしょう。その後、COMPILE_POPEDマクロを用いてiseq_compile_each関数が呼ばれています。ensureの値は無視されるという仕様のためでしょう。iseq_compile_each関数から戻ってくるとgetdynamic(1, 0)とthrow(0)が追加されています。

NEW_CHILD_ISEQVALマクロから帰ってくるといろいろ情報が設定されています。この情報はメソッドからreturnで抜ける時にensure部分が実行されるようにするためなどに利用されるようです。

次に、開始ラベルの追加、本体(rescue含む)のコンパイル、終了ラベルの追加が行われています。例外処理ではこのラベルの位置が重要なようです。

次にensureの本体がコンパイルされています。さっきやったじゃんと理解に時間がかかったのですが例外が起こらなかった場合に実行されるようです。

その後、ensureの終わり部分にラベルを追加しています。このラベルも例外が発生した場合に利用されるようです。

最後にADD_CATCH_ENTRYマクロを利用して例外が発生したときのための情報を記録しています。

NODE_RESCUE

次にensureの本体NODE_RESCUEを見てみましょう。まずrescueの本体がNEW_CHILD_ISEQVALマクロにかけられています。ensureと同様にlocal_tableが設定され、今度はCOMPILEマクロでコンパイルが行われています。ここら辺から例外を補足したときのコードは別のフレームを割り当てて実行されるんだろうな〜と想像できます。

NEW_CHILD_ISEQVALマクロから帰ってくると開始ラベルの追加、begin部分のコンパイル、終了ラベルの追加が行われています。elseはないので無視して、nopと終わり部分のラベルが追加されています。

最後にADD_CATCH_ENTRYマクロを利用して例外が発生したときのための情報を記録しています。

NODE_RESBODY

最後にrescue本体のNODE_RESBODYを見てみましょう。

まず、発生された例外が指定された例外かチェックし、そうならrescue本体に飛ぶという命令が追加されています。補足する例外の種類を指定していない場合はStandardErrorかのチェックが行われています。

次にrescueの後ろのラベルへのjumpを追加しています。例外が指定したもの以外の場合に実行されます。

次に例外が指定したものの場合のjump先ラベルを追加し、rescue本体をコンパイル、leaveが追加されています。

最後に例外が指定したもの以外の場合のjump先ラベルを追加しています。

コンパイル結果

というわけでコンパイル結果です。fileexc.yarv.txt

YARVコードの実行

それでは例外が発生した場合の処理の流れを見てみましょう。例によって実行の全体像はYARVコードの実行を読むを参照してください。

rb_f_raise(eval.c)

raiseの処理関数はrb_f_raise関数です。引数に応じて例外オブジェクト、今回は引数1つなのでRuntimeErrorオブジェクト、を作った上でrb_raise_jump関数を呼んでいます。rb_raise_jump関数は引数tagをTAG_RAISEとしてrb_longjmp関数を呼び出しています。

rb_longjmp(eval.c)

rb_longjmp関数はいろいろやってますが、引数で渡された例外オブジェクトを現在の実行スレッドのerrinfoに設定した上でJUMP_TAGマクロを実行しているという部分が肝のようです。

make_backtrace(eval.c)

rb_longjmp関数の途中で例外オブジェクトにバックトレースが設定されています。バックトレースを生成しているのはmake_backtrace関数なようなので見てみましょう。

make_backtrace関数は引数levを-1としてbacktrace関数を呼んでいるだけです。次にbacktrace関数は現在の実行スレッドを引数にvm_backtrace関数(vm.c)を呼び出しています。

次にvm_backtrace関数に移ります。まず一番上のフレームを計算しています。何故-2なのかというとフレーム情報は以下のようになっているからです。

             cfp→|現在実行しているフレーム情報
                  ...
      top_of_cfp→|rb_vm_set_finish_env関数で積んだフレーム情報
                  |th_init2関数で積んだフレーム情報
stack+stack_size→|

その後、vm_backtrace_each関数にてバックトレースが生成されています。top_of_cfpからcfpまでのフレームのファイル名、行番号、iseqの名前(Rubyで書かれている場合)もしくはメソッドの名前(Cで書かれている場合)を設定しています。なお、finishのフレームはiseqが0なのでバックトレースに設定されません。

JUMP_TAG(eval_intern.h)

JUMP_TAGマクロの定義は以下のようになっています。

#define TH_JUMP_TAG(th, st) do { \
  ruby_longjmp(th->tag->buf,(st)); \
} while (0)

#define JUMP_TAG(st) TH_JUMP_TAG(GET_THREAD(), st)

というわけでlongjmpしています。対応するsetjmp(EXEC_TAG)はどこかというとここです。

vm_eval_body(rb_thread_t *th)
{
    int state;
    VALUE result, err;
    VALUE initial = 0;

    TH_PUSH_TAG(th);
    if ((state = EXEC_TAG()) == 0) {
      vm_loop_start:
	 result = vm_eval(th, initial);

vm_eval_body(vm.c)

というわけでvm_eval_body関数に飛んできてelseが実行されます。まず、フレームをRubyで書かれているもの(raiseはCで書かれたものです)までさかのぼった上で例外が発生した命令のpcを計算しています。今回の場合は以下の命令です。

     :label(0) # start
     putnil
     putobject("Exception")
epc→send(:raise, 1, 0, VM_CALL_FCALL_BIT, 0)
     :label(1) # end
     putnil
     putobject("ensure in exc_func")
     send(:puts, 1, 0, VM_CALL_FCALL_BIT, 0)
     pop
     :label(2) # cont
     leave

次にiseqのcatch_tableからepcに対応するensureのiseqが引き当てられます。その後、pcをensureの終わり部分に設定した上でensureを実行するためのフレームが割り当てensureのiseqを実行しています。

	    /* enter catch scope */
	    GetISeqPtr(catch_iseqval, catch_iseq);
	    cfp->sp = cfp->bp + cont_sp;
	    cfp->pc = cfp->iseq->iseq_encoded + cont_pc;

	    cfp->sp[0] = err;
	    vm_push_frame(th, catch_iseq, FRAME_MAGIC_BLOCK,
			  cfp->self, (VALUE)cfp->dfp, catch_iseq->iseq_encoded,
			  cfp->sp + 1, cfp->lfp, catch_iseq->local_size - 1);
	    state = 0;
	    th->errinfo = Qnil;
	    goto vm_loop_start;

というわけでensureはブロックと同じ形式で実行されるようです。スタックは以下のような感じ。

   lfp→|GC_GUARDED_PTR(0)
        ...
        |例外オブジェクト
   dfp→|GC_GUARDED_PTR(cfp->dfp)
 sp,bp→|

throw

ensureのiseqの最後の命令はthrow(0)です。コメントにも書いてありますが例外処理を継続するためのものなようです。

throwの一つ前でgetdynamic(1, 0)が実行されているのでスタックには例外オブジェクトが積まれています。throw命令の定義を見るとまずvm_throw関数が呼ばれています。

vm_throw関数はいろいろ*1やっていますがstate = 0なのでさっくり無視してelse部分です。実行スレッドのstateをTAG_RAISEにして例外オブジェクトをそのまま返すことになります。

戻ってきてTHROW_EXCEPTIONマクロ、コンパイルオプションによって変わりますがデフォルトはOPT_DIRECT_THREADED_CODEなので

#define THROW_EXCEPTION(exc) return (VALUE)(exc)

と展開されるとします。

vm_eval_body再び

さてというわけでまたvm_eval_body関数に戻ってきました。ただし今回はlongjmpではなくvm_eval関数が終了することでvm_eval_body関数に戻っています。

	result = vm_eval(th, initial);
	if ((state = th->state) != 0) {
	    err = result;
	    th->state = 0;
	    goto exception_handler;
	}

現在のiseq(ensureのiseq)には例外が発生したpcに対応するものはないためフレームをひとつさかのぼります。ひとつさかのぼったフレームはexc_funcメソッドを実行しているフレームで現在のpcは以下です。

    :label(0) # start
    ...
    :label(1) # end
    ...
    :label(2) # cont
pc→leave

というわけでexc_funcメソッドのフレームでも例外に反応するものはないのでさらにフレームをさかのぼります。

    :label(6) # start(NODE_RESCUE)
    putnil
pc→send(:exc_func, 0, 0, VM_CALL_VCALL_BIT, 0)
    :label(7) # end(NODE_RESCUE)
    nop
    :label(8) # cont(NODE_RESCUE)
    :label(4) # end(NODE_ENSURE)
    putnil
    putobject("ensure in top")
    send(:puts, 1, 0, VM_CALL_FCALL_BIT, 0)

というわけでrescueのiseqがひっかかりました。

rescueのiseqを実行していくとleaveが実行されるのでrescueのiseqを実行しているフレームが終了します。これで例外処理終了です。rescueのiseqから戻った時点のpcは、

    :label(6) # start(NODE_RESCUE)
    putnil
    send(:exc_func, 0, 0, VM_CALL_VCALL_BIT, 0)
    :label(7) # end(NODE_RESCUE)
    nop
    :label(8) # cont(NODE_RESCUE)
    :label(4) # end(NODE_ENSURE)
pc→putnil
    putobject("ensure in top")
    send(:puts, 1, 0, VM_CALL_FCALL_BIT, 0)

なのでensureの部分が実行されます。このensureは例外処理としてではなく通常の命令実行として行われます。

おわりに

今回はRuby1.9の例外処理を見てきました。わかったこととして以下があります。

ふうむ、今回はかなり難解でした。それではみなさんもよいコードリーディングを。


*1 throwはbreakの実装などにも使われています

トップ   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS