Linuxのメモリアロケータに関するあれこれ

リソースが潤沢とはいえない組込みシステムにおいて,ある機能の使用前と使用後でリソース(特にメモリの使用量)が同じ状態に戻るということはとても重要なことです.しかし組込Linuxシステムにおいて,メモリリークのような不具合が見当たらないにも関わらずある機能の使用後にすべてのメモリが開放されていないと思われる動きをすることがあります.

本稿ではメモリアロケータの動きからこういった現象が起きる原因を紐解き,メモリの確保や解放をどのようにするべきなのかを考えていきたいと思います.

なお,ここでは広くLinuxで使用されているglibcのメモリアロケータを想定しています.

malloc() / free() で物理メモリが解放されないことがある

malloc() で確保したメモリは free() で開放しても物理メモリが解放されないケースがあることに注意が必要です.

MMAP_THRESHOLD 以上の malloc() の動作

MMAP_THRESHOLD (デフォルトでは128KB) 以上のメモリを malloc() で確保する場合,malloc() は内部で mmap() を用いて指定サイズの領域を確保し,そのアドレスを返します.mmap() によるメモリ確保には以下のような特徴があります.

  • 確保したメモリは munmap() で物理メモリを解放する.malloc() 内で mmap() された領域は free() する時に munmap() される.
  • 4KB単位でしかメモリの確保はできない
  • mmap() で確保したメモリはゼロ初期化は不要(ゼロ保証されている) したがって,free() されたメモリは”解放済メモリリスト”には登録されず,物理的に解放されます.

MMAP_THRESHOLD 未満の malloc() の動作

MMAP_THRESHOLD 未満のサイズのメモリを malloc() で確保する場合,”解放済メモリリスト”を検索し指定サイズの領域が確保できた場合はそのアドレスを返します.ヒープ領域が足りない場合は malloc() 内部で sbrk() を呼び出しヒープの拡張を行います.こうして確保されたメモリが free() されると”解放済メモリリスト”に登録され,物理的には解放されません.

sleep状態で常駐するプログラムが多い組込システムでは,この動作が問題となります.

ではすべてmmapすれば良いのではないか?というのは間違いではないのですが,メモリ確保の性能は malloc() > mmap() なのでなんでもかんでも mmap() するわけにもいきません.

物理メモリをできるだけ解放する

上述の問題を回避するための対策として,

  • MMAP_THRESHOLD の動的な変化を抑制する
  • ページキャッシュの解放をカーネルにアドバイスする
  • 不要なゼロ初期化を避ける といった対策が考えられます.以下,1つづつみていきます.

MMAP_THRESHOLD の動的な変化を抑制する

MMAP_THRESHOLD は128KB以上512KB未満の malloc() と free() を行うことによってそのサイズが最大512KBまで引き上げられます.これにより,以降の malloc() / free() では引き上げられた MMAP_THRESHOLD のサイズ未満の物理メモリは解放されなくなります.この動作を抑制するために mallopt() というインタフェースが存在します.mallopt() で MMAP_THRESHOLDの値を設定することで動的な閾値変更を無効化し malloc() の閾値を変更できます.これにより,MMAP_THRESHOLD 以上のサイズの malloc() では mmap() が使用されるようになり,free() を実行した際に物理的にメモリが解放されるようになります.

ページキャッシュを解放する

通信用バッファなどで単方向のメモリアクセスをする場合,ページキャッシュを解放することで物理メモリの空き容量増加が期待できます.ページキャッシュの解放は madvise() で MADV_DONTNEED を指定します.これでカーネルに対して,リソースはもう解放しても良いと伝える(アドバイスする)ことができます.このアドバイスを受け入れるかどうかはカーネルに任されます.この方法は静的メモリを解放する場合に有効です.静的メモリの場合,いちど物理メモリが割り当てられてしまうと(わたしの知る限りでは)他の方法でメモリを解放することができません.

動的メモリの確保と解放は上述の通りそれぞれ対応するインタフェースがあるので,madvise() は基本的に使用すべきではありません.しかし,メモリを解放したいけれどそのメモリにそれ以上アクセスしないことを保証できない場合は madvise() で解放するといった戦略を採ることもできます.その場合,物理メモリの解放が行われるますが,論理メモリの解放は行われないため,もしアクセスがあった場合0が読まれることになります.

madvise() で解放するメモリは4KBバウンダリから4KB単位であることに注意しましょう.解放したい領域が madvise() する領域に完全に包含されていない場合,予期しない動作を引き起こすことになります.

不要なゼロ初期化をしない

上述の通り,MMAP_THRESHOLD を超えるサイズのメモリを malloc() で確保すると,mmap() が呼び出されます.この時 mmap() は物理メモリをまだ割り当てておらず,指定サイズの論理アドレスのみが割り当てられます.この領域に対して読み書きを行う段階ではじめて4KB単位で物理メモリが確保され,確保された物理メモリは free() / munmap() されるまでは解放されません.また,上述の通りこの領域はゼロ保証されています.したがって,malloc() した後 memset() などでゼロ初期化する場合,以下の2つの問題があります.

  • ゼロ保証されている領域をゼロ初期化する(無駄な処理)
  • 本来必要ないかもしれないメモリを物理的に確保してしまう 組込システムのソースコードではいまだに memset() で初期化するお作法をよく見かけますが,少なくともLinuxベースのシステムではやめるべきです.

まとめ

  • 確保するメモリサイズによって malloc() の動作が異なる
  • 組込システムでは MMAP_THRESHOLD の動的変化は抑制しておいた方が良い
  • madvise() でカーネルにリソースが不要であることを伝えることはできる(が解放されるかはカーネルしだい)
  • ゼロ初期化はやめよう

参考