Gauche:OOM
要求
Gaucheは現状(0.8.13)、メモリを使い尽くした時は、Scm_Panic()を実行して、その時点で即座に終了します。
;; メモリが1G以下のマシンで
$ gosh
gosh> (make-vector 530000000)
GC Warning: Out of Memory! Returning NIL!
out of memory (2120000008). aborting...
これで困る事は普通は無いですが、特定の状況では、これでプロセス自体が終了してしまうのが嫌な場合があります。
仮パッチ
nekoie: 前述の問題に対する仮パッチを作ってみました。
- http://d.tir.jp/Gauche-0.8.13-oom_handler_survivable.patch
- パッチをあてたら、以下のような感じで、CFLAGSに-DGAUCHE_EXPERIMENTAL_GC_OOM_FNを含めて、コンパイルします(このフラグをつけないと、元と同じGaucheが生成されてしまいます)
CFLAGS="-O2 -march=pentium4 -DGAUCHE_EXPERIMENTAL_GC_OOM_FN" ./configure ...
make all check
- コンパイルしたら、試します
$ src/gosh -ftest -Ilib
gosh> (make-vector 530000000)
GC Warning: Out of Memory! Returning NIL!
*** SYSTEM-ERROR: out of memory (2120000008): Cannot allocate memory
Stack Trace:
_______________________________________
gosh>
パッチの概要
パッチは短いので、直接見てもいい気がします。
- Scm_Init()時に、OOM用領域をGC_malloc_atomic_uncollectable()しておく
- メモリが確保できなくなって、GC_oom_fnが呼ばれたら、以下の挙動を行う
- OOM用領域がNULLならScm_Panic()で終了
- OOM用領域がNULLでないなら、GC_FREE()してGC_gcollect()して空き領域を確保してから、Scm_SysError()を投げる
- SCM_MALLOC()及びSCM_MALLOC_ATOMIC()時に、以下を実行する関数を呼ぶ
- OOM用領域がNULLかつGC内の空きメモリが一定値以上なら、Scm_Init()時と同じように、OOM用領域をGC_malloc_atomic_uncollectable()して再確保する
この挙動により、OOMが発生した場合は、以下のどれかの状況になります。
- 突然、超巨大な領域が要求された場合は、ただ単にエラー例外が投げられて失敗する(安全)
- 慢性的にメモリが不足してOOMが発生した場合
- OS自体のoom-killerによって、やっぱりプロセス自体が殺される
- 自分の環境で「(let loop ((r '())) (loop (cons 1 r)))」とかのコードで試したら、そうなった(linux-2.6.11)
- 一時的に空きメモリがわずかに回復してから、エラー例外が投げられる。そして……
- エラー例外によって(ある程度の)大域脱出がなされる事で、GC可能な領域が増え、システム自体は維持される
- エラー例外によって(ある程度の)大域脱出がなされる途中で、guardやdynamic-windのafter thunkに引っかかって、そこでまたメモリが消費され、二度目のOOMが発生してScm_Panic()で終了
さっき例に出した、ブラウザ組み込みのJavaScript的な状況では、1番目の状況を回避できる事が重要です。
- 2番目の状況は、そうなる前に(gc-stat)とかをたまに見て、消費量が大きくなりだしたら自分で中断する事が一応可能ですが、1番目の状況はそういう訳にはいかないので……。
問題点
- 前述の通り、「OOMを完全に防げている」とは言い難い
- Scm_GCRecoverOOM()は、もう少し軽量化する余地がある
- SCM_MALLOCマクロの方に手を入れて、oom_reserved_ptrがNULLの時だけこの関数を呼ぶようにすれば、普段の増加コストはポインタ一個のNULLチェック分だけで済む。しかし、あまり綺麗に書けなかったので、とりあえずこのままにしときます
- OOMが発生すると、プロセスのstderrに「GC Warning: Out of Memory! Returning NIL!」が出力される
- GC_current_warn_procを適当に設定すれば出力しないようにできるっぽいが、あった方が「OOMが発生した」という事が分かりやすい気がするので、一応残しておきます
- OOM用領域の予約サイズの決定方法が謎
- 関数名やコメントの英語が怪しい
- 本当に、OOMが発生した時の対応は「エラー例外」でいいのか?
Scheme上での、OOMのセマンティクスは?
このパッチでは、OOMが起こったら「その場所からエラー例外を投げる」だけです。
- 単にエラー例外を投げるだけの場合、「単純なconsですらエラー例外が発生する可能性が常にある」という事で、厳密なコードを書く場合、ほぼあらゆる場面でエラー例外が発生する可能性を考えなくてはならなくなる
- 現実的には、そこまで考える必要はまず無いとは思うけれど
- じゃあ、どうすればいいのかと言うと、「途中でOOMが発生したevalは信用ならないので、もう途中で中断する」という扱いにするのが妥当?
- ok。じゃあ、Scm_Eval直後で、脱出用継続を保持しといて、oom_handler()からそれを辿って、Scm_Evalまで戻ってから改めてエラー例外を投げる事にしようか。
- でも、OOMが発生する直前とかに、eval内からeval外の変数に継続をset!されてたら、せっかく抜けても、またevalが再実行されそうなんだけど?
- それと、継続を使って抜ける場合は、dynamic-windのafter thunkが実行される気がするけど、いいの?
- 仮に無理矢理dynamic-windのafter thunkを実行せずに抜けたとして、Scheme的にはそれでいいの?
- 例えば、さっきの、eval外の変数にset!された継続を辿って再突入した場合、after thunkは実行されてないのに再度before thunkは実行される事になるけど、これって、beforeとafterの対応が取れなくなるよね?
- そう考えると、dynamic-windのafter thunkは実行されるべきだよね。でも、OOM発生してるのに、そんな余裕あるの?下手したら二回目のOOMが発生して結局、Scm_Panic()されそうだけど。
- そもそも、OOMのScheme的セマンティクスって、どんな扱いになるべきなの?無限エクステントと相反してる気もするんだけど?
このパッチの結論
- このパッチは「突然、超巨大なメモリが要求されてOOMが発生」した状況には確かに有効だけど、「慢性的なメモリ不足からOOMが発生」した状況ではあまり役に立ちそうにない
おまけ(このパッチの為に調査した結果など)
別の用途にも再利用できそうな気がするので、情報として残しておきます。
間違いがあったらごめんなさい。
SCM_MALLOC/SCM_MALLOC_ATOMICをカスタマイズする際の注意点
- src/gauche.hの中のSCM_INLINE_MALLOC_PRIMITIVESを0にしておく。または、gauche/memory.h内の該当箇所(GC_*を使ってるところ)にも対応コードを入れる。
- src/vm.cの中の、run_loop()の外側で実行される、GC_*類及びSCM_MALLOC類(及び内部でmalloc類を使用している各手続き)を含むC関数が危険な可能性がある(これらのC関数内でmallocされた際にmallocがNULLを返したり、エラー例外を投げたりすると……)
- 通常はvmは一つなので問題にならないけれど、複数のvmを扱う事になった時に困る(例えばgauche.threadsを使う時とか?)。
- もし、SCM_MALLOC等からエラー例外なり継続の呼び出しなりを行うのであれば、後述の、SCM_INTERNAL_MUTEX_LOCKの部分を全てチェックして、必要な箇所に手を入れる必要がある
クリティカルセクションについて
- Gaucheでは、クリティカルセクションは(本来はスレッド安全の目的で?)SCM_INTERNAL_MUTEX_LOCK()とSCM_INTERNAL_MUTEX_UNLOCK()に囲まれている。
- つまり、SCM_INTERNAL_MUTEX_LOCK()したら、必ずSCM_INTERNAL_MUTEX_UNLOCK()してから抜ける必要がある。
- しかし、このパッチを導入すると、単なるconsですらエラー例外を投げる可能性が出てくる為、クリティカルセクション中からmallocを行う部分があるなら、それをSCM_UNWIND_PROTECTで囲み、確実にSCM_INTERNAL_MUTEX_UNLOCK()(及び、他に必要な処理があれば、それも)が実行されるように修正を行う必要がある。
具体的に、Gauche-0.8.13のソースを見て、必要だと判定された部分は、全部で以下の通り(※目でチェックしたので漏れのある可能性があります。また、バージョンが進むと増えたり減ったりするでしょう)
- src/char.c内、install_charsets()にSCM_UNWIND_PROTECT導入(Scm_MakeEmptyCharSet)
- src/core.c内、Scm_AddFeature()にSCM_UNWIND_PROTECT導入(Scm_Cons)
- src/keyword.c内、Scm_MakeKeyword()にSCM_UNWIND_PROTECT導入(SCM_NEW, Scm_CopyString, Scm_HashTablePut)
- src/load.c内、Scm_GetLoadPath()とScm_GetDynLoadPath()にSCM_UNWIND_PROTECT導入(Scm_CopyList)
- src/load.c内、Scm_AddLoadPath()にSCM_UNWIND_PROTECT導入(SCM_LIST1, Scm_Append2, Scm_Cons)
- src/load.c内、Scm_DynLoad()にSCM_UNWIND_PROTECT追加または拡張(Scm_Cons)
- src/load.c内、Scm_Require()にSCM_UNWIND_PROTECT導入(Scm_Acons)
- src/load.c内、Scm_Provide()にSCM_UNWIND_PROTECT導入(Scm_Cons)
- src/module.c内、lookup_module_create()にSCM_UNWIND_PROTECT導入(SCM_DICT_CREATE)
- src/module.c内、Scm_FindBinding()にSCM_UNWIND_PROTECT導入(Scm_Cons)
- src/module.c内、Scm_Define()とScm_DefineConstにSCM_UNWIND_PROTECT導入(Scm_MakeGloc, Scm_MakeConstGloc, Scm_HashTableSet, Scm_Cons)
- src/module.c内、Scm_ImportModules()にSCM_UNWIND_PROTECT導入(Scm_Cons)
- src/module.c内、Scm_ExportSymbols()にSCM_UNWIND_PROTECT導入(Scm_Cons, Scm_MakeGloc, SCM_DICT_CREATE)
- src/module.c内、Scm_ExportAll()にSCM_UNWIND_PROTECT導入(Scm_Cons)
- src/module.c内、Scm_AllModules()にSCM_UNWIND_PROTECT導入(SCM_APPEND1)
- src/port.c内、register_buffered_port()とunregister_buffered_port()とScm_FlushAllPorts()にSCM_UNWIND_PROTECT導入(Scm_WeakVectorSet)
- src/read.c内、Scm_DefineReaderCtor()にSCM_UNWIND_PROTECT導入(Scm_HashTablePut)
- src/system.c内、win_process_get_array()にSCM_UNWIND_PROTECT導入(Scm_ListToArray)
こうして見る限りでは、パフォーマンスに影響を与えそうな箇所は、Scm_MakeKeyword()ぐらいのように思えます。
議論
Shiro(2008/04/16 15:41:20 PDT): これは結構大きい問題なんで、ページを別にして
議論しませんか?
で、だんだん思い出して来たんですが、クリティカルセクションの中
(SCM_INTERNAL_MUTEX_LOCKとSCM_INTERNAL_MUTEX_UNLOCKに挟まれてる間)に
Scm_Consを呼ぶケースってのはあったように思います。Scm_Consはエラーを
投げない (OOM時はそもそも帰ってこないので) という前提だっので。
そういう箇所をすべてSCM_UNWIND_PROTECTすると性能に大きな影響が
出そうに思えます。
漸進的にOOMになるケースがどうせ救えないのであれば、
巨大なメモリチャンクのアロケーションが要求される可能性のある
個別のケースに限り、特別な処理を入れるべきなのかもしれません。
nekoie(2008/04/17 04:41:49 PDT): 了解です。とりあえずページを作ってみました。
なるほどです……。
それだと確かに全コードをチェックして直していく必要がありますし、
性能面への影響も大きそうですね。
逆に、巨大なメモリ確保時のOOMの対応だけでいいのなら、
事前にOOM用領域を(再)確保したりする必要はないし、
性能的にも影響はほぼ無いですね。
「巨大なメモリ確保」かどうかをどうやって判定すべきかの問題はありますけど。
koguro(2008/04/18 09:13:20 PDT): 元々やりたかったことからすると、OOM対策を施した特殊なevalを作ればいいんじゃないかな、と思って上記のパッチの方向性とは若干異なりますが拡張モジュールを作ってみました(http://homepage.mac.com/naoki.koguro/prog/sandbox-0.0.tgz )。やっていることは以下の通りです。
- 無名モジュールをラップしたオブジェクト sandbox を作成できるようにする。OutOfMemoryで終了させたくない処理はこのモジュール内で実行するようにします。
- 先のsandboxでevalするための sandbox-eval という手続きを用意する。このsandbox-evalは以下のような特徴を持ちます。
- GC_oom_fnをすげ替えて、もしeval中にOutOfMemoryエラーが発生した場合、sandboxが持つ無名モジュールをパージするようにします。これにより、メモリを食いつぶすような処理がeval内で実行されてもモジュールごとGCされることが期待できる。
- もしかしたらScmVMが変な状態になるかもしれないので、evalを実行するときのVMはsandbox用に新しく作ったものですげ替えておく。
適当に作ったものなので、スレッドセーフではありませんし、上で書かれているクリティカルセクション中にOutOfMemoryが発生した場合はデッドロックが起こる、といった問題を抱えていますが、こんなアプローチもあるんじゃないかなと思いました。あと、無名モジュールの領域が本当にGCされるタイミングがよく分からないので、nekoieさんのパッチのように予約領域を確保した方が安全かもしれません。
ちなみにこんな感じで使えます(Gauche本体へのパッチはいりません)。
gosh> (use sandbox)
#<undef>
gosh> (define sandbox (make-sandbox))
sandbox
gosh> (while #t (sandbox-eval `(define ,(gensym) (make-vector 1000000)) sandbox))
(.. たくさんGC Warningがでる ..)
GC Warning: Out of Memory! Returning NIL!
*** ERROR: Out of memory
Stack Trace:
_______________________________________
0 (sandbox-eval `(define ,(gensym) (make-vector 1000000)) sandbox)
At line 3 of "(stdin)"
gosh> (gc-stat)
((:total-heap-size 3645329408) (:free-bytes 666996736) (:bytes-since-gc 56020976) (:total-bytes 2982020496))
gosh> (gc)
#<undef>
gosh> (gc-stat)
((:total-heap-size 3645329408) (:free-bytes 1019314176) (:bytes-since-gc 1152) (:total-bytes 2982023504))
gosh> (define a (make-vector 1000000))
a
gosh>
- nekoie(2008/04/19 05:31:11 PDT): おお、早速試させてもらいました。
継続のエクステントをsandbox-evalの外と中で切ってあるので、
前述の、継続を辿ってeval内に戻ってきたりの問題も解決されてますね。
でも、クリティカルセクション中の場合にどうするかはやっぱり難しいですね……。
このモジュールをベースに、eval/memlimit的なメモリ使用量監視機能も
組み込めば、とりあえず自分が欲しかった機能は得られそうです。
どうもありがとうございます。
Tag: GC
Last modified : 2012/02/02 12:29:13 UTC