読んだことを自分の目で確かめる

第11章までで、システムコール、スレッド、スレッドプール、イベントループ、cgroupと、Node.jsの下で動いている仕組みをひととおり説明しました。 ただ、説明を読んだだけの知識は、本番で想定外の挙動に出会ったときに使えないことが多いものです。 本章では5つの演習で、これまでの章の主張を手元のマシンで確かめます。後半では2026年のOSとランタイムの動向をまとめ、この先の学習ロードマップを示します。

演習はLinuxを前提にしています。Macを使っている場合は、Docker DesktopでLinuxコンテナを立てて、その中で実行してください。 以下の出力は、筆者のMac(M3 Pro)上のDocker(node:24-bookworm-slim)で実行したときのものです。スレッドIDや時間は環境ごとに変わります。

演習A: straceでNode.jsのシステムコールを覗く

なぜMacではDockerを使うのか

macOSにもdtrussというシステムコールトレーサーがありますが、System Integrity Protection(SIP)が有効な状態では、システムのバイナリや多くのプロセスをトレースできません。 SIPを無効にするのはセキュリティ上おすすめできないので、Linuxコンテナの中でstraceを使います。 straceはptraceシステムコールでほかのプロセスを観察するので、コンテナにSYS_PTRACEの権限を追加して起動します。

# 作業ディレクトリをマウントして Linux コンテナに入る
docker run --rm -it --cap-add=SYS_PTRACE -v "$PWD":/work -w /work node:24-bookworm-slim bash

# コンテナの中で strace を入れる
apt-get update && apt-get install -y strace

readFileSyncとfs.readFileを比べる

