malloc の内部:glibc アロケータ

このページの目次

glibc の malloc は ptmalloc2 に由来していますが、長年の進化により、初期の ptmalloc2 のフローチャートを現在の完全な実装と見なすことはできません。それを理解するための最も有用な方法は、3つの層を区別することです。アプリケーションが所有するオブジェクト、アロケータが管理するチャンクとキャッシュ、そしてオペレーティングシステムによるマップと物理的な駐留です。

オブジェクトサイズとチャンクサイズは異なる

malloc はバイト単位の要求を受け取りますが、内部ではアラインメント、メタデータ、サイズクラスの要件も満たす必要があります。そのため、33バイトのオブジェクトに対して割り当てられる空間は実際にはそれ以上になる可能性があり、その差額は内部オーバーヘッドまたは内部断片化に該当します。

アプリケーションはインターフェース契約に基づいて有効なオブジェクト範囲にのみアクセスでき、ある割り当てで余分なスペースが提供されたからといって、メタデータに対して境界外書き込みを行ってはなりません。calloc は要素数とサイズの乗算におけるオーバーフローをチェックしますが、手動で malloc(n * size) を呼び出す場合、呼び出し前にすでにオーバーフローしている可能性があります。realloc は、ゼロでないターゲットサイズに対して失敗した場合、元のポインタを保持すべきであり、唯一の参照を直接上書きしてはいけません。malloc(3)

拡張時はまず計算し、所有権を移す

realloc はオブジェクトを移動する可能性があります。成功した場合、元のポインタおよび元のオブジェクト内部を指すポインタは使用できなくなります。失敗し、かつターゲットサイズがゼロでない場合、元のオブジェクトは依然として呼び出し主が所有します。以下に、乗算チェック、ゼロでない規約、および一時ポインタを使用した実行可能な例を示します。

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

static int resize_ints(int **items, size_t count) {
    if (count == 0 || count > SIZE_MAX / sizeof **items) return 0;
    int *replacement = realloc(*items, count * sizeof **items);
    if (replacement == NULL) return 0;
    *items = replacement;
    return 1;
}
int main(void) {
    int *items = NULL;
    if (!resize_ints(&items, 2)) return 1;
    items[0] = 7;
    items[1] = 9;
    if (!resize_ints(&items, 4)) { free(items); return 1; }
    if (items[0] != 7 || items[1] != 9) { free(items); return 1; }
    /* 拡張部分は未初期化です。書き込んでから読み取ります。 */
    items[2] = 11;
    items[3] = 13;
    int *before_rejection = items;
    if (resize_ints(&items, 0) || items != before_rejection) abort();
    if (sizeof(int) > 1) {
        size_t too_many = SIZE_MAX / sizeof(int) + 1;
        if (resize_ints(&items, too_many) || items != before_rejection) abort();
    }
    printf("%d %d %d %d\n", items[0], items[1], items[2], items[3]);
    free(items);
    return 0;
}

これを resize-demo.c として保存し、cc -Wall -Wextra resize-demo.c -o resize-demo でコンパイルすると、出力は 7 9 11 13 になります。count=0 はインターフェースによって明示的に拒否され、この「元のオブジェクトを保持するか、成功して拡張する」という契約に、各標準や実装における複雑な realloc(ptr, 0) の動作が混入しないようにしています。realloc(3)

この小さなインターフェースは、所有ポインタの更新のみを示しており、呼び出し主に代わって論理的な長さや容量を管理するものではありません。拡張に成功した後、呼び出し主は新しい容量を提出すべきです。また、要素の構築など失敗する可能性があるステップが残っている場合、より完全なロールバックまたはコミット設計が必要です。ゼロ長さやオーバーフローの拒否は決定論的に再現可能です。真のメモリ不足による割り当て失敗は、巨大なオブジェクトを要求してシステムの挙動を賭けるのではなく、制御可能な障害注入によって検証すべきです。

tcache と arena は異なる階層

アロケータキャッシュは既存のメモリ:free は直ちにOSへの返却を意味しない スレッドキャッシュ tcache 適合するサイズのチャンクを高速に再利用 arena の空き構造 bins、マージ、top chunk ミス OS からさらに領域を取得 brk / mmap;条件を満たせば返却 スレッドと arena は 1 対 1 ではありません。独立した mmap 割り当て、具体的なキャッシュ範囲とプロセスは glibc のバージョンによって異なります。

