Dev Study
← OSとカーネルの土台
レッスン 5 / 10·約 9 分·第10期 OSとカーネルの土台

ファイルディスクリプタが指しているもの — 番号の先にある表

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup
  5. プロセスアプリ / DB今ここ

アプリ層『Webサーバはただのプロセス — listen するとは何か』では socket(2) が返すのが fd であることまで見ました。今回はその番号の先を開きます。

まず結論

fd はファイルを指していません。open(2) は「fd は open file description への参照である」と書き、その open file description が「開いたファイルの表(システム全体)の1エントリ」で、オフセットとフラグはそこに記録されます。

つまり fd → open file description → ファイルの実体(inode)の三層です。同じファイルを2回 open するのと fd を dup するのとで結果が変わるのは、増えた層が違うからです。

? 読む前に予測

同じファイルを2回 open して fd を2本得たあと、片方だけ lseek で50バイト目へ移動させました。もう片方の読み書き位置はどうなりますか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

番号の先は、ファイルではない

アプリ層『Webサーバはただのプロセス — listen するとは何か』で、socket(2) が返すのは fd だというところまで見ました。今回はその番号の先を開きます。

「fd はファイルを指す番号」と言いたくなりますが、open(2) はそう書いていません。

この1段落で三つの層が出そろいます。

  1. fd — "a reference to an open file description"。参照する側
  2. open file description — "an entry in the system-wide table of open files"。オフセットと状態フラグはここ
  3. ファイルの実体 — path が指す先。fd の参照は path が消えても壊れないと明言されている

層が分かれていることは、NOTES がもう一度書いています。

1つの inode に対して description が複数ありうる。description 層と実体の層は別という宣言です。

表の帰属も書き分けられています。description の表は system-wide。一方 dup(2) は新しい fd 番号を "the lowest-numbered file descriptor that was unused in the calling process" と規定します。第21週『ポートという多重化 — 1本の線に何本もの会話』の「fd 3番はそのプロセス内でのみ一意」は、この書き分けの帰結です。

具体実物

オフセットがどこにあるか、数値で見る

Docker のコンテナ(Linux 6.10.14-linuxkit、aarch64、Python 3.12.14)で確かめます。同じファイルを2回 open して fd 3 と fd 4 を得、fd 3 を dup して fd 5 を作り、fd 3 だけを 42 バイト目へ動かしました。

