Linuxのメモリ管理システムについて

ryichk
·
公開:2023/12/14

Linuxはシステムに搭載されている全メモリを、カーネルのメモリ管理システムによって管理している。

メモリは、各プロセスもカーネル自身も使う。

メモリ関連情報の取得

「システムが搭載するメモリの量」と「使用中のメモリの量」は、`free`コマンドで調べられる。

  • totalフィールド

    システムに搭載されている全メモリ量。

  • freeフィールド

    見かけ上の空きメモリ。

  • buff/cacheフィールド

    バッファキャッシュ、およびページキャッシュが利用するメモリ。

    システムの空きメモリ(freeフィールドの値)が減少してきたら、カーネルによって解放される。

    ページキャッシュとバッファキャッシュは、ストレージデバイス上にあるファイルのデータを、メモリ上に一時的に保持することで、見かけ上のアクセス速度を上げるためのカーネルの機能である。

  • availableフィールド

    実質的な空きメモリ。

    freeフィールドの値に、ページキャッシュなどの解放可能なカーネル内メモリ領域のサイズを足したもの。

  • usedフィールド

    システムが使用中のメモリからbuff/cacheフィールドの値を引いたもの。

    プロセスが使うメモリとカーネルが使うメモリの両方を含む。

    プロセスのメモリ使用量に従って増える。

    プロセスが終了するとカーネルは該当プロセスのメモリを全て解放する。

`sar`コマンドによるメモリ関連情報の取得

`sar -r`コマンドを使うと、第2引数に指定した間隔でメモリに関する統計情報を得られる。

以下は、5秒間にわたって1秒ずつメモリに関するデータを採取した結果である。

`free`コマンドと`sar -r`コマンドの対応関係については以下の通り。

`free`:`sar -r`

totalフィールド:該当なし

freeフィールド:kbmemfreeフィールド

buff/cacheフィールド:kbbuffersフィールド + kbcachedフィールド

availableフィールド:該当なし

`sar`コマンドは一行に情報がまとまっているので継続的に情報を採取するような場面で使い勝手が良い。

メモリの回収処理

システムの負荷が高まりfreeメモリが少なくなってくると、カーネルのメモリ管理システムは回収可能なメモリ領域を解放してfreeメモリを増やそうとする。

回収可能なメモリには、例えばディスクからデータを読み出してまだ変更していないページキャッシュなどが該当する。

プロセスの削除によるメモリの強制回収

回収可能なメモリを回収してもメモリ不足が解消されない場合、「Out Of Memory」(OOM)という状態になる。

メモリ管理システムには、OOMの時に適当なプロセスを強制終了させて空きメモリを作る「OOM killer」という機能がある。

OOM killerが動作すると、`dmesg`コマンドから得られるカーネルログに

oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),...

のように出力される。

メモリ量は十分あるはずなのにOOM killerが発動する場合は、いずれかのプロセス、あるいはカーネルがメモリリークを起こしている可能性がある。

メモリリークとは、解放すべきメモリを解放せずに確保したままになってしまっているバグのことである。

`ps aux`で表示されるRSSフィールドは、プロセスが使用しているメモリ量を示す。

仮想記憶について

仮想記憶は、プロセスがメモリアクセスする際に、仮想アドレスを用いて間接的に物理メモリにアクセスさせる機能である。

仮想記憶は、ハードウェアとソフトウェア(カーネル)の連携によって実現している。

仮想アドレスに対して、物理メモリのアドレスを「物理アドレス」と呼ぶ。

アドレスによってアクセス可能な範囲を「アドレス空間」と呼ぶ。

仮想アドレス空間の大きさは固定である。

`readelf`コマンドや`cat /proc/<pid>/maps`の出力に記載されているアドレスは、全て仮想アドレスである。

プロセスから物理アドレスに直接アクセスする方法はない。