tcache はスレッドローカルキャッシュであり、要求が適合する場合、そこから直接再利用でき、arena に到達する回数を減らします。arena はより広範な割り当て状態と空き構造を管理し、通常は同期保護があります。複数のスレッドが 1 つの arena を共有でき、1 つの arena が必ずしも独立したアプリケーションオブジェクトを意味するわけではありません。

「スレッドごとに独立した arena があり、したがってすべてロックフリーである」というのは不正確です。スレッドキャッシュのヒットと arena の競合は別々に測定する必要があります。スレッドをまたいだ解放は、オブジェクトがどの層でキャッシュされ、どれくらいで再利用可能になるかに影響を与えます。

具体的な tcache がキャッシュできるサイズ、bin の数、arena の上限、およびチューニングインターフェースはバージョンによって変化します。新しいソースコードでは、より大きなチャンク用の tcache 分類が存在しますが、一部のマニュアルではまだ古い範囲が記述されています。ビルド環境の対応するソースコードとドキュメントを確認し、数十バイト、1 KiB、または CPU 数に特定の定数を掛けたものがすべての glibc における不変の規則であるかのように記述しないでください。

空き構造はそれぞれ何を最適化しているか

構造基本的な目的区別すべき点
tcacheスレッドローカルな再利用arena 自身の bins とは異なる
fastbins適合する小サイズの高速なチェーン化、マージの遅延マージは通常、consolidation などのパスに延期される
unsorted bin新しく入ってきた空きブロックに近期的な再利用の機会を与えるすべての free がまずここに入るわけではない
small bins正確なサイズクラスのブロックを管理クラスの幅と境界はターゲット設定による
large bins大きなサイズで検索を整理純粋な FIFO キューとして一般化できない
top chunkarena 末尾の切り分け可能な空間bin 内の独立した穴や独立した mmap ブロックとは異なる

大まかなパスは、適切なキャッシュと既存の空きスペースを試すことから始まり、必要に応じて分割、マージ、またはシステムへの追加要求を行います。実際の分岐順序はサイズとバージョンに依存し、「まず 128 KiB で判断し、それ以上は必ず mmap、以下は厳密に bin スキャン」という統一されたフローとして描くことはできません。glibc malloc ソースコード

free は所有権を誰に渡したのか?

図中のアドレスは記憶場所を、#番号はオブジェクトのライフサイクルの1回を識別します。解放後に再割り当てされると、アドレスは同じでも番号は異なる可能性があります。新しいオブジェクトが同じアドレスを占めたからといって、古いポインタが自動的に有効性を回復することはありません。キャッシュは重複した申請を減らしますが、保持されているブロック数とアプリケーションのアクティブなブロック数は分離される可能性があります。

図を準備しています
free は所有権を誰に渡したのか?

free はアプリケーションのオブジェクトへのアクセス権を終了しますが、ブロックはアロケータ内に保持される可能性があります。再割り当てによりアドレスが再利用されますが、新しいオブジェクトライフサイクルが確立されます。

mmap 閾値は静的な境界線ではない

メインの arena は通常、プログラムブレークを通じて拡張され、他の arena は mmap 対応の領域を確立できます。独立した mmap による大規模割り当てはまた別の用途です。小規模オブジェクトも mmap 対応の arena に存在する可能性があります。

glibc は mmap 閾値を動的に調整できます。典型的な調整は、条件を満たす大きな mmap チャンクを解放する際に行われ、閾値を対応するサイズまで引き上げ、システムへの繰り返し申請や類似ブロックの解除を回避します。これは、大きなブロックを解放するたびに閾値を下げるわけではありません。

明示的な閾値の設定は動的調整に影響を与えます。閾値は1つの戦略に過ぎず、既存の再利用可能なスペースや他の制限と組み合わせて理解する必要があります。関連する名称と意味については、glibc 割り当てチューナブル を参照してください。

通常のプライベート匿名 mmap は、fork 後も COW(Copy-On-Write)に従います。これはコピーなしの共有マップではありません。独立したブロックは個別に解除可能ですが、それがシステムに断片化やページテーブルのコストが全くないことを意味するわけではありません。

free 後も RSS が下がらない理由

