LabHub

ブログ

[オペレーティングシステム] 09. メインメモリ管理

한국어English日本語

背景

基本概念

CPUが直接アクセスできる記憶装置はレジスタとメインメモリだけである。ディスクのデータは必ずメモリにロードされてからCPUが処理できる。

メモリアクセスには時間がかかるため、CPUとメモリの間にキャッシュを置いて速度差を緩和する。

CPU <-> レジスタ (~1ns) <-> キャッシュ (~10ns) <-> メインメモリ (~100ns)

アドレスバインディング(Address Binding)

プログラムで使用するアドレスを実際のメモリアドレスに変換するプロセスである。バインディングの時点によって3つに分かれる。

[アドレスバインディングの時点]

1. コンパイル時バインディング
   - プロセスがメモリのどこにロードされるかコンパイル時に決定
   - 位置が変わると再コンパイルが必要
   - 例:MS-DOS.COMプログラム

2. ロード時バインディング
   - プログラムをメモリにロードする時にアドレスを決定
   - 再配置可能コード(relocatable code)を使用
   - ロード後はアドレス変更不可

3. 実行時バインディング(現代のOS   - 実行中にアドレスを動的に変換
   - MMU(Memory Management Unit)ハードウェアが必要
   - プロセスをメモリ内で移動可能

論理的アドレスと物理的アドレス

[MMUによるアドレス変換]

CPU --[論理アドレス: 346]--> MMU --[物理アドレス: 14346]--> メモリ
                              |
                        再配置レジスタ
                        (基準値: 14000)

物理アドレス = 論理アドレス + 再配置レジスタ値
346 + 14000 = 14346

ユーザープログラムは論理的アドレスのみを扱い、物理的アドレスを直接見ることはできない。

動的ロードと動的リンク

動的ロード: ルーチンが呼び出された時のみメモリにロードする。使用しないルーチンはメモリを占有しないため、メモリ利用率が向上する。

動的リンク: ライブラリを実行時にリンクする。

[静的リンク vs 動的リンク]

静的リンク:
  プログラム A: [コード + libcコピー]   -- 50MB
  プログラム B: [コード + libcコピー]   -- 50MB
  総メモリ: 100MB

動的リンク(共有ライブラリ):
  プログラム A: [コード + libc参照]     -- 10MB
  プログラム B: [コード + libc参照]     -- 10MB
  libc.so:   [共有ライブラリ]          -- 40MB
  総メモリ: 60MB

Linux: .soファイル(Shared ObjectWindows: .dllファイル(Dynamic-Link Library)

連続メモリ割り当て

最もシンプルなメモリ管理方式で、各プロセスを連続したメモリ領域に配置する。

メモリ保護

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

     ベース(base)            リミット(limit)
        |                       |
        v                       v
+-------+=======================+-------+
| OS    | プロセスPの領域        | その他 |
+-------+=======================+-------+
  0     300040                 420940

CPUが生成したアドレス addr に対して:
  if (addr >= base && addr < base + limit)
      アクセス許可 -> 物理メモリアクセス
  else
      トラップ発生 -> OSがエラー処理

メモリ割り当て戦略

空き領域(hole)リストからプロセスにメモリを割り当てる方法である。

[メモリ割り当ての例]

初期状態:
|---OS---|--P1--|-------空き-------|--P3--|---空き---|

割り当て戦略:
1. 最初適合(First Fit):最初の十分な空き領域に割り当て
   利点:高速

2. 最適適合(Best Fit):最小の十分な空き領域に割り当て
   利点:小さな残り領域を生成
   欠点:全体探索が必要、非常に小さな断片を生成

3. 最悪適合(Worst Fit):最大の空き領域に割り当て
   利点:残り領域が大きく再利用可能
   欠点:全体探索が必要

断片化(Fragmentation)

[外部断片化 vs 内部断片化]

外部断片化:
|P1|  空き  |P2| 空き |P3|  空き  |P4|
    100KB     50KB     80KB

総空き領域:230KBなのに200KBのプロセスをロードできない!
(連続領域ではないため)

解決:圧縮(Compaction)- プロセスを一方に移動
|P1|P2|P3|P4|-----230KB空き-----|

内部断片化:
メモリを固定サイズブロックで割り当てる際
プロセスがブロックより小さいと内部に残り領域が発生

プロセスサイズ:18,462バイト
割り当てブロックサイズ:20,000バイト
内部断片化:1,538バイトの無駄

断片化ができあがる過程

上の図は断片化がすでに生じた状態を見せている。その状態がどうやってできるのかを割り当てと解放の順に追ってみると、なぜこれが避けられない現象なのか理解しやすい。

[外部断片化が生じる過程:合計1000KBのメモリ]

0) 開始:全体が1つの空き領域
   |----------------- 1000 空き -----------------|

1) A(200), B(300), C(150), D(250) の順に割り当て(最初適合)
   |--A:200--|----B:300----|--C:150--|---D:250---|-100-|