同期版と非同期版で1つずつファイルを開くスクリプトを用意します。非同期版のコールバックでは、/proc/self/task/*/commを読んでプロセス内のスレッド名を一覧にします。

// read.js(CommonJS)
const fs = require('node:fs');

fs.readFileSync('/etc/hostname');            // 同期版
fs.readFile('/etc/os-release', () => {       // 非同期版
  const names = fs.readdirSync('/proc/self/task').map((tid) =>
    fs.readFileSync('/proc/self/task/' + tid + '/comm', 'utf8').trim());
  console.log(names.join(','));
});
# -f: 子スレッドも追う / -o: 各行の先頭にTIDを付けてファイルに書く
strace -f -o trace.txt -e trace=openat,clone,clone3,prctl node read.js
grep -nE 'hostname|os-release|clone|PR_SET_NAME' trace.txt

筆者の環境での出力を抜粋します(cloneのフラグは一部省略)。

128  prctl(PR_SET_NAME, "MainThread") = 0
128  clone(..., flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|..., parent_tid=[130]) ...
130  prctl(PR_SET_NAME, "V8Worker") = 0
  …(V8Worker が計4本、SignalInspector などが起動)…
128  openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 20
128  clone(..., flags=CLONE_VM|...|CLONE_THREAD|..., parent_tid=[135]) ...
  …(同様の clone があと3回)…
135  prctl(PR_SET_NAME, "libuv-worker") = 0
136  prctl(PR_SET_NAME, "libuv-worker") = 0
137  prctl(PR_SET_NAME, "libuv-worker") = 0
138  prctl(PR_SET_NAME, "libuv-worker") = 0
138  openat(AT_FDCWD, "/etc/os-release", O_RDONLY|O_CLOEXEC) = 20

読み取れることは3つあります。

1つ目は、/etc/hostnameのopenatをメインスレッド(TID 128)が自分で発行していることです。readFileSyncは、ファイルを読み終えるまでメインスレッドを止めます。 2つ目は、/etc/os-releaseのopenatをTID 138のlibuv-workerが発行していることです。fs.readFileはメインスレッドから仕事をスレッドプールに渡し、メインスレッドはすぐにイベントループへ戻ります。 3つ目は、libuv-workerの4本が起動直後ではなく、最初の非同期ファイル操作の直前に作られていることです。スレッドプールは初めて使われるときに遅延生成されます。UV_THREADPOOL_SIZE=8を付けて実行すると、cloneとlibuv-workerが8回ずつ現れます。

cloneのフラグにも注目してください。CLONE_VM、CLONE_FILES、CLONE_THREADは、第4章で見た「アドレス空間とFDテーブルを共有し、同じスレッドグループに入る」という指定です。 Linuxではスレッドもプロセスも同じcloneで作られ、フラグの違いだけでスレッドになることが、実際のトレースで確認できます。 コールバックで表示されるスレッド名の一覧はMainThread,DelayedTaskSche,V8Worker,V8Worker,V8Worker,V8Worker,SignalInspector,libuv-worker,libuv-worker,libuv-worker,libuv-workerでした。 「Node.jsはシングルスレッド」と言われますが、実際のプロセスには10本以上のスレッドがあります。シングルスレッドなのは、JavaScriptを実行するスレッドの数です。

演習B: スレッドプールの「階段」を見る

スレッドプールを使うcrypto.pbkdf2を8本同時に投げ、それぞれの完了時刻を記録します。

// pb.js
const { pbkdf2 } = require('node:crypto');
const t0 = performance.now();

for (let i = 0; i < 8; i++) {
  pbkdf2('pw', 'salt', 300_000, 64, 'sha512', () => {
    console.log('task' + i, Math.round(performance.now() - t0) + 'ms');
  });
}
node pb.js                        # 既定の4本
UV_THREADPOOL_SIZE=8 node pb.js   # 8本
nproc                             # コンテナに割り当てられたCPU数(筆者の環境は4)

pbkdf2 8並列の完了時刻(完了順、単位: ms、4 CPUのコンテナ)

既定の4本では、最初の4つが約70msでそろって終わり、残りの4つはスレッドが空くのを待ってから約135msで終わります。完了時刻が2段の階段になります。 8本にすると階段は消え、8つが92〜166msの間にばらけて終わります。

ここで、8本にしても最後のタスクの完了は135msから166msに遅くなった点に注目してください。 このコンテナのCPUは4つなので、8本のスレッドが4コアを奪い合い、スケジューラが時分割で切り替えています(第4章)。 スレッドを増やすと「待たされるタスク」はなくなりますが、CPUを使う処理の合計時間はコア数以上には縮みません。 筆者がより多くのコアを使える環境で試したときは、既定の4本で約250msと約500msの階段になり、8本にすると全タスクが290〜380msで終わりました。 コアに余裕があれば、スレッドを増やした分だけ全体が早く終わります。

演習C: /blockと/offloadを比べる

第10章の/blockと/offloadの比較サーバーを、そのまま手元で動かします。手順は次のとおりです。

# 1. サーバーを起動(Node 24 は型注釈を取り除いて .ts をそのまま実行できる)
node server.ts

# 2. 別ターミナルで重いエンドポイントを叩き、すぐに /ping の応答時間を測る
curl -s localhost:3000/block > /dev/null &
sleep 0.2; curl -s -o /dev/null -w '%{time_total}s\n' localhost:3000/ping

# 3. /offload でも同じことをする
curl -s localhost:3000/offload > /dev/null &
sleep 0.2; curl -s -o /dev/null -w '%{time_total}s\n' localhost:3000/ping

筆者の環境では、/blockの最中の/pingは約2.8秒、/offloadの最中は0.00秒台でした。 余裕があれば、第10章のmonitorEventLoopDelayのコードをサーバーに足して、/blockのときだけイベントループ遅延のp99が跳ねることも確かめてください。

演習D: 実行順を予想する

次のコードをCommonJS(order.cjs)として実行したとき、何がどの順で表示されるかを予想してから実行してください。

// order.cjs
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync');

答えはsync → nextTick → promise → (timeoutとimmediate)です。 同期コードが最初に終わり、次にnextTickキュー、Promiseのマイクロタスクの順で消化されます。timeoutとimmediateの順は決まっていません。setTimeout(fn, 0)は1msに丸められるので、ループがtimersフェーズを最初に確認した時点で1ms経っていればtimeoutが先、経っていなければimmediateが先になります。 何度か実行すると、順序が入れ替わることがあります。

同じsetTimeoutとsetImmediateをfs.readFileのコールバックの中に移すと、今度は必ずimmediateが先になります。 I/Oコールバックはpollフェーズで実行され、その直後がcheckフェーズ(setImmediate)だからです。 ファイルをorder.mjs(ESM)にするとpromiseがnextTickより先に表示されることも、第9章の説明どおりか試してみてください。

演習E: memoryUsageと同期・非同期の読み込み

最後に、300MBのファイルを同期と非同期で読み、その間にタイマーが何回動けたか(tick数)と、process.memoryUsage()の変化を見ます。

// 事前に300MBのファイルを作る:
//   node -e "require('fs').writeFileSync('big.bin', Buffer.alloc(300 * 1024 * 1024))"
// mem.cjs
const fs = require('node:fs');
const mb = (n) => Math.round(n / 1024 / 1024) + 'MB';
const show = (label) => {
  const m = process.memoryUsage();
  console.log(label, 'rss=' + mb(m.rss), 'heapUsed=' + mb(m.heapUsed), 'external=' + mb(m.external));
};

let ticks = 0;
const timer = setInterval(() => ticks++, 10); // 10msごとに数える
show('before');
const t0 = performance.now();

if (process.argv[2] === 'sync') {
  const buf = fs.readFileSync('big.bin');
  console.log('sync', Math.round(performance.now() - t0) + 'ms', 'ticks=' + ticks);
  show('after'); clearInterval(timer);
} else {
  fs.readFile('big.bin', (err, buf) => {
    console.log('async', Math.round(performance.now() - t0) + 'ms', 'ticks=' + ticks);
    show('after'); clearInterval(timer);
  });
}
読み方所要時間読み込み中のtick数メモリの変化
readFileSync58ms0回rssとexternalが約300MB増え、heapUsedはほぼ変わらない
fs.readFile43ms4回同上

表の時間は筆者のMacで直接実行したときの値です(Docker上では読み込み自体がやや遅くなりましたが、傾向は同じでした)。 同期版はメインスレッドがファイルを読み終えるまでタイマーを1回も動かせません。非同期版は、libuv-workerが読んでいる間にメインスレッドがタイマーを4回処理しています。 このファイルサイズでは同期版の58msは大した時間に見えませんが、リクエストごとに実行されれば、その時間がそのまま全員の待ち時間になります。

メモリの変化も見てください。300MBを読み込んでもheapUsedはほとんど増えません。Bufferの中身はV8ヒープの外に確保され、externalとrssに計上されるからです。 第11章で「ヒープ上限をコンテナ上限いっぱいにしてはいけない」と書いた理由が、この数字に表れています。

OSとランタイムは、この1年でもいくつか大きな節目を迎えました。本シリーズで扱った仕組みに関わるものを時系列で並べます。

Linux 7.0リリース

io_uringにcBPFによるオペコードのフィルタが入り、使ってよい操作を絞れるようになった。セキュリティ上の理由でio_uringを丸ごと無効にしていた環境に、部分的に許可する選択肢ができた

Bun 1.4

実装言語をZigからRustに書き換えた。LinuxではNode.js・Denoと同じくepollでイベントループを回す

libuv 1.53.0

child_processが内部で使うuv_spawnがfork + execではなくposix_spawnを使うようになった

Linux 7.3リリース

sched_extのサブスケジューラ機能が完成し、BPFで書いた複数のスケジューラを組み合わせて使えるようになった

Node.js 26 "Lithium"がActive LTSに

v26.13.0(V8 14.6、libuv 1.52.1)。Temporal APIが既定で有効になった。27以降は年1回のメジャーリリースで、すべてがLTSになる

第4章で見たEEVDFスケジューラは7.xでも改良が続いています。Linux 7.2では、CPUキャッシュの共有関係を考慮してタスクを配置するロードバランシングが入りました。 データを共有するタスクを同じL3キャッシュのコアに寄せられれば、CPUシリーズで見たキャッシュミスを減らせます。 もう1つの流れがsched_extです。BPFでスケジューラを書いてカーネルに読み込める仕組みで、Metaは本番環境で使っており、ゲーム向けのscx_lavdのような用途特化のスケジューラも作られています。 7.3でサブスケジューラが完成したことで、ワークロードごとに異なるスケジューラを同じマシン上で使い分けられるようになりました。

メモリでは、第5章で触れたTransparent Huge Pagesの発展として、4KiBと2MiBの間の中間サイズを使うmTHP(multi-size THP)の整備が進んでいます。Linux 7.2ではkhugepagedがmTHPへの集約(collapse)に対応しました。 ページが大きいほどTLBのエントリ1つで広い範囲を引けるので、大きなヒープを持つランタイムではTLBミスが減ります。

Node.jsは26で偶数=LTSの慣習を終え、27からは年1回のメジャーリリースがすべてLTSになります。アップグレード計画は「偶数版を待つ」から「毎年1回上げる」に変わります。 libuv 1.53のposix_spawn化は、大きなヒープを持つプロセスからchild_processを起動するときに効きます。forkはアドレス空間のコピー(コピーオンライトでもページテーブルの複製)を伴うからです(第3章)。 他言語では、Go 1.27でgoroutineのリークを見つけるプロファイルが使えるようになり、JavaもJDK 27がリリースされています。

観測の分野では、OpenTelemetryのeBPF計装(OBI)が広がっています。アプリのコードを変えずに、カーネル側でシステムコールやソケットの動きを捉えてHTTPやgRPCのトレースを作る仕組みです。 本章のstraceが「1プロセスを手で覗く」道具だとすれば、eBPFは同じことを本番で常時、低いオーバーヘッドで行う道具です。

学習ロードマップ

本シリーズは、Webエンジニアが仕事で出会う現象から逆算してOSを説明してきました。さらに深く学ぶなら、次の順番で書籍と教材を進めるのがおすすめです。 手を動かしやすい日本語の入門書から始め、OSの教科書、システムプログラミングの古典を経て、最後に小さな教育用OSを自分で改造します。

graph TD
  A["[試して理解]Linuxのしくみ 増補改訂版<br/>武内覚 2022"] --> B["ふつうのLinuxプログラミング 第2版<br/>青木峰郎"]
  B --> C["OSTEP<br/>Operating Systems: Three Easy Pieces(無料公開)"]
  C --> D["CS:APP<br/>コンピュータ・システム プログラマの視点から"]
  D --> E["詳解UNIXプログラミング<br/>Stevens"]
  E --> F["xv6 / MIT 6.1810<br/>教育用OSを読んで改造する"]
  A -.->|実験で全体像| G(("ゴール: 本番の現象を<br/>OSの言葉で説明できる"))
  F -.-> G
おすすめの学習順。前半で全体像をつかんで実験し、後半で理論と実装を深める
教材得られるもの本シリーズとの対応
[試して理解]Linuxのしくみ実験しながらプロセス・メモリ・ファイルシステムの全体像をつかむ第1〜6章の復習と実験
ふつうのLinuxプログラミングCでシステムコールを呼び、ストリームとプロセスを扱う第2・3・6章
OSTEP仮想化・並行性・永続性の3本柱を理論から学ぶ第2・4・5章(LDE、スケジューラ、TLB、スワップ)
CS:APPCPUからリンカ・仮想メモリ・ネットワークまでを1冊で通すCPUシリーズと本シリーズの橋渡し
詳解UNIXプログラミングシグナル・端末・プロセス間通信などのPOSIX APIの詳細第3・6・7章
xv6 / MIT 6.1810小さなOSのソースを読み、システムコールやページテーブルを自分で実装するシリーズ全体の総仕上げ

シリーズのまとめ — 12章で答えた問い

第1章では、「なぜNode.jsは1スレッドで大量の接続をさばけるのか」「なぜ本番でOOMKilledになるのか」を根拠を持って説明できるようになることを目標にしました。 12章で答えてきた問いを一覧にします。

章問い答えの要点
第1章OSは何をしているのか抽象化・資源管理・保護の3つ。CTSSからUnix、Linux、Node.jsへと系譜がつながっている
第2章アプリはどうやってカーネルに頼みごとをするのかsyscall命令でカーネルモードに切り替わる。直接コストより、キャッシュやTLBを汚す間接コストが大きい
第3章プロセスはどう生まれ、どう死ぬのかfork(コピーオンライト)とexecで生まれ、waitされないとゾンビになる。シグナル死は128+N
第4章スレッドとプロセスは何が違うのかcloneで何を共有するかの違い。スケジューラはCFSからEEVDFへ移った
第5章RSSとheapUsedはなぜ違うのか仮想メモリはページ単位で必要になってから割り当てられる。Bufferはヒープの外にある
第6章EMFILEはなぜ起きるのかFDはプロセスごとの有限な番号で、open file descriptionとinodeを指している
第7章なぜepollは大量の接続を待てるのか登録と待機を分け、準備ができたFDだけを返す。completion型のio_uringも登場した
第8章並行処理モデルはどう選ぶのかプロセス・スレッド・イベントループ・軽量スレッドにはそれぞれ得意な負荷がある
第9章Node.jsは本当にシングルスレッドなのかJavaScriptの実行は1本。I/Oはepollとスレッドプール(既定4本)が受け持つ
第10章Node.jsはなぜ突然遅くなるのか1つの長いコールバックが全員を待たせる。ReDoS・巨大JSON・プール枯渇を避け、計測する
第11章なぜ本番でOOMKilledになるのかcgroupのmemory.maxを超えるとカーネルがSIGKILLを送る。ヒープ以外のメモリも見込む
第12章説明したことは本当かstraceと実験で、スレッドの生成、スレッドプールの階段、ブロッキングを自分の目で確かめた

OSの知識は、障害対応やパフォーマンス調査で「なんとなく」を「たぶんこれ」に変え、straceやメトリクスで「確かにこれ」に変えるための道具です。 次の単元「データ構造とアルゴリズム」では、本シリーズで何度も出てきた赤黒木(CFSの実行キュー)、ハッシュテーブル、リングバッファ(io_uring)といった構造そのものを扱います。 OSがそれらのデータ構造を選んだ理由を、計算量の言葉で説明できるようになることが次の目標です。

理解度チェック

問題 0 / 50%
Q1

strace -fでreadFileSyncとfs.readFileをトレースしたとき、fs.readFileのopenatを発行していたのはどのスレッドですか?

キーボード: 1〜4 で選択、Enter で回答