DUICUO

Glibc のメモリ割り当てと解放のメカニズムの詳細な説明

I. はじめに

メモリオブジェクトの割り当てと解放は、バックエンド開発者がコード設計において考慮しなければならない重要な問題です。十分な考慮がないと、メモリリークや境界外アクセスなどの問題に容易につながります。メモリ例外が発生すると、開発者はユーザーレベルコードのトラブルシューティングに多くの時間を費やしてしまい、Cランタイム、ライブラリ層、オペレーティングシステム層自体もメモリ問題を引き起こす可能性があるという事実を見落としがちです。この記事では、まず実際のメモリインシデントを用いてこの問題を紹介し、glibcライブラリのメモリレイアウト設計、メモリ割り当て、解放ロジックを段階的に説明し、最後に適切な解決策を提示します。

II. メモリアラームイベント

オンラインメンテナンス操作中に、サービスでメモリアラームが検出されました。

[監視システム - カスタム監視 - アラーム - 連続アラーム]

検出ルール: メモリ使用量の監視: 一般的な異常 (>4096)
クラスターID:xxx
クラスター名: xxxxxx
例外オブジェクト(現在の値):xx.xx.xx.xx-xxxxxxx(11335)
開始時間: 2023-08-10 17:10:30
アラーム時刻: 2023-08-10 18:20:32
所要時間: 1時間10分
異常率: 2.1918 (8/365)
異常レベル: 一般 注記: -

サービス関連の監視を確認したところ、業務トラフィックの急増によりメモリ使用量が一時的に増加したか、メモリリークが発生していることが判明しました。

OPSおよびサービス本体のメモリ監視統計を確認したところ、アラーム発生時間帯に業務トラフィックが急増したものの、メモリは正常値まで低下していたことが判明しました。しかし、アラームは18時20分まで回復せず継続しており、監視パフォーマンスとの整合性が取れませんでした。マシンにログイン後、インスタンスのメモリが回復していないことが判明し、ユーザーレベルでのメモリリークが疑われました。

分析の結果、メモリ統計コードは「new」と「delete」の各呼び出し後にのみ統計を増減していることが判明しました。監視中にサービスのメモリ使用量が減少していたことから、「delete」がメモリを正しく解放していたことが示唆されていますが、オペレーティングシステムは依然として高いメモリ使用量を示していました。これは、Cランタイムライブラリ glibc のメモリ解放に問題があることを示唆しています。

III. glibcのメモリ管理メカニズム

3.1 glibcの紹介

glibc (GNU C ライブラリの略) は、オペレーティング システム関連の呼び出しをカプセル化し、数学、文字列、ファイル I/O、メモリ管理、マルチスレッドなどの分野でユーザーに標準関数とシステム コール インターフェイスを提供するオープン ソースの標準 C ライブラリです。

3.2 メモリ管理レイアウト

Linux カーネル v2.6.7 以降の 32 ビット モードの仮想メモリ レイアウトを例に挙げます。


  1. カーネル スペース - カーネルとドライバーのコードとデータを保存します。
  2. スタック - プログラム実行中にローカル変数と関数パラメータを格納し、上位メモリ アドレスから下位メモリ アドレスへと増加します。
  3. メモリ マッピング セグメント (mmap と略記) は、ファイルまたはその他のオブジェクトをメモリにマッピングするために使用されます。
  4. ヒープ — malloc、new、free、delete などの関数によって管理される、動的に割り当てられたメモリ領域。
  5. BSS セグメント (初期化されていない変数領域) には、初期化されていないグローバル変数と静的変数が格納されます。
  6. DATA セグメント - ソース コード内で事前定義された値を持つグローバル変数と静的変数を格納します。
  7. テキスト セグメント (コード領域) には、読み取り専用のプログラム実行コード、つまりマシン命令が格納されます。

ヒープ領域と Mmap 領域は、ユーザー プログラムに提供できる仮想メモリ空​​間です。