2) B 終了 -> 300KB を返却
   |--A:200--|---300 空き---|--C:150--|---D:250---|-100-|

3) E(120) を割り当て -> B のあった場所の前半に入る
   |--A:200--|-E:120-|-180-|--C:150--|---D:250---|-100-|

4) D 終了 -> 250KB を返却
   |--A:200--|-E:120-|-180-|--C:150--|--250 空き--|-100-|

これで空き領域の合計 = 180 + 250 + 100 = 530KB
ところが 300KB のプロセスは入れない。
最大の連続区間が 250KB しかないからである。

3番の段階が核心である。300KB の穴に120KB を入れた瞬間、180KB という使い道のない切れ端が生まれた。どんな割り当て戦略もこの問題をなくせない。最適適合は残る切れ端を小さくしようとしてかえって細かく砕き、最悪適合は大きな塊を保とうとして大きな穴を早く使い切る。プロセスの大きさがまちまちである以上、穴の大きさと要求の大きさがぴったり合う理由はない。

圧縮は原理上この問題を解決する。生きているプロセスをすべて片側に寄せれば530KB が1つにまとまる。問題はコストである。プロセスを動かすとはそのプロセスのメモリ全体を実際にコピーするという意味であり、コピーしている間そのプロセスは実行できない。数 GiB を動かせば秒単位の停止が生じる。しかも移動後は再配置レジスタを更新しなければならないので、実行時バインディングが前提になる。コンパイル時やロード時にアドレスが固定されたプログラムはそもそも動かせない。

内部断片化は性格が違う。こちらは割り当て単位を固定サイズにした瞬間に確定的に発生し、平均の無駄を前もって計算できる。4 KiB ページを使えばプロセスあたり平均2 KiB 程度が最後のページで無駄になる。要求サイズがページ境界に対して均等に散らばると見れば、最後のページは平均して半分しか埋まらないからである。ページサイズを大きくすると後で見るアドレス変換の効率は良くなるが、この無駄も一緒に大きくなる。どちらを取るかはワークロードが決める。


ページング(Paging)

ページングは論理的アドレス空間を非連続的に割り当てて外部断片化を完全に除去する技法である。

基本概念

[ページングアドレス変換]

論理アドレス = ページ番号(p) + ページオフセット(d)

例:ページサイズ 4KB (2^12)、論理アドレス 32ビット
  上位 20ビット = ページ番号(最大 2^20 = 1Mページ)
  下位 12ビット = オフセット(0 ~ 4095
+--------+--------+
| p (20) | d (12) |       論理アドレス
+--------+--------+
     |
     v
[ページテーブル]
 p -> f(フレーム番号)
     |
     v
+--------+--------+
| f (20) | d (12) |       物理アドレス
+--------+--------+
[ページングの例]

論理メモリ(4ページ):         物理メモリ(8フレーム):
+------+                     +------+
|ページ0| ----+              |      | フレーム0
+------+     |              +------+
|ページ1| --+ |              |ページ2| フレーム1
+------+   | |              +------+
|ページ2| -+| |              |      | フレーム2
+------+  || |              +------+
|ページ3|  || +------------>|ページ0| フレーム3
+------+  ||                +------+
          |+--------------->|ページ1| フレーム4
          |                 +------+
          +---------------->|ページ2| フレーム5 (X)
                            +------+
                            |ページ3| フレーム6
                            +------+
                            |      | フレーム7
                            +------+

ページテーブル:
  ページ0 -> フレーム3
  ページ1 -> フレーム4
  ページ2 -> フレーム1
  ページ3 -> フレーム6

アドレスを1つ最後まで追ってみる

上の図はページがフレームに対応するという事実しか見せていない。実際に CPU がアドレスを1つ投げたときどんな算術が起きるのか、数字を入れて最後まで追ってみよう。条件はページサイズ 4 KiB、論理アドレス32ビットである。

ページサイズが 4 KiB なら、1ページの中のバイトを指すのに必要なビット数が自動的に決まる。4096は2の12乗なのでオフセットはちょうど12ビットである。この12という数字は設計者が選んだのではなく、ページサイズから出てきた結果だ。32ビットアドレスで下位12ビットをオフセットが取れば、上に残る20ビットがページ番号になる。

[論理アドレス 0x00004A3C を変換する]

論理アドレス: 0000 0000 0000 0000 0100 1010 0011 1100  (0x00004A3C)
               |<------ 上位20ビット ---->|<- 12ビット ->|

1) オフセット d = 下位12ビット
   0xA3C = 2620
   (検算: 0x4A3C = 19004, 190044096 で割った余り = 2620)

