メモリの次にOSが管理する資源
前章では、プロセスが使うメモリをOSが仮想アドレスとページでどう管理しているかを見ました。 この章で扱うのは、もう1つの大事な資源であるファイルです。ここでいうファイルはディスク上のファイルだけではありません。 Unix系のOSでは、キーボードや端末、プロセス間のパイプ、ネットワークのソケットまで、同じ「ファイルディスクリプタ」という番号で扱います。
Node.jsでfs.readFileを呼んだときも、HTTPサーバーが接続を受け付けたときも、裏ではファイルディスクリプタが1つ増えています。 この章では、ファイルディスクリプタの正体と、writeしたデータがいつディスクに届くのか、そしてNode.jsでよく見るEMFILEエラーがなぜ起きるのかを見ていきます。
Everything is a file
1974年、Dennis RitchieとKen ThompsonはCACMに論文「The UNIX Time-Sharing System」を発表し、Unixの設計を説明しました。 その特徴の1つが、ディスク上のファイル、ディレクトリ、そして入出力デバイスを、同じファイルシステムの名前空間と同じシステムコールで扱う点です。 プリンタに出力したければ/dev以下の特別なファイルにwriteし、端末から入力を受け取りたければ端末のファイルをreadします。 この考え方は後に「Everything is a file」と呼ばれるようになりました。
Linuxはこれをさらに広げました。/proc以下には実行中のプロセスの情報がファイルとして並び、第4章で/proc/<pid>/taskをlsしてスレッドを数えたように、普通のファイル操作でカーネルの状態を読めます。 プログラムはopen・read・write・closeの4つを覚えれば、相手が何であってもほぼ同じ書き方でデータをやり取りできます。
ファイルディスクリプタの正体
open()が返すファイルディスクリプタ(FD)は、ただの小さな整数です。 この整数はプロセスごとに持つFDテーブルの添字で、そこから先にカーネル内部の構造が2段続きます。
FD → open file description → inodeの3段
graph LR
subgraph PA["プロセスA"]
A3["FD 3"]
A4["FD 4"]
end
subgraph PB["プロセスB(Aがforkした子)"]
B3["FD 3"]
end
OFD1["open file description 1<br/>オフセット・O_APPENDなどのフラグ"]
OFD2["open file description 2<br/>オフセット・フラグ"]
INODE["inode<br/>ファイル本体(サイズ・権限・データの場所)"]
A3 --> OFD1
B3 --> OFD1
A4 --> OFD2
OFD1 --> INODE
OFD2 --> INODE
style OFD1 fill:#8b5cf6,stroke:#6d28d9,color:#fff
style OFD2 fill:#8b5cf6,stroke:#6d28d9,color:#fff
style INODE fill:#f97316,stroke:#ea580c,color:#fff1段目のFDテーブルはプロセスごとにあり、番号と2段目への参照を持ちます。 2段目のopen file descriptionはopen()を呼ぶたびにカーネル内に1つ作られ、「今ファイルのどこまで読んだか」というオフセットと、O_APPENDなどのフラグを持ちます。 3段目のinodeはファイルそのものを表し、サイズ・所有者・権限・データがディスクのどこにあるかを持ちます。 同じファイルを2回openすると、open file descriptionが2つできてオフセットは別々になります。どちらも同じinodeを指しているので、片方で書き込んだ内容はもう片方からも読めます。
forkとdupで共有されるもの
第3章で、forkした子は親のFDを複製すると書きました。正確には、複製されるのは1段目のFDテーブルで、2段目のopen file descriptionは親子で共有されます。 そのため、子がファイルを100バイト読むと、親のオフセットも100バイト進みます。 dup()とdup2()も同じで、既存のFDと同じopen file descriptionを指す新しいFD番号を作ります。シェルの2>&1はdup2()でFD 2をFD 1と同じopen file descriptionに向けているので、標準エラーが標準出力と同じ場所に出ます。
0・1・2は最初から開いている
プロセスが起動した時点で、FD 0(標準入力)、FD 1(標準出力)、FD 2(標準エラー出力)の3つはすでに開いています。親のシェルからforkとexecを通じて引き継いだものです。 Node.jsのprocess.stdout.fdが1なのはこのためで、console.logは最終的にFD 1へのwriteになります。 open()は空いている番号のうち最も小さいものを返すので、新しく開いたファイルのFDはたいてい3から始まります。
VFS — ファイルシステムの違いを吸収する層
Linuxはext4、XFS、Btrfs、NFS、/procのような仮想ファイルシステムなど、たくさんのファイルシステムに対応しています。 アプリケーションがどれを使っていても同じopen()やread()で済むのは、カーネルのVFS(Virtual File System)という層が共通のインターフェイスを提供しているからです。 VFSは4種類のオブジェクトでファイルシステムを表します。
| オブジェクト | 表すもの | 主な中身 |
|---|---|---|
| superblock | マウントされたファイルシステム1つ | ブロックサイズ、ファイルシステムの種類、そのファイルシステム用の操作関数 |
| inode | ファイル1つ(ディレクトリも含む) | サイズ、所有者、権限、タイムスタンプ、データの位置 |
| dentry | パスの1要素(ディレクトリエントリ) | 名前とinodeの対応。パス解決を速くするためにキャッシュされる |
| file | 開かれたファイル1つ(open file description) | オフセット、フラグ、対応するdentry |
前の節のopen file descriptionは、VFSのfileオブジェクトのことです。ファイル名がinodeではなくdentry側にあるのは、1つのinodeに複数の名前(ハードリンク)を付けられるようにするためです。
ページキャッシュとfsync
writeはページキャッシュに書くだけ
ディスクは遅いので、Linuxは読み書きするファイルの中身を物理メモリ上のページキャッシュに置きます。readはまずページキャッシュを探し、なければディスクから読み込んでキャッシュに残します。 writeはもっと極端で、基本的にはページキャッシュ上のページにデータをコピーし、そのページを「ディスクと内容が違う(dirty)」と印を付けた時点で返ります。ディスクへの書き出しは、後でカーネルがまとめて行います。 freeコマンドのbuff/cacheが大きいのはこのページキャッシュで、空いているメモリをキャッシュに回しているだけなので、必要になれば解放されます。
fsyncを呼ぶまで永続化は保証されない
writeが成功しても、データはまだメモリ上にしかないかもしれません。その状態で電源が落ちると、書いたはずのデータが消えます。 確実にディスクに届けたいときはfsync()を呼びます。fsync()はそのFDのファイルについてdirtyなページをすべてディスクに書き出し、デバイスが書き込みを終えるまで戻りません。 データベースがトランザクションのコミット時にfsync()するのはこのためで、コミットが遅い原因の多くはこの待ち時間です。
Node.jsのfs.writeFileはfsyncを呼びません。設定ファイルを安全に書き換えたいなら、一時ファイルに書いてfilehandle.sync()(fsync相当)を呼び、rename()で本来の名前に置き換えるのが定番の手順です。 renameによる名前の付け替えはディレクトリへの変更なので、厳密にはディレクトリ側のfsyncも必要になります。
パイプとソケットもFD
パイプ — cmd | cmdの仕組み
pipe()システムコールは、カーネル内にバッファを1つ作り、読み出し用と書き込み用の2つのFDを返します。Linuxのパイプのバッファは既定で64KiBです。 シェルでcat access.log | grep 500を実行すると、シェルは次の手順を踏みます。
graph TD S["シェルがpipeを作る<br/>読み出し側と書き込み側の2つのFD"] --> F1["forkして子1を作る"] S --> F2["forkして子2を作る"] F1 --> D1["子1: dup2で書き込み側をFD 1に"] F2 --> D2["子2: dup2で読み出し側をFD 0に"] D1 --> E1["exec cat"] D2 --> E2["exec grep"] E1 -->|"64KiBのパイプバッファ"| E2 style S fill:#8b5cf6,stroke:#6d28d9,color:#fff style E1 fill:#f97316,stroke:#ea580c,color:#fff style E2 fill:#f97316,stroke:#ea580c,color:#fff
catもgrepも、相手がパイプだとは知りません。catはFD 1に書き、grepはFD 0から読んでいるだけです。Everything is a fileの設計のおかげです。 grepの処理が遅くてバッファの64KiBが埋まると、catのwriteはブロックして待たされます。逆にバッファが空ならgrepのreadが待ちます。 速い書き手を遅い読み手に合わせるこの仕組みは、Node.jsのstreamのbackpressureと同じ考え方です。streamのwrite()がfalseを返したら'drain'イベントまで書くのを待つ、というルールで、Node.jsはユーザー空間で同じことをしています。
ソケットもFD
ネットワーク通信の端点であるソケットも、socket()を呼ぶとFDとして返ってきます。HTTPサーバーは待ち受け用のソケットを1つ持ち、accept()で接続を受け付けるたびに、その接続専用のFDが1つ増えます。 1万の接続を同時に抱えるサーバーは、1万個のFDを開いていることになります。 この1万個のFDのうち、どれにデータが届いたかを効率よく知る方法が、次章で扱うselect・poll・epollです。
EMFILEとulimit
FDには上限がある
| エラー | 意味 | 上限の確認と変更 |
|---|---|---|
| EMFILE | このプロセスが開けるFDの上限に達した(Too many open files) | ulimit -n で確認・変更。ソフトリミットとハードリミットがある |
| ENFILE | システム全体で開けるファイルの上限に達した | /proc/sys/fs/file-max |
プロセスごとの上限はRLIMIT_NOFILEというリソース制限で、ulimit -nで確認できます。 プロセスは自分のソフトリミットをハードリミットまで自由に上げられます。Node.jsは起動時にこの引き上げを自動で行います。 手元のMacでulimit -Sn 256にしてからNode.jsの子プロセスでulimit -Snを表示すると、1048575に上がっていました。 そのためNode.jsでEMFILEが起きるのは、ハードリミットそのものが低い環境か、ハードリミットまで使い切った場合です。
Node.jsでEMFILEが起きる典型的な場面は2つあります。1つは、数千個のファイルに対してfs.promises.readFileをPromise.allで一斉に投げる場合です。 もう1つは、ファイルやHTTP接続を閉じ忘れてFDが少しずつ漏れる場合です。前者はすぐに落ち、後者は数日動いてから落ちます。
開いているFDを観察する
# Linux: プロセスが開いているFDの一覧(リンク先が実体。出力は一例)
$ ls -l /proc/<pid>/fd
lrwx------ 1 node node 64 ... 0 -> /dev/pts/0
lrwx------ 1 node node 64 ... 1 -> /dev/pts/0
lrwx------ 1 node node 64 ... 2 -> /dev/pts/0
lr-x------ 1 node node 64 ... 21 -> /app/data/users.json
lrwx------ 1 node node 64 ... 22 -> socket:[123456]
# 数だけ知りたいとき
$ ls /proc/<pid>/fd | wc -l
# macOS でも使える lsof
$ lsof -p <pid>FDの数が時間とともに増え続けているなら、どこかで閉じ忘れています。lsofの出力で同じファイルや同じ接続先が大量に並んでいれば、そこが漏れている箇所の手がかりになります。
TypeScriptで確かめる — EMFILEを起こして直す
2000個のファイルを開いて、50ms待ってから閉じる処理を書きます。まず全部を一斉に投げ、次に同時に開くのを最大64個に制限します。
import { open, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
const DIR = join(tmpdir(), "emfile-demo");
const N = 2000;
// 準備: 小さなファイルを N 個作る
await mkdir(DIR, { recursive: true });
for (let i = 0; i < N; i++) await writeFile(join(DIR, i + ".txt"), "hello");
// ファイルを開いて少し待ってから閉じる(処理中にFDを握り続ける状況を再現)
async function touch(i: number): Promise<void> {
const fh = await open(join(DIR, i + ".txt"), "r");
try {
await new Promise((r) => setTimeout(r, 50));
await fh.read(Buffer.alloc(5), 0, 5, 0);
} finally {
await fh.close();
}
}
// 並列数を制限する小さなヘルパー(p-limit と同じ考え方)
function limit(concurrency: number) {
let active = 0;
const queue: Array<() => void> = [];
const next = () => {
if (active >= concurrency || queue.length === 0) return;
active++;
queue.shift()!();
};
return <T>(fn: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
queue.push(() => {
fn().then(resolve, reject).finally(() => { active--; next(); });
});
next();
});
}
const ids = Array.from({ length: N }, (_, i) => i);
// 1) 全部いっぺんに投げる → EMFILE
// (allSettled で全部の後始末を待つ。Promise.all は最初の失敗で返るが、残りの open は走り続ける)
const results = await Promise.allSettled(ids.map((i) => touch(i)));
const failed = results.filter((r) => r.status === "rejected");
console.log("unlimited: failed=" + failed.length, (failed[0] as PromiseRejectedResult | undefined)?.reason?.code);
// 2) 同時に開くのは最大 64 個まで → 成功
const run = limit(64);
await Promise.all(ids.map((i) => run(() => touch(i))));
console.log("limited(64): ok");ハードリミットごと下げるためにbashのulimit -n 256を使い、その中で実行します(zshのulimit -nはソフトリミットしか下げないので、Node.jsに引き上げられてしまいます)。
$ bash -c 'ulimit -n 256; node emfile.ts'
unlimited: failed=1755 EMFILE
limited(64): ok一斉に投げると2000件中1755件がEMFILEで失敗し、64件ずつに制限すると全件成功しました。Linuxコンテナでもほぼ同じ結果(1764件失敗)でした。 最初の版をPromise.allで書いていたときは、失敗した時点で次の処理に進んだせいで、まだ閉じていないFDが残ったまま2つ目の実験が始まり、そちらもEMFILEで落ちました。Promise.allは最初の失敗で返りますが、他の処理を止めるわけではありません。
実務ではp-limitのようなライブラリで並列数を制限するのが基本です。 npmなどが使っているgraceful-fsは、fsを差し替えてEMFILEが起きたopenを待ち行列に入れ、他のFDが閉じたら再試行します。既存のコードを書き換えずに済みますが、FDの閉じ忘れによる漏れは直せません。ulimit -nを上げるのは、本当に多くのFDが必要なサーバーで行うもので、並列数を制限しないコードの対症療法として使うものではありません。
まとめ — FDは番号、実体はその先にある
Unixは、ファイル・デバイス・パイプ・ソケットを同じFDと同じシステムコールで扱います。FDはプロセスごとのFDテーブルの番号で、その先にオフセットを持つopen file descriptionと、ファイル本体のinodeがあります。 forkやdupはopen file descriptionを共有するので、オフセットも共有されます。 writeはページキャッシュに書いた時点で返り、ディスクに届いたことを保証するにはfsyncが必要です。FDには上限があり、Node.jsで一斉に開きすぎるとEMFILEになるので、並列数を制限して防ぎます。
次章(第7章)では、ソケットのFDが1万個あるとき、どれにデータが届いたかをどう知るかという問題を扱います。ブロッキングとノンブロッキング、同期と非同期の2つの軸を整理し、selectからepoll・kqueue・io_uringへの進化と、C10K問題に対するnginxの答えを見ていきます。
理解度チェック
プロセスAがファイルをopenしてFD 3を得たあとforkしました。子プロセスがFD 3から100バイトreadしたとき、親のFD 3のファイルオフセットはどうなりますか?
キーボード: 1〜4 で選択、Enter で回答