← OSと低レイヤのしくみ コース
18. read()を呼んでからデータが届くまで — VFSとページキャッシュ
read() が返るまでにカーネルの中でどこを通っているのかを、図で追っていきましょう。
① fd という整数から、実体にたどり着く
read(fd, buf, count)ユーザ空間が渡すのは整数の fd だけ
プロセスごとのfd表を引く
struct file「プロセスが開いたファイル」を表す
読み書き位置や操作関数の表を持つfile が指す先
struct dentryパス名を高速に引くためのキャッシュ
RAM上だけの存在。ディスクには保存されないdentry が指す先
struct inodeファイルそのもの(サイズ・権限・データの居場所)
- ・struct file は open 時に確保され、dentry へのポインタと操作関数の組で初期化されます
- ・dentry は性能のためだけに存在するキャッシュで、ディスクには書かれません
- ・同じファイルを2回 open すると struct file は2つできますが、inode は1つを共有します
ユーザ空間が持っているのは fd という整数1つです。カーネルはそこから file → dentry → inode と3段たどって、ようやく「どのファイルか」に到達します。
② VFSは「関数ポインタの表」で実装を差し替える
VFS(仮想ファイルシステム層)read() の共通処理はここに1つだけ書かれている
実装は関数ポインタ経由で呼ぶ
struct file_operationsread_iter / write_iter など
struct file が指しているstruct inode_operationsVFSがinodeを操作する方法
struct address_space_operationsread_folio / readahead など
表の中身をFSごとに埋める
ext4
XFS
Btrfs
NFS
- ・VFSはカーネル内の抽象化層で、異なるファイルシステム実装が同時に共存できるようにします
- ・read_iter は iov_iter を書き込み先とする(非同期になりうる)読み取りメソッドです
- ・read_folio は「ページキャッシュが背後のストレージから1 folio 読む」ときに呼ばれるメソッドです
なぜこの設計なのか。read() の中に if (ext4) ... else if (xfs) ... と書く代わりに、構造体に関数ポインタを並べておけば、新しいファイルシステムは表を埋めるだけで追加できます。
③ まずページキャッシュを引く(ここで返れば最速)
VFSの読み取り共通処理inode に紐づく address_space を見る
該当オフセットの folio を探す
ページキャッシュファイル内容をメモリに保持する索引
キーはファイル内のインデックス分岐
ヒットfolio がすでにメモリ上にある
ミスまだ読まれていない範囲
ヒットした側
folio から iov_iter 経由でユーザバッファへコピーディスクI/Oはゼロ。read() はここで返る
- ・folio はページキャッシュ上のまとまった単位で、1ページより大きくなりうるものです
- ・データがページキャッシュに無い場合にだけ、readahead / read_folio が呼ばれます
- ・ヒットした場合はファイルシステム固有の実装を一切呼ばずに、VFSの共通処理だけで read() が完了します
read() は必ずディスクに行くわけではありません。同じファイルを2回目に読むと、たいていはこのブロックで完結します。
④ ミスしたら、ファイルシステムが「論理ブロック番号」を答える
ページキャッシュ・ミスこの範囲はメモリに無い
address_space_operations を呼ぶ
read_folio1 folio 分を読む
readahead先読みでまとめて読む
VMがまとめて要求するここでFS固有の実装に入る
ファイルシステムのマッピング処理「ファイルの何バイト目」を「デバイスの何ブロック目」に翻訳する
翻訳結果をもとに
struct bio を組み立てる読み先のブロック + 格納先のページ
- ・iomap は小さな単位ごとに引くのではなく、その操作に対して作れる最大のマッピングをFSに問い合わせます
- ・大きくまとめて引くことで、マッピング呼び出しのコストを広い範囲に薄められます
- ・ここまでがファイルシステムの仕事で、この先はブロック層の仕事になります
ext4 などが知っているのは「このファイルの中身はデバイスのどこにあるか」だけです。その答えを bio という形にして、下の層へ渡します。
⑤ blk-mq:ソフトウェアキューからハードウェアキューへ
struct bioブロック層に入ってくる読み取り要求
ブロック層が新しい構造体を組み立てる
struct requestデバイスドライバとやり取りするための形
キュー長の範囲内の整数タグが付く直接ドライバに渡らない場合はここに積む
struct blk_mq_ctxソフトウェア・ステージングキュー
CPUまたはノードごと。各自ロックを持つディスパッチ
struct blk_mq_hw_ctxハードウェア・ディスパッチキュー
デバイスの投入キューやDMAリングに対応struct blk_mq_ops の queue_rq
デバイスドライバ → デバイスqueue_rq は「ブロックIOから新しい要求をキューへ」
- ・ソフトウェアキューはCPU/ノードごとに分かれ、それぞれがロックを持つのでロック競合を避けられます
- ・ハードウェアキューの数はハードとドライバがサポートするコンテキスト数に依存しますが、コア数を超えることはありません
- ・タグはブロック層が生成し、後でデバイスドライバが再利用します。struct blk_mq_tag_set がキュー間で共有されるタグを管理します
なぜ2段構えなのか。ソフトウェアキューをCPUごとに分けることでロック競合を消し、その後でハードウェアが実際に持てる本数のキューへ束ね直しています。
⑥ 読み終えたページはキャッシュに残る
デバイスがデータを返す完了はタグで識別される
struct request → bio 完了
読んだ内容が folio に入る最初に確保しておいたページがそのまま埋まる
folio をキャッシュに載せたまま
ユーザバッファへコピーここでようやく read() が返る
次に同じ範囲を read() すると
③のヒット経路に戻るブロック層まで降りない
- ・読み取りのために確保した folio はページキャッシュに残るので、次回の同じ範囲の読み取りに使えます
- ・readahead は要求より先の範囲もまとめて読むため、連続読みでは次のミスも事前に埋まっていることがあります
1回目の read() のコストが、2回目以降のために先払いされている、と考えると全体がつながります。これがページキャッシュの効きどころです。