仮想メモリ
Virtual Memory ・ かそうメモリ
各プロセスに専用のメモリ空間があるように見せる仕組み。番地の翻訳で隔離と物理メモリ以上の割り当てを実現する。
✦ 30秒で試す — 航海2つのプログラムが同じ番地に書いても、互いに壊れない枠を7から6へ1つ減らすと、1回の読み出しの平均時間は?予想してみる概要
仮想メモリは、各プロセスに「広い専用のメモリ空間を独り占めしている」ように見せる仕組みです。実際の物理メモリ(RAM。メモリ階層でいう主記憶)は1組しかなく、多数のプロセスがそれを分け合っています。しかし各プロセスから見えるのは、自分だけの広大なアドレス空間です。プログラムが触る番地(仮想アドレス)は、実行のたびに CPU とOSの連携によって本当の場所(物理アドレス)へ翻訳されます。この「見せかけの番地」と「本当の場所」を切り離したことが、仮想メモリの核心です。
この1枚の翻訳の層があるおかげで、現代の OS はいくつもの難題を同時に解いています。プロセス同士が互いのメモリを壊せない隔離、物理メモリより大きな空間を約束できる容量の拡張、そして物理メモリのどこが空いているかに関係なくプログラムを置ける配置の自由です。ふだん意識することはありませんが、malloc でメモリを確保するのも、大きなファイルを開くのも、あるプログラムが落ちてもほかが無事なのも、すべてこの仕組みの上に成り立っています。
仮想メモリの働きが表に出るのは、たいてい問題が起きたときです。メモリ不足でプロセスが強制終了される、スワップが多発して極端に遅くなる、コンテナがメモリの上限に達して落ちる — こうした場面の背後には、必ず仮想メモリと物理メモリのやりくりがあります。
なぜ生まれたか
物理メモリをプログラムに直接使わせていた時代には、3つの困りごとがありました。第一に安全性です。すべてのプログラムが同じ物理アドレス空間を共有すると、あるプログラムが誤った番地に書き込めば、隣で動く別のプログラムやOS本体のデータを平気で壊せてしまいます。第二に容量です。物理メモリが足りなければ、それより大きなプログラムは動きませんでした。プログラマは、プログラムを部分に切り分けて必要なときにディスクから差し替える「オーバーレイ」を手で書いていました。第三に配置です。プログラムがどの物理番地に読み込まれるかは、そのとき空いている場所次第なので、コードは「どこに置かれても動く」ように書くか、読み込むたびに番地を書き換える必要がありました。
これらをまとめて解決したのが、「各プロセスに独立した仮想アドレス空間を与え、ページという固定サイズの塊ごとに物理メモリへ対応付ける」という発明です。1962年にイギリスのマンチェスター大学の Atlas というコンピュータで初めて実用化され、主記憶と磁気ドラムを1つの大きなメモリに見せました。プロセスは自分の仮想アドレスだけを見ればよく(配置の悩みが消える)、他のプロセスのページには手が届かず(安全性)、いま使っていないページはディスクへ退避して必要なときに戻せる(容量)。メモリ階層の「主記憶とディスクの間」を、ハードウェアと OS が自動で橋渡しするようになったのです。
詳細
ページ・ページテーブル・MMU・TLB
仮想メモリは、アドレス空間を「ページ」と呼ぶ固定サイズの塊(多くは4KB)に区切って管理します。物理メモリ側も同じ大きさの「フレーム」に区切られ、どの仮想ページがどの物理フレームにあるかの対応表がページテーブルです。ページテーブルはプロセスごとに1つずつ持ちます。だからこそ、同じ仮想アドレスでもプロセスによって別の物理フレームを指し、隔離が成り立ちます。
この翻訳を毎回ソフトウェアで行っていては遅すぎるので、CPU には MMU(Memory Management Unit)という専用の回路があります。プログラムが仮想アドレスに触れるたび、MMU がページテーブルを引いて物理アドレスに変換します。ページテーブル自体も主記憶の上にあるので、毎回引いていては読み出しのたびに余分なメモリアクセスが増えてしまいます。そこで、直近に使った翻訳結果を覚えておく小さな高速キャッシュ TLB(Translation Lookaside Buffer)が MMU の中にあります。TLB はCPUキャッシュと同じ発想で、TLB に翻訳が載っていれば(TLB ヒット)翻訳の手間はほぼゼロ、載っていなければ(TLB ミス)ページテーブルをたどり直します。
デマンドページングとページフォールト
プログラムを起動するとき、その全体を最初から物理メモリに読み込む必要はありません。仮想メモリはデマンドページング、つまり「実際に触られたページだけを、そのとき初めて物理メモリに載せる」方式をとります。ページテーブルで「不在」の印が付いたページに触れると、MMU はそれを検知してページフォールトを起こします。これは外部の装置が知らせる割り込みではなく、実行中の命令そのものが引き起こす例外(トラップ)です。制御を受け取ったカーネルは、必要なページをディスクから空いているフレームに読み込み、ページテーブルを書き換えてから、フォールトした命令をもう一度実行させます。プログラムからは、何事もなかったように見えます。
ページフォールトには重さの違いがあります。ディスクを読む必要があるもの(メジャーフォールト)は SSD でも数十〜100マイクロ秒、主記憶への読み出しの約1000倍かかります。一方、共有ライブラリのように中身がすでに別のプロセスによって物理メモリに載っていて、自分のページテーブルに書き込むだけで済むもの(マイナーフォールト)は、ずっと軽く済みます。
下の部品で、2つのプロセスのページを押して読みに行ってみてください。翻訳が TLB → ページテーブル → フレームとたどられる様子、同じページ番号でもプロセスごとに行き先が違うこと、ページフォールトで何が起きるかを確かめられます。物理メモリの枠を減らしてから「おまかせで流す」を押すと、次の節のスラッシングも再現できます。
プロセス A か B のページを押して、読みに行ってみてください。押したページの番地が、上の段の順に TLB → ページテーブル → 物理フレーム と翻訳されます。「不在」と書かれたページは、まだ物理メモリにありません。
スワップとスラッシング
物理メモリが逼迫すると、カーネルは「しばらく使われていないページ」を選んで、ディスク上のスワップ領域へ書き出し、空いたフレームを別の用途に回します。追い出されたページにまた触れると、ページフォールトが起きてディスクから読み戻されます。これで物理メモリより大きな作業が可能になりますが、ディスクは主記憶より桁違いに遅いため、使うページ(作業セット)の合計が物理メモリを超えると、追い出したページをすぐまた使うことになります。フォールトが連鎖して、CPU はほとんどディスク待ちになる — これがスラッシングで、メモリ不足のマシンが突然ほとんど応答しなくなる正体です。部品で見たように、足りない量がわずかでも、はまると一気に崖を落ちます。
超過コミットと OOM Killer
各プロセスに広大な仮想空間を約束する以上、OS は全プロセスの要求の合計が物理メモリを超えても割り当てを許します。これを超過コミット(オーバーコミット)と呼びます。多くのプロセスは約束された空間をすべては使わないので、平常時はこれで回ります。malloc で1GB確保しても、実際に書き込んだページにしか物理フレームは割り当てられません。しかし実際に使った量が物理メモリとスワップの合計を超えると破綻し、Linux では OOM Killer(Out Of Memory Killer)がプロセスを選んで強制終了します。
アドレス空間のレイアウトと隔離
1つのプロセスの仮想アドレス空間には、決まった区画があります。下位の番地から順に、機械語のコード、初期化済みのデータ、上へ伸びるヒープ、共有ライブラリや mmap で対応付けたファイルが置かれる領域、そして上位の番地から下へ伸びるスタックが並びます。教科書ではよく「ヒープとスタックが両端から向かい合って、ぶつかるまで伸びる」と描かれますが、これは空間の狭かった時代の図式です。64ビットの現代の OS では空間が桁違いに広く、ヒープとスタックの間には mmap の領域が挟まり、スタックには上限(Linux では ulimit -s、既定で8MB程度)が設けられています。
また、「どのプロセスも0番地から始まる同じ配置」も、今ではそのままではありません。0番地付近はわざと対応付けずに空けてあり、NULL ポインタをたどるバグは即座にフォールトして落ちるようになっています。さらに、攻撃者が狙う番地を予想しにくくするため、ヒープ・スタック・ライブラリの位置を起動のたびにずらす ASLR(アドレス空間配置のランダム化)が既定で有効です。配置は変わっても、プロセスごとに独立した空間であることは変わりません。
プロセス A の仮想アドレス 0x1000 とプロセス B の 0x1000 は、ページテーブルが別なので別の物理フレームを指します。あるプロセスがどれだけ暴走しても、自分のページテーブルに載っていない物理フレームには手が届きません。プロセスの「隔離」は、この翻訳の層がハードウェアのレベルで保証しているのです。反対に、fork の直後の親子や共有ライブラリのように、あえて同じフレームを指させることで、メモリを節約することもできます。
実務での顔
仮想メモリは運用の現場でさまざまな姿で現れます。メモリリークは、確保したまま解放しないヒープが積み上がる現象で、監視ではプロセスの RSS(実際に物理メモリを占めている量)が右肩上がりに増える形で見えます。放置すれば OOM Killer に狙われます。コンテナのメモリ制限は、cgroup というカーネルの機能で「このコンテナが使える物理メモリの上限」を課すもので、超えるとそのコンテナの中のプロセスが OOM で殺されます。ホストに余裕があってもコンテナが落ちるのはこのためです。仮想メモリの大きさ(VSZ)と RSS は別物で、VSZ が巨大でも実際に使っていなければ問題はありません。
性能の面では、スワップの多発(vmstat の si/so 列)や高いメジャーフォールト率が「メモリが足りていない」サインです。また、大きなファイルを mmap のシステムコールで仮想アドレス空間に対応付ければ、read・write を繰り返さずにファイルをメモリのように扱えます。触ったところだけがデマンドページングで読み込まれるので、巨大なファイルの一部だけを読む処理に向きます。仮想メモリを理解していると、「なぜ落ちたか」「なぜ遅いか」の多くが一本の筋で説明できるようになります。
試問
画面には、2つのプロセス A・B のページテーブル(仮想のページ番号から物理メモリの枠への対応表)と、物理メモリの枠、ディスクへの退避場所があります。「おまかせで流す」を押すと、A と B が決まったページを順番に使い続け、ページが物理メモリに無いたびにページフォールト(ディスクからの読み込み)が起きます。物理メモリの枠は − + で2〜8枠に変えられ、いまは8枠です。
プロセス A か B のページを押して、読みに行ってみてください。押したページの番地が、上の段の順に TLB → ページテーブル → 物理フレーム と翻訳されます。「不在」と書かれたページは、まだ物理メモリにありません。