/proc/self/fdinfo の pos(fd 3 = open #1、fd 4 = 別 open、fd 5 = fd 3 の dup)コマンド出力
=== /proc/self/fdinfo: pos is per open file description ===
fd 3: pos: 42 | flags: 02400002 | mnt_id: 270
fd 4: pos: 0 | flags: 02400002 | mnt_id: 270
fd 5: pos: 42 | flags: 02400002 | mnt_id: 270

動かしていない fd 5 が 42 になり、同じファイルを開いただけの fd 4 は 0 のまま。 fd は3本、pos は2種類です。位置が fd ごとにあるなら3種類、ファイルごとにあるなら1種類のはずで、どちらでもありません。間に層があることが数値で見えます。

同じことを lseek の戻り値で追ったものです。

f2-fd-lab.py の [1] と [2](同じ実行の連続する2ブロック)コマンド出力
=== [1] two separate open() calls ===
fd a=3 b=4
initial offsets: a=0 b=0
after lseek(a,50): a=50 b=0
 
=== [2] dup() ===
fd a=3 dup->c=5
offsets: a=50 c=50
after lseek(a,10): a=10 c=10

[1] は open 2回なので description が2つ、[2] は dup なので1つ。man-page 自身がこの挙動を例示しています。

/proc/<PID>/fdinfo は Linux 固有で、macOS や BSD にはありません。上の実験はすべて Docker のコンテナ内です。

✓ ここまでの確認

読み書きの位置(オフセット)を保持しているのは、三層のうちどれですか。

抽象原理

何を増やしたのかで、結果が決まる

三層が分かると、「fd が増える場面」を1つの基準で仕分けできます。増えたのが参照(fd)か、状態(description)か。

  • open をもう一度呼ぶ → description が増える。オフセットは別々
  • dup する → fd だけが増える。オフセットは共有
  • fork する → fd 表はコピーされるが、指す先は同じ

3つ目は誤解しやすいので原文を置きます。

"copies of ... descriptors"(表はコピー)なのに "the same open file description"(指す先は同一)。二段構えが原文にそのまま書かれています。

fd 3 / fd 4 / fd 5 が指す先(上の fdinfo の実測)
fd 3open #1
fd 4open #2
fd 5fd 3 を dup
参照する(fd 表はプロセスごと)
open file description #1pos: 42 / 状態フラグ
fd 3 と fd 5 が共有
open file description #2pos: 0 / 状態フラグ
fd 4 のみ
同じ inode を指す(description の表はシステム全体)
ファイルの実体(inode)中身・サイズ
  • ・オフセットは fd にも inode にも無く、真ん中の層にある
  • ・open(2): there may be multiple open file descriptions corresponding to a file inode
  • ・FD_CLOEXEC は上の段(fd 表)にあるので dup しても共有されない
具体実物

対比例: 書き込みで何が起きるか

オフセットがどの層にあるかは、書くときに結果へ現れます。同じファイルを O_APPEND 無しで2回 open し、片方に AAAAA、もう片方に BBBBB を書いた実測です。

f2-fd-lab.py の [3] と [4](同じ実行の連続する2ブロック)コマンド出力
=== [3] kcmp-free check: write via two independent fds (NO O_APPEND) ===
file content after a.write+b.write (independent fds): b'BBBBB'
file size: 5
 
=== [4] same test with O_APPEND ===
content with O_APPEND: b'AAAAABBBBB'

10バイト書いたのに5バイトしか残っていません。 両方の description がオフセット0を持っていたので、2本目が1本目を踏みました。[4] の O_APPEND 付きでは10バイトが残ります。この差は open(2) に書かれています。

注意が2つあります。ひとつは、man-page が書いているのは O_APPEND があるときアトミックだという側だけで、「無いと上書きになる」とは明文では書いていないことです。無い場合の挙動は「open ごとに独立した description がある」からの帰結で、[3] がその実測にあたります。

もうひとつは但し書きです。同じ項は続けて、複数プロセスが同時に追記する場合、NFS 上では O_APPEND がファイルを壊しうると書いています。NFS が追記に対応せずクライアント側のカーネルが模倣するため、競合が残るという理由です。

fork も見ます。

f2-fd-lab.py の [6](fork 後、親子が同じ description を参照)コマンド出力
=== [6] fork: parent and child share offset? ===
[child] offset after write: 5
[parent] offset seen after child wrote: 5
[parent] offset after own write: 11
final content: b'CHILDPARENT'

親は何も書いていないのに、子が5バイト書いた後で親から見たオフセットが5になっています。だから親の PARENT は子の CHILD を踏まず、CHILDPARENT と連結されました。共有されていなければ親は0から書き、CHILD が消えていたはずです。

✓ ここまでの確認

`dup(2)` で複製した2本の fd について、共有されるものとされないものの組み合わせが正しいのはどれですか。

⇄ 2つを見比べる
具体例 A

同じファイルを2回 open する

open(2) は「ファイルを open するたびに新しい open file description が作られる」と書いています。description が2つできるので、オフセットも2つ。実測では fd 3 を 42 へ動かしても、fd 4 の pos は 0 のままでした。

具体例 B

fd を dup で増やす

dup(2) は「oldfd と同じ open file description を参照する新しい fd を割り当てる」と書いています。description は1つのまま。実測では fd 3 を 42 へ動かすと、触っていない fd 5 の pos も 42 になりました。

どちらも「fd が2本」ですが、片方だけ lseek したときの結果が逆になります。何が違うのでしょうか。

増えたのは参照する側か、参照される側か。オフセットがどちらに書かれているかと合わせて考えてみてください。

この章のまとめ

  • open(2) は **fd を「open file description への参照」**と定義している。fd はファイルを直接指してはいない。
  • オフセットとファイル状態フラグは open file description に記録される。 fd 表はプロセスごと、description の表はシステム全体。
  • open を2回呼ぶと description が2つできてオフセットは独立(実測 a=50 b=0)。dup は fd だけを増やすので連動(実測 a=10 c=10)。
  • fork は表をコピーするが、指す先は同じ description。実測でも親子がオフセットを共有し、CHILDPARENT と連結された。
  • dup で共有されるのは状態フラグ、共有されないのは FD_CLOEXEC。名前が似た2つが別の層にある。
  • O_APPEND 無しで2本の独立した fd から書くと上書きが起こる(実測で5バイトの BBBBB だけが残った)。man-page が明記しているのは「O_APPEND があるときアトミック」という側だけ。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

「fd 表はプロセスごと、開いたファイルの表はシステム全体」という対比の根拠になるのはどれですか。

用語集(4語)
ファイルディスクリプタ (file descriptor, fd)
open(2) の言い方では「open file description への参照」。dup(2) は「呼び出したプロセス内で未使用の最小番号」を割り当てる。本教材では説明のため「表の番号」と呼ぶが、man-page が「添字」と書いているわけではない。
open file description
open(2) の言い方では「開いたファイルのシステム全体の表の1エントリ」。ファイルオフセットとファイル状態フラグを記録する。man-page 自身が「open file object」「file handle」「open file table entry」、カーネル開発者の言い方では「struct file」とも呼ばれると書いている。
2種類のフラグ
ファイル状態フラグ(`O_APPEND` など)は open file description 側にあり、dup した fd の間で共有される。ファイルディスクリプタフラグ(FD_CLOEXEC)は fd 表の側にあり、dup(2) は「2本はこれを共有しない」と明記している。名前が似ているが別の層。
O_APPEND
open(2) の追記モード。「各 write(2) の前に、lseek(2) したかのようにファイルオフセットが末尾へ置かれる。オフセットの変更と write は単一のアトミックな手順として行われる」。ただし man-page は、複数プロセスが同時に追記する NFS 上では壊れうるとも書いている。