HN 日本語サマリー

← 一覧へ戻る
プログラミング

C言語におけるアリーナアロケータ

Arena Allocators in C (eliasebner.com)

41 pointsby dimonomid13 コメント

要約

C言語ではメモリ管理が手動で行われるため、メモリリークやパフォーマンスの問題が発生しがちです。この記事では、アリーナアロケータというメモリ管理手法を紹介します。アリーナアロケータは、メモリを大きなチャンクとして一括で確保し、必要に応じてポインタを進めることで、個々の小さなメモリ割り当てと解放のオーバーヘッドを削減します。これにより、メモリ管理が簡素化され、パフォーマンスが向上し、メモリリークのリスクが大幅に低減します。

全文翻訳

2026年10月11日 C言語におけるアリーナアロケータ ご存知の通り、C言語は非常に低レベルな言語です。それはあなたに(そしてある意味強制的に)使用するメモリを自分で管理することを許可します。これは、人々が日々のプログラミングにおいて他の言語に切り替える理由の一つ(おそらく多く)です。この記事では、非常に高性能であるだけでなく、非常に汎用性の高いメモリ管理のパラダイムを紹介します。それはタスクを大幅に容易にし、メモリリークの可能性を大幅に減少させます。あなたはすでにそれらについて知っているかもしれませんが、そうでないかもしれません。いずれにしても、私が話しているのはアリーナアロケータのことです。 実装に進む前に、まずアリーナアロケータの概念について簡単に紹介します。 アリーナアロケータとは何か? 人々が通常メモリを管理する方法は、malloc を使ってヒープから必要なときに少量のメモリを割り当てることです。ちょうど必要な分だけです。後で、最初に計画したよりも多くのメモリが必要になった場合、realloc を使用してメモリ割り当てのサイズを増やします。そのメモリの使用が終わったら、free を呼び出して、そのメモリはもはや必要なく、他のタスクに使用できることをオペレーティングシステムに伝えます。 これは素晴らしいですが、いくつかの問題があります: 1. プログラマは、メモリの使用が終わったらそれを解放することを覚えておく必要があります。これは、特にループ内で割り当てが発生する場合(例えばゲームなど)に危険です。その場合、オペレーティングシステムはますます多くのメモリを割り当て続けますが、そのメモリは決して解放されません。メモリはリソースであり、無限ではありません。ある時点で、あなたのコンピュータはメモリを使い果たし、プログラムはオペレーティングシステムによって終了されます(つまり、クラッシュします)。 2. 多くの小さなメモリチャンクの割り当てと解放は遅いです。 3. 次のような関数がある場合: i32 *create_array(usize len) { i32 *elements = (i32 *)malloc(sizeof(int)); return elements; } さて、教えてください。この関数を呼び出すプログラマは、この関数がメモリ割り当てを実行することを知るにはどうすればよいでしょうか?この場合、関数の名前を見て、関数が何をしている可能性があるかを考えることで推測できますが、常にそれほど単純ではありません。覚えておいてください、これは重要です。なぜなら、プログラマはいつメモリを解放する必要があるかを知る必要があるからです。プログラマは、メモリを解放する責任があるという事実に気づく必要があります。 私が思いついた主なものですが、これら以外にもおそらく私が考えていない問題があるでしょう。 解決策。 アリーナアロケータの登場です。 ほとんどのライフタイム(つまり、特定のメモリ領域が解放される前に使用される期間)は、数少ないカテゴリのいずれかにグループ化できます: メモリはプログラム全体の期間存続する メモリは一部のタスクの完了まで存続する(つまり、ゲームのフレームやHTTPリクエストの処理) メモリは関数のスコープまで存続する これは通常十分なグループ化ですが、グループと見なすものによっては、さらに多く存在する可能性があります。いずれにしても、私たちは大きなチャンクを割り当てて、似たようなライフタイムを持つもの(つまり、一緒に解放できるもの)を処理するために使用できます。そうすると、使用できる大量のメモリが得られます。そのメモリの一部を使用して何かを行うたびに、ポインタを進めて、メモリがどこまで使用されたか、そして空きメモリがどこから始まるかを記憶します。実装を見ると、これがより明確になります。 その大きなチャンク内のどのデータも不要になったら、それをすべて一度に解放できます(または、同じメモリを再利用したい場合は、メモリ領域を初期状態に再初期化して、最初から割り当てを開始します)。これは個々のメモリ割り当て(覚えておいてください、それは遅いです)を節約し、個々の配列に使用される小さな断片ではなく、数個の異なる「チャンク」のメモリだけを心配すればよいため、メモリ管理について心配することを減らします。 実装の明確化 始める前に、私はCコードの先頭にこのファイルを含めることがほとんどです。これは単に組み込み型に型エイリアスを付け、いくつかの有用で一般的に使用されるユーティリティを持っています。私は.cファイルを直接含めます。なぜなら、コンパイルを高速化するためにUnityビルドを使用するからです。好みに応じて、これらのヘッダーファイルを簡単に作成できます。 // types.c #ifndef TYPES_C #define TYPES_C #include <stddef.h> #include <stdint.h> typedef uint8_t u8; typedef uint16_t u16; typedef uint32_t u32; typedef uint64_t u64; typedef int8_t i8; typedef int16_t i16; typedef int32_t i32; typedef int64_t i64; typedef float f32; typedef double f64; typedef size_t usize; #define KiB(x) x * 1024 #define MiB(x) KiB(x) * 1024 #define GiB(x) MiB(x) * 1024 #define MIN(a, b) a < b ? a : b #define MAX(a, b) a > b ? a : b #endif // TYPES_C arena.c 実際のアリーナ実装は次のとおりです。インクルードガード(ここでもUnityビルドのため)で始め、使用するいくつかのlibcヘッダーを含めます: #ifndef ARENA_C #define ARENA_C #include "types.c" #include <stdbool.h> #include <stdlib.h> #include <string.h> // コードはここ #endif // ARENA_C アリーナ構造体の定義は次のとおりです: struct arena { void *data; usize size; usize used; }; これは、どれだけのメモリが使用されたかを追跡します。これは、未使用のメモリ領域の開始を指すポインタを使用することでも達成できます。サイズフィールドは、割り当て時にアリーナをオーバーフローしているかどうかを理解するために必要です(メモリを使いすぎている場合は発生する可能性があります)。そして data はアリーナの開始を単純に指します。 これまでは順調ですが、新しいアリーナを作成する方法が必要になります。以下がその方法です: struct arena_create_out { struct arena arena; bool success; }; [[nodiscard]] struct arena_create_out arena_create(usize size) { struct arena_create_out out = {0}; out.arena.data = malloc(size); if (out.arena.data == NULL) return out; out.arena.size = size; out.arena.used = 0; out.success = true; return out; } 複数の値を返す場合、私が混乱を招き、APIを直感的でないと感じる出力パラメータを使用する代わりに、構造体を返すことをはるかに好みます。 次に、このアリーナにメモリを割り当てる方法が必要です。これに先立ち、このマクロを定義します(私はそれをファイルの先頭に置いていますが、関連性が高くなった今、ここで紹介します): #define ARENA_ALIGN 8 これはデータの配置に使用されます。何らかの理由で、私たちのコンピュータは8バイトに配置されたデータを扱う方がはるかに優れています。これは、新しい割り当ての開始が8バイトの倍数である必要があることを意味します。malloc はこれを私たちに代わって処理しますが、ここでは malloc を使って割り当てるのではなく、すでに割り当てられたメモリチャンク内のスペースを単純に予約しているため、アクセスを高速化するために自分でデータを配置する必要があります。 割り当て関数は次のとおりです: struct arena_alloc_out { void *ptr; bool success; }; [[nodiscard]] struct arena_alloc_out arena_alloc(struct arena *arena, usize size) { struct arena_alloc_out out = {0}; usize alignment = (ARENA_ALIGN - (arena->used % ARENA_ALIGN)) % ARENA_ALIGN; out.ptr = arena->data + arena->used + alignment; if (out.ptr + size > arena->data + arena->size) return out; arena->used += size + alignment; out.success = true; return out; } ここで、次の割り当てが8バイトに配置されるように、割り当ての終わりにどれだけの「パディング」を追加する必要があるか(alignment 変数)を理解します。私は、成功フィールドを関数の戻り値の型に持つパターンを使用しており、エラー時には早期に返します(それはすべてゼロで初期化されているため、success は false になります)。このアプローチが気に入らない場合は、APIを別の規約に移行するのはかなり簡単でしょう。 作業のほとんどは完了しました。割り当てのサイズを変更するための簡単なラッパーを作成しました。これは、アリーナの末尾に新しいサイズで割り当て、データをコピーするだけです。古いデータはそのまま残され、アリーナが解放されるときに解放されます(覚えておいてください、個々の割り当てを解放することはできません。すべてのアリーナか、何もかです。