2) ページ番号 p = 上位20ビット
   0x00004A3C >> 12 = 4
   (検算: 190044096 で割った商 = 4)

3) ページテーブルの4番エントリを読む
   有効ビットが 0 ならここでページフォールト -> OS へトラップ
   有効ビットが 1 ならフレーム番号 f を得る。ここでは f = 9

4) 物理アドレス = (f * ページサイズ) + d
   = (9 * 4096) + 2620
   = 36864 + 2620
   = 39484  (0x00009A3C)

ここで注目すべき点が2つある。1つ目は、オフセットが変換されずそのまま通り抜けることだ。ページとフレームのサイズが同じだからであり、だから下位3桁の A3C が結果にもそのまま残っている。2つ目は、実際のハードウェアが掛け算ではなくビットシフトでこれを行うことである。ページサイズを2のべき乗にする理由がここにある。割り算や掛け算の回路なしに配線だけでアドレスを切り出せる。

この変換はプログラムがメモリに触れるたびに1回ずつ起きる。命令を取ってくるときに1回、その命令がデータを読めばもう1回だ。ところがページテーブル自体がメモリにあるので、素直に実装するとメモリアクセス1回がメモリアクセス2回になる。プログラムがちょうど2倍遅くなるという意味であり、これが TLB の存在理由である。

TLB(Translation Lookaside Buffer)

毎回ページテーブルをメモリから参照するとメモリアクセスが2倍になる。TLBはページテーブルのキャッシュ役割を果たす。

[TLBによるアドレス変換]

CPU --論理アドレス(p, d)--> TLB検索
                              |
              +---------------+---------------+
              |                               |
          TLBヒット                        TLBミス
          (高速、~1ns)                     (低速)
              |                               |
          フレーム f 取得                 ページテーブル参照
              |                        フレーム f 取得
              |                        TLBに登録
              |                               |
              +---------------+---------------+
                              |
                   物理アドレス (f, d)                   メモリアクセス

実効アクセス時間(EAT)計算:
  TLBヒット率 = 99%(一般的)
  メモリアクセス時間 = 100ns
  TLBアクセス時間 = 10ns

  EAT = 0.99 * (10 + 100) + 0.01 * (10 + 100 + 100)
      = 0.99 * 110 + 0.01 * 210
      = 108.9 + 2.1
      = 111ns

  TLBなし:200ns(メモリ2回アクセス)
  TLBあり:111ns(44.5%改善)

EAT の式がなぜあの形なのか手で確かめる

上の EAT の式がなぜあの形をしているかは、ミスが起きたとき実際にメモリを何回触るか数えてみると見えてくる。

ヒットのとき、ハードウェアは TLB を1回引き(上の例で10ns)、得た物理アドレスでデータを1回読む(100ns)。合計110ns である。ミスのときは TLB を引いて無いことを確認し(10ns)、ページテーブルを読みにメモリへ1回行き(100ns)、そうして得た物理アドレスでデータを読みにもう1回行く(100ns)。合計210ns である。つまりミスのコストはヒットのコストにメモリアクセス1回を足した値であり、EAT はこの2つの値をヒット率で加重平均したものにすぎない。