仮想記憶がない場合の課題

  • メモリの断片化

    プロセスが生成された後、メモリの獲得と解放を繰り返すとメモリの断片化という問題が発生する。

  • マルチプロセスの実現が困難

    あるプログラムAとBがあり、それらが同じメモリ領域にマップされることを期待している場合はAとBを同時に動かせない。

    複数のプログラムを動かそうとする時、全プログラムのメモリの配置場所が重ならないように意識する必要がある。

  • 不正な領域へのアクセス

    カーネルや複数のプロセスがメモリ上に配置されている場合、あるプロセスがカーネルや他のプロセスに割り当てられたメモリのアドレスを指定すればそれらの領域にアクセスできてしまうので、データの漏洩や破壊のリスクがある。

仮想記憶による課題解決

  • メモリの断片化

    プロセスのページテーブルをうまく設定すれば、物理メモリ上では断片化している領域をプロセスの仮想アドレス空間上では大きな1つの領域として表現できる。

  • マルチプロセスの実現が困難

    仮想アドレス空間はプロセスごとに作られる。

    そのため、マルチプロセス環境において各プログラムは他のプログラムとのアドレス重複を避けられる。

  • 不正な領域へのアクセス

    プロセスごとに仮想アドレス空間があることで、あるプロセスから他プロセスのメモリへのアクセスは不可能になる。

    カーネルのメモリも、通常プロセスの仮想アドレス空間にはマップされていないのでアクセスはできない。

ページテーブル

仮想アドレスから物理アドレスへの変換には、カーネルのメモリ内に保存されている「ページテーブル」という表を用いる。

CPUは全てのメモリをページという単位で区切って管理しており、アドレスはページ単位で変換される。ページのサイズはCPUアーキテクチャごとに決められており、x86_64アーキテクチャでは4KiBである。

ページテーブルを作るのはカーネルである。

カーネルはプロセス生成時にプロセスのメモリを確保し、そこに実行ファイルの内容をコピーするが、その時同時にプロセス用のページテーブルも作るのである。

ただし、プロセスが仮想アドレスにアクセスした際に物理アドレスに変換するのはCPUの仕事である。

ページテーブルエントリ

ページテーブル内の1つのページに対応するデータを「ページテーブルエントリ」と呼ぶ。ページテーブルエントリには仮想アドレスと物理アドレスの対応情報が入っており、ページに対応する物理メモリが存在するかどうかを示すデータも入っている。

ページフォールト

物理メモリに紐づいていない仮想アドレスにプロセスがアクセスすると、CPU上で「ページフォールト」という例外が発生する。

CPUの例外とは、CPUの仕組みによって実行中のコードに割り込んで別の処理を動かすための仕組みである。

ページフォールト例外によって、CPU上で実行中の命令が中断され、カーネルのメモリに配置された「ページフォールトハンドラ」という処理が実行される。

カーネルは、ページフォールトハンドラで、プロセスによるメモリアクセスが不正であること検出する。

その後、SIGSEGVシグナルをプロセスに送る。

SIGSEGVを受信したプロセスは、通常は強制終了させられる。

プロセスへの新規メモリの割り当て

カーネルがプロセスに新規メモリを割り当てる機能は次のようなシステムコールによって実現できる。

  1. プロセスは、システムコール呼び出しによって「XXバイトのメモリが欲しい」とカーネルに依頼する

  2. カーネルは、システムの空きメモリからXXバイトの領域を獲得する

  3. 獲得したメモリ領域をプロセスの仮想アドレス空間にマップする

  4. マップした仮想アドレス空間の先頭アドレスをプロセスに返す

メモリ領域の割り当て:mmap()システムコール

動作中のプロセスに新規メモリ領域を割り当てるにはmmap()システムコールを使う。

mmap()システムコールには、メモリ領域のサイズを指定する引数がある。

mmap()システムコールが呼ばれると、カーネルのメモリ管理システムはプロセスのページテーブルを書き換えて要求されたサイズの領域(x86_64アーキテクチャにおいてページサイズは4KiBなので、それ未満のサイズを要求されるとページサイズの倍数に切り上げられる)をページテーブルに追加でマップした上で、マップされた領域の先頭アドレスをプロセスに返す。

