モダンオペレーティングシステム 第5版 上

第3章前半(3.1〜3.3)。メモリ管理の基本。


第3章前半 メモリ管理の基礎

ベースレジスタとリミットレジスタ

CPUのハードウェアレジスタを使ったシンプルなメモリ保護の仕組み。

プログラムがメモリにロードされる
    ↓
ベースレジスタ ← プログラムの開始物理アドレス
リミットレジスタ ← プログラムの長さ
    ↓
プロセスがメモリにアクセスするたびに
    ↓
CPUがアクセスアドレス + ベース値を自動加算
    ↓
リミットを超えてないか同時チェック
    ↓
超えてたらフォールト(アクセス中断)

これによってプロセスAはプロセスBのメモリに触れない。CPUレベルで物理的にブロックしてる。

ProxmoxのVM(KVM)との絡み

KVMの場合はベース/リミットだけじゃなく、**EPT(Extended Page Table)**というIntel VT-xの機能でさらに一段上の分離をしてる。

ゲストの仮想アドレス
    ↓
ゲストのページテーブル
    ↓
ゲストの物理アドレス(実はまだ仮想)
    ↓
EPT(ここがKVMの追加レイヤー)
    ↓
本当の物理アドレス

これをネストされたページングと呼ぶ。ゲストOSごとに完全に別の物理アドレス空間にマッピングされるので、VM同士はお互いのメモリに絶対アクセスできない。

EPTはソフトウェアじゃなくてCPUがハードウェアで変換するので速い。


KVMに必要なCPU機能

KVMは以下のCPU機能が必須:

CPU 仮想化支援機能
Intel VT-x
AMD AMD-V(SVM)
ARM(ラズパイ4等) ARMv8 Virtualization Extensions

BIOSでVT-xが無効になってるとKVMが動かない。ProxmoxでVM作れないエラーの原因の大半がこれ。

MacのDockerが遅かった理由もここ

Intel Mac時代
→ HyperKitでソフトウェア仮想化
→ 遅い・重い

Apple Silicon(M1〜)
→ ARMのVirtualization Extensions使える
→ ハードウェア仮想化
→ 速い

M1でDockerが速くなったのはApple SiliconがARMの仮想化支援機能をちゃんと使えるようになったから。ただしARMなのでx86イメージは--platform=linux/amd64でエミュレーションになり遅い。


スワップ(Swapping)

メモリが足りなくなった時にプロセスをディスクに退避する仕組み。

メモリ逼迫
    ↓
OSが使っていないプロセスを選ぶ
    ↓
そのプロセスのメモリをディスクに書き出す(スワップアウト)
    ↓
空いたメモリを別のプロセスに使わせる
    ↓
スワップアウトしたプロセスが必要になったら
    ↓
ディスクからメモリに読み戻す(スワップイン)

ディスクはメモリに比べて桁違いに遅いので、スワップが多発するとシステム全体が重くなる。スワップ領域がMaxになると逃がす場所もなくなり詰む。

k3sとの比較

k3sはデフォルトでswapを無効にすることを要求する(--fail-swap-on)。

理由はシンプルで、swapがあるとスケジューラのリソース計算が狂うから。

Podのメモリ制限 = 512MB と設定
    ↓
swapがあると実際には512MB以上使える
    ↓
スケジューラが「このNodeはまだ余裕ある」と誤判断
    ↓
Podを詰め込みすぎる
    ↓
全体が遅くなる

メモリ逼迫時はswapに逃がすんじゃなくOOMKillerがPodを強制終了する。これはKubernetesの設計思想「Podは死ぬもの、死んだら再起動すればいい」に基づいてる。

複数台前提の設計なので、OOMKillerで殺されても別のNodeで再起動されればユーザーは気づかない。


仮想アドレスと物理アドレス

物理アドレス:実際のRAMの場所。

仮想アドレス:各プロセスが「自分のメモリ」として認識する仮想的なアドレス空間。

物理メモリ(実際のRAM)
┌────┬────┬────┬────┬────┐
│ 使用│ 空き│ 使用│ 空き│ 使用│
│プロA│     │プロB│     │プロC│
└────┴────┴────┴────┴────┘
  ↑ バラバラに散らばってる

仮想アドレス空間(各プロセスから見える世界)
┌────────────────────────────┐
│  0番地から連続してるように見える  │
│  実際は物理メモリのバラバラな場所  │
│  にマッピングされてる            │
└────────────────────────────┘

プロセスは「自分のメモリは0番地から連続してる」と思ってる。実際の物理メモリはバラバラ。その変換をページテーブルがやってる。

これで外部断片化(穴問題)を隠蔽できる。


malloc()した時に何が起きるか

Dockerコンテナ内でmalloc()を呼んでも、コンテナはただのLinuxプロセスなので動きは同じ。

malloc(1GB)を呼ぶ
    ↓
OSは「わかった、仮想アドレス空間に1GB分確保したよ」と返す
    ↓
でもこの時点で物理メモリは割り当てていない
    ↓
実際にそのアドレスに書き込もうとする
    ↓
ページフォルト発生(「あ、物理メモリまだ割り当ててなかった」)
    ↓
ここで初めて物理メモリを割り当てる

これをデマンドページングと呼ぶ。要求された時に初めてページを割り当てる。

docker run --memory=512mでコンテナ起動直後にメモリを512MB食わない理由がこれ。

ページフォルト:仮想アドレスを参照した時に物理アドレス側にまだ何も割り当てられていない時に発火するエラー。エラーといっても正常系の処理の一部。


外部断片化(穴問題)

ページングが登場する前の話。プロセスをメモリに連続して配置していた時代の問題。

プロセスA開始 → 終了
プロセスB開始 → 終了
プロセスC開始 → 終了

メモリの状態:
[プロA][    空き    ][プロC]
          ↑
       穴(使えない空白地帯)

プロセスが終了するとその場所が空くが、断片化して小さな穴だらけになる。大きなプロセスを入れたくても穴が小さすぎて入らない。

穴を埋める3つの戦略

戦略 方法 特徴
ファーストフィット 最初に見つかった空きに入れる シンプル・速い
ベストフィット 全部走査して一番小さい穴に入れる 小さい残骸が増えて断片化悪化
ワーストフィット 全部走査して一番大きい穴に入れる 残った穴をなるべく大きく保つ

ベストフィットは一見効率よさそうだが、「ぴったりサイズを狙う」ので使えない小さい残骸が増えて結局断片化が悪化する。

現代のOSがこの問題を解決した方法

ページングで4KBに分割することで外部断片化自体が発生しなくなった。

【断片化時代】
「100KBの連続した空きを探す」→ サイズを比較しながら走査(フィット必要)

【ページング以降】
「空きページを1個見つける」→ サイズ比較なし・最初の空きで終わり

連続したメモリを探す必要がなくなったので、フィット戦略自体が不要になった。ページテーブルのビットマップで「空き[0]か使用中[1]か」を管理するだけでいい。


クソ長いので一旦ここで切る

まとめ

第3章前半
├── ベースレジスタ・リミットレジスタ(シンプルなメモリ保護)
├── EPT・ネストされたページング(VMレベルのメモリ分離)
├── KVMに必要なCPU仮想化支援機能(VT-x / AMD-V)
├── スワップ(メモリ逼迫時にディスクに退避)
│   └── k3sはswap無効・OOMKillerで対処
├── 仮想アドレスと物理アドレス(ページテーブルで変換)
├── デマンドページング(malloc→仮想確保→アクセス→ページフォルト→物理割当)
└── 外部断片化(穴問題)→ ページングで解決

次回はページテーブルとTLBの話(3.4〜3.5)。