Dev Study
OSと低レイヤのしくみ コース

10. mallocの内部 — brkとmmapの使い分け

glibc の malloc が「ヒープから切り出す」か「mmap で独立した領域を取る」かをどう決めているのか、その判断と理由を図で追っていきましょう。

① mallocは1つの入口、内部は2つの道

要求サイズによる分岐
malloc(size)アプリからの要求
size を MMAP_THRESHOLD と比較
size は閾値以下小さい要求
size は閾値より大きい大きい要求
確保方法が分かれる
ヒープから切り出すbrk(2) / sbrk(2) で伸ばした領域
多くの場合システムコールなし
mmap(2) で新規マッピングMAP_PRIVATE | MAP_ANONYMOUS
毎回システムコール
  • ・malloc(3) NOTES: 通常はヒープから割り当て、必要に応じて sbrk(2) でヒープのサイズを調整する
  • ・malloc(3) NOTES: MMAP_THRESHOLD バイトより大きいブロックは private anonymous mapping として mmap(2) で確保される
  • ・したがって、ちょうど閾値と同じサイズはヒープ経路に入ります
  • ・呼び出す側から見える API は malloc ひとつだけで、この分岐は完全に内部の話です

malloc は入口こそ1つですが、内部では要求サイズを閾値と比べて2つの確保方法を使い分けています。

② 小さい要求 — program break を伸ばして切り分ける

brk/sbrk とヒープの構造
プロセスのアドレス空間低位アドレス → 高位アドレス
データセグメントの末尾が program break
text / data / bssプログラムの静的部分
ヒープmalloc の作業場
上端の位置が program break
足りなければ sbrk(increment) で break を上へ
ヒープが拡張される新しく使える連続領域が増える
sbrk は「拡張前の break」を返す
その中から必要な分だけ切り出す
chunk をユーザーに返す次の malloc は残りから切り出せる
  • ・brk(2): program break はプロセスのデータセグメントの末尾、すなわち未初期化データ領域の直後の位置を指す
  • ・brk(2): break を増やすとプロセスにメモリが割り当てられ、減らすと解放される
  • ・brk(2): sbrk() は相対値で break を動かし、成功時に「変更前の break」を返す
  • ・brk(2): Linux では sbrk() は brk() システムコールを使うライブラリ関数として実装されている

ヒープは1本の連続した領域です。malloc は break を一度大きめに伸ばしておき、以後の小さな要求はその中から切り出して応えます。

③ なぜ小さい要求で毎回システムコールを避けるのか

設計の理由 — コストの非対称性
仮に毎回システムコールしたらmalloc(32) を100万回
1回ごとに発生するコスト
ユーザ→カーネル遷移特権レベルの切り替え
カーネル側の管理処理マッピング情報の作成・登録
ページフォルト実ページの割り当て
32バイトを得るには割に合わない
まとめて確保して自前で分割する1回の brk で多数の要求をまかなう
結果
多くの malloc はシステムコールなしで完了ユーザ空間のデータ構造の操作だけで済む
  • ・小さい要求ほど「管理コスト ÷ 得られるバイト数」の比率が悪化します
  • ・大きい要求では逆に、1回のシステムコールで大量のメモリが得られるため相対コストが小さくなります
  • ・この非対称性が、サイズで方法を切り替える設計の理由です

システムコールのコストは要求サイズにあまり依存しません。だから小さい要求ほど「まとめ買いして自前で分ける」方が有利になります。

④ 大きい要求 — mmapで独立したマッピングを取る

MMAP_THRESHOLD を超える経路
malloc(1MiB)既定の閾値 128KiB を超える
ヒープの連続性を壊さないため別経路へ
mmap(2) を呼ぶMAP_PRIVATE | MAP_ANONYMOUS
カーネルが独立した無名マッピングを作る
ヒープとは別の領域program break とは無関係
内容はゼロで初期化ファイルに紐づかない
MAP_ANONYMOUS の定義
  • ・mallopt(3): M_MMAP_THRESHOLD の既定値は 128*1024 バイト
  • ・mmap(2): MAP_ANONYMOUS はどのファイルにも backed されず、内容はゼロに初期化される
  • ・mmap(2): MAP_PRIVATE は private copy-on-write マッピングを作り、更新は同じファイルをマップした他プロセスにも元のファイルにも反映されない
  • ・確保された領域はページ単位に切り上げられるため、要求サイズちょうどとは限りません