mmap()システムコールとGo言語のmmap()関数の引数は若干異なっている。

mmap()システムコールでは、要求するメモリのサイズを第2引数で指定するが、Go言語のmmap()関数では第3引数で指定する。

以下、Go言語のMmap関数

func Mmap(fd int, offset int64, length int, prot int, flags int) ([]byte, error)

引数の意味は以下の通り:

  • fd

    メモリマップドファイルを作成する対象のファイルディスクリプタ。ファイルディスクリプタは、osパッケージなどで開いたファイルから得られる。

  • offset

    ファイル内のオフセット。メモリマップの対象となるファイルのどの部分をマップするかを指定する。

  • length

    マップする領域のサイズ(バイト単位)。

  • prot

    メモリの保護モード。syscall パッケージにはいくつかの PROT_ 定数が定義されている。

    例えば、syscall.PROT_READは読み取り専用、syscall.PROT_WRITEは書き込み可能、syscall.PROT_EXECは実行可能など。

    これらの定数をビット演算子 `|` を使用して組み合わせて指定することができる。

  • flags

    マップの挙動や特性を指定するフラグ。syscall パッケージにはいくつかの MAP_ 定数がある。例えば、syscall.MAP_SHAREDは複数のプロセスでメモリを共有することを指定し、syscall.MAP_PRIVATEはプロセスごとにコピーを作成することを指定する。

Mmap関数は、メモリマップが成功した場合はマップされたデータへのスライスとnilのerrorを返し、失敗した場合はnilとエラーの値が返る。

エラーの値は、例えばsyscall.ENOMEMがエラーコードとして返る可能性がある。syscall.ENOMEMは、メモリが不足している場合に発生するエラーコードである。

`/proc/<pid>/maps`の出力結果は、各行が個々のメモリ領域に対応しており、第1フィールドがメモリ領域を指す。

Meltdown脆弱性

Linuxが登場してから2018年まで、カーネルの実装がシンプルになることや性能向上が見込めるなどの理由で、Linuxカーネルのメモリはプロセスの仮想アドレス空間にマップされていた。

しかし、2018年にハードウェア脆弱性「Meltdown」対策のためにカーネルのメモリはプロセスのアドレス空間にマップされなくなった。

仮想アドレス空間にマップされたカーネルメモリは、プロセスがユーザ空間で動作している時はアクセスできず、システムコール発行などをきっかけにカーネル空間で動作している時のみアクセスできるようハードウェア的に保護されていたが、Meltdown脆弱性は、この保護を突き破ってカーネルメモリを読み出せるというものだった。

メモリの割り当て:デマンドページング

mmap()システムコール呼び出し直後は、新規の仮想メモリ領域に対応する物理メモリはまだ存在せず、新規獲得した仮想メモリ領域内の各ページに最初にアクセスした際に物理メモリが割り当てられる。

この仕組みをデマンドページングと呼ぶ。

次のような流れでメモリを獲得する。

  1. プロセスがページにアクセス

  2. ページフォールトが発生

  3. ページフォールトハンドラが発動し、ページに対する物理メモリを割り当てる

ページフォールトハンドラは、ページテーブルエントリが存在しないページにアクセスした場合はプロセスにSIGSEGVを送るが、ページテーブルエントリが存在する場合は新規物理メモリを割り当てる。

仮想メモリ領域を獲得しても、その領域にアクセスしなければメモリ使用量(kbmemusedフィールドの値)は変化しない。

プロセスが終了するとメモリ使用量はプロセス開始前の状態に戻る。

`sar -B`コマンドでシステム全体のページフォールト発生回数を確認できる。

システム全体ではなく、プロセス単体の獲得済み仮想メモリ領域の量、獲得済み物理メモリの量、プロセス生成時からのページフォールト総数を確認するには、`ps -o vsz rss maj_flt min_flt`コマンドを使う。

