I. はじめにメモリオブジェクトの割り当てと解放は、バックエンド開発者がコード設計において考慮しなければならない重要な問題です。十分な考慮がないと、メモリリークや境界外アクセスなどの問題に容易につながります。メモリ例外が発生すると、開発者はユーザーレベルコードのトラブルシューティングに多くの時間を費やしてしまい、Cランタイム、ライブラリ層、オペレーティングシステム層自体もメモリ問題を引き起こす可能性があるという事実を見落としがちです。この記事では、まず実際のメモリインシデントを用いてこの問題を紹介し、glibcライブラリのメモリレイアウト設計、メモリ割り当て、解放ロジックを段階的に説明し、最後に適切な解決策を提示します。 II. メモリアラームイベントオンラインメンテナンス操作中に、サービスでメモリアラームが検出されました。 [監視システム - カスタム監視 - アラーム - 連続アラーム] 検出ルール: メモリ使用量の監視: 一般的な異常 (>4096) サービス関連の監視を確認したところ、業務トラフィックの急増によりメモリ使用量が一時的に増加したか、メモリリークが発生していることが判明しました。 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 ビット モードの仮想メモリ レイアウトを例に挙げます。
ヒープ領域と Mmap 領域は、ユーザー プログラムに提供できる仮想メモリ空間です。 ヒープ操作オペレーティングシステムは `brk()` 関数を提供し、Cランタイムライブラリはヒープからメモリを割り当てるための `sbrk()` 関数を提供します。関数の宣言は次のとおりです。
Mmap操作 Linuxは、仮想メモリ空間を操作するためのmmap()関数とmunmap()関数を提供しています。これらの関数の宣言は以下の通りです。 mmap はファイルやその他のオブジェクトをメモリにマップできますが、munmap は特定のアドレス範囲のメモリ マッピングを削除できます。 3.3 メモリアロケータオープンソース コミュニティでは、dlmalloc、ptmalloc、jemalloc、tcmalloc など、多くの既製のメモリ アロケータをリリースしています。glibc は ptmalloc を使用するため、この記事ではそのメモリ アロケータについてのみ紹介します。 3.3.1 アリーナ(割り当てエリア) ヒープ管理構造を以下に示します。 ptmalloc は、プロセスメモリをアロケーション領域を介して管理します。アロケーション領域は、プライマリアロケーション領域(arena)と非プライマリアロケーション領域(narena)に分かれています。この2つの違いは、プライマリアロケーション領域では sbrk と mmap を使用してオペレーティングシステムからメモリを要求できるのに対し、非プライマリアロケーション領域では mmap を介してのみメモリを要求できることです。 プロセスには、メイン割り当て領域が1つと、非メイン割り当て領域が複数存在します。メイン割り当て領域は、最初のスレッドによってのみ作成および保持されます。メイン割り当て領域と非メイン割り当て領域は、循環リンクリストの形式で相互に接続されています。割り当て領域全体は、可変のミューテックスロックを介してマルチスレッドアクセスをサポートします。 スレッドがメモリを要求するために `malloc` を呼び出すと、まずスレッドプライベート変数に割り当て領域が既に存在するかどうかを確認します。存在する場合、割り当て領域をロックします。ロックの取得に成功した場合、その領域を使用してメモリを割り当てます。失敗した場合、循環リンクリストでロックされていない割り当て領域を検索します。すべての割り当て領域がロックされている場合、 `malloc` は新しい割り当て領域を作成し、それを循環リンクリストに追加してロックし、メモリを割り当てます。メモリを解放する場合も、ロックを取得する必要があります。 3.3.2 チャンク ptmalloc は、次のように定義される malloc_chunk を通じてメモリを管理します。
このデータ構造を使用すると、リンク リスト内の空きチャンクの検索と割り当てが高速化されます。 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:ビン内でまだオペレーティングシステムに返されていないチャンクを検索します。具体的なプロセスは下の図に示されています。
ステップ 3: 上位チャンクがまだ割り当て要求を満たすことができない場合は、sbrk または mmap を使用して上位チャンクのサイズを増やし、ユーザーにメモリを割り当てます。 3.5 メモリ解放
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 最適化手法
4.2 効果比較テスト最適化されたメモリ使用量の有効性を検証するために、オンライン パイプライン モードで 3,000 万件の連続リクエストをシミュレートし、リクエスト処理中のピーク時のメモリ使用量と接続が切断された後のメモリ使用量を比較するテスト コードを作成しました。 glibc メモリアロケータ 記憶のピーク 接続が失われた後のメモリ使用量 jemalloc メモリアロケータ 記憶のピーク 接続が失われた後のメモリ使用量 テスト結果によると、jemalloc は空きメモリの解放において glibc よりも 12% 高速です。 参考リンク
|