
2026/11/09 20:31
malloc は実際になんの仕事をしているのでしょうか?
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
GNU libc の核心的な優位点は、高度なメモリ割り当て管理にあり、これはオペレーティングシステムカーネルとの高価な相互作用を大幅に削減します。bump allocator などの単純な実装は、各リクエストに対して
sbrk などの高価なシステムコールをトリガーするのに対し、glibc は内部で帳簿処理を行い、操作をバッチ化するとともにスレッドローカルキャッシュ(tcache)およびバインを利用します。この戦略により、アプリケーションはプログラムブレークから数ギバイトもの仮想アドレス空間を効率的に扱いつつ、デマンドペーシングを通じて物理 RAM を最小限に消費できます。さらに、glibc は mmap を使用して大規模なブロックを独立したマップとして割り当て、小規模な割り当ては 8 バイトの最適化されたチャンクヘッダーで管理します。これらの技術により、malloc の実行速度は直接システムコールを実装した場合と比較して約 22 倍向上し、厳格なメモリ制約下でもアプリケーションの起動加速および高スループット性能を実現可能となっています。本文
メモリ管理とアロケータの仕組み:glibc から学ぶ最適化の道筋
1. malloc
の魔法と実装の背景
mallocmalloc は極めてシンプルで使いやすいインターフェースを提供します。
- 特定のサイズのメモリを要求するだけで、連続したバイト列が生成され、
として返されます。void* - 使用終了時は、同じポインタを
に渡すことで、メモリは「虚空(ether)」へ消滅します。free()
システムコールの overhead
ユーザー空間のプログラムから
malloc を呼び出すと、実際には以下のプロセスを経ています。
- システムコール(syscall)を通じてカーネルにメモリ割り当てを要求する。
- 多数の小さなブロックを割り当てる試みを繰り返すと、システムコールが高コストになる(例:空の syscall は約 575 ns かかる)。
そのため、バックグラウンドで効率的なメモリの管理が行われます。GNU libc (glibc) における具体的な仕組みは以下のコード例で見られます。
static void *ptrs[1000]; int main(void) { for (int i = 0; i < 1000; i++) { ptrs[i] = malloc(16); } }
strace
で確認する最適化
straceこのループ全体で、ヒープ拡張を行う
システムコールはわずか 2 回しか実行されていません。brk
- 1 回目: プログラムブレーク(プロセスのヒープ領域の終了アドレス)を問い合わせる。
- 2 回目: ヒープ領域を拡張させる(例:132 KiB)。
残りの 998 回の割り当ては、既に割り当てられたメモリ領域から確保されます。
$ strace -e trace=brk,mmap,munmap,write \ ./malloc1000 > /dev/null ... brk(NULL) = 0x59faca231000 brk(0x59faca252000) = 0x59faca252000 +++ exited with 0 +++
補足:
とbrkの関係sbrk
: 指定アドレスでプログラムブレークを設定するシステムコール。brk : libc のラッパー関数で、増分(Delta)を受け取りプログラムブレークを増加させる。sbrk
2. プロセスのアドレス空間とメモリ領域
実行中のプロセスには独自のアドレス空間があり、以下の領域に分かれています。
| 領域名 | 説明 | 例 |
|---|---|---|
| text | 実行可能コード用 | 関数 |
| data | 初期化済みのグローバル変数 | |
| BSS | 未初期化のグローバル変数 | |
| heap | ヒープ領域(動的確保) | |
| stack | スタック領域 | ローカル変数 |
#define SHOW(label, p) \ printf("%-22s %p\n", label, (void *)(p)) SHOW("main (text)", main); SHOW("initialised (data)", &initialised); SHOW("uninitialised (bss)", &uninitialised); SHOW("malloc(16) (heap)", malloc(16)); SHOW("program break", sbrk(0)); SHOW("printf (libc)", printf); SHOW("local (stack)", &local);
アドレス配置の特徴
- アドレスは、バイナリコード領域からスタック領域に向かって上昇しています。
でヒープを拡張すると、ブレークアドレス(program break)も上昇します。malloc- 未マップ領域: ブレークと libc の間で数百テラバイトのギャップが存在する場合でも、物理メモリのコストは発生しません(マッピングされていないため)。
3. バンプアロケータ(Bump Allocator)とその限界
プログラムブレークを直接操作できるため、独自の小型アロケータを作成できます。これを「バンプアロケータ」と呼びます。
- 仕組み: 各呼び出しで利用可能なメモリの先端を進めるだけ。
- 欠点: 各リクエストごとにシステムコールが発生する(高コスト)。
- 解放機能: 解放されたメモリの再利用手段を持っていない。
システムコールコストの比較
100 万回呼び出した際の平均時間を測定しました。
| 関数 | コスト (ns) | 解説 |
|---|---|---|
| 575.2 ns | 空の syscall の基準 |
| 610.1 ns | システムコールによる割り当て |
| 26.9 ns | glibc が内部で最適化しているため高速 |
結論:
malloc はシステムコールを呼び出して大きなブロックを取得し、ユーザー空間内で細かく分割する方が、個別に sbrk を呼ぶよりも遥かに高速です。
メモリ断片化のリスク
バンプアロケータは解放したメモリの再利用を行いません。そのため、解放されたメモリ(例:
b)があっても、その先の領域が埋まっていると、新しい割り当て(例: d)はその先頭に戻らず、連続したブロック確保ができなくなります。
4. チャンクとヘッダー
glibc の
は、要求する量より多くのメモリを割り当てます。これにより サイズヘッダー を持つことが可能になり、malloc
free() でサイズ情報を渡す必要がなくなります。
ヘッダー構造
- チャンクサイズは常に 16 の倍数(最低 32 バイト)。
- ヘッダーにはフラグ(Arena, Mapping, Pointer など)が格納されます。
- メリット: ポインタが 16 バイト整列 を保たれ、あらゆる C の型に対応します。
| 要求サイズ | 割り当てチャンクサイズ |
|---|---|
| 1 Byte | 32 Bytes |
| 24 Bytes | 32 Bytes |
| 25 Bytes | 48 Bytes |
| 1000 Bytes | 1008 Bytes |
フリーリスト(Free List)の仕組み
- 解放時: チャンクはフリーリストに入れられます。
- 再割り当て時: フリーリストから適合するチャンクを探し、大きくて不要な部分は再分割します。
- 統合 (Coalescing): 隣接するフリーチャンクを結合して大きなブロックを作ります。
- glibc はサイズの直後に次のチャンクへのポインタを格納し、ユーザーデータを再利用しています。
簡易的なアロケータでも同じ構造を持たせると、以下のようになります。
typedef struct chunk { size_t size; struct chunk *next; } chunk_t;
5. 分割と統合(Splitting and Coalescing)の改善
より実用的なアロケータにするために、以下を導入します。
- 丸め上げ: 要求量 + ヘッダーを 16 バイト倍数に調整。
- 分割 (Splitting): 適当なチャンクを取り出し、余りをフリーリストに戻す。
- 統合 (Coalescing): 隣接するフリーチャンクを結合して再利用性を高める。
my_malloc
と my_free
の実装例
my_mallocmy_freevoid *my_malloc(size_t size) { // フリーリストを検索またはヒープ拡張 } void my_free(void *ptr) { // ヘッダーからチャンクを取得し、隣接フリーチャンクと統合 // trim() を呼んで不要な領域を解放 }
動作確認
テストでは、
a, b, c を割り当てて a, b を解放すると、これらが統合され、d を割り当てる際にその統合ブロックから分割されます。
| ステップ | 状態 | free_list の変化 |
|---|---|---|
| 割り当て (a,b,c) | 分散配置 | |
| 解放 | |
| 統合 (a+b) | |
| 分割・割り当て | |
6. デマンドページング(Demand Paging)
はメモリの再利用を行いますが、カーネルには物理メモリを返しません。my_free()
- カーネルはアドレス空間を 4 KiB ページ単位で管理しています。
- アドレス空間がマップされていても、書き込まれていないページは物理 RAM に割り当てられていません(空き状態)。
- 仮想ページの最初の書き込み時に初めて物理メモリにマップされます(ページフォールト)。
検証プログラム
1 GiB のメモリを確保し、各ページに 1 バイト書き込むことで、デマンドページングの動作を確認しました。
| 状況 | 仮想サイズ | RSS (レジデント) | ページフォールト数 |
|---|---|---|---|
の直後 | > 1 GiB | 1308 KiB | 3 (初期化用) |
| 256 MiB 書き込み後 | > 1 GiB | 263 MiB | ~6.5 万回 |
注意:
で解放しても、そのページが書き込まれていれば RSS は維持されます。物理メモリのコストは削減されません。my_free()
7. メモリの返却(Giving Memory Back)
カーネルにメモリを返すには、ブレークを下げる必要があります。新しい関数
を導入します。trim()
- ヒープのトップにあるフリーチャンクが閾値(例:128 KiB)を超えると、余計な領域を
で解放します。sbrk(-N) - ただし、解放しすぎると再び割り当て時にシステムコールが増えるため、**パディング(TOP_PAD)**を残して調整します。
断片化の問題
ヒープのトップしか返却できません。ライブなチャンクがある限り、その以下のフリーチャンクは物理メモリとして維持されます(レジデント)。
- 長い実行により、ライブチャンクの間に多くのフリーギャップが生じます(断片化)。
- これらはレジデント状態のため、RSS を増加させ、メモリ制限でプロセスを終了させる原因になります。
| 状態 | ブレーク位置 | RSS (物理使用) |
|---|---|---|
| 解放後直後(トップに空きあり) | トップから下がっている | 高い(解放されていない) |
が実行される場合 | トップまで戻す | 低下(物理メモリの削減) |
8. 大規模ブロック用の mmap
mmap128 KiB 以上の大型ブロックを確保する際、
sbrk よりも mmap() を使用するのが効率的です。
mmap
の利点
mmap- 即時解放:
を呼び出すと、カーネルに即座に戻ります(隣接領域とは独立して)。munmap() - ゼロ初期化: 物理メモリへのマップはデマンドページングにより行われるため、初期コストが低い。
- glibc の活用: glibc は大規模な割り当てやスレッド拡張時に、メインヒープのロック争いを避けるために
で別領域(アリーナ)を作成します。mmap
改善後のアロケータ (my-mmap-4
)
my-mmap-4// 大きなブロックは mmap を使用 static void *map_chunk(size_t need) { ... } // my_free は mapp ed チャンクを検知して munmap する void my_free(void *ptr) { if (mapped_flag) { munmap(ptr); // 即座に物理メモリ解放 return; } insert_free(ptr); trim(); }
パフォーマンス比較結果
| アロケータ | RSS (解放後) | ステップあたりの時間 | 評価 |
|---|---|---|---|
(最適化なし) | 1425 MiB | 1.72 µs | メモリ返却なし |
| 17.8 MiB | glibc より65 倍遅い | リスト検索のコスト |
| 0.188 MiB | glibc より142 倍遅い | 統合時の追加コスト |
| 11.2 MiB | glibc と同等 | 大規模ブロックは mmap で最適化 |
結論:
はシステムコールを避けるために、大きなブロックをユーザー空間で分割・再結合する工夫をしています。また、glibc のmalloc(スレッドローカルキャッシュ)やtcacheしきい値の調整により、さらに高速化・低メモリ化が可能です。mmap
9. リンクと参照
この記事を深く理解するために、以下のリソースを確認してください。
コードの再現性
- 各プログラムのスニペットは再現可能にリンクされています。
- チャンク、バイン(bin)、アリーナ、
について詳しく知りたい場合は、関連記事をご覧ください。tcache
マニュアルページ
標準ライブラリの関数についての詳細は以下の man page を参照してください。