なぜ仮想メモリを使用するのか#
一般的なオペレーティングシステムでは、複数のプロセスが同じ物理メモリリソースを使用します。オペレーティングシステムがメモリ管理を行う際、プロセスが直接物理メモリを使用することを許可すると、いくつかの問題が発生します。
- オペレーティングシステムがプロセスに割り当てるスペースが不足する可能性があり、メモリを再度要求する際にスペースが連続していない場合があります。
- 同じ物理アドレス空間を共有するプロセス A が、プロセス B が使用しているアドレスに書き込む可能性があります。
効率とセキュリティの観点から、仮想メモリが導入されました。アプリケーションプロセスは、仮想アドレスを使用して CPU 内の物理メモリに読み書きし、MMU(メモリ管理ユニット)が仮想アドレスを具体的な物理アドレスに変換して対応する読み書き操作を行います。オペレーティングシステムは、各プロセスに対して仮想アドレスから物理アドレスへのマッピングを作成します。各プロセスは、自身が連続した完全なアドレス空間を持っていると考えることができます。
初期の多くのシステムでは、仮想メモリ管理方式としてセグメンテーションが使用されていました。オペレーティングシステムは、仮想アドレス空間と物理アドレス空間を異なるサイズのセグメント(コードセグメント、データセグメントなど)に分割し、セグメントテーブルを使用して仮想アドレスから物理アドレスへのマッピングを実現していましたが、物理セグメント間に断片化が発生しやすいという問題がありました。
- 仮想アドレスは、セグメント番号 + セグメント内オフセットで構成されています。
- MMU は、
セグメントテーブルベースレジスタ
を使用してセグメントテーブルを見つけます。 - セグメント番号を使用してセグメントテーブルで対応するセグメントの開始物理アドレスを見つけます。
- 物理セグメントの開始アドレス + オフセットで物理アドレスを得ます。
仮想メモリのページング#
現代のオペレーティングシステムでは、通常、ページングメカニズムを使用して仮想メモリを管理します。
オペレーティングシステムはまず、仮想アドレス空間と物理アドレス空間をより小さな連続した等しい長さのページに分割し、ページテーブルを使用して仮想アドレスから物理アドレスへのマッピングを実現します。ユーザープロセスは、完全な仮想アドレス空間を使用できます。カーネルは、各プロセスに対してページテーブルを維持します。したがって、2 つのプロセスが同じ仮想アドレスを使用していても、ページテーブルでマップされる物理アドレスが異なる場合は競合しません。
- 仮想アドレスは、ページ番号 + ページオフセットで構成されています。
- MMU は、
ページテーブルベースレジスタ
を使用して対応するプロセスのページテーブルを見つけます。 - ページテーブルでページ番号に対応する物理ページの開始アドレスを見つけます。
- 物理ページの開始アドレス + ページオフセットで物理アドレスを得ます。
多段ページテーブル#
物理メモリの使用量を削減するために(64 ビット仮想アドレス空間でページサイズが 4KB の場合、単一のページテーブルの使用量を考慮する)、通常、多段ページテーブルが使用されます。多段ページテーブルでは、仮想アドレスのページ番号部分を複数の部分に分割し、各部分に対応するページテーブルエントリがあります。例えば、AArch64 アーキテクチャでは、仮想アドレスには 48 ビットの有効ビット(仮想アドレス空間のサイズは 2^48)があり、そのうちの下位 12 ビットはページオフセットを表し、残りの 36 ビットが 4 段階のページテーブルに分割されます。
- 最初に、
TTBR0_EL1
レジスタを使用して 0 段目のページテーブルの物理アドレスを見つけます。 - 47-39 ビットを使用して 0 段目のページテーブル内のエントリを見つけます:1 段目のページテーブルの物理アドレス。
- 38-30 ビットを使用して 1 段目のページテーブル内のエントリを見つけます:2 段目のページテーブルの物理アドレス。
- 29-21 ビットを使用して 2 段目のページテーブル内のエントリを見つけます:3 段目のページテーブルの物理アドレス。
- 20-12 ビットを使用して 3 段目のページテーブル内のエントリを見つけます:対応する物理ページ番号。
- 11-0 ビット(ページオフセット)に物理ページ番号を加えると、実際の物理アドレスが得られます。
通常、ページテーブルエントリには物理アドレスの他に、ページテーブルエントリが存在するかどうか、ページテーブルエントリがダーティページであるかどうかなどのフラグが格納されます。多段ページテーブルの検索中に、特定のページテーブルエントリが存在しない場合、検索を直ちに終了することができます。
多段ページテーブルの各段は、すべてのページテーブルエントリが次の段のページテーブルを指すわけではありません。あるプロセスが 4KB のメモリスペースしか使用しない場合、その 0 段目のページテーブルには 1 つのエントリがあり、1 段目のページテーブルを指します。同様に、1 段目のページテーブルには 1 つのエントリがあり、2 段目のページテーブルを指します。以降も同様です。したがって、合計で 4 つのページテーブルページのみを使用します。もし連続した 4KB * 64 のメモリスペースを使用する場合、64 個のページの最初の 3 段階のページテーブルアドレスがまったく同じであれば、最終的には 4 つのページテーブルページのみを使用することになります。これにより、スペースが大幅に節約されます。以下の図を参照してください。
TLB(Translation Lookaside Buffer)とページテーブルの切り替え#
アドレス変換の効率を向上させるために、MMU 内には完全な仮想ページ番号から物理ページ番号へのマッピングをキャッシュする TLB(Translation Lookaside Buffer)ハードウェアがあります。TLB 内部には、CPU のような複数のキャッシュレベル(L1 命令キャッシュ、L1 データキャッシュ、L2 キャッシュなど)があります。一般的に、TLB に格納できるエントリの数は少なく、数千のエントリしかありません。アドレス変換のプロセスでは、MMU はまず TLB をクエリし、キャッシュヒットが発生した場合は直ちに結果を返します。キャッシュミスが発生した場合は、多段ページテーブルをクエリし、最終的な結果を TLB に書き戻します。TLB がいっぱいの場合は、ハードウェアのポリシーに従ってエントリを置き換えます。また、オペレーティングシステムでは通常、複数のプロセスが実行されるため、異なるプロセスが同じ仮想アドレスを使用することがあります。そのため、TLB のキャッシュ内容と現在のプロセスのページテーブルが一致するようにする必要があります。オペレーティングシステムは、プロセスの切り替え(ページテーブルの切り替え)を実行する際に TLB を明示的にフラッシュします。
プロセスの切り替え時に TLB をフラッシュすると、プロセスが実行を開始するときに大量の TLB ミスが発生する可能性があるため、性能に影響を与えます。そのため、モダンな CPU ハードウェアでは、プロセスタグの機能が提供されています。たとえば、amd64 アーキテクチャでは、オペレーティングシステムは異なるプロセスに異なる PCID(プロセスコンテキスト ID)を割り当て、それをページテーブルベースレジスタと TLB のキャッシュエントリに書き込むことができます。これにより、プロセスの切り替え時に TLB をクリアする必要がなくなります。ただし、ページテーブルを変更する際には、オペレーティングシステムは TLB を適時にフラッシュする必要があります。これにより一貫性が保たれます。この方法の欠点は、単一のプロセスが使用できる TLB エントリの数がさらに減少することです。
AArch64 アーキテクチャでは、一般的にカーネルとユーザープロセスは異なるページテーブルベースレジスタを使用するため、カーネルモード(システムコールの実行時)に切り替える際にページテーブルを切り替える必要はありません。一方、x86-64 アーキテクチャでは、ページテーブルベースレジスタは 1 つしかなく、カーネルは独自のページテーブルを使用せず、自身のアドレス空間を各プロセスのアドレス空間の上位部分にマップします。したがって、ページテーブルの切り替えは必要ありません。
ページングとページフォールト#
注意:以下のテキストでは、マッピングとは、プロセスがオペレーティングシステムに一定のサイズのスペースを要求し、オペレーティングシステムがプロセスに仮想アドレス空間内の一部のスペースを割り当て、開始アドレスと終了アドレスをプロセスに返すことを指します。
アプリケーションプロセスの実行中に、あるメモリスペース(Linux では brk や mmap システムコールを使用)を事前に要求することがありますが、これらのアドレス上のデータに読み書きする前に、オペレーティングシステムはそれらを実際の物理ページにマッピングしません。つまり、ページテーブルには、仮想ページに対応する物理ページのマッピングが存在しません。最初の読み書きが行われると、CPU はページテーブルをチェックし、対応する物理ページが存在しないことがわかると、ページフォールト例外が発生します。この場合、CPU は事前に設定されたpage fault handler
関数を実行し、適切な物理ページを見つけてページテーブルにアドレスを書き込みます。実際の状況では、ページフォールト処理関数は、隣接する複数の仮想ページに対しても同時にマッピングを行うことがあります。
プロセスが物理メモリサイズを超えるメモリリソースを使用する場合、ページフォールトが発生し、オペレーティングシステムは一部の物理ページをディスクなどのより低レベルのストレージデバイスにスワップアウトして、現在実行中のプロセスに空いている物理ページを提供します。このプロセスはページスワップアウトと呼ばれます。ページスワップアウトの実行中、オペレーティングシステムは物理ページの内容をディスクに書き込み、対応するページテーブルエントリをクリアし、物理ページのディスク上の位置を保存します。スワップアウトされた仮想ページが再度アクセスされると、オペレーティングシステムは以前スワップアウトした内容を新しい物理ページに書き戻し、プロセスのページテーブルを変更して仮想ページを新しい物理ページにマッピングします。このプロセスはページスワップインと呼ばれます。
ファイルシステムのスワップ領域は、ページング時に使用されるディスクパーティションです。ディスク I/O の効率が低いため、オペレーティングシステムはスワップイン時に複数のアクセスされる可能性のあるページを予測し、一括でスワップインすることで I/O 回数を減らすことができます。
アプリケーションプログラムが特定の仮想ページにアクセスし、ページフォールトが発生すると、オペレーティングシステムはその仮想ページが割り当てられているか、割り当てられているがマッピングされていないかを判断する必要があります。オペレーティングシステムは、割り当てられている仮想ページが特定の VMA(仮想メモリエリア)に属しているかどうかをチェックすることで、これを判断します。Linux では、プロセスの仮想アドレス空間は複数のVirtual Memory Area
に分割され、各 VMA には一定の範囲(開始アドレスから終了アドレス)があります。たとえば、コード、スタック、ヒープなどは異なる VMA に対応します。ページフォールトが発生すると、オペレーティングシステムは仮想ページが特定の VMA に属しているかどうかをチェックすることで、割り当てられているかどうかを判断できます。
仮想メモリの機能#
共有メモリ#
仮想ページはプロセスのページテーブルによって物理ページにマッピングされるため、オペレーティングシステムは 2 つのプロセス A と B の 2 つの仮想ページ A1 と B1 を同じ物理ページ P にマッピングすることで、プロセス間でメモリを共有することができます。共有メモリに書き込まれたデータは、他のプロセスから読み取ることができます。
Copy On Write#
共有メモリを使用することで、Copy On Write(COW)機能を実現することができます。たとえば、Linux では、プロセスが fork を使用して子プロセスを作成すると、子プロセスは最初に親プロセスと完全に同じメモリデータを持っています。したがって、Linux は子プロセスに新しいページテーブルをコピーするだけで、マッピングを変更せずに済みます。同様に、Linux はページテーブルでこの共有メモリ領域を読み取り専用に設定します。この共有メモリ領域に書き込みが行われると、アクセス権が不足してページフォールトが発生し、オペレーティングシステムは不足しているページのデータを新しい物理ページにコピーし、ページテーブルのエントリを更新し、アクセス権を復元します。fork 以外にも、複数のプロセスが同じ共有メモリをマップするために共有メモリを使用することができます。これにより、メモリ使用量が削減されます。
メモリデデュープリケーション#
COW を基にして、オペレーティングシステムは定期的に同じ内容を持つ複数の物理ページを 1 つにマージし、関連するすべてのページテーブルエントリをこの 1 つの物理ページに変更してメモリの使用効率を向上させることができます。この機能はメモリデデュープリケーションと呼ばれます。Linux では、これを実現するための機能として「Kernel Same-page Merging」が提供されており、この機能を使用すると、同じ内容の複数の物理ページを 1 つにマージし、関連するすべてのページテーブルエントリを新しい物理ページに変更することができます。ただし、この方法はパフォーマンスに一定の影響を与える可能性があります。また、いくつかのセキュリティ上の問題も発生します。たとえば、攻撃者はデータを列挙し、メモリデデュープリケーションが発生したかどうかをアクセスの遅延(COW によるページフォールトによる)から判断し、データがデデュープリケーションされた場合、攻撃者は現在の物理アドレス空間に同じデータが存在することを推測することができます。ただし、オペレーティングシステムは、この問題を回避するために、デデュープリケーションを同じユーザープロセス間でのみ実行することができます。
メモリ圧縮#
オペレーティングシステムは、メモリの使用量を減らすために、あまり頻繁に使用されないメモリデータを圧縮することができます。Linux では、圧縮されたメモリデータを格納するための zswap 領域があります。メモリデータが圧縮されると、最初に zswap に配置されます。物理メモリリソースが不足すると、zswap は一括でディスクにスワップアウトします。これにより、I/O 回数を減らすことができます。
ラージページ#
先述のように、TLB のキャッシュエントリ数は少ないため、4KB のメモリページサイズの場合、TLB の使用量が不足し、効率が低下する可能性があります。そのため、多くのオペレーティングシステムは、ラージページの機能を提供しており、メモリページのサイズを 2MB または 1GB に拡大して TLB の使用量を減らすことができます。Linux では、transparent huge page
と呼ばれるこの機能が提供されており、連続する 4KB のページを自動的に 2MB のページにマージします。
ラージページを使用することで、TLB キャッシュエントリの使用量を大幅に削減し、TLB ヒット率を向上させることができますが、ラージページはメモリの使用効率を低下させる可能性があります。たとえば、実際に使用されていない 256KB のメモリページが 2MB のラージページとして割り当てられると、非常に高いリソースの浪費が発生します。