メモリプールの背後にある通常の実装の詳細は何ですか?

あらゆる種類の「プール」は、実際には事前に取得/初期化したリソースにすぎないため、クライアントの要求ごとにその場で割り当てられるのではなく、準備が整っています。クライアントがそれらの使用を終了すると、リソースは破棄される代わりにプールに戻ります。

メモリ プールは基本的に、事前に (そして通常は大きなブロックで) 割り当てた単なるメモリです。たとえば、事前に 4 キロバイトのメモリを割り当てることができます。クライアントが 64 バイトのメモリーを要求した場合、そのメモリー・プール内の未使用スペースへのポインターを渡すだけで、クライアントが必要なものを読み書きできるようになります。クライアントが完了したら、メモリのそのセクションを再び未使用としてマークするだけです。

アラインメント、安全性、または未使用の (解放された) メモリをプールに戻すことに煩わされない基本的な例として:

class MemoryPool
{
public:
    MemoryPool(): ptr(mem) 
    {
    }

    void* allocate(int mem_size)
    {
        assert((ptr + mem_size) <= (mem + sizeof mem) && "Pool exhausted!");
        void* mem = ptr;
        ptr += mem_size;
        return mem;
    }

private:
    MemoryPool(const MemoryPool&);
    MemoryPool& operator=(const MemoryPool&);   
    char mem[4096];
    char* ptr;
};

...
{
    MemoryPool pool;

    // Allocate an instance of `Foo` into a chunk returned by the memory pool.
    Foo* foo = new(pool.allocate(sizeof(Foo))) Foo;
    ...
    // Invoke the dtor manually since we used placement new.
    foo->~Foo();
}

これは事実上、スタックからメモリをプールするだけです。より高度な実装では、ブロックを連鎖させ、メモリ不足を回避するためにブロックがいっぱいかどうかを確認するためにいくつかの分岐を行い、ユニオンである固定サイズのチャンクを処理します (解放されている場合はノードをリストし、使用されている場合はクライアントのメモリをリストします)。間違いなくアラインメントに対処する必要があります (最も簡単な方法は、メモリ ブロックを最大アラインし、各チャンクにパディングを追加して後続のチャンクをアラインすることです)。

バディ アロケーター、スラブ、フィッティング アルゴリズムを適用するものなどは、より凝ったものになります。アロケーターの実装は、データ構造とそれほど違いはありませんが、生のビットとバイトに深く入り込み、アライメントなどについて考える必要があります。コンテンツをシャッフルします (使用中のメモリへの既存のポインタを無効にすることはできません)。データ構造と同様に、「これを行う必要がある」というゴールデン スタンダードはありません。さまざまなアルゴリズムがあり、それぞれに長所と短所がありますが、特に一般的なメモリ割り当てアルゴリズムがいくつかあります。

アロケータを実装することは、メモリ管理が少しうまく機能する方法に慣れるために、多くの C および C++ 開発者に実際にお勧めすることです。要求されているメモリがそれらを使用してデータ構造にどのように接続するかをもう少し意識することができ、新しいデータ構造を使用せずに最適化の機会のまったく新しい扉を開くこともできます。また、通常はあまり効率的ではない連結リストなどのデータ構造をより便利にし、ヒープのオーバーヘッドを回避するために不透明/抽象型をより不透明にしようとする誘惑を減らすこともできます。ただし、最初は興奮してすべてのカスタム アロケーターを作りたくなるかもしれませんが、後で追加の負担を後悔するだけです (特に、興奮してスレッド セーフやアラインメントなどの問題を忘れてしまった場合)。そこでゆっくりする価値があります。あらゆるマイクロ最適化と同様に、プロファイラーを使用して後知恵で個別に適用するのが一般的に最適です。


メモリ プールの基本的な概念は、アプリケーションにメモリの大部分を割り当てることです。後で、プレーンな new を使用する代わりに O/S からメモリを要求するには、代わりに以前に割り当てられたメモリのチャンクを返します。

これを機能させるには、メモリ使用量を自分で管理する必要があり、O/S に依存することはできません。つまり、独自のバージョンの new を実装する必要があります。 と delete 、独自のメモリ プールの割り当て、解放、またはサイズ変更の可能性がある場合にのみ、元のバージョンを使用してください。

最初のアプローチは、メモリ プールをカプセル化し、new のセマンティクスを実装するカスタム メソッドを提供する独自のクラスを定義することです。 と delete 、ただし、事前に割り当てられたプールからメモリを取得します。このプールは、new を使用して割り当てられたメモリ領域に過ぎないことを思い出してください。 任意のサイズを持ちます。 new のプールのバージョン /delete resp。ポインタを取ります。最も単純なバージョンはおそらく C コードのようになります:

void *MyPool::malloc(const size_t &size)
void MyPool::free(void *ptr)

これにテンプレートを追加して、変換を自動的に追加できます。例:

template <typename T>
T *MyClass::malloc();

template <typename T>
void MyClass::free(T *ptr);

テンプレート引数のおかげで、 size_t size コンパイラでは sizeof(T) を呼び出すことができるため、引数は省略できます。 malloc() で .

単純なポインターを返すということは、隣接するメモリが使用可能な場合にのみプールが拡大し、「境界」のプールメモリが使用されない場合にのみ縮小できることを意味します。より具体的には、malloc 関数が返したすべてのポインターが無効になるため、プールを再配置することはできません。

この制限を修正する方法は、ポインターをポインターに返すことです。つまり、T** を返します。 単に T* の代わりに .これにより、ユーザー向けの部分は同じままで、基になるポインターを変更できます。ちなみに、それは「ハンドル」と呼ばれていた NeXT O/S で行われました。ハンドルの内容にアクセスするには、(*handle)->method() を呼び出す必要がありました 、または (**handle).method() .最終的に、Maf Vosburg は、演算子の優先順位を悪用して (*handle)->method() を取り除く疑似演算子を発明しました。 構文:handle[0]->method(); それは sprong オペレーターと呼ばれていました。

この操作の利点は次のとおりです。まず、new への一般的な呼び出しのオーバーヘッドを回避できます。 と delete 、そして第二に、メモリプールは、メモリの連続したセグメントがアプリケーションによって使用されることを保証します。つまり、メモリの断片化を回避します。 したがって、CPU キャッシュ ヒットが増加します。

したがって、基本的には、メモリ プールを使用すると、アプリケーション コードがより複雑になる可能性があるため、スピードアップが得られます。ただし、boost::pool など、実証済みで簡単に使用できるメモリ プールの実装がいくつかあります。


基本的に、メモリ プールを使用すると、メモリの割り当てと解放を頻繁に行うプログラムでメモリを割り当てる費用の一部を回避できます。あなたがしていることは、実行の開始時に大量のメモリを割り当て、一時的に重複しない別の割り当てに同じメモリを再利用することです。使用可能なメモリを追跡し、そのメモリを割り当てに使用するための何らかのメカニズムが必要です。メモリを使い終わったら、解放するのではなく、再び使用可能としてマークします。

つまり、new を呼び出す代わりに /mallocdelete /free 、自分で定義したアロケーター/デアロケーター関数を呼び出します。

これを行うと、実行中に割り当てを 1 回だけ行うことができます (合計で必要なメモリ量がおよそわかっている場合)。プログラムがメモリ バウンドではなくレイテンシに依存している場合は、malloc よりも高速に実行される割り当て関数を記述できます。 多少のメモリ使用量を犠牲にして.