ファイルディスクリプタとオープンファイルテーブルの構造
dupやforkでfdを共有するとオフセットまで連動するのはなぜか。fdテーブル・オープンファイル記述・inodeの三層構造を押さえれば、共有の範囲とリダイレクトの挙動が原理から読めるようになります。
- 1.openは三層構造を作る。プロセスごとのfdテーブル(整数→ポインタ)・システムワイドのオープンファイル記述(オフセットとモードを持つ)・inode(実体のメタデータ)で、どの層を共有するかで挙動が決まる。
- 2.ファイルオフセットはfdにもinodeにもなく、中間のオープンファイル記述に1つだけ宿る。dupとforkはこの同一記述を共有するためオフセットも連動し、独立openは別記述になるため独立する。
- 3.forkはfdテーブルを複製するがエントリは同じ記述を指す。execではFD_CLOEXECが立ったfdだけが閉じ、それ以外は引き継がれる。これがシェルのリダイレクトとパイプの土台になる。
ファイルディスクリプタは「整数の引換券」にすぎない
ユーザー空間が open で受け取るファイルディスクリプタ(file descriptor、fd)は、3 や 4 といったただの 小さな非負整数 です。この整数自体にファイルの実体やオフセットが入っているわけではありません。fd は、カーネルが各プロセスごとに持つ fdテーブル(配列)の添字 であり、いわば引換券の番号です。read(3, ...) と書くと、カーネルは「呼び出し元プロセスのfdテーブルの3番」を引いて、そこから本体へ辿ります。
なぜ整数で間接化するのか。理由は 隔離と再割り当て です。fd番号はプロセスごとに独立した名前空間で、あるプロセスの3と別プロセスの3は無関係です。番号は最小の空き番号から割り当てられ、close で返却されると再利用されます。0/1/2 が標準入力・標準出力・標準エラーに慣習的に割り当てられるのも、この「小さい空き番号から埋める」規則の帰結です。fd を整数に抽象化したことが、後述するリダイレクトやパイプの自由度を生みます。
fd は単なる番号ではなく、「このファイルを読む/書く権限を、open時に検査済みの形で持っている」という 能力(ケーパビリティ) を表します。open の瞬間に権限チェックが行われ、以後の read/write は番号を提示するだけで通ります。だからこそ、自分では開けないファイルのfdを fork で受け継いだり、Unixドメインソケット経由で他プロセスへ渡したりできます。
三層構造:fdテーブル・オープンファイル記述・inode
open が成功すると、カーネルは概念上 3つの層 を結びつけます。ここを取り違えると、共有やオフセットの挙動を必ず読み違えます。まず各層の役割を厳密に区別します。
| 層 | Linux内部での名前 | 粒度 | 保持する主な情報 |
|---|---|---|---|
| fdテーブル | files_struct / fdtable | プロセス(スレッド群)ごと | fd番号→オープンファイル記述へのポインタ・FD_CLOEXECフラグ |
| オープンファイル記述 | struct file | open 1回ごと | 現在のファイルオフセット・アクセスモード・状態フラグ・対応inode |
| inode(実体) | struct inode | ファイルの実体1つに1つ | サイズ・権限・所有者・データブロック位置(名前は持たない) |
- fdテーブル(プロセス層):プロセスが持つ、fd番号をキーにしたポインタ配列です。各エントリは2つの要素を持ちます。1つは下層のオープンファイル記述へのポインタ、もう1つは FD_CLOEXEC フラグ(exec時に自動で閉じるか)です。FD_CLOEXEC が fd 単位、つまり このテーブルに属する 点が後で効いてきます。
- オープンファイル記述(システムワイド層):POSIX用語で open file description、Linux実装では
struct fileです。これが本記事の主役で、ファイルオフセット・アクセスモード(読み/書き/追記)・状態フラグ(O_APPEND・O_NONBLOCKなど)を保持します。openを1回呼ぶごとに1つ作られ、システム全体で共有されうるオブジェクトです。 - inode(実体層):ファイルの 実体メタデータ です。サイズ・権限・タイムスタンプ・リンク数・データの所在を持ちますが、名前もオフセットも持ちません。1つの実体に inode は1つで、複数のオープンファイル記述から共有されます。この層の位置づけは VFS(仮想ファイルシステム)層の抽象化 と ファイルシステム で詳しく扱っています。
プロセスA fdテーブル システムワイドの
(整数→ポインタ) オープンファイル記述 inode(実体)
┌────────────┐ ┌──────────────────┐
│ 0 stdin │───────────►│ 記述#7 │
│ 1 stdout │ │ offset=0 │──┐
│ 2 stderr │ │ mode=読み書き │ │
│ 3 ─────────│───────────►│ flags=O_APPEND │ │ ┌──────────┐
│ ... │ └──────────────────┘ ├──►│ inode "x"│
└────────────┘ │ │ size/権限 │
┌──────────────────┐ │ └──────────┘
プロセスB fdテーブル │ 記述#9 │ │
┌────────────┐ │ offset=4096 │──┘
│ 3 ─────────│───────────►│ mode=読み取り │
└────────────┘ └──────────────────┘
この図の肝は、オフセットが中間層に1つだけ宿る ことです。fd(上層)にもinode(下層)にもオフセットはありません。だから「どのオープンファイル記述を指しているか」がオフセット共有の有無を決めます。
よくある誤解が「オフセットはfdが持つ」「オフセットはファイル(inode)が持つ」の2つです。どちらも誤りです。オフセットは オープンファイル記述(struct file) に属します。この一点を押さえるだけで、dup・fork・独立openの差がすべて演繹できます。
オフセットが分かれる場合・連動する場合
同じファイル x に対する2つのfdが、オフセットを共有するか否かは「同じオープンファイル記述を指すか」だけで決まります。代表的な3パターンを整理します。
| 操作 | fdテーブルの扱い | オープンファイル記述 | オフセットとフラグ |
|---|---|---|---|
| 別々に open を2回 | 同じプロセス内に別エントリ | 別の記述が2つ生成 | 独立(片方を読み進めても他方は不動) |
| dup / dup2 | 新しいfd番号を追加 | 同一の記述を共有 | 連動(offset・O_APPEND等を共有) |
| fork | 子がテーブルを複製 | 親子で同一の記述を共有 | 連動(親が読み進めると子のoffsetも進む) |
- 独立openが2回:
open("x")を2回呼ぶと、オープンファイル記述が2つできます。一方のfdで読み進めても、もう一方のオフセットには影響しません。それぞれが独自の現在位置を持ちます。 - dup / dup2:
dup(3)は 新しいfd番号 を作りますが、その指す先は 3番と同じオープンファイル記述 です。よってオフセットもO_APPEND等の状態フラグも共有されます。dup2(fd, 1)で標準出力を別のfdに張り替えるリダイレクトは、この「同じ記述を別番号からも指す」性質そのものです。 - fork:子プロセスは親のfdテーブルを 複製 しますが、各エントリは親と 同じオープンファイル記述 を指します(テーブルは別、記述は共有)。だから親子が交互に書いても、共有オフセットのおかげで出力が上書きされず順に追記されます。シェルが
cmd > fileを実装するとき、この共有が正しい連結を保証します。
dup(3) 後のfdテーブル: 別々にopenした場合:
3 ───┐ 3 ──► 記述#7 (offset 独立)
├──► 記述#7 (offset 共有)
4 ───┘ 4 ──► 記述#9 (offset 独立)
O_APPEND で開いた記述への write は、「末尾を求めて」「そこへ書く」を1つの不可分操作としてカーネルが行います。オフセットの所在が記述側であり、追記位置を都度inodeの現在サイズから取り直すため、複数プロセスが同一記述・別記述いずれで追記しても各 write が混ざりません。ログを複数プロセスから同じファイルに追記しても壊れないのはこの保証によります。ただしこれは1回の write システムコール内の話で、書き込み量がパイプ容量や PIPE_BUF を超える場合の原子性とは別問題です。
fork・exec・closeでテーブルがどう変わるか
fd の継承規則は、プロセス生成の各段階で次のように決まります。fork と exec を分けて考えるのが要点です(両者の内部は プロセス生成の内部(fork/clone/vfork/exec) を参照)。
fork 時:
子のfdテーブル = 親のfdテーブルの複製(エントリ単位でコピー)
各エントリのポインタ先(オープンファイル記述)は親子で共有
→ fd番号も開いているファイルも、そのままの並びで引き継がれる
exec 時(execve):
for fd in テーブル:
if FD_CLOEXEC が立っている: その fd を閉じる
else: 新プログラムへそのまま引き継ぐ
→ 0/1/2 は通常 FD_CLOEXEC 無しなので、標準入出力は新プログラムに残る
ここで効くのが、FD_CLOEXEC が fdテーブルのエントリ側 に属することです。同じオープンファイル記述を2つのfdが指していても、片方だけ FD_CLOEXEC を立てられます。exec で前者は閉じ、後者は残る、という選択的な継承ができるのは、フラグの所在が記述ではなくテーブルだからです。
ライブラリが内部で開いたfdに FD_CLOEXEC を付け忘れると、fork+exec した子プロセスへ意図せず継承され、機密ファイルやソケットが漏れます。これを避けるため、現代のAPIは open(..., O_CLOEXEC)・socket(..., SOCK_CLOEXEC)・pipe2(fds, O_CLOEXEC) のように 生成時に原子的に FD_CLOEXEC を立てる手段を用意しています。後から fcntl(F_SETFD) で立てると、その隙にレースで fork され漏れる窓があるためです。
シェルのパイプライン A | B は、この三層構造の総合演習です。親シェルが pipe() で読み口・書き口の2つのfd(同じパイプinodeを指す別記述)を作り、fork で両子へ継承し、各子で dup2 を使って書き口を標準出力(あるいは読み口を標準入力)へ張り替え、不要なfdを close してから exec します。fd番号の付け替えがファイルやパイプの中身を一切動かさずに行えるのは、まさに「整数の引換券」と「実体」を分離した三層設計の恩恵です。
(1)三層の役割:fdテーブル=プロセスごとの整数→ポインタとFD_CLOEXEC、オープンファイル記述=オフセットとモード(openごとに1つ)、inode=実体メタデータ(名前もオフセットも持たない)。(2)オフセットは 中間のオープンファイル記述 に宿る。(3)dupとforkは同一記述を共有するためオフセット連動、独立openは別記述で独立。(4)FD_CLOEXEC はfdエントリ側にあり、exec時に立っているfdだけ閉じる。この4点が頻出です。
まとめ
- ファイルディスクリプタは 整数の引換券 であり、プロセスごとの fdテーブル の添字にすぎない。実体やオフセットは番号自体には入っていない。
openは fdテーブル(プロセス層)・オープンファイル記述(システムワイド層)・inode(実体層) の三層を結びつける。どの層を共有するかで挙動が決まる。- ファイルオフセット は fd にも inode にもなく、中間の オープンファイル記述 に1つだけ宿る。
dupとforkは同一記述を共有するためオフセットが連動し、独立したopenは別記述になるため独立する。 forkはfdテーブルを複製しつつ記述を共有し、execでは FD_CLOEXEC が立った fd だけが閉じる。フラグがテーブル側にあるからこそ選択的な継承ができ、これがリダイレクト・パイプの土台になる。
土台となるオブジェクトは VFS(仮想ファイルシステム)層の抽象化、実体メタデータの設計は ファイルシステム、継承の仕組みは プロセス生成の内部(fork/clone/vfork/exec)、open/read がカーネルへ届く道筋は システムコール を合わせて読むと、fd という小さな整数が支える世界の全体像が立体的に見えてきます。
OS Article
ファイルディスクリプタとオープンファイルテーブルの構造を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ファイルディスクリプタ
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
ファイルオフセットはfdにもinodeにもなく、中間のオープンファイル記述に1つだけ宿る。dupとforkはこの同一記述を共有するためオフセットも連動し、独立openは別記述になるため独立する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ファイルディスクリプタ / オープンファイルテーブル」に近いか確認する。
- 強みである「openは三層構造を作る。プロセスごとのfdテーブル(整数→ポインタ)・システムワイドのオープンファイル記述(オフセットとモードを持つ)・inode(実体のメタデータ)で、どの層を共有するかで挙動が決まる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。