数字を変えてもう一度計算してみよう。ヒット率が98パーセントに下がり、メモリアクセスが80ns、TLB アクセスが1ns の機械ならこうなる。

[ヒット率98%, メモリ80ns, TLB 1ns の場合]

ヒットのコスト = 1 + 80        = 81ns
ミスのコスト   = 1 + 80 + 80   = 161ns

EAT = 0.98 * 81 + 0.02 * 161
    = 79.38 + 3.22
    = 82.6ns

TLB がまったく無ければ: 80 + 80 = 160ns
理想値 81ns に対するオーバーヘッド = 82.6 / 81 =1.02
ヒット率が 90% に下がると:
EAT = 0.90 * 81 + 0.10 * 161
    = 72.9 + 16.1
    = 89.0ns
オーバーヘッド = 89.0 / 81 =1.10

ヒット率98パーセントと90パーセントの差はわずか8ポイントなのに、オーバーヘッドは2パーセントから10パーセントへと5倍になる。ミス1回のコストがヒットより2倍近く大きいからであり、キャッシュ性能を語るときヒット率の最後の数パーセントがやけに高くつくと言われる理由がこれである。

TLB のエントリ数は数十から数百程度なので、カバーできるメモリ範囲は限られる。4 KiB ページのエントリを64個持つ TLB なら、一度にカバーする範囲は256 KiB しかない。これより広い領域をランダムに走査するプログラムはヒット率が急激に落ちる。このときページサイズを大きくする方法が効くのは、同じエントリ数ではるかに広い範囲をカバーできるからである。先の節で見た内部断片化の増加がその代価になる。

ページテーブル構造

プロセスのアドレス空間が大きい場合(例:64ビット)、ページテーブル自体が非常に大きくなる。

どれくらい大きくなるかは実際に掛けてみると感覚がつかめる。Linux カーネルのドキュメントによれば、現在 x86-64 でサポートされている仮想アドレス幅は48ビットと57ビットである。48ビットのアドレス空間に 4 KiB ページを使うとページ数は2の36乗、約687億個になる。エントリ1つを8バイトとしてもテーブル1つが 512 GiB であり、これがプロセスごとに1つ必要になる。

[平坦なページテーブルのサイズ計算]

48ビット仮想アドレス, 4 KiB(2^12) ページ:
  ページ数       = 2^48 / 2^12 = 2^36 = 68,719,476,736  エントリサイズ = 8バイトと仮定
  テーブルサイズ = 2^36 * 8 = 2^39 バイト = 512 GiB

プロセス1つが実際に使うメモリが 10 MiB でも
テーブルは 512 GiB を先に確保しなければならない。

57ビット仮想アドレスなら:
  ページ数       = 2^57 / 2^12 = 2^45
  テーブルサイズ = 2^45 * 8 = 2^48 バイト = 256 TiB

物理メモリよりページテーブルのほうがはるかに大きいというこの滑稽な状況が、階層構造が必要な理由である。核心となる観察はアドレス空間が疎であることだ。プロセスは48ビット空間のごく一部しか使わず、コード領域とヒープとスタックの間には巨大な空き区間がある。階層構造はこの空き区間に対応する下位テーブルをそもそも作らないという方法でサイズを削る。上位テーブルの該当エントリを無しと印を付けておけば、その下にぶら下がるはずだったテーブル全体が消える。

階層的ページテーブル(Multi-level Page Table)

[2段階ページテーブル]

32ビットアドレス、4KBページ:
+--------+--------+--------+
| p1(10) | p2(10) | d(12)  |
+--------+--------+--------+

外部ページテーブル(1段階)
+---+
| 0 |---> 2段階テーブル A
+---+      +---+
| 1 |--+   | 0 |--> フレーム番号
+---+  |   +---+
| 2 |  |   | 1 |--> フレーム番号
+---+  |   +---+
       |   | ...|
       |   +---+
       |
       +-> 2段階テーブル B
           +---+
           | 0 |--> フレーム番号
           +---+
           | ...|
           +---+

利点:使用していない領域の2段階テーブルは作成しない
-> メモリ節約

x86-64 のページウォーク

