読者です 読者をやめる 読者になる 読者になる

restore process state

プロセスの内部状態を保存して後で復元したい、ということがゲーム開発ではよくあります (いわゆる state save)。チェックポイントから再開みたいな機能の実現の他、あると開発中何かと役に立ちます。定期的に state save しつつ、気になったところがあったら巻き戻して Alcantarea の実行時コード編集で改良、という TAS みたいなやりかたが最近の私の開発スタイルです。
通常は boost::serialization とかを使って必要なデータを serialize する処理を書いてタイトル毎に実装しますが、これをもっと簡単に、汎用的に、できれば既存のプログラムに外部から適用できる形で実現する方法はないか、と夢見た方も多いんじゃないかと思います。これを実現できるかもしれない方法を思いついて、ある程度のところまで成果を出せたので経過を書き残します。
(Virtual Machine 使うのが最も確実な方法ではありますが、大げさすぎるので今回なしの方向で)


汎用 state save は、以下の 3 つを保存して復元できれば実現できそうな気がします。

  • メモリの状態
  • スレッドの状態
  • カーネルオブジェクトの状態

メモリの状態とスレッドのレジスタの状態が完全に再現できればポインタ、プログラムカウンタ、リターンアドレスなどがそのまま有効になり、実行を再開できるはずです。ファイルや mutex などのカーネルオブジェクトも多くの場合復元が必須になるでしょう。
今回は前提として state の有効期限はプロセスの生存期間中のみとします。プロセスを再生成するところまで考慮すると厄介な障害が増えるためです。 基本戦略は対象プロセスに DLL Injection で独自のコードをねじ込み、主要な WinAPI を API hook で乗っ取って復元可能にする独自ルーチンを挟む、というものです。

  • メモリの状態

メモリにはモジュール、ヒープ、ページメモリ、スタック、TLS などがあり、それぞれ別の対処が必要です。

・モジュール
メモリにマップされた exe や dll の領域です。プロセスの再生成までやる場合、これの復元が非常に難しいチャレンジになると予想されますが、今回そこは考えません。しかし global 変数や関数内 static 変数なんかはこのモジュール領域内にあるため、これらは保存&復元が必要になります。
CreateToolhelp32Snapshot(), Module32First(), Module32Next() でロードされている全モジュールの詳細な情報を取得できます。これでモジュールの領域を取得し、VirtualQuery() で順次属性を調べて書き込み可能になってる箇所を探して保存します。

・ヒープ
malloc() や new で動的に確保される領域です。これらが確保する領域のアドレスは予測が難しく、そのままでは正確なメモリレイアウトの復元は困難です。このためちょっとしたハックが必要になります。過去に何度か触れたように、Windows の CRT の malloc() は最終的に HeapAlloc() に行き着きます。なので HeapAlloc() を乗っ取って独自のメモリ管理ルーチンに差し替えれば malloc() を復元可能にできます。
今回の例では、初期化時に巨大な領域を確保してその領域を dlmalloc で管理、保存するときはその領域をまるごと書き出しています。

・ページメモリ
VirtualAlloc() で確保された領域です。ヒープと違ってアドレス指定の確保ができるため、メモリレイアウトの復元は簡単です。
今回の例では VirtualAlloc()/VirtualFree() を hook して領域を記録し、それをまるごと保存&復元しています。

・スタック
各スレッドのスタック領域で、関数内ローカル変数やリターンアドレスがある領域です。どちらかというとスレッドの状態に属する話です。 GetContext() でスレッドのレジスタの状態を取得でき、esp (x64 なら rsp) がそのスレッドのスタックのどこかを指しているので、そこを VirtualQuery() で調べることでベースアドレスとサイズを取得できます。あとはその領域をまるごと保存するだけです。
スレッドの巡回には CreateToolhelp32Snapshot(), Thread32First(), Thread32Next() を使います。この API は全プロセスの全スレッドを巡回するので、該当スレッドが自身のプロセスに属するかをチェックする必要がある点に注意が必要です。

・TLS
未検証。TlsAlloc() 一族を乗っ取ればなんとかできそうな気がします。fs レジスタから Thread Information Block をたどって直接保存することでも実現できるかもしれません。

  • スレッドの状態

スタックの保存についてはメモリの項で触れた通りで、それ以外の状態、レジスタの内容については、GetContext() & SetContext() するだけです。意外と簡単です。

  • カーネルオブジェクトの状態

ファイルとか mutex とか socket とか。厄介な部分です。
これらに関しては汎用的な対処法がなく、個別対応が必要になります。関連する API を乗っ取って状態を追跡して復元可能にするための情報を記録。HANDLE 自身も復元可能にする必要があるため、WinAPI が返した HANDLE は直接は見せず、独自に生成&管理した復元可能な HANDLE を返して翻訳する処理を挟む必要があります。ネットワークやプロセス間通信が絡むと復元は困難を極めると予想されます…。また、DirectX の COM オブジェクトなんかも同じ対応が必要になると思われます。



https://github.com/i-saint/scribble/tree/master/RestoreProcessState
上記解説のメモリとスレッドの保存を実装して、既存プログラムへの外部からの state save & load を限定的ながら実現したのがこちら。メインスレッドのみ状態を保存、メモリはメインモジュール (exe) が扱うメモリのみ状態を保存。カーネルオブジェクトの類は未考慮。

動画は勝手に アスタブリード を実験に使わせていただいたものです。state load 後にサウンドのスレッドのストリーミング処理と思しき部分でクラッシュするという問題があり、対応が大変そうだったため、事前にデバッガでそのスレッドを止めることで強引に回避しています。真面目に対処する場合カーネルオブジェクトまで含めてきちんと面倒を見る必要がありそうです。
カーネルオブジェクトの類が未考慮なので、save -> load の間にテキスチャの破棄とかがあったら死にますし、save/load のタイミングが悪くても死にます。(mutex を lock した直後に load してしまうと dead lock が発生、など) 実用には程遠いですが、運が良ければ機能する可能性がなきにしもあらずです。

主要なカーネルオブジェクトと DirectX あたりまで対応できたら実用の域まで行けそうな気がしますが、たぶん数ヶ月を要する労力が必要な上、本当に可能かどうかも定かではないので今回はここで切り上げました。余裕がある時期が訪れたら再度真剣に検証してみたいところです。