Gauche:EscapeHandlerAndFrameRelocation
2005/04/07 04:17:16 PDT: 開発中の0.8.4に向けたブランチでscmailがSEGVる事例に遭遇。 調査を進めたところ、これまでの全ての版に存在する、結構深いバグであることが わかった。後々のためにメモしておく。
症状
エラーハンドラ内でフレームリロケーションが起こった後に、エラーハンドラから 復帰すると死ぬ。
簡単な再現方法
gosh> (gauche-version) "0.8.3" gosh> (define (stack-buster k) (if (zero? k) 1 (+ (stack-buster (- k 1)) 1))) stack-buster gosh> (with-error-handler (lambda (e) (stack-buster 100000) "ok") (lambda () (error "foo"))) Segmentation fault
問題の所在
(with-error-handler handler body) は、原理的には呼ばれた時点の 継続kを保存して、bodyを実行し、エラーが起きた場合はhandlerを kを継続として実行する、という動作をする (要するに、エラーが起きてhandlerが 呼ばれて返った値はwith-error-handlerから返って来るということ)。
ただ、with-error-handlerはかなり頻繁に呼ばれるプリミティブであり、 いちいち第一級の継続を作るのはGaucheの実装ではちと重い。with-error-handler で作られる継続はupward-onlyであるという事実を利用して、内部的には escapePointというオブジェクトにVMスタック上の継続フレームのポインタを保存する だけで済ませている。
VMスタック上にある継続フレームは、スタックオーバフローや第一級の継続が 捕捉された場合にヒープに移動される。VMスタック上のフレームを指している ポインタはその際に移動先を指すように正しくアップデートされなければならない。 VMはアクティブなescapePointのチェインを保持していて、フレームリロケーションが 起きるとチェインをなめてポインタをアップデートしている。escapePointのチェインの 先頭は常に、現在のエラーハンドラを指しているescapePointだ。
以降、説明のために、escapePointはエラーハンドラehandlerと、with-error-handler 突入時の継続(VMスタックフレームを指す、半人前の継続) cont、そして 外側のescapePointへのポインタ prevを持つものとする。
エラーが起きた場合は、現在のescapePointチェインの先頭(*current-EP*)にあるescapePointの ehandlerが呼ばれるわけだが、そのハンドラの実行中に起きたエラーは 外側のescapePointで捕捉されなければならない。そこで0.8.3では、ehandlerの実行前に 現在のescapePointをPOPしてehandlerを実行し、その結果を継続contに 渡す、という動作をしている。概略をコードで説明するとこんな感じ。
(define (handle-error exception) (let1 ep *current-EP* (set! *current-EP* (ref ep 'prev)) ((ref ep 'cont) ((ref ep 'ehandler) exception))))
ここで、ehandler実行中にフレーム移動が起きた場合、 既にepはvmの指しているescapePointチェイン上に無いため、 そのcontポインタはアップデートされない。ehandlerから 返って来た後に (ref ep 'cont) を起動しようとすると、 フレーム移動後のごみを起動することになり、死ぬ。
(上のSchemeは疑似コードであり、実際はcontはCのポインタなので、 escapePointをポップする前にcontを保存しておく、というわけにも いかない。)
自明な解
解決するのがわかってるけど、Gaucheでは採れない解。
- 全てを第一級の継続で扱う
- strict copying GCを使い、stack frameの回収もそれで行う。
(継続絡みの問題の多くは、原理通りの第一級の継続を素直に実装していれば 現れない。実装上の制限からいろいろごまかそうとしてひっかかることが多い。)
解決策
epをvmから見える場所に保持しておけば良いだけなんだが、これが意外と厄介だ。
エラーハンドラ内で再びwith-error-handlerが呼ばれる可能性があり、その場合、 escapePointのチェインは単鎖でなくツリーになる。新たに作られるescapePointを ep2とすると、ep2->prev が ep->prev と同じフレームを指すことになるからだ。 この分岐はいくらでも起きるので、epを固定位置に保存することはできないし、 double linked listで管理するわけにもいかない。
ehandler内で別の継続が起動された場合はepが呼ばれることが無くなるので、 その場合はepへの参照を落とさないとならない。双方向ポインタとかは これが低コストで出来ないのでだめ。
現在のescapePoint (EP)からこのケースのようなfloating escapePoint へのback pointerを持たせて、エラーハンドラ突入時と新しいEPを つなぐ時にうまくポインタを切替えてやったらうまくいくかな?
- with-error-handlerのbodyを実行開始:
(let1 newEP (make-EP :prev *current-EP* :back (ref *current-EP* 'back)) (set! *current-EP* newEP))
- with-error-handlerのhandlerを実行開始:
(set! (ref (ref *current-EP* 'prev) 'back) *current-EP*) (set! *current-EP* (ref *current-EP* 'prev))
- handlerから復帰するか脱出した際:
(set! (ref *current-EP* 'back) (ref (ref *current-EP* 'back) 'back))
検証中…