Linux カーネルのドキュメントは、ページテーブルの階層を上から PGD(Page Global Directory)、P4D(Page Level 4 Directory)、PUD(Page Upper Directory)、PMD(Page Middle Directory)、PTE(Page Table Entry)の5つの名前で呼ぶ。P4D は5段階ページテーブルを収めるために導入された層であり、4段階構成では PUD がその位置を担う。ハードウェアベンダのマニュアルは同じ階層を別の名前で呼ぶが、ここでは確認できたカーネルドキュメントの名前をそのまま使う。

同じカーネルドキュメントは、元々の x86-64 が4段階ページングによって仮想アドレス空間を 256 TiB に制限されていたこと、5段階ページングがこれを 128 PiB に広げたことを説明している。ただし5段階の機械でも既定では47ビットより上のアドレスを割り当てない。アプリケーションが明示的に高いヒントアドレスを要求したときにだけその上を使う。ポインタの上位ビットに自前のタグを埋め込んだプログラムがあるため、アドレス空間を急に広げるとそれらが壊れるからである。

アドレス1つが4段階をどう通り抜けるか、番号を付けるとこうなる。

[4段階ページウォーク:48ビットアドレスの切り方]

48ビット仮想アドレスを 9 + 9 + 9 + 9 + 12 に分ける
+--------+--------+--------+--------+--------------+
| L4 (9) | L3 (9) | L2 (9) | L1 (9) | オフセット(12) |
+--------+--------+--------+--------+--------------+
  各段階のインデックスが9ビット = テーブル1つにエントリ512  8バイト * 512= 4096バイト = ちょうど1ページ

1) CPU のページテーブルベースレジスタから
   最上位テーブル(PGD)の物理アドレスを読む
2) L4 インデックスで PGD のエントリを読む   -> メモリアクセス1回目
   次の段階のテーブルの物理アドレスを得る
3) L3 インデックスでそのテーブルのエントリを読む -> メモリアクセス2回目
4) L2 インデックスで次のテーブルのエントリを読む -> メモリアクセス3回目
5) L1 インデックスで最終エントリ(PTE)を読む     -> メモリアクセス4回目
   ここでフレーム番号と保護ビットが出る
6) フレーム番号にオフセットを付けて物理アドレスを完成させ
   実際のデータを読む                        -> メモリアクセス5回目

TLB ミス1回のコスト = メモリアクセス5TLB ヒット1回のコスト = メモリアクセス1

各段階のインデックスが9ビットなのは偶然ではない。エントリ1つが8バイトなので512個集まると4096バイト、つまりちょうど1ページになる。ページテーブル自体をページ単位で管理できるので、割り当てと置換が均一になる。

そして先の節の EAT 計算が4段階環境でははるかに重くなることも確認できる。ミス1回がメモリアクセス1回ではなく4回を追加で要求する。実際の CPU はこの負担を減らすため中間段階のエントリを別途キャッシュしておくが、TLB ミスが多いワークロードがなぜあれほど遅いのかはこの図1つで説明される。

ハッシュページテーブル

[ハッシュページテーブル]

論理ページ番号 p をハッシュ関数に入力
-> ハッシュテーブルのスロットにマッピング
-> チェーン(連結リスト)で p を検索
-> 対応するフレーム番号を返す

64ビットアドレス空間のような非常に大きい場合に有用

逆ページテーブル(Inverted Page Table)

[逆ページテーブル]

通常ページテーブル:プロセスごとに1つ(論理 -> 物理)
逆ページテーブル:システムに1つ(物理フレーム -> プロセス、ページ)

物理メモリの各フレームに対して1つのエントリ:
フレーム 0: (PID=5, ページ=3)
フレーム 1: (PID=2, ページ=7)
フレーム 2: ()
フレーム 3: (PID=5, ページ=0)
...

利点:物理メモリサイズに比例するテーブルサイズ
欠点:アドレス変換時にテーブル全体の検索が必要(ハッシュで解決)

スワッピング(Swapping)

プロセス全体または一部をディスクに退避してメモリを確保する技法である。

[標準スワッピング]

メインメモリ                    バッキングストア(ディスク)
+---------+                   +---------+
|   OS    |                   |         |
+---------+                   |  P2|
|   P1    |  <-- swap in ---  |  イメージ |
+---------+                   |         |
|   P3    |  --- swap out --> |         |
+---------+                   +---------+
|  空き    |
+---------+

