ファイルを開くとき OS は何をしているのか:ファイルディスクリプタとカーネルの仕組み

Python で open() を呼んだとき、内部では何が起きているのか。「リソースを確保する」「ファイルを閉じる」とは具体的に何を意味するのか。OS とカーネルのレベルまで掘り下げて解説する。

ファイルを開くとは何か

Python の open("data.txt") は、最終的に OS のシステムコール open() を呼び出す。このシステムコールが返すのは「ファイルディスクリプタ」と呼ばれる小さな整数だ。

import os

# Python の open() が返すファイルオブジェクト
f = open("data.txt")

# その内部にあるファイルディスクリプタ(整数)
print(f.fileno())  # 例: 3

ファイルディスクリプタは、カーネルが管理するリソースへの「チケット番号」のようなものだ。プログラムはこの番号を使って「3 番のファイルから読んでくれ」とカーネルに依頼する。

カーネル内部の 3 つのテーブル

ファイルを開くと、カーネル内部で 3 つのデータ構造が関係する。

プロセスのファイルディスクリプタテーブル

各プロセスが持つテーブル。ファイルディスクリプタ(整数)から、システム全体のオープンファイルテーブルへのポインタを保持する。

システム全体のオープンファイルテーブル

カーネルが管理するテーブル。ファイルの現在位置(オフセット)、読み書きモード、参照カウントなどを保持する。

inode テーブル(vnode)

実際のファイルのメタデータ。ファイルサイズ、パーミッション、ディスク上のブロック位置などを保持する。inode はファイルシステム上の「実体」への参照。

この 3 層構造のおかげで、同じファイルを複数のプロセスが異なる位置で読み書きできる。

open() システムコールで起きること

C 言語で書くと、ファイルを開く処理は以下のようになる。

#include <fcntl.h>
#include <unistd.h>

int main() {
    // open() システムコール
    int fd = open("data.txt", O_RDONLY);
    
    // read() でデータを読む
    char buf[100];
    ssize_t n = read(fd, buf, sizeof(buf));
    
    // close() でリソースを解放
    close(fd);
    
    return 0;
}

open() が呼ばれると、カーネル内部で以下の処理が行われる。

パス名を解決して inode を特定

オープンファイルテーブルにエントリを作成

プロセスの FD テーブルに空きスロットを確保

ファイルディスクリプタ(整数)を返す

「リソース」とは何か

ファイルを開いたときに確保される「リソース」は、メモリとは異なる。

メモリ(ヒープ)

malloc() で確保、free() で解放。プロセス内で自由に使える領域。

ファイルディスクリプタ

カーネルが管理する番号。プロセスごとに上限がある(デフォルトで 1024 程度)。カーネル全体でも上限がある。

# プロセスあたりの FD 上限を確認
ulimit -n
# 1024

# システム全体の上限を確認
cat /proc/sys/fs/file-max
# 9223372036854775807

ファイルを閉じ忘れると、この「枠」を消費し続ける。大量のファイルを開いたまま放置すると「Too many open files」エラーになる。

close() で何が解放されるか

close() システムコールは以下の処理を行う。

FD テーブルのエントリを削除

プロセスのファイルディスクリプタテーブルから該当エントリを削除し、その番号を再利用可能にする。

オープンファイルテーブルの参照カウントを減らす

参照カウントがゼロになれば、オープンファイルテーブルのエントリも削除される。fork() で子プロセスと共有している場合は、両方が close() するまで残る。

inode の参照カウントを減らす

オープンファイルテーブルと同様、参照がゼロになれば inode もキャッシュから解放される可能性がある。

fork() とファイルディスクリプタ

プロセスを fork() すると、ファイルディスクリプタテーブルがコピーされる。親子は同じオープンファイルテーブルエントリを共有するため、ファイルオフセットも共有される。

int fd = open("data.txt", O_RDONLY);
pid_t pid = fork();

if (pid == 0) {
    // 子プロセス: 5 バイト読む
    char buf[5];
    read(fd, buf, 5);  // オフセットが 5 に進む
} else {
    // 親プロセス: 子の後に読むと 6 バイト目から始まる
    wait(NULL);
    char buf[5];
    read(fd, buf, 5);  // オフセット 5 から読む
}

これが「オープンファイル記述」を共有するということだ。

Python の with 文とシステムコール

Python の with 文は、最終的に close() システムコールを呼ぶ。

# これは
with open("data.txt") as f:
    data = f.read()

# 内部的にはこうなっている(簡略化)
f = open("data.txt")  # → open() システムコール
try:
    data = f.read()   # → read() システムコール
finally:
    f.close()         # → close() システムコール

with 文が保証するのは「どんな例外が起きても close() システムコールが呼ばれる」ということだ。

なぜ「閉じる」必要があるのか

ファイルを閉じないとどうなるか。

FD 枯渇

プロセスあたりの上限(通常 1024)に達すると、新しいファイルを開けなくなる。

データ未書き込み

書き込みはバッファリングされており、close() や flush() するまでディスクに書き込まれない可能性がある。

他プロセスへの影響

排他ロックを取得したまま閉じないと、他のプロセスがファイルにアクセスできない。

まとめ

Python レベル

open() でファイルオブジェクトを取得、close() で解放。with 文で自動管理。

OS レベル

システムコール open() で FD を取得、close() でカーネルのテーブルエントリを解放。FD はカーネルが管理する有限リソース。

「ファイルを開く」とは、カーネルにお願いして番号札(FD)をもらうこと。「ファイルを閉じる」とは、その番号札を返却してカーネル内のテーブルをクリーンアップすること。メモリとは異なる、OS レベルのリソース管理が行われている。