JA EN
体系OSと実行環境
·無料·16分で読めます

プロセスとメモリを1から — OSは何を守っているのか

アプリが1つ落ちてもパソコン全体は落ちない。その当たり前を支えているのが、プロセスの隔離と仮想メモリです。私書箱の比喩から出発して、ページング・アドレス変換・TLB・ページフォルト・スワップ・OOM killer までを前提知識ゼロで積み上げ、最後は free や dmesg を自分で読めるところまで持っていきます。

対象textタスクsystems

落ちるのはアプリだけで、機械は落ちない

ブラウザのタブが固まっても、隣のエディタで書きかけの文章は無事です。動画の書き出しが暴走してメモリを食い尽くしても、たいていはそのプログラムだけが強制終了され、あなたはデスクトップに戻ってきます。

これは当たり前ではありません。プログラムはどれも同じ1つのCPUと1つのメモリを使っているので、何も仕切りがなければ、あるプログラムが書き込んだ数値が別のプログラムの変数を上書きし、全部が道連れで壊れるはずです。実際、大昔の家庭用パソコンはそういう世界でした。

その仕切りを作っているのがOS(オペレーティングシステム)です。OSが守っているものは、煎じ詰めると3つあります。隔離(互いに触らせない)、公平(CPUとメモリを分け合わせる)、そして(有限の物理メモリを、各プログラムには「広い専用の空間」として見せる)。この記事はそのうちメモリに関する部分を、比喩から実際のコマンドまで一本の線でつなぎます。

プロセス = 「実行中のプログラム」に与えられた世界

まず用語を1つだけ。ディスクの上に置かれた実行ファイルは、まだプログラムです。それをOSが読み込んで動かし始めた瞬間、プロセスになります。レシピと、実際に火にかかっている鍋の違いだと思ってください。同じレシピから鍋を3つ作れるように、同じ実行ファイルからプロセスは何個でも作れます。

プロセスには番号(PID)が振られ、OSは1つずつ台帳を持ちます。台帳に載っているのは、開いているファイル、権限、そして本題であるアドレス空間です。アドレス空間はプロセス専用の「番地の地図」で、ざっくり次の区画に分かれています。

ヒープとスタックの間は、たいてい広大な空白です。この空白があるおかげで両方が自由に伸び縮みできます。

ファイルを読む、メモリを増やす、画面に出す — どれもハードウェアを直接触る操作なので、プロセスはシステムコールという窓口を通してOSに頼みます。このときCPUは特権の低いユーザーモードから特権の高いカーネルモードに切り替わります。プログラムが暴走してもOSまで巻き込まれないのは、この2階建て構造があるからです。

比喩: 番地はぜんぶ「私書箱の番号」だった

ここからが本題です。プロセスAとプロセスBが、両方とも「0x400000番地」を使っていると聞くと、ぶつかりそうに思えます。ぶつかりません。プログラムが見ている番地は、物理メモリの番地ではないからです。

郵便局の私書箱を思い浮かべてください。あなたの名刺には「私書箱12番」と書いてあり、相手はその番号宛に出します。でも実際の手紙が郵便局の奥のどの棚に置かれるかは、番号とは無関係です。対応表を持っているのは郵便局だけで、棚を入れ替えても名刺は書き換えなくていい。

プログラムが使う番地(仮想アドレス)が私書箱番号、実際のメモリチップ上の番地(物理アドレス)が奥の棚、対応表を持つ郵便局がOSとCPUです。だからAの12番とBの12番は、別々の棚に割り当てられます。隔離は「見張り役が毎回チェックしている」のではなく、そもそも相手の棚に届く番号を持っていないという形で実現されています。

仕組み: ページングとアドレス変換

対応表を「1番地ごと」に持つと、表が本体より大きくなります。そこでメモリをページという固定サイズの塊に切り、塊ごとに対応させます。よくある大きさは4KB(Apple Silicon の macOS など、16KBの環境もあります)。物理側の同じ大きさの枠はフレームと呼びます。