ヒープ操作

オペレーティングシステムは `brk()` 関数を提供し、Cランタイムライブラリはヒープからメモリを割り当てるための `sbrk()` 関数を提供します。関数の宣言は次のとおりです。

 int brk(void *addr); void *sbrk(intptr_t increment); 
  • `brk()` 関数は、プロセスヒープの終了アドレスを設定することでメモリの割り当てと解放を行います。これにより、連続したメモリブロックを一度に割り当てる、または解放することができます。これは、大きなメモリブロックを一度に割り当てるのに適しています。ただし、終了アドレスの設定が大きすぎたり小さすぎたりすると、メモリの断片化やメモリの無駄が発生する可能性があります。
  • sbrk関数は、増分パラメータを渡すことで、ヒープ領域のサイズを増減するかどうかを決定します。この関数は、必要なメモリ量のみを要求する効果を実現するために、複数回動的にメモリを割り当てたり解放したりすることができ、メモリの断片化や無駄なメモリ消費の問題を効果的に回避します。

Mmap操作

Linuxは、仮想メモリ空​​間を操作するためのmmap()関数とmunmap()関数を提供しています。これらの関数の宣言は以下の通りです。

 void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); int munmap(void *addr, size_t length);

mmap はファイルやその他のオブジェクトをメモリにマップできますが、munmap は特定のアドレス範囲のメモリ マッピングを削除できます。

3.3 メモリアロケータ

オープンソース コミュニティでは、dlmalloc、ptmalloc、jemalloc、tcmalloc など、多くの既製のメモリ アロケータをリリースしています。glibc は ptmalloc を使用するため、この記事ではそのメモリ アロケータについてのみ紹介します。

3.3.1 アリーナ(割り当てエリア)

