今回は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ツリーに変換されるかはスクリプト解析を読むを参照してください。ここでは目新しい項目だけを取り上げます。なお、raiseは予約語ではなく関数なのでNODE_FCALLになります。
beginが見つかるとprimary規則のkeyword_beginがひっかかります。
nd_type = NODE_BEGIN u1.value = 0 u2.value = bodystmt u3.value = 0
次に、beginの中身とrescue以下がbodystmt規則にひっかかります。トップレベルの方ではelseは付けていないので以下のNODEが構築されます。
NODE_ENSURE
NODE_RESCUE
compstmt
opt_rescue
0(opt_else)
opt_ensure
opt_rescue規則です。補足する例外クラスは指定しておらず他のrescueもないので以下のNODEが構築されます。
nd_type = NODE_RESBODY u1.value = 0(opt_rescue) u2.value = compstmt u3.value = 0(exc_list)
opt_ensure規則ではensureに続くcompstmtがそのままNODEになります。今回の場合はともにNODE_FCALL(puts)です。
というわけで変換結果です。
exc.node.txt
それでは次にNODEからYARVコードへの変換です。全体的な流れについてはYARVコードへのコンパイルを読むを参照してください。
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マクロを利用して例外が発生したときのための情報を記録しています。
次にensureの本体NODE_RESCUEを見てみましょう。まずrescueの本体がNEW_CHILD_ISEQVALマクロにかけられています。ensureと同様にlocal_tableが設定され、今度はCOMPILEマクロでコンパイルが行われています。ここら辺から例外を補足したときのコードは別のフレームを割り当てて実行されるんだろうな〜と想像できます。
NEW_CHILD_ISEQVALマクロから帰ってくると開始ラベルの追加、begin部分のコンパイル、終了ラベルの追加が行われています。elseはないので無視して、nopと終わり部分のラベルが追加されています。
最後にADD_CATCH_ENTRYマクロを利用して例外が発生したときのための情報を記録しています。
最後にrescue本体のNODE_RESBODYを見てみましょう。
まず、発生された例外が指定された例外かチェックし、そうならrescue本体に飛ぶという命令が追加されています。補足する例外の種類を指定していない場合はStandardErrorかのチェックが行われています。
次にrescueの後ろのラベルへのjumpを追加しています。例外が指定したもの以外の場合に実行されます。
次に例外が指定したものの場合のjump先ラベルを追加し、rescue本体をコンパイル、leaveが追加されています。
最後に例外が指定したもの以外の場合のjump先ラベルを追加しています。
というわけでコンパイル結果です。
exc.yarv.txt
それでは例外が発生した場合の処理の流れを見てみましょう。例によって実行の全体像はYARVコードの実行を読むを参照してください。
raiseの処理関数はrb_f_raise関数です。引数に応じて例外オブジェクト、今回は引数1つなのでRuntimeErrorオブジェクト、を作った上でrb_raise_jump関数を呼んでいます。rb_raise_jump関数は引数tagをTAG_RAISEとしてrb_longjmp関数を呼び出しています。
rb_longjmp関数はいろいろやってますが、引数で渡された例外オブジェクトを現在の実行スレッドのerrinfoに設定した上でJUMP_TAGマクロを実行しているという部分が肝のようです。
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マクロの定義は以下のようになっています。
#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関数に飛んできて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→|
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関数に戻ってきました。ただし今回は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の例外処理を見てきました。わかったこととして以下があります。
ふうむ、今回はかなり難解でした。それではみなさんもよいコードリーディングを。