現代のシステムではプロセス全体ではなく
ページ単位でスワッピング(ページスワッピング)
[ページ単位スワッピング]

プロセスの特定のページのみをディスクに移動:
- 長時間使用していないページをswap out
- 必要な時に再びswap in
- 仮想メモリの基盤技術

メモリ保護

ページング環境でのメモリ保護はページテーブルに保護ビットを追加して実装する。

[ページテーブルエントリの構造]

+-------+-----+-----+-----+-------+
| フレーム| 有効 | 読み | 書き | 実行  |
| 番号   | ビット| 可能 | 可能 | 可能  |
+-------+-----+-----+-----+-------+

有効ビット(Valid bit):
  1 = このページがプロセスの論理アドレス空間に属する
  0 = 無効なページ(アクセス時にトラップ発生)

保護ビット:
  読み取り専用ページへの書き込み試行 -> ハードウェアトラップ
  コードページに対して実行のみ許可、書き込み不許可
// メモリ保護の例:mprotectシステムコール
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void segfault_handler(int sig) {
    printf("セグメンテーションフォルト発生!保護されたメモリへのアクセス試行\n");
    exit(1);
}

int main() {
    signal(SIGSEGV, segfault_handler);

    // ページサイズ単位でメモリ割り当て
    size_t page_size = getpagesize();  // 通常 4096
    void *ptr = aligned_alloc(page_size, page_size);

    // データ書き込み
    *(int *)ptr = 42;
    printf("値:%d\n", *(int *)ptr);

    // メモリを読み取り専用に保護
    mprotect(ptr, page_size, PROT_READ);

    // 書き込み試行 -> セグメンテーションフォルト!
    *(int *)ptr = 100;

    free(ptr);
    return 0;
}

失敗事例と落とし穴

メモリの問題はたいてい、マシンが遅いという曖昧な報告として届く。症状を数字に分解する順序さえ決まっていれば、原因までは数分で着く。以下のコマンドは Linux 基準であり、vmstatfree は procps-ng パッケージに入っている。

症状1:マシン全体が遅くディスクが回り続ける

まず見るのはスワッピングの有無である。vmstat を1秒間隔で回す。

vmstat 1

出力例である。

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 2  1 512340  81234  12044 240188  184  392  2210  1180 1420 3100 12  8 41 39  0
 1  2 528900  76120  11980 231044  256  512  2680  1420 1510 3320 11  9 38 42  0
 3  1 541220  72880  11902 226310  312  604  3010  1690 1620 3480 10 10 35 45  0

man ページのフィールド説明は次のとおりである。si は "Amount of memory swapped in from disk (/s)"、so は "Amount of memory swapped to disk (/s)" である。メモリの節の swpd は "the amount of swap memory used"、free は "the amount of idle memory"、buff は "the amount of memory used as buffers"、cache は "the amount of memory used as cache" である。

判定基準は単純だ。siso が0でない状態が続いていれば今スワッピング中である。逆に swpd が大きな値でも siso が0なら、以前に追い出されたページがスワップに残っているだけで今は問題がないという意味になる。この区別を見落として swpd の数字だけを見て驚くことがよくある。上の出力では so が増え続けており、I/O 待ちを表す wa 列が40付近なので、メモリが足りずディスクへ押し出している最中で、CPU はそのディスクを待って遊んでいる。

症状2:free がほぼ0だが本当に足りないのか

ここで最も多い誤解が出る。

free -h

出力例である。

               total        used        free      shared  buff/cache   available
Mem:            15Gi       4.2Gi       324Mi       210Mi        11Gi        10Gi
Swap:          8.0Gi       501Mi       7.5Gi

free 列が 324 MiB だからといってメモリが無いわけではない。man ページによれば buff/cache は buffers と cache の合計であり、available は "Estimation of how much memory is available for starting new applications, without swapping" である。カーネルは余ったメモリをページキャッシュで埋めておき、必要なら即座に回収するので、見るべき数字は free ではなく available だ。上の出力では 10 GiB 残っているので余裕のある状態である。同じドキュメントは used が total から available を引いた値として計算されると明記している。-h オプションは各値を3桁で読める単位に変え、B, Ki, Mi, Gi, Ti, Pi のような単位表記を付ける。

症状3:プロセスがログを1行も残さず消えた