ヒープ管理構造を以下に示します。

 struct malloc_state { mutex_t mutex; /* Serialize access. */ int flags; /* Flags (formerly in max_fast). */ #if THREAD_STATS /* Statistics for locking. Only used if THREAD_STATS is defined. */ long stat_lock_direct, stat_lock_loop, stat_lock_wait; #endif mfastbinptr fastbins[NFASTBINS]; /* Fastbins */ mchunkptr top; mchunkptr last_remainder; mchunkptr bins[NBINS * 2]; unsigned int binmap[BINMAPSIZE]; /* Bitmap of bins */ struct malloc_state *next; /* Linked list */ INTERNAL_SIZE_T system_mem; INTERNAL_SIZE_T max_system_mem; };

ptmalloc は、プロセスメモリをアロケーション領域を介して管理します。アロケーション領域は、プライマリアロケーション領域(arena)と非プライマリアロケーション領域(narena)に分かれています。この2つの違いは、プライマリアロケーション領域では sbrk と mmap を使用してオペレーティングシステムからメモリを要求できるのに対し、非プライマリアロケーション領域では mmap を介してのみメモリを要求できることです。


プロセスには、メイン割り当て領域が1つと、非メイン割り当て領域が複数存在します。メイン割り当て領域は、最初のスレッドによってのみ作成および保持されます。メイン割り当て領域と非メイン割り当て領域は、循環リンクリストの形式で相互に接続されています。割り当て領域全体は、可変のミューテックスロックを介してマルチスレッドアクセスをサポートします。

スレッドがメモリを要求するために `malloc` を呼び出すと、まずスレッドプライベート変数に割り当て領域が既に存在するかどうかを確認します。存在する場合、割り当て領域をロックします。ロックの取得に成功した場合、その領域を使用してメモリを割り当てます。失敗した場合、循環リンクリストでロックされていない割り当て領域を検索します。すべての割り当て領域がロックされている場合、 `malloc` は新しい割り当て領域を作成し、それを循環リンクリストに追加してロックし、メモリを割り当てます。メモリを解放する場合も、ロックを取得する必要があります。

3.3.2 チャンク

ptmalloc は、次のように定義される malloc_chunk を通じてメモリを管理します。

 struct malloc_chunk { INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */ INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */ struct malloc_chunk* fd; /* double links -- used only if free. */ struct malloc_chunk* bk; /* Only used for large blocks: pointer to next larger size. */ struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */ struct malloc_chunk* bk_nextsize; }; 


  • `prev_size`: 前のチャンクのサイズを格納します。前のチャンクが使用されていない場合、`prev_size` の値は前のチャンクのサイズを表します。前のチャンクが使用されている場合、`prev_size` の値は意味を持ちません。
  • size: 要求された有効なデータのサイズだけでなく、チャンク ヘッダーと末尾の管理情報などの追加情報のサイズも含め、現在のチャンクのサイズを示します。
  • `fd` と `bk`: これらは、フリーリスト内のチャンクの前後のヒープブロックへのポインタを表します。チャンクが占有されている場合、これらのポインタは意味を持ちません。
  • `fd_nextsize` と `bk_nextsize`: これらは、同じフリーリスト内の次のヒープチャンクへのポインタを表します。`fd_nextsize` は現在のチャンクよりも大きい最初の空きチャンクを指し、`bk_nextsize` は現在のチャンクよりも小さい最初の空きチャンクを指します。これらの2つのフィールドを追加することで、空きチャンクのトラバースと適切な空きチャンクの検索を高速化できます。

このデータ構造を使用すると、リンク リスト内の空きチャンクの検索と割り当てが高速化されます。

3.3.3 空きリスト(ビン)

ptmallocでは、同サイズのチャンクがフリーリスト(bin)にリンクされ、合計128個のbinがptmallocで使用できます。ユーザーがfree関数を呼び出してメモリを解放すると、ptmallocはそれをすぐにオペレーティングシステムに返すのではなく、binに格納します。これにより、次にmalloc関数がメモリを要求するために呼び出された際に、binからブロックが取得され、返されます。これにより、システム関数の頻繁な呼び出しが回避され、メモリ割り当てのオーバーヘッドが削減されます。

ptmalloc では、bin は主に次の 4 つのタイプに分けられます。

  • 高速ビン
  • 未分類のビン
  • 小さなゴミ箱
  • 大きなゴミ箱

ビンの分類に基づいて、ビンは高速ビンとビンに分けられ、ビンはさらに未分類ビン、小さいビン、大きいビンに分けられます。



高速ビン

実行時には、プログラムは小さなメモリブロックの割り当てと解放を頻繁に行う必要があります。アロケータが隣接する複数の小さなチャンクをマージした後、すぐに別の小さなメモリ要求が続くことがあります。この場合、アロケータは大きな空きメモリからブロックを割り当てる必要があり、これは明らかに非効率的です。そのため、mallocは割り当てプロセス中に高速ビンを導入しました。

合計10個の高速ビンがあり、これは基本的に10個の単一リンクリストです。各高速ビンのチャンクサイズは8バイトずつ増加します。例えば、最初の高速ビンに16バイトのチャンクが含まれている場合、2番目の高速ビンには24バイト、というように続き、最後の高速ビンには80バイトのチャンクが含まれます。高速ビン内の解放されたチャンクは、隣接する空きチャンクとマージされないことに注意してください。これは、高速ビンが小さなメモリブロックの高速割り当てと解放を目的として設計されているためです。そのため、システムは高速ビンに属するチャンクのP(未使用フラグ)を常に1に設定します。これにより、高速ビン内のチャンクが空きチャンクに隣接していても、自動マージは行われません。

malloc操作:

malloc を用いてメモリを割り当てる際、要求されたメモリサイズが高速ビンの範囲内にある場合、まず高速ビンを検索します。高速ビンに空きチャンクが存在する場合は、それが返されます。そうでない場合は、スモールビン、未ソートビン、ラージビンの順に検索します。

無料操作:

まず、chunksize 関数を使用して、渡されたアドレス ポインターに対応するチャンクのサイズを取得します。次に、チャンク サイズに基づいてチャンクが属する高速ビンを取得し、チャンクを高速ビンの末尾に追加します。

未分類のビン

これはビン用のバッファです。名前の通り、未ソートビン内のチャンクは順序が乱れています。この設計により、glibcのmallocメカニズムは最近解放されたチャンクを再利用する機会を再度得られるため、メモリ割り当て時間が短縮されます。

高速ビンとは異なり、未ソートビンでは FIFO (先入先出) アプローチが使用されます。

malloc操作:

必要なメモリサイズが高速ビンの最大サイズより大きい場合、まず未ソートビンで検索が行われます。適切なチャンクが見つかった場合は、それが直接返されます。そうでない場合は、小さいビンと大きいビンで検索が続行されます。

無料操作:

解放されたメモリのサイズが高速ビンの最大サイズを超える場合、解放されたチャンクは未ソート ビンに書き込まれます。

小さなゴミ箱

512バイト未満のチャンクはスモールチャンクと呼ばれ、スモールチャンクを格納するビンはスモールビンと呼ばれます。スモールビンは62個あり、隣接するスモールビンのサイズは8バイトずつ異なります。同じスモールビン内のチャンクのサイズは同じです。

「小さなビン」ポインタは、空きブロックを含む二重リンクの循環リストを指します。メモリの割り当てと解放のロジックは次のとおりです。

malloc操作:

必要なメモリが高速ビンまたは未ソートビンに存在せず、そのサイズが512バイト未満の場合、小型ビンで検索が行われます。適切なチャンクが見つかった場合は、それが直接返されます。

無料操作:

チャンクを解放する際、隣接するチャンクが空いているかどうかを確認します。空いている場合は、まずそれらをマージする必要があります。その後、マージされたチャンクは自身のリンクリストから削除され、新しいチャンクにマージされます。この新しいチャンクは、未ソートのビンリンクリストの先頭に追加されます。

大きなゴミ箱

512バイト以上のチャンクは「ラージチャンク」と呼ばれ、ラージチャンクを格納するビンは「ラージビン」と呼ばれます。ラージビン内の各ビンには、指定された範囲内のチャンクが格納され、チャンクはサイズの降順、またはチャンクのサイズが同じ場合は使用頻度の高い順に並べられます。63個のラージビンはそれぞれ、スモールビンと同様に動作しますが、固定サイズのブロックを格納するのではなく、サイズ範囲内のブロックを格納します。各ラージビンのサイズ範囲は、スモールビンのブロックサイズや他のラージビンの範囲と重複しないように設計されています。

malloc操作:

まず、ユーザーが要求したサイズがどの大きなビンに属するかを判断します。次に、その大きなビン内の最大チャンクのサイズがユーザーの要求サイズより大きいかどうかを確認します。大きい場合は、大きなビンの末尾から走査し、ユーザーが要求したサイズと等しいかそれに近い最初のチャンクを見つけて、ユーザーに割り当てます。チャンクがユーザーの要求サイズより大きい場合は、それを2つのチャンクに分割します。最初のチャンクはユーザーの要求サイズと同じサイズでユーザーに返され、残りのチャンクは未ソートビンに新しいチャンクとして追加されます。

無料操作:

大きいビンの料金操作は小さいビンの料金操作と同じなので、ここでは再度説明しません。

3.3.4 特別なチャンク

上部の塊

トップチャンクはヒープの最上位にある領域です。どのビンにも属しません。すべてのビンが割り当て要件を満たせない場合、この領域から割り当てが行われます。割り当てられた領域はユーザーに返され、残りの部分が新しいトップチャンクとなります。トップチャンクの領域もユーザーの要求を満たさない場合、brkまたはmmapを使用してシステムにヒープ領域の追加を要求します(brkとsbrkはプライマリ割り当て領域で使用され、mmapは非プライマリ割り当て領域で使用されます)。

mmapされたチャンク

割り当てられたメモリが非常に大きい場合(割り当てしきい値(デフォルトは128KB)を超える場合)、mmapを使用してマッピングする必要があります。この場合、メモリはmmapされたチャンクに配置されます。mmapされたチャンク上のメモリを解放すると、そのメモリはオペレーティングシステムに直接返されます(チャンク内のMフラグは1に設定されます)。

最後の残りのチャンク

ユーザーが要求したサイズが小さいビンに属しているものの、完全に一致しない場合は、最も一致するものが使用されます(例えば、128バイトが要求されているが、対応するビンが空で、256バイトのビンのみが空でない場合、チャンクは256バイトのビンから割り当てられます)。これにより、チャンクは2つの部分に分割され、1つはユーザーに返され、もう1つは残りのチャンクとして未ソートビンに挿入されます。

3.3.5 ハンクのマージと分割

マージ

チャンクが解放されるときに、その前後の 2 つの隣接チャンクが両方とも空いている場合は、そのチャンクは前後の 2 つの隣接チャンクと結合され、結合された結果は未ソート ビンに配置されます。

スライス

割り当てるメモリがチャンクよりも小さい場合、割り当てるチャンクは2つのチャンクに分割され、そのうちの1つは割り当てるメモリと同じサイズになります。分割後のチャンクは両方とも元のチャンクの最小サイズよりも大きくなければならないことに注意してください。そうでない場合、分割は行われません。

3.4 メモリ割り当て

メモリ割り当てプロセスは、次の 3 つのステップに分けられます。

ステップ 1: ユーザー要求のサイズを、割り当てる必要のある実際のチャンク スペースに変換します。

ステップ2:ビン内でまだオペレーティングシステムに返されていないチャンクを検索します。具体的なプロセスは下の図に示されています。


  • 必要なチャンクサイズがmax_fast(高速ビンで必要な最大チャンクサイズ、デフォルトは64バイト)以下の場合、高速ビンからチャンクを取得しようとします。チャンクを取得できた場合は戻ります。それ以外の場合は、次のステップに進みます。
  • 必要なサイズが小さなビンに収まるかどうかを判断します。つまり、chunk_size < 512B であるかどうかを確認します。チャンクサイズが小さなビンに収まる場合は、それらのビン内で適切なチャンクを検索します。適切な小さなビンが見つかった場合は、そのビンの末尾からサイズ要件を満たすチャンクを抽出して返します。小さなビン内に適切なチャンクが見つからない場合は、次のステップに進みます。
  • このステップは、割り当てるメモリブロックが大きなチャンクであるか、または小さなビンに見つからないことを示します。アロケータはまず、高速ビン内のチャンクをマージし、それらを未ソートチャンクに書き込みます。次に、未ソートチャンクを反復処理します。適切なチャンクが見つかった場合、必要に応じてチャンクを分割します(分割は必ずしも必要ではありません)。生成されたチャンクの1つは、小さなビンまたは大きなビンに配置され、もう1つのチャンクは、割り当てるメモリブロックと同じサイズで返されます。
  • 大きなビンの中から適切なチャンクを探します。適切なチャンクが見つかった場合は、必要なメモリサイズに分割し、残りの部分をビンに書き込みます。適切なチャンクが見つからない場合は、次のステップに進みます。
  • 最上位チャンクからメモリ ブロックをユーザーに割り当て、残りの部分を使用して新しい最上位チャンクを作成します。

ステップ 3: 上位チャンクがまだ割り当て要求を満たすことができない場合は、sbrk または mmap を使用して上位チャンクのサイズを増やし、ユーザーにメモリを割り当てます。

3.5 メモリ解放


  1. 現在のチャンクが mmap によってメモリマップされているかどうかを判断します。マップされている場合は、munmap を使用してメモリマップを直接解放します(メモリマップはマークによって識別できます)。
  2. チャンクが最上位のチャンクに隣接しているかどうかを判別します。隣接している場合は、それを最上位のチャンクと直接結合します。
  3. チャンクのサイズが max_fast (64B) より大きい場合、そのチャンクはソートされていないビンに配置されます。
    マージが可能かどうかを確認します。マージが可能な場合は、チャンクをマージし、サイズに応じて適切なビンに追加します。
  4. チャンクのサイズが
    `max_fast` が 64 バイトの場合、それは直接高速ビンに配置されます。マージが行われない場合、メモリは解放されます。隣接するチャンクが空いている場合はマージがトリガーされ、マージされた結果が未ソートビンに書き込まれます。マージされた結果が `max_fast` (64 バイト) より大きい場合は、すべての高速ビンに対してマージ操作がトリガーされます。その後、高速ビンが反復処理され、隣接するすべての空きチャンクがマージされ、マージされたチャンクが未ソートビンに書き込まれます。この時点で高速ビンは空になります。マージされたチャンクが最上位チャンクに隣接している場合は、最上位チャンクにマージされます。
  5. 上位チャンクのサイズが mmap 縮小しきい値 (デフォルト 128 KB) より大きい場合、メイン割り当て領域については、上位チャンクの一部をオペレーティング システムに返す試みが行われ、その時点で解放操作は終了します。

3.6 メモリの断片化

glibc のメモリ割り当て戦略に従って、次のシナリオを考えてみましょう。

1. brk の開始アドレスが 512k であると仮定します。

2. `malloc 40k memory`、つまりチャンクA、brk = 512k + 40k = 552k

3. `malloc 50k memory`、これはチャンクBです。`brk = 552k + 50k = 602k`

4. メモリの `malloc 60k`、つまりチャンク C、brk = 602k + 60k = 662k

5. チャンクAを解放します。

この時点で、チャンクAは空きブロックです。しかし、チャンクCとチャンクBが解放されていない場合、brkポインタを移動してもチャンクAのメモリを直接解放することはできません。チャンクBとチャンクCが解放されるまで待ってから、最上位チャンクとマージしてメモリをオペレーティングシステムに返す必要があります。

IV. 問題分析と解決

その理由は、メモリアロケータの仕組みを理解すれば簡単に導き出せます。プログラム内で「free/delete」を継続的に呼び出すと、メモリはアロケータのビンに書き込まれるだけで、オペレーティングシステムには返されません。そのため、メモリが回収されていないように見える状況が発生します。さらに、各「delete」が最上位チャンクに隣接していない場合、そのチャンクは長期間フリーリストに残り、最上位チャンクにマージできず、結果としてオペレーティングシステムへのメモリ解放が妨げられます。

4.1 最適化手法

  1. サーバー メモリの最大サイズを制限すると、C ランタイム ライブラリがメモリを過負荷にし、サーバーのメモリ不足 (OOM) が発生するのを効果的に防ぐことができます。
  2. C ランタイム ライブラリは jemalloc に置き換えられました。jemalloc は glibc とは異なる実装になっており、より速くメモリをオペレーティング システムに戻すことができます。

4.2 効果比較テスト

最適化されたメモリ使用量の有効性を検証するために、オンライン パイプライン モードで 3,000 万件の連続リクエストをシミュレートし、リクエスト処理中のピーク時のメモリ使用量と接続が切断された後のメモリ使用量を比較するテスト コードを作成しました。

glibc メモリアロケータ

記憶のピーク

接続が失われた後のメモリ使用量

jemalloc メモリアロケータ

記憶のピーク


接続が失われた後のメモリ使用量

テスト結果によると、jemalloc は空きメモリの解放において glibc よりも 12% 高速です。

参考リンク

  1. https://www.gnu.org/software/libc/manual/html_node/
  2. https://github.com/hustfisher/ptmalloc/blob/master/README
  3. https://stackoverflow.com/questions/13480235/libc-メモリ管理
  4. https://zhuanlan.zhihu.com/p/637659294