仮想アドレス AA は、ページ番号とページ内の位置に分解されます。

VPN=AP,offset=AmodP\mathrm{VPN} = \left\lfloor \frac{A}{P} \right\rfloor, \qquad \mathrm{offset} = A \bmod P
(1)

PP はページの大きさ(例: 4096)、VPN\mathrm{VPN} は「何番目のページか」、offset\mathrm{offset} は「そのページの先頭から何バイト目か」です。要するに、番地を「ページ番号」と「ページ内の席番号」に割った余りで分けているだけです。

PP が2のべき乗であることがここで効きます。4096で割った商と余りは、2進数で見れば下位12ビットを切り取るだけ。割り算は要らず、CPUは配線だけでこの分解ができます。

対応表(ページテーブル)が返すのは物理側のフレーム番号 PFN\mathrm{PFN} で、最終的な物理アドレスはこうなります。

PA=PFN×P+offset\mathrm{PA} = \mathrm{PFN} \times P + \mathrm{offset}
(2)

つまり、「棚の番号 × 棚1つ分の大きさ」で目当ての棚の先頭が出るので、あとはそこから席番号のぶんだけ数えて進めばいい、ということです。「ページごと棚を移しても、ページの中での席順は変わらない」という意味です。offsetがそのまま持ち越されるので、変換で書き換わるのは上位ビットだけです。

ページテーブルの各行には、フレーム番号のほかに小さな旗が並んでいます。present(このページは今メモリ上にあるか)、R/W(書いてよいか)、NX(実行してよいか)、user(ユーザーモードから触ってよいか)、dirty(書き換えられたか)、accessed(最近使われたか)。読み取り専用のはずのページに書き込もうとした瞬間にプログラムが止まるのは、この旗をCPUが毎回見ているからです。

ページテーブルはなぜ「多段」なのか

素朴に考えると、対応表は全ページ分の行を持つ1枚の表になります。その大きさは、アドレスのビット数 bb、ページ内オフセットのビット数 pp、1行のバイト数 ee から決まります。

S=2bp×eS = 2^{\,b-p} \times e
(3)

つまり、番地のビットのうちページ内の位置に使う分(pp)を差し引いた残りが「ページが何個あるか」を決め、その個数だけ行が並び、1行あたり ee バイトかかる、ということです。「表の行数はアドレス空間をページで割った数、その行数×1行の大きさが表のサイズ」というだけの式です。ところが x86-64 の仮想アドレスは48ビット、ページは4KB(p=12p=12)、1行8バイト。代入すると 236×8=512 GB2^{36} \times 8 = 512\ \mathrm{GB} です。プロセス1個ごとに512GBの表が要る計算で、完全に破綻しています。

FIG 1横軸 n を「アドレスのビット数」、縦軸を「ページテーブルの行数」と読み替えてください。n を 48 まで動かすと、赤い O(2ⁿ) の線が他とは別世界の高さに行きます。1枚の表が成立しないのはこの爆発のためで、だから表を多段にして「使う枝だけ」作ります

現実の解決策は多段ページテーブルです。VPNをさらに9ビットずつ4つに切り、1段目の表が2段目の表の場所を指し、2段目が3段目を…と辿ります(x86-64は4段、より広い空間向けに5段もあります)。木構造にすると何が嬉しいか。使っていない枝を作らなくていいのです。ヒープもスタックも使っていない広大な空白は、上位の段で「そこには何もない」と1行示せば済みます。実際に数MBしか使わないプロセスのページテーブルは、数十KBに収まります。

代償は速度です。1回のメモリアクセスのたびに表を4回辿る(ページウォーク)なら、単純計算でメモリアクセスが5倍になります。

TLB: 変換結果をしまっておく

そこでCPUは、直近の変換結果を専用の小さなキャッシュに置きます。TLB(Translation Lookaside Buffer)です。私書箱で言えば「よく来る人の番号と棚の対応を、窓口の手元のメモに書いておく」ことに当たります。