大きな塊はヒープの中に置くと後始末が難しくなるため、最初から独立したマッピングとして切り出します。

⑤ free した後 — 経路によって行き先が違う

解放時の非対称な挙動
free(ptr)どちらの経路で取ったかを内部で判別
確保元によって分岐
ヒープ由来brk で伸ばした領域の一部
mmap 由来独立したマッピング
それぞれの行き先
bin(空きリスト)に戻るプロセス内に留まり再利用を待つ
多くの場合 OS には返りません
munmap(2) で即座に返るマッピングが削除される
以後アクセスすれば不正参照
  • ・mmap(2): munmap() は指定範囲のマッピングを削除し、以後その範囲への参照は不正なメモリ参照になる
  • ・ヒープ由来のブロックは、解放してもアロケータの管理下に残って次の malloc に再利用されます
  • ・そのため free 直後に RSS が下がらないのは、多くの場合バグではなく設計どおりの挙動です

同じ free でも、mmap 由来はその場で OS に返り、ヒープ由来はプロセス内の空きリストに戻るだけ、という違いがあります。

⑥ ヒープが縮むのは「末尾が空いたとき」だけ

trim の条件 — 断片化との関係
ヒープの中身低位 → program break
ケースA: 途中だけが空いた
使用中
空き
使用中break の手前を占有
break を下げると使用中の領域を壊すので縮められない
ヒープサイズは維持空きは bin に留まる
再利用はされる
ケースB: 末尾が連続して空いた
使用中
末尾の連続した空きM_TRIM_THRESHOLD 以上
break を下げられる
sbrk でヒープを縮小OS にメモリが返る
  • ・mallopt(3): ヒープ頂上の連続した空きメモリが十分大きくなると、free(3) は sbrk(2) を使ってそのメモリをシステムに返す
  • ・mallopt(3): M_TRIM_THRESHOLD はそのために必要な最小サイズで、既定値は 128*1024 バイト。-1 を設定すると trim は完全に無効になる
  • ・環境変数 MALLOC_TRIM_THRESHOLD_ でも同じパラメータを制御できます
  • ・malloc_trim(3): sbrk(2) または madvise(2) を使ってヒープの空きメモリの解放を試みる。glibc 2.8 以降はすべての arena で、ページ全体が空いた chunk を解放する

ヒープは1本の連続領域なので、sbrk による縮小は末尾からしか行えません。途中の空きは主に再利用によって活かされます。

⑦ 閾値は固定ではない — 動的 mmap 閾値

実行時に閾値が育つ仕組み
初期状態MMAP_THRESHOLD = 128KiB
200KiB のブロックが free された
そのサイズは mmap 経路だった確保も解放も毎回システムコール
「このサイズは繰り返し使われる」と判断
閾値を解放されたブロックのサイズへ引き上げ上限を超えない範囲で
以後の同サイズ要求
ヒープ経路に回るシステムコールを避けて再利用できる
  • ・mallopt(3): 現在の glibc は既定で動的 mmap 閾値を採用しており、初期値は 128*1024 バイト
  • ・mallopt(3): 現在の閾値より大きく DEFAULT_MMAP_THRESHOLD_MAX 以下のブロックが free されると、閾値はそのブロックのサイズに合わせて引き上げられる
  • ・mallopt(3): DEFAULT_MMAP_THRESHOLD_MAX は 32bit で 512*1024、64bit で 4*1024*1024*sizeof(long)
  • ・mallopt(3): M_TRIM_THRESHOLD / M_TOP_PAD / M_MMAP_THRESHOLD / M_MMAP_MAX のいずれかを設定すると、この動的調整は無効になる

閾値を固定せず実際の使われ方に合わせて育てることで、アプリごとの性質に自動で寄せていきます。手動で調整するとこの自動化は止まります。

公式ドキュメントで詳しく ↗