アプリケーションがオブジェクトを解放した後、アロケータはチャンクをキャッシュまたは arena に保持し、次の要求のために再利用する可能性があります。一部のバイトが空いていても、それらが属するページにまだアクティブなオブジェクトが存在する場合、ページ全体を返却できるとは限りません。

例えば、1つのページに長寿命の小規模オブジェクトと短寿命オブジェクトが交錯して割り当てられている場合、短寿命オブジェクトがすべて解放されても、残りの長寿命オブジェクトがこのページを保持し続けます。すべての free バイトを単純に合計しても、返却可能なページ数を導き出すことはできません。これは、レイアウトと寿命に起因する外部断片化の問題です。

malloc_trim は、解放可能な空きメモリを返却しようとし、ページ内容の縮小または解放を通じて実現する可能性があります。すべての arena のすべての空きスペースが返却されることは保証されず、アクティブなオブジェクトが消えることもありません。頻繁な trim は、その後のページフォールトと再割り当てのコストを増加させる可能性があります。malloc_trim(3)

同じ 12 KiB を解放しても、レイアウトによって整ページ空きが形成されるかどうかが決まる 交錯した寿命 ページ 1 ページ 2 ページ 3 ページ 4 寿命ごとの集約 ページ 1 ページ 2 ページ 3 ページ 4 青色:生存中;灰色:空き済み。各ページは 4 KiB を示す。 上段には整ページの空きはありません。下段には3ページの候補があります。実際に返却できるかどうかは、アロケータのレイアウトと戦略によって制限されます。

まず空きバイトを計算し、次に返却可能なページを計算する

メタデータを無視したモデルにおいて、4ページの各ページに 1 KiB の長寿命オブジェクトと 3 KiB の短寿命オブジェクトがあります。短寿命オブジェクトを解放した後、アクティブ量は 4 KiB、空闲量は 12 KiB ですが、各ページにはまだアクティブなデータが存在します。同じ寿命のオブジェクトが集約されている場合、4 KiB のアクティブコンテンツは1ページしか占めず、残りの3ページのみが整ページ回収の候補となります。

page = 4096
interleaved_live = [1024, 1024, 1024, 1024]
grouped_live = [4096, 0, 0, 0]
for live in [interleaved_live, grouped_live]:
    assert sum(live) == 4096
    assert sum(page - n for n in live) == 12288
assert sum(n == 0 for n in interleaved_live) == 0
assert sum(n == 0 for n in grouped_live) == 3

これは、glibc が図のように4つのオブジェクトを厳密に配置するという約束ではなく、任意の malloc オブジェクトを移動できることを意味するものではありません。外部に保存されたポインタがあるため、通常の C アロケータは、いくつかの GC がアクティブなオブジェクトを自由に圧縮できるようなことはできません。明確に同じ生滅を持つ一連のオブジェクトに対して、アプリケーションレベルのリージョン割り当てにより、一括解放を容易にすることができます。ただし、その中に特に寿命が長いオブジェクトが数個ある場合、そのリージョン全体がそれらによって保持される可能性があります。

12 KiB の空きがあるのに、なぜ1ページも回収できないのか?

2つのレイアウトでは、オブジェクトの数と解放バイト数が完全に同じであり、違いは長寿命オブジェクトが各ページを占有しているかどうかだけです。まず交錯レイアウトで解放し、次に集約レイアウトに切り替えてやり直し、空闲量は同じだが整ページ候補が 0 から 3 に変わることを観察します。通常の malloc では、長寿命オブジェクトを自由に移動できないため、レイアウトは通常、割り当て戦略とオブジェクトの寿命から始める必要があります。

図を準備しています
12 KiB の空きがあるのに、なぜ1ページも回収できないのか?

空闲バイトの合計は、回収可能な整ページ数を決定しません。少数の長寿命オブジェクトが異なるページに分散していると、各ページが保持されます。

ロック競合、擬似共有、およびリークは異なる

複数のスレッドが同じ arena mutex を争うのはロック競合です。2つのスレッドが同じキャッシュライン上の異なるオブジェクトを修正し、一貫性トラフィックを生成するのが典型的な擬似共有です。arena の数を制限すると、保持メモリは減少するかもしれませんが、ロック競合が増加し、2つの問題を同時に解決する万能オプションにはなりません。