実効的なアクセス時間はこう書けます。

T=TTLB+(1h)TwalkT = T_{\text{TLB}} + (1-h)\,T_{\text{walk}}

hh はTLBに当たる割合(ヒット率)、TwalkT_{\text{walk}} は外れたときに表を辿るコストです。当たれば手元のメモを見るだけ、外れたときだけ奥の棚まで行く、と言っています。つまり、平均の変換コストは「毎回必ず払うメモ確認の代金」に「外した割合のぶんだけ発生する遠回りの代金」を足したもの、ということです。hh が0.99でも、TwalkT_{\text{walk}} が桁違いに重ければ効いてきます。

TLBのエントリ数はせいぜい数百から数千です。1エントリが4KBを担当するので、TLBが一度に面倒を見られる範囲は数MB程度。これを超えて広い範囲をランダムに触るプログラムは、データがキャッシュに載っていてもTLBミスで詰まります。データベースやインメモリ処理でヒュージページ(1ページを2MBや1GBにする)が効くのはこのためで、1エントリの守備範囲が512倍になれば同じTLBで512倍の領域をカバーできます。この「同じO(n)でも実行時間が変わる」感覚はキャッシュに優しいコードと地続きです。

ページフォルト: 「無い」ことにして、後で用意する

present の旗が立っていないページに触ると、CPUは命令を中断してOSを呼びます。これがページフォルトです。障害の名前がついていますが、大半は正常運転です。

この仕組みがあるおかげで、OSは触られるまで物理メモリを渡さないという戦略が取れます(デマンドページング)。1GBを確保しても、実際に書き込んだページの分しか物理メモリは減らない。だから「確保したメモリの合計」が物理メモリを超えても、その場では何も起きません。これをオーバーコミットと呼びます。後で効いてくるので覚えておいてください。

同じ仕掛けの応用がコピーオンライトです。プロセスを複製する fork は、メモリを丸ごとコピーしません。親子で同じ物理ページを共有し、全部を読み取り専用にしておく。どちらかが書き込もうとした瞬間にページフォルトが起き、そこで初めてそのページだけを複製します。数GBのプロセスの fork が一瞬で終わるのはこのためです。ファイルを mmap したときも同様で、中身はページキャッシュとして物理メモリに載り、複数プロセスで共有されます。ストレージ側がページ単位でしか読み書きできない事情はB木とLSM木から見ると立体的になります。

スワップとスラッシング

物理メモリが足りなくなったとき、OSは「しばらく使われていないページ」をディスクへ追い出し、空いた枠を新しい要求に回します。これがスワップです。追い出されたページは present の旗が下ろされ、次に触られたときにメジャーフォルトとして読み戻されます。

「しばらく使われていない」の判定には、先ほどの accessed ビットを使います。真面目に最終使用時刻を記録すると全アクセスにコストが乗るので、Linuxはページを2つのリスト(activeとinactive)で管理し、参照ビットを定期的にクリアしながら往復させるLRUの近似で済ませます。

問題は、同時に必要なページの総量(ワーキングセット)が物理メモリを超えたときです。追い出したページがすぐ必要になり、読み戻すために別のページを追い出し、それもすぐ必要になる。CPU使用率は低いのに何も進まないスラッシングの状態で、体感は「重い」ではなく「操作を受け付けない」に近く、SSHすら通らなくなることがあります。

Linuxには vm.swappiness(多くのディストリで既定60)という目盛りがあり、値が大きいほど積極的にスワップし、小さいほどページキャッシュを削ってスワップを避けます。macOSはスワップの前段に圧縮メモリを挟み、ディスクに出す代わりにメモリ上で圧縮して詰め込みます。

OOM killer: 守れなくなった約束の後始末

オーバーコミットは「みんなが同時に全額引き出さない」という前提の上に成り立っていて、その前提が崩れる日が来ます。スワップも尽き、追い出せるページもなく、それでもページフォルトが物理ページを要求する — この行き止まりで動くのが OOM killer(Out Of Memory killer)です。