アプリケーションのログに終了の痕跡がなく、シェルが報告する終了コードが SIGKILL を指しているなら、カーネルが殺した可能性が高い。カーネルのリングバッファを見る。

dmesg -T | grep -i -E 'out of memory|oom'

出力例である。

[Sat Aug 15 04:12:31 2026] myserver invoked oom-killer: order=0, oom_score_adj=0
[Sat Aug 15 04:12:31 2026] Out of memory: Killed process 4711 (myserver)
                           total-vm:8394204kB, anon-rss:7912044kB, file-rss:0kB

正確な文言はカーネルのバージョンによって変わるので、上の出力は形を見せるための例として読むのがよい。確実なのは、どのプロセスを殺すかをカーネルがスコアで決めるという事実である。man ページは /proc/PID/oom_score を "the current score that the kernel gives to this process for the purpose of selecting a process for the OOM-killer" と説明している。スコアは主にメモリ使用量を反映し、oom_score_adj の設定がその値を調整する。

どのプロセスが実際にどれだけ使っているか確認するときは、プロセスのメモリマップを見る。man ページによれば pmap は "the memory map of a process or processes" を報告し、構文は pmap [option ...] pid ... である。-x は拡張形式を、-X はそれよりさらに詳しい情報を見せる。ただし -X の出力形式は /proc/PID/smaps に従って変わるとドキュメントが警告している。

getconf PAGESIZE
pmap -x 4711 | tail -3

出力例である。

4096

Address           Kbytes     RSS   Dirty Mode  Mapping
...
total kB         8394204 7912044 7910020

getconf PAGESIZE が返す値は POSIX が定義したシステム設定値である。man ページは PAGESIZE を "Size of a page in bytes. Must not be less than 1." と定義し、PAGE_SIZE はその同義語だと述べている。この値を知って初めて先ほど計算したオフセットのビット数が決まり、プロセスが実際に何ページ使っているかも割って確認できる。


使わないほうがよい場面

この章の半分は連続メモリ割り当ての話だが、今日の汎用オペレーティングシステムでこの方式をそのまま使っているところはない。Linux も Windows も macOS もページングを使い、最初適合や最適適合でプロセス全体を連続区間に配置したりしない。外部断片化のせいで実用性がなく、その問題をなくすために出てきたのがまさにページングだからである。

それでもこの内容を学ぶ理由は2つある。1つはページングが何を解決したのか理解するには解決される前の状態を知らなければならないということ、もう1つは連続割り当てが今も生きている場所があるということだ。

1つ目の場所は、MMU が無いか使わない組み込みシステムとリアルタイムオペレーティングシステムである。ここではアドレス変換自体が無いので、物理メモリを区間に切って配る以外に方法がない。リアルタイムシステムはむしろこちらを好むこともある。ページフォールトがいつ起きるかわからないより、アクセス時間が常に一定であるほうがよいからだ。

2つ目の場所は現代のシステムの中にもある。デバイスが DMA で直接読み書きするバッファは物理的に連続でなければならない場合が多く、ラージページをあらかじめ予約しておく設定も結局は大きな連続区間を確保する作業である。こうした予約をなぜ起動直後に行うほうが成功率が高いのかも、この章の内容で説明される。システムが長く動くほど物理メモリが細かく砕け、大きな連続区間を見つけにくくなる。

逆にこの章のページングの部分には有効期限がない。アドレス変換、TLB、多段テーブル、保護ビットは、今使っているすべてのサーバとノートパソコンと携帯電話で命令ごとに動いている。アプリケーション開発者がページテーブルを直接触ることはないが、性能問題を追いかけていくと結局この層が顔を出す。なぜメモリを順番に走査するコードがランダムに走査するコードよりはるかに速いのか、なぜラージページが特定のワークロードでだけ効くのかといった問いの答えは、すべてここにある。


参考資料


まとめ

メインメモリ管理はプロセスに効率的で安全なメモリ空間を提供するための核心機能である。MMUが論理的アドレスを物理的アドレスに変換し、ページングが外部断片化を除去し、TLBがアドレス変換性能を保証する。階層的ページテーブルと逆ページテーブルは大容量アドレス空間を効率的に管理し、保護ビットによりプロセス間のメモリ分離を保証する。

コメント

まだコメントはありません。

ログインするとコメントできます