ただの技術メモ

個人備忘録

Linuxのメモリ管理

コンピューター上では全ての処理がメモリにロードして実行される。

メモリ管理の仕組みについて書いてみる。

物理メモリと仮想メモリ

プログラムが実行されてプロセスが生成されると、カーネルは生成されたプロセスにメモリを割り当てる。カーネルによってプロセスごとに使用できるメモリ領域が割り当てられるが、プロセスは物理メモリに直接アクセスしているわけではなく、仮想メモリを通してアクセスしている。

cat /proc/${PID}/mapsで、指定したプロセスに割り当てられているメモリの仮想アドレスを確認できる。

カーネルから割り当てられた仮想メモリアドレスから物理メモリアドレスにアクセスする際は、ページテーブルという仮想メモリと物理メモリの対応表が使われている。ページテーブルはカーネルのメモリ領域にある。

仮想アドレス外のアドレスにアクセスすると「ページフォルトハンドラ」という処理が動き、カーネルはプロセスによるメモリアクセスが不正であることを検出して、「SIGSEGV」というシグナルを用いてプロセスに通知し、プロセスは強制終了される。

デマンドページング

プロセス生成時に、カーネルがプロセスに必要なメモリを物理メモリ上に確保して、ページテーブルに仮想アドレスと物理アドレスを紐付ける単純なメモリ割り当ての方法は、プロセスが割り当てられたメモリ全てを使わない可能性があり、メモリに無駄が生じる可能性がある。

そのため、プロセス生成時に仮想アドレスだけプロセスに割り当てて、物理アドレスは実際に仮想アドレスにアクセスされたタイミングで割り当てるデマンドページングというメモリ割り当て方法がある。

スワップ

メモリ使用量が増え続けると、メモリ管理システムは適当なプロセスを選んで強制終了することでメモリ領域を解放する。これをOOM(Out Of Memory) killerという。

このOOMに対する仕組みとして、ストレージデバイスの一部を一時的にメモリの代わりとして使うスワップという仕組みがある。

物理メモリが枯渇した際に使用中の物理メモリの一部をストレージデバイスに退避することによって空きメモリを作り出す。このメモリを退避する領域をスワップ領域、カーネルが使用中の物理メモリの一部をスワップ領域に退避させることをスワップアウトという。

物理メモリに空きが出てきたタイミングでスワップアウトしていたデータを物理メモリに戻す。これをスワップインという。

スワップは一見すると、メモリ量が物理メモリ+スワップ領域になるため良いように思えるが、ストレージデバイスへのアクセス速度はメモリへのアクセス速度に比べてかなり遅いので避けた方が良い。

メモリが常に足りない場合はメモリアクセスのたびにスワップインとスワップアウトが繰り返されるスラッシングという状態になる。

スワップ領域の使用量は以下のコマンドで確認できる。

$ swapon --show
NAME      TYPE SIZE   USED PRIO
/swap.img file   2G 358.3M   -2

仮想メモリ空間の詳細

仮想メモリ空間は大きく分けて4つのメモリ領域に分けて考えることができる。

メモリ領域

アドレスが小さい領域にはプログラム自体とグローバル変数のような静的変数が置かれる。静的変数とは、プログラム中で使用する変数のうち、プログラムの開始から終了まで値が保持され続けるもの。

その先はカーネルから動的にもらうヒープと呼ばれるメモリ領域があり、アドレスが大きい領域にはスタックと呼ばれるメモリ領域がある。ヒープとスタックはお互いに向かってメモリを伸ばしていく。

コンパイル時点でサイズが決まるようなローカル変数などがスタック領域に確保される。しかしスタック領域だけでは動的に使用するメモリのサイズが変わる場合に最大サイズを確保しておかねばならずメモリの無駄が生じる。そこで、ヒープ領域がある。

ヒープ領域はアプリケーションが動的に確保する領域。C言語mallocで確保する領域。コンパイル時点ではサイズが決まっていない場合によく使われる。ヒープ領域に積まれた変数はOSが自動的に判断し必要なときに確保して不要になったら解放してくれることがある(ガベージコレクション

C言語ではローカル変数を定義するとスタックにメモリが確保され、mallocを使うとヒープにメモリが確保されるシンプルな仕組みなっている。

一方で、Goの場合はヒープに置くかスタックに置くかはコンパイラが判断する。ローカル変数として宣言しても、他の関数に渡したり、関数の返り値として返すような場合はヒープに置かれることが多い。 スタックとヒープどちらに確保されているのかを知りたい場合は、ビルド時に-gcflags -mを渡す。

package main

import "fmt"

func main() {
    hogeStr := "hoge"
    fmt.Println(hogeStr)

    fugaStr := "fuga"
    _ = hogeStr + fugaStr
}
❯ go build --gcflags -m tmp/main.go
# command-line-arguments
tmp/main.go:7:13: inlining call to fmt.Println
tmp/main.go:7:13: hogeStr escapes to heap
tmp/main.go:7:13: []interface {}{...} does not escape
tmp/main.go:10:14: hogeStr + fugaStr does not escape
<autogenerated>:1: .this does not escape

このようにhogeStrはヒープに確保され、hogeStr + fugaStrはスタックに確保されていることが分かる。 基本的にスタックの方が高速なので本来はスタックに確保しようとするが、変数の寿命が長くなる可能性がある場合はヒープに確保されることが多い。