ページフォールトの数はmaj_flt(メジャーフォールト)とmin_flt(マイナーフォールト)の2つに分かれている。

プログラミング言語処理系のメモリ管理

プログラムのソースコードにおいてデータを定義すると、データに対応するメモリを割り当てる必要があるが、プログラミング言語処理系はデータ定義のたびにmmap()システムコールを呼び出しているわけではない。

通常は、mmap()システムコールによってある程度大きな仮想メモリ領域をプログラム開始時に獲得しておき、データ定義のたびにこの領域からメモリを小分けにして割り当て、領域を使い切るとまたmmap()を呼び出して追加の仮想メモリ領域を確保するという作りになっている。

ページテーブルの階層化

x86_64アーキテクチャにおいて、仮想アドレス空間の大きさは128TiBで、1ページの大きさは4KiB、ページテーブルエントリの大きさは8バイト。

単純計算で1プロセスあたりのページテーブルに256GiB(8バイト x 128TiB / 4KiB)必要になる。

しかし、ページテーブルはフラット構造ではなく、メモリ使用量を削減するために階層化されている。

階層化することで、使用していない仮想メモリ量の分、ページテーブルエントリを減らせる。

使用する仮想メモリ量が大きくなるとページテーブルの使用量も増える。

仮想メモリ量がある程度多くなると階層型ページテーブルの方がフラットなページテーブルよりもメモリ使用量が大きくなるが、そのようなことは稀だそう。

x86_64アーキテクチャはページテーブルが4段構造になっている。これによってページテーブルに必要なメモリ量を大幅に削減している。

システムが使用している物理メモリのうちページテーブルとして使用しているメモリは`sar -r ALL`コマンドのkbpgtblフィールドから得られる。

ヒュージページ

ヒュージページは、通常より大きなサイズのページである。

プロセスが確保したメモリ量が増えると、そのプロセスのページテーブルに使用する物理メモリ量も増えていく問題を解決するための仕組みである。

ヒュージページにより、ページテーブルエントリの数を減らせるため、プロセスのページテーブルに必要なメモリ量を減らせる。

また、fork()関数の実行時にページテーブルをコピーするコストも低下するため、fork()関数の高速化も期待できる。

ヒュージページはmmap()関数のflags引数にMAP_HUGETLBフラグを与えるなどすれば獲得可能。

DBや仮想マシンマネージャなど、仮想メモリを大量に使うソフトウェアには、ヒュージページを使う設定が用意されていることがある。

トランスペアレントヒュージページ

ヒュージページはメモリ獲得時にわざわざ「ヒュージページが欲しい」と要求しなければならないので面倒である。

この面倒を解決するためにLinuxには「トランスペアレントヒュージページ」という機能がある。

これは仮想アドレス空間内の連続する複数の4KiBページが所定の条件を満たせば、それらをまとめて自動的にヒュージページにしてくれるという機能である。

トランスペアレントヒュージページは一見良いことばかりに見えるが、複数のページをまとめてヒュージページにする処理、および、条件を満たせなくなった時にヒュージページを4KiBページに再分割する処理によって局所的に性能が劣化する場合がある。

そのため、トランスペアレントヒュージページは、システム管理者が機能を有効にするかどうか選べるようになっている。

トランスペアレントヒュージページの設定は`/sys/kernel/mm/transparent_hugepage/enabled`ファイルを見れば分かる。このファイルに3つの値を設定できる。

  • always

    システムに存在するプロセスの全メモリについて有効。

  • madvise

    maddvise()システムコールに、MADV_HUGEPAGEというフラグを設定することによって明に指定したメモリ領域についてのみ有効。

  • never

    無効

参照

[試して理解]Linuxのしくみ ―実験と図解で学ぶOS、仮想マシン、コンテナの基礎知識【増補改訂版】

@ryichk
wanna be a good hacker.