8MiBを予約しても8MiBは使わない
前章で、1万本のスレッドを立てるとVSZは約80GiBになるのに、RSSは約80MiBにしかならないという話をしました。 スレッドごとに予約した8MiBのスタックは、触った分しか物理メモリを使わないからです。 この「予約したメモリ」と「実際に使っているメモリ」のずれを生んでいるのが仮想メモリです。
ずれを理解していないと、本番で困ります。topに表示されたVIRTの大きさに驚いて調査を始めたり、逆にheapUsedしか見ずにコンテナがOOMKilledになる理由を見落としたりします。 この章では、仮想アドレスの仕組みからページフォールト、スワップ、OOM Killerまでを順に見て、最後にNode.jsのprocess.memoryUsage()の数字を読めるようにします。 キャッシュ階層とスワップの基礎はCPUシリーズ第4章で扱ったので、ここではOSがメモリをどう管理しているかに絞ります。
なぜ仮想アドレスが必要か
JavaScriptで変数を作ったり、Cでmalloc()したりして得られるアドレスは、メモリチップ上の番地(物理アドレス)ではありません。 プロセスごとに用意された仮想アドレスで、CPUのMMU(メモリ管理ユニット)が実行のたびに物理アドレスへ変換します。 1962年のAtlasが初めて実用化したこの仕組みには、主に3つの目的があります。
1つ目は保護と隔離です。プロセスAのアドレス0x1000とプロセスBのアドレス0x1000は、別々の物理ページに対応しています。 AはBのメモリを指すアドレスをそもそも持てないので、バグのあるプロセスが他のプロセスやカーネルのメモリを書き換えてしまうことはありません。
2つ目は、連続した広い空間を持っているように見せることです。物理メモリがばらばらに空いていても、プロセスからは連続したアドレスに見えます。x86-64のLinuxでは、ユーザー空間だけで128TiBの仮想アドレスが使えます。
3つ目は共有と節約です。同じ共有ライブラリを読み込んだ複数のプロセスは、同じ物理ページを自分のアドレス空間に対応付けて使い回せます。
プロセスのアドレス空間のレイアウト
1つのプロセスの仮想アドレス空間は、おおむね次のように区画が分かれています。
graph TD K["カーネル空間<br/>ユーザーからはアクセス不可"] --- S["スタック<br/>関数呼び出しのたびに下へ伸びる"] S --- M["mmap領域<br/>共有ライブラリ・匿名mmap・スレッドのスタック"] M --- H["ヒープ<br/>mallocで上へ伸びる"] H --- B["bss<br/>初期値なしのグローバル変数"] B --- D["data<br/>初期値ありのグローバル変数"] D --- T["text<br/>機械語のコード 読み取り専用"] style K fill:#64748b,stroke:#475569,color:#fff style S fill:#8b5cf6,stroke:#6d28d9,color:#fff style M fill:#3b82f6,stroke:#1d4ed8,color:#fff style H fill:#f97316,stroke:#ea580c,color:#fff style T fill:#10b981,stroke:#059669,color:#fff
Linuxでは/proc/<pid>/mapsを読むと、この区画の実際のアドレス範囲と権限(読み・書き・実行)が1行ずつ表示されます。 Node.jsのプロセスなら、node本体のtext、libcなどの共有ライブラリ、V8がヒープ用に確保した大きな匿名領域が並んでいるのが見えます。
ページングとページテーブル
仮想アドレスを1バイトずつ物理アドレスに対応付けると、変換表が巨大になります。そこでOSはメモリをページという固定長のブロックに区切り、ページ単位で対応付けます。 この対応表がページテーブルで、プロセスごとに1つずつあります。前章で、別プロセスへのコンテキストスイッチではページテーブルを切り替えると書いたのはこの表のことです。
仮想アドレスは「仮想ページ番号」と「ページ内オフセット」に分けられます。ページテーブルで仮想ページ番号を物理フレーム番号に置き換え、オフセットをそのまま足せば物理アドレスになります。 全アドレス分の表を1枚で持つと大きすぎるので、x86-64では4段(新しいCPUでは5段)の木構造にして、使っている部分の表だけを作ります。
TLB — 変換結果のキャッシュ
メモリにアクセスするたびに4段の表を引いていたら、1回の読み書きにメモリアクセスが5回必要になります。 これを避けるために、CPUは最近の変換結果をTLB(Translation Lookaside Buffer)という小さなキャッシュに保存しています。
OSTEP第19章の例がわかりやすいので紹介します。16バイトのページに、4バイトの整数10個からなる配列が3ページにまたがって置かれているとします。 先頭から順に読むと、各ページの最初の要素だけがTLBミスになり、残りはヒットします。10回のアクセスで7回ヒット、ヒット率は70%です。 同じ配列をすぐもう一度読めば、変換結果がまだTLBに残っているので全部ヒットします。CPUシリーズ第4章で見た空間的局所性と時間的局所性は、TLBにもそのまま当てはまります。
プロセスを切り替えるとTLBの中身は別プロセスの変換になってしまいます。全部消すと切り替え直後にミスが続くので、多くのCPUはTLBの各エントリにASID(アドレス空間ID)を付けて、どのプロセスの変換かを区別できるようにしています。
ページサイズ — 4KiB、16KiB、Huge Pages
x86-64のLinuxでは、ページサイズは4KiBです。Apple SiliconのmacOSでは16KiBで、手元のM3 Proでsysctl hw.pagesizeを実行すると16384が返ります。 ページが大きいほど、TLBの1エントリで広い範囲をカバーできます。その代わり、少ししか使わない領域でも1ページ分の物理メモリを取ります。
Linuxには、2MiBなどの大きなページを自動で使うTHP(Transparent Huge Pages)があります。大きなヒープを持つデータベースやJVMではTLBミスが減って速くなる一方、メモリの使用量が増えたり遅延がばらついたりすることもあり、無効にする運用もあります。 Linux 7.2では、4KiBと2MiBの中間サイズのページ(mTHP)にまとめ直す処理がkhugepagedに入りました。
デマンドページングとページフォールト
malloc()やmmap()でメモリを確保しても、カーネルはその時点で物理ページを用意しません。ページテーブルに「この範囲は使ってよい」と記録するだけです。 プログラムが初めてそのページを読み書きすると、ページテーブルに対応する物理ページがないのでCPUが例外を起こします。これがページフォールトです。 カーネルはその場で物理ページを割り当ててページテーブルを書き換え、命令を再実行させます。プログラムからは何事もなかったように見えます。 必要になったページだけを後から用意するこの方式を、デマンドページングと呼びます。
マイナーページフォールトとメジャーページフォールト
ページフォールトには重さの違う2種類があります。マイナーページフォールトは、必要なデータがすでに物理メモリ上にあり、ページテーブルに対応付けるだけで済む場合です。初めて触る匿名メモリに0埋めのページを割り当てる場合や、他のプロセスがページキャッシュに読み込み済みのファイルをmmapした場合がこれにあたります。 メジャーページフォールトは、ディスクやスワップから読み込まないとデータが揃わない場合で、I/Oを待つぶん桁違いに遅くなります。 回数はgetrusage()で取れ、Node.jsではprocess.resourceUsage()のminorPageFaultとmajorPageFaultで確認できます。
mmapの種類
mmap()はアドレス空間に新しい区画を作るシステムコールで、フラグの組み合わせで性質が決まります。
| 組み合わせ | 中身 | 書き込んだとき | 主な用途 |
|---|---|---|---|
| ファイル + MAP_PRIVATE | ファイルの内容 | コピーオンライトで自分専用のコピーができ、ファイルは変わらない | 実行ファイルや共有ライブラリの読み込み |
| ファイル + MAP_SHARED | ファイルの内容 | ファイルに反映され、同じファイルをmmapした他プロセスにも見える | データベースのファイルアクセス、プロセス間共有 |
| MAP_ANONYMOUS + MAP_PRIVATE | 0埋め | 自分だけに見える | mallocの大きな確保、スレッドのスタック、V8のヒープ |
| MAP_ANONYMOUS + MAP_SHARED | 0埋め | forkした子と共有される | 親子プロセス間の共有メモリ |
forkのコピーオンライトの正体
第3章で、fork()はコピーオンライト(CoW)なので速いと説明しました。その中身は、ここまでの道具で説明できます。 fork()はメモリの中身をコピーせず、ページテーブルだけを複製して、親子の両方で全ページを読み取り専用にします。 どちらかが書き込もうとするとページフォールトが起き、カーネルはそこで初めてそのページだけをコピーして書き込み可能にします。 書き込まれなかったページは親子で共有されたままなので、forkの直後にexecすれば、ほとんどコピーは発生しません。
スワップとスラッシング
物理メモリが足りなくなると、カーネルはしばらく使われていないページをディスクのスワップ領域に書き出し、空いた物理ページを他の用途に回します。書き出したページに再びアクセスすると、メジャーページフォールトになって読み戻されます。
OSTEP第22章の計算で、どれだけ遅くなるかを見積もれます。メモリアクセスを100ns、ディスクアクセスを10msとすると、メモリ上にページがある割合(ヒット率)が90%のとき、平均アクセス時間は約1msになります。 ヒット率を99.9%まで上げても約10.1µsで、メモリだけのときの100倍です。ミスがわずかでも、平均アクセス時間はディスクの遅さに大きく左右されます。 稼働中のプロセスが必要とするメモリが物理メモリを上回ると、書き出しと読み戻しを繰り返すだけで処理がほとんど進まなくなります。これがスラッシングです。
オーバーコミットとOOM Killer
デマンドページングのおかげで、カーネルは確保の要求を受けた時点では物理メモリを消費しません。 そこでLinuxは、実際の物理メモリとスワップの合計を超える確保要求も既定で受け付けます。これをオーバーコミットといい、vm.overcommit_memoryで方針を選べます。
| vm.overcommit_memory | 方針 | 起きること |
|---|---|---|
| 0(既定) | ヒューリスティック | 明らかに無理な要求だけ拒否し、ほとんどの確保は通す |
| 1 | 常に許可 | どんな大きさの確保も通す。足りなくなるのは実際に触ったとき |
| 2 | 厳格 | スワップと物理メモリの一定割合の合計を上限に、超える確保はその場で失敗させる |
オーバーコミットを許すと、確保には成功したのに、後でページに触った時点で物理メモリが足りないという事態が起こり得ます。 そのときカーネルはOOM(Out Of Memory)Killerを動かし、プロセスを1つ選んでSIGKILLで殺してメモリを取り戻します。 どれを殺すかはbadnessスコアで決まり、メモリを多く使っているプロセスほど高くなります。現在の値は/proc/<pid>/oom_scoreで見られます。 /proc/<pid>/oom_score_adjに-1000〜1000の値を書くとスコアを調整でき、-1000にしたプロセスは対象から外れます。データベースなど殺されると困るプロセスを守るのに使います。
RSSとVSZ
ここまでの話を踏まえると、psやtopに出る2つの数字の意味が読めます。 VSZ(topではVIRT)は、プロセスが予約している仮想アドレス空間の合計です。mmapしただけの領域や、まだ一度も触っていないスタックも含みます。 RSS(topではRES)は、そのうち今、物理メモリに載っているページの合計です。共有ライブラリのように他のプロセスと共有しているページも、それぞれのプロセスのRSSに数えられます。
メモリ不足を調べるときに見るべきなのはRSSのほうです。V8は起動直後から大きな仮想領域を予約するので、Node.jsのVSZは小さなスクリプトでもGiB単位になりますが、それだけでは問題になりません。
Node.jsのメモリを読む
process.memoryUsage()の各項目
| 項目 | 意味 | 含まれるもの |
|---|---|---|
| rss | プロセス全体のうち物理メモリに載っている量 | V8ヒープ、Buffer、node本体のコード、スレッドのスタックなどすべて |
| heapTotal | V8がJSオブジェクト用に確保したヒープの大きさ | 使用中の部分と空き部分の両方 |
| heapUsed | V8ヒープのうち実際に使っている量 | JSのオブジェクト、文字列、クロージャなど |
| external | V8の外にあるがJSオブジェクトに結び付いたメモリ | Bufferの中身、C++アドオンが確保したメモリ |
| arrayBuffers | externalのうちArrayBufferとSharedArrayBufferの分 | Bufferの中身もここに含まれる |
Bufferの中身はV8のヒープではなく、その外に確保されます。そのため、大きなBufferを抱えているプロセスはheapUsedが小さいままRSSだけが膨らみます。heapUsedだけを監視していると、メモリ不足の原因を見落とします。
TypeScriptで確かめる — Bufferとページフォールト
200MiBのBufferを確保し、中身を書き込み、最後にJSのオブジェクトを100万個作ります。各段階でmemoryUsage()とminorPageFaultを表示します。
import v8 from "node:v8";
const MiB = 1024 * 1024;
function snapshot(label: string): void {
const m = process.memoryUsage();
const ru = process.resourceUsage();
const f = (n: number) => (n / MiB).toFixed(1).padStart(7) + "MiB";
console.log(
label.padEnd(14),
"rss", f(m.rss),
"heapUsed", f(m.heapUsed),
"external", f(m.external),
"minorPF", String(ru.minorPageFault).padStart(7),
);
}
snapshot("start");
// 1) 確保しただけ(中身は未初期化。ページにはまだ触っていない)
const buf = Buffer.allocUnsafe(200 * MiB);
snapshot("allocUnsafe");
// 2) 全ページに書き込む → ここで物理ページが割り当てられる
buf.fill(1);
snapshot("after fill");
// 3) JSオブジェクトを大量に作る → こちらは heapUsed が増える
const objs = Array.from({ length: 1_000_000 }, (_, i) => ({ i, s: "x" + i }));
snapshot("1M objects");
const limit = v8.getHeapStatistics().heap_size_limit / MiB;
console.log("heap_size_limit:", limit.toFixed(0) + "MiB", objs.length);Linuxコンテナ(node:24-bookworm-slim、ページサイズ4KiB)での結果は次のとおりです。
start rss 58.3MiB heapUsed 8.8MiB external 10.6MiB minorPF 9408
allocUnsafe rss 59.1MiB heapUsed 8.5MiB external 210.7MiB minorPF 9486
after fill rss 259.1MiB heapUsed 8.5MiB external 210.7MiB minorPF 60687
1M objects rss 370.4MiB heapUsed 89.5MiB external 208.0MiB minorPF 87605
heap_size_limit: 2240MiB 1000000allocUnsafeの直後はexternalが200MiB増えていますが、rssはほとんど変わりません。仮想アドレスを予約しただけで、ページにはまだ触っていないからです。 fillで全ページに書き込むとrssが200MiB増え、minorPageFaultが約51,200回増えます。200MiBを4KiBで割った数と一致するので、ページごとに1回フォールトが起きたことがわかります。 同じコードをApple SiliconのmacOSで動かすと、増えるのは約12,800回でした。こちらは200MiBを16KiBで割った数です。 heapUsedが増えるのは、最後にJSのオブジェクトを作ったときだけです。
V8のヒープ上限はどう決まるか
heapUsedがheap_size_limitに近づくと、V8はGCを繰り返したあと「JavaScript heap out of memory」で落ちます。この上限はNode.jsの起動時に決まります。 64bit環境のV8は、物理メモリのおよそ半分を基準に256MB〜4GBの範囲で上限を決めます。手元のMacではheap_size_limitが4288MB、上のDocker環境では2240MBでした。old領域の上限に新しい世代の領域が加わるので、表示はきりのよい値より少し大きくなります。
コンテナでは「物理メモリ」が何を指すかが問題になります。Node.jsはv12.7.0から、cgroupのメモリ制限と物理メモリの小さいほうをV8に渡すようになりました。cgroup v2に対応したのは20.3.0からです。 それより古いNode.jsをコンテナで動かすと、ホスト全体のメモリを基準にした大きすぎる上限になり、V8がGCを急ぐ前にコンテナごとOOM killされることがあります。 上限は--max-old-space-size(MB単位)で明示的に指定できます。Bufferはこの上限の外にあるので、指定するときはヒープ以外に使う分を残しておく必要があります。
まとめ — 予約と実体を分けて考える
プロセスが扱うアドレスはすべて仮想アドレスで、OSはページ単位でページテーブルを使って物理メモリに対応付けます。変換結果はTLBにキャッシュされます。 mmapやmallocは仮想アドレスを予約するだけで、物理ページは最初に触ったときのページフォールトで割り当てられます。forkのコピーオンライトも、スタック8MiBが実メモリを食わないのも、この仕組みのおかげです。 Linuxは既定でオーバーコミットを許し、足りなくなったらOOM KillerがSIGKILLを送ります。Node.jsで見るべきなのはheapUsedだけではなく、Bufferを含むexternalと、プロセス全体のRSSです。
次章(第6章)では、メモリと並ぶもう1つの資源であるファイルを扱います。「Everything is a file」というUnixの設計、ファイルディスクリプタの3段構造、writeしたのにデータが消える理由、Node.jsでよく見るEMFILEの正体を見ていきます。
理解度チェック
Buffer.allocUnsafe(200 * 1024 * 1024) を実行した直後、まだ中身に一度も書き込んでいない時点の process.memoryUsage() について正しいものはどれですか?
キーボード: 1〜4 で選択、Enter で回答