OOM killer は生きているプロセスに点数(oom_score)を付け、最も高い1つを SIGKILL で殺します。点数は基本的に「使っている物理メモリが多いほど高い」。ここが実務上の罠で、メモリを食っている犯人ではなく、いちばん太っているプロセスが死にます。バッチジョブがじわじわ食い、道連れで本番のデータベースが落ちる、という事故が典型です。/proc/<pid>/oom_score_adj(-1000〜1000、-1000で対象外)で優先度を調整できます。

殺されたことは dmesg に残ります。コンテナ環境では、cgroup ごとの上限(cgroup v2 の memory.max)を超えた時点でそのcgroup内でOOMが起き、Kubernetes では Pod の状態が OOMKilled、終了コードが 137(128 + SIGKILL の 9)になります。「なぜか137で落ちる」の正体はほぼこれです。

コードで触ってみる

オーバーコミットとページフォルトは、10行で目に見えます。

#include <sys/mman.h>
#include <stdio.h>

int main(void) {
    size_t n = 1UL << 30;                       /* 1GB */
    char *p = mmap(NULL, n, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    getchar();                                  /* ここで ps を見る: VSZ は +1GB, RSS はほぼ0 */
    for (size_t i = 0; i < n; i += 4096) p[i] = 1;   /* 各ページに1バイト書く */
    getchar();                                  /* ここで ps を見る: RSS が +1GB */
    return 0;
}

確保した直後は「地図に区画を書いた」だけで、物理メモリは1バイトも減りません。ループで各ページに触った瞬間に、ページフォルトが26万回起きて物理ページが埋まります。フォルトの回数はこう数えられます。

/usr/bin/time -v ./a.out        # Minor / Major page faults の行を見る
ps -o pid,vsz,rss,comm -p $!    # VSZ=約束した広さ, RSS=実際に載っている量
perf stat -e page-faults,minor-faults,major-faults ./a.out

現場ではこう使う

誰が、いつ触るか。 サーバの容量設計をするSRE・バックエンド開発者、学習ジョブが落ちる原因を追うML基盤担当、そして「本番だけ落ちる」を調べる全員です。犯人はたいていメモリ不足そのものではなく、メモリの数え方の間違いです。

見る場所とパラメータ。

知らないと事故になる落とし穴。

  1. malloc が成功したのにプロセスが死ぬ。 オーバーコミットのせいで、失敗は確保時ではなく最初の書き込み時に、しかも SIGKILL としてやってきます。戻り値のNULLチェックだけでは足りません
  2. コンテナの中で freenproc はホストの値を返す。 cgroupの上限を見ないランタイムは、ホストの搭載量を前提にヒープやワーカー数を決めて即OOMします(新しいJVMは UseContainerSupport で cgroup を見ます)
  3. スワップを切ると安定する、は半分だけ本当。 スラッシングでじわじわ死ぬ代わりに、OOM killer で突然死するようになります。どちらが運用しやすいかは、監視で検知できるかどうかで決まります
  4. 透過的ヒュージページ(THP)は諸刃。 大きな連続領域が取れないと、確保時にページを寄せ集める処理で数百msの停止が入ることがあり、レイテンシ重視のデータベースでは無効化するのが定石です
  5. OOM killer は犯人を殺さない。 重要なプロセスは oom_score_adj で守り、暴走しうるジョブは cgroup で上限を切って「その中だけで死ぬ」ようにします

面接や障害対応で問われる形。 「VSZが巨大なのに問題ないのはなぜ?」「malloc が失敗しないのに落ちるのはなぜ?」「メモリ使用率が98%だが異常か?」— どれも答えの筋道は同じで、約束(仮想)/実体(物理)/再利用可能な余り(キャッシュ)を分けて数えることです。物理メモリ側の値段の話はメモリの壁を1からに続きます。

まとめ

コメント

コメントにはログインが必要です