クリエイティブ コーディングが失敗する例は後を絶ちませんが、ランダム メモリの上書きの問題は、必ずと言っていいほど動的メモリ割り当てに関連したものです。あるシステムではメモリを割り当て、しばらく使用し、そのメモリをアロケータに戻して、必然的にそれを使い続けます。一方、メモリ アロケータはメモリ ブロックをリサイクルし、それを他のシステムに与えて使うようにします。メモリは共有リソースであるため、別のサブ システムやスレッド、またはコンソールや場合によっては GPU にも与えられることがあります。
アロケータに戻されたメモリ ブロックへのポインタは、古い (stale) ポインタと呼ばれます。古いポインタの読み込みや書き込みはバグやクラッシュを引き起こす可能性が高くなります。古いポインタの読み込みは、悪いコードと同じサブ システムでクラッシュする可能性が高いためどちらかというとわかりやすいものです。古いポインタの書き込みは、関連性があるようには見えない多様なバグやクラッシュを引き起こす不可解なものです。
古いポインタの使用によるメモリ問題を見つけるための従来の対処法は、様々な戦略を用いる特殊なアロケータを使って問題を特定しようとするものです。以下はアロケータがバグを見つけ出すために試みることができる一部の方法です。
- ブロック解放後、そのブロックは特殊なビット パターンで満たされますが、古いポインタを通してこのブロックを読み出そうとするとこれが問題を引き起こす可能性があります。
- ブロック解放後、特殊なビット パターンで満たされ、しばらくそのままになります。しばらくたった後、それはリサイクルされますが、その前にビット パターンがチェックされます。ビット パターンが変わっていると、誰かが古いポインタを通して書き込んだと考えられます。
- 特殊なビット パターンを含むブロックの最初と最後にパディングした新規ブロックが作成されます。これらは、「炭鉱のカナリア (canary in a coal mine)」のようにカナリアと呼ばれます。ブロックが解放される時 (または別の時に)、カナリアをチェックし、それらが期待した値でなければ、誰かがそのブロックの始まりか終わりに書き込みしたと考えられます。これは古いポインタではありませんが、同じような不可解な挙動につながることがあります。
- 最近の CPU にはページと呼ばれる読み出し、書き込みから保護するメモリ範囲を可能にするハードウェア機能があります。これについては、この投稿の残り部分で重点的に説明します。
メモリのバグを見つけるためにメモリ保護を使用する
最近の CPU の仮想メモリ システムには、この投稿で取り上げる範囲を超えた多くの機能があります。メモリ バグを見つけ出すために、以下の 2 つの機能を使用します。
- メモリに対して膨大な量のアドレス空間を割り当てる機能。ある意味これは同じアドレス範囲を二度と使用する必要がないことを表します。これにより、正当なメモリ アクセスとバグの区別がつきやすくなります。
- アドレス空間の範囲を読み出し不可 / 書き込み不可としてマークする機能。古いポインタを介して読み出しや書き込みをしようとすると CPU がクラッシュする原因。
CPU にはメモリ保護をどれくらい細かいレベルでするかという決まった下限があります。メモリは、ページ単位で割り当て、マッピング、開放、保護されます。ほとんどの CPU では、様々なページ サイズを使えますが、通常は利用可能な最小ページは 4096 バイトになります。従って、CPU の仮想メモリ機能を使ってメモリのバグをチェックするには、4k のチャンクを割り当てる必要があります。例えば、16 バイトしか必要ないのに 4096 バイトを割り当てると多くの無駄が生じます。そのため、メモリ保護を使用するためにページ単位で割り当てるアロケータは多くのメモリを使います。通常 2GB のメモリで実行するゲームでは、こうしたアロケータで 30GB を消費することがあります。割り当てと解放を行う度に OS を呼び出すためパフォーマンスも低下します。
ページの終わり部分にメモリ ブロックを入れて、次のページを保護することで、CPU のハードウェアを使ってそのメモリ ブロックの終わり部分を超えた場所の書き込み/読み出しから保護することもできます。または、ページの開始部分の前にメモリ ブロックを入れて前のページを保護するようにして、メモリ ブロックの前の読み出しや書き込みに対して保護することもできます。半端なサイズや小さなサイズのメモリ ブロックの場合、ページが細かすぎるため、ブロックの前後両方へのアクセスに対して保護する方法はありません。
最近、Paragon はランダムなメモリの上書きの症状に悩まされていました。幸いなことに、我々のオープン ソース コミュニティで活動しているメンバーが素晴らしいアロケータを実装してくれました。これを、 pull request として提供し 優れたブログ投稿記事で説明しています。
これを使って直ちに 5 つのクリティカルなバグを見つけました。実際のバグは非常に複雑な場合がありますが、以下のように単純な問題もあります。
GameplayCueDataMap.Add(ThisGameplayCueTag) = GameplayCueDataMap.FindChecked(Parent);
この場合、GameplayCueDataMap は連想配列であり、UE4 の用語では TMap です。まず、FindChecked でその親アイテムを探して、参照 (ポインタ) をアイテムに戻します。次に Add を呼び出すと、TMap 内のデータ構造がリサイズし、戻されたアイテムがポイントするメモリ ブロックを解放します。最後に、アサインメントを試みると、古いポインタがデリファレンスされます。特殊なデバッグ アロケータがなければ、こうしたバグはまず見つかりません。誤った挙動を示すことすらないでしょう。問題が顕在化するのは、一年で最悪の日の真夜中になることもあるでしょう。
解決策は簡単です。コンテナを展開する可能性が生じる前に、デリファレンスを強制します。そうすれば、古いポインタは存在しません。
int32 ParentValue = GameplayCueDataMap.FindChecked(Parent);
GameplayCueDataMap.Add(ThisGameplayCueTag, ParentValue);
幸いなことに、この種のバグはあまり一般的ではありません。もしこうしたことが起こったら、適切なツールを使用することがそれを突き止める鍵となります。