C# と C++ : Memory Allocation (2)

C#C++ もオブジェクトを生成するために "new" 命令を記述した箇所とそのオブジェクトへの (ヒープ) メモリ割り当てが発生する箇所は一致します*1.しかし,実際にメモリ割り当てが完了するまでに行われる処理については大きく異なります.

  1. C# では,標準アロケータによる利用可能なメモリ領域の探索は一般的に非常に低コストである*2
  2. C++ では,標準アロケータによる利用可能なメモリ領域の探索は一般的に高コストである.

確かにメモリ解放処理に比べれば,メモリ確保処理は C#C++ソースコードレベルでは類似しています.しかしそこに働く力学が大きく異なるために,コピー & ペーストでソースコードをやりとりするのは危険かもしれません.CLR と標準 CRT について,メモリアロケータの特徴をそれぞれよく理解しておく必要があります.

一般的なヒープ機能の問題とは?

ヒープを使用する場合に起こる最も一般的な障害を以下に示します。

  • 割り当て操作の結果として、スローダウンが起こる 割り当てには時間がかかります。最も可能性のある原因としては、フリー リストにブロックがなく、そのためにランタイム アロケータのコードによって、大きな空きブロックを検索するサイクル、またはバックエンド アロケータからの新しいブロックを割り当てるサイクルが行われることが挙げられます。
  • 解放操作の結果として、スローダウンが起こる 主に結合が有効になっている場合、解放操作にはより多くのサイクルが必要になります。結合が行われている間、各解放操作は隣のブロックを "検索" し、それらを抽出してより大きなブロックを作成し、そのブロックをフリー リストに再挿入します。検索の間、メモリはランダムに操作されるため、キャッシュの喪失やパフォーマンスのスローダウンが起こります。
  • ヒープの競合によりスローダウンが起こる 競合とは、2 つ以上のスレッドが同時にデータへアクセスしようとして、1 つのスレッドが作業を完了するまでほかのスレッドが作業を実行できないでいる状態を示します。競合は常に問題を引き起こします。マルチプロセッサ システムでは最も大きな問題となります。メモリ ブロックを大量に使用するアプリケーションや DLL は、マルチプロセッサ システムで複数のスレッドと共に実行した場合にスローダウンを起こします。単一ロックを使用する一般的なソリューションでは、ヒープの操作がシリアル化されています。シリアル化により、スレッドはロックを待つ間にコンテキストの切り替えを行います。点滅している赤信号での停止および発進によって生じるスローダウンを想像してください。
    競合は通常、スレッドおよびプロセスのコンテキスト切り替え要因になります。コンテキストの切り替えには非常にコストがかかりますが、プロセッサ キャッシュから失ったデータを、後にスレッドが回復した際に再構築する場合よりはかかりません。
  • ヒープの破損によりスローダウンが起こる アプリケーションがヒープ ブロックを適切に使用しなかった場合に破損が起こります。一般的なシナリオとしては、解放した後のブロックを再び解放したり使用すると、境界を越えて上書きを行ってしまうという明らかな問題があります (破損についてはこの記事では扱いません。詳細については、Visual C++ プログラマーズ ガイド プログラムのデバッグで、メモリの上書きおよびメモリ リークについて参照してください)。
  • 頻繁な alloc および realloc によりスローダウンが起こる これは、スクリプト言語を使用する場合に起こる非常に一般的な現象です。文字列が繰り返し割り当てられることによって文字列が大きくなり、解放されます。このようなコードは実行しないでください。可能であれば、大きな文字列を割り当てるようにして、バッファを使用してください。別の方法として、連結操作を最小限に抑えることもできます。
  • 次に、Cランタイムヒープのメモリの確保方法を見てみよう。Cランタイムヒープがオブジェクトにメモリを割り当てるためには、データ構造のリストをたどる必要がある。十分なサイズを持つブロックが見つかったら、それを分割し、全体に変動が起きないようにするためにリストの節点のポインタを書き換えなければならない。それに対し、管理ヒープでは、オブジェクトの確保とは、単純にポインタへの値の加算である。比較すると、これは明白に速い。実際、管理ヒープからのオブジェクトの確保は、スレッドのスタックからのメモリの確保とほとんど同じスピードである。

    ほとんどのヒープ (Cランタイムヒープなど) は、フリースペースを見つけた場所に、オブジェクトを割り当てていく。そのため、連続して複数のオブジェクトを作成したとき、これらのオブジェクトは数MBも離れた位置に配置される可能性がある。しかし、管理ヒープでは、連続して確保されたオブジェクトは、メモリの連続領域に配置されることが保証されている。

    先ほど述べた仮定のなかには、新しいオブジェクトは互いに強い関係を持つ傾向があり、同時にアクセスされる可能性が高いというものが含まれていた。新オブジェクトがメモリの隣り合った位置に配置されるので、参照の局所性による処理性能向上が見込める。より具体的に言えば、それらすべてのオブジェクトがCPUのキャッシュに格納される可能性が高い。CPUがキャッシュミスによるRAMアクセスを起こさずに、ほとんどの処理を実行できるようになるので、アプリケーションからのこれらのオブジェクトへのアクセスはかなり高速なものにある。

    同期不要のメモリ確保 マルチプロセッサシステムでは、管理ヒープのジェネレーション0は、スレッド当たり1つずつ、複数のメモリアリーナに分割される。こうすれば、複数のスレッドが同時にメモリ確保を行なっても、ヒープに対する排他的なアクセスは不要になる。

    cf) Javaの理論と実践: ガベージコレクションとパフォーマンス

    *1:もちろん C++ については "常に" は成り立ちません.例えば STLvector は placement new を用いた高度なメモリ管理が行われることが多いです

    *2:ただこれ,Arena からアロケートする場合に限った話であって Large Object Heap を使用する場合は分けて考えた方がいいかもしれません