リークは通常、必要な解放パスを失うか、不要であるにもかかわらず継続的に保持されているオブジェクトを指します。アロケータが空きブロックを保持するのは別の層の現象です。判断する際は、アクティブなオブジェクト量、アロケータの統計、RSS/PSS、および負荷フェーズを照らし合わせます。プロセスメモリレイアウト を参照してください。

観察と小刻みなチューニング

#include <malloc.h>
#include <stdio.h>

int dump_allocator(void) {
    return malloc_info(0, stdout);
}

これは glibc 専用のインターフェースであり、arena などの XML 情報を出力しますが、すべてのスレッドキャッシュオブジェクトの業務上の生存リストとして完全かつ正確であると理解すべきではありません。malloc_stats と mallinfo2 もそれぞれ統計的な口径と制限があります。

GLIBC_TUNABLES=glibc.malloc.arena_max=2 ./prog
GLIBC_TUNABLES=glibc.malloc.tcache_count=0 ./prog

これらはそれぞれ対照実験を行うために使用され、すべてのプログラムに同時にデプロイすべきであることを意味するものではありません。スループット、テールレイテンシ、割り当て呼び出しスタック、およびメモリカーブを記録し、実行終了時の RSS のみで長期サービスを評価することを避けてください。

MALLOC_TRACE 環境変数自体は、現在の glibc がすべての割り当てを自動的に記録することを保証するものではありません。mtrace の有効化とデバッグライブラリの要件は、バージョンに応じて設定する必要があります。通常の境界外アクセスや解放後の使用は、優先的に ASan または Memcheck によって特定し、閾値を引き下げることがメモリエラーの修正になると考えてはいけません。

観測は負荷フェーズに沿ってサンプリングする

フェーズアプリケーション自身が記録アロケータとシステム側の記録判断に使用
予熱前初期アクティブオブジェクト数とバイト数arena 情報、RSS/PSSベースライン
ピーク時サイズ分布、ピーク同時実行数、寿命保持スペースと駐留ピークを駆動する要因
短命オブジェクト解放後解放された量、残っているもの空き構造と RSS/PSSオブジェクト解放が整ページ回収につながったか
次の同じ負荷新しい要求量、割り当てレイテンシ再利用されたか、再成長したかキャッシュが安定して再利用されているか

業務上のアクティブバイト数がベースラインに戻り、RSS が高いままでも、次のピークで増加しない場合、保持スペースが再利用の役割を果たしている可能性があります。同じループ内で業務上のアクティブ量が継続的に上昇している場合、まず保持者解放パスを見つける必要があります。アクティブ量は安定しているがシステム占用が継続的に上昇している場合、断片化、スレッド/arena の数、マップ、統計の盲点を調べ、1つのカーブだけでリークを宣言してはいけません。

malloc_info は複数の arena の情報を出力できますが、それは業務上のオブジェクトの帳簿ではありません。サンプリング自体にもコストがかかります。すべての malloc の後に XML 全体をフォーマットし、得られた時間をアロケータの元のレイテンシとして使用しないでください。malloc_info(3)

アロケータを置換する際に比較すべき点

Google TCMalloc の現在の設計では、per-CPU キャッシュを使用でき、異なるモードもあります。gperftools の tcmalloc とは、ライブラリ名だけで同じバージョンであると混同してはいけません。jemalloc は、arena、スレッドキャッシュ、extent などのメカニズムを採用しており、統計と回収動作は構成可能です。TCMalloc 設計、jemalloc マニュアル

「大規模割り当てには固定の閾値があり、断片化の高低がランク付けされる」という、すべての負荷で安定して成立するルールはありません。オブジェクトのサイズ分布、寿命、スレッドをまたいだ解放、同時実行数、および RSS カーブをテストすべきです。ヒーププロファイラは CPU プロファイラとは異なり、レポートの次元を確認する必要があります。

LD_PRELOAD は、適用可能な動的リンクとシンボル解決パスに対してのみ有効です。静的リンク、カスタム割り当てインターフェース、制限付き実行環境、および複数のランタイムは、効果を変更する可能性があります。割り当てと解放は、互換性のあるアロケータプロトコルでペアリングされなければならず、malloc を変更しながら他のコンポーネントが一致しない free を使用することはできません。

キーワード:malloc, glibc, ptmalloc2, tcache, arena, bins, fragmentation, mmap threshold, RSS, malloc_trim, jemalloc, TCMalloc