Page Cache と I/O: キャッシュ、ライトバック、永続化

このページの目次

数秒間の書き込みは高速だが、持続的な書き込みでは速度が低下する。write が成功した後でも、fsync はエラーを返す可能性がある。Page Cache は内容を保持するだけでなく、⁠並行するキャッシュ読み込み、ダーティページの予算、完了の境界を調整する。

説明すべき現象参照箇所
複数の fd はそれぞれ独立したキャッシュを持つのか?第 1 節: ファイルオフセット、XArray、folio
キャッシュヒットしてもなぜ待たなければならないのか?第 2~3 節: キャッシュ読み込みとライトバック
なぜキャッシュは持続的な高速書き込みに耐えられないのか?第 4~5 節: レートリミングと完了セマンティクス
mmap やダイレクト I/O はどのパスを変更するのか?第 6~7 節: インターフェースと実験

1. キャッシュはファイル単位で構成され、fd ごとに複製されない

通常のファイルの inode は通常、i_mapping を介して address_space に結び付けられます。ここで i_pages は XArray を使用してキャッシュ内容をインデックス付けしています。同じ inode へのハードリンクや、独立して open された複数の fd は、通常、このコンテンツキャッシュを共有します。address_space はプロセスの仮想アドレス空間 mm_struct とは異なります。

手計算: ファイルオフセット 10000 がどのインデックスに属するか

PAGE_SIZE=4096 バイトと仮定すると、10000 = 2 × 4096 + 1808 となります。キャッシュインデックスは 2、ページ内オフセットは 1808 です。

インデックスの単位は基本ページサイズであり、ファイルシステムブロック、デバイスの論理セクタ、または folio の数ではありません。基本ページサイズはアーキテクチャとカーネルの設定によって依然として異なります。

folio が導入された理由

folio は、全体として管理され、物理的に連続した基本ページのグループを表します。サイズは基本ページの 2 の累乗倍で、1 ページのみを含むこともできます。「ここで操作しているのは全体のページグループである」と明確にすることで、複合ページ(compound page)の末尾ページを独立したオブジェクトと誤認する曖昧さを減らし、キャッシュの一括管理にも役立ちます。

大きな folio がすべてのファイルで 2 MiB ページを使用する意味を持つわけではなく、ファイルシステムがサイズを気にする必要がないという意味でもありません。割り当て能力、アライメント、ファイルシステムのサポート、読み書きの有効範囲、回収、分割はすべて実際の選択に影響します。複数の基本ページにまたがる folio は、ファイルのページインデックスに従って対応する範囲をカバーするため、インデックスを「何番目の folio か」に変換することはできません。

XArray はマルチインデックスエントリ、タグ、並列検索をサポートしますが、見つかったエントリが必ずしも通常のポインタであるとは限りません。シャドウ/スワップなどの値エントリである可能性もあります。内部同期は、格納されているオブジェクトの寿命やファイル範囲のロックを自動的に解決しません。ページキャッシュコードは、対応する filemap/folio インターフェースを使用する必要があり、単に xa_store() でポインタを格納したからといって、挿入、参照、会計処理が完了したと見なしてはいけません。XArray ドキュメント を参照してください。

2. 読み込みパス: キャッシュに存在しても、データが利用可能とは限らない

バッファ付き読み込みを行うには、要求された範囲を特定し、有効な参照を取得し、対応するコンテンツが準備完了であることを確認してから、ユーザーバッファにコピーする必要があります。folio が別のリクエストによってキャッシュ読み込み中の場合、読み込み側は待機する可能性があります。そのため、キャッシュヒットしても待たないことが保証されません。ファイルシステムはブロック単位で部分的な有効状態を追跡することがあり、folio 全体の uptodate と特定の範囲が有効であることは区別する必要があります。

同じ未キャッシュ範囲を複数のリクエストが読み込む場合、同じキャッシュ読み込み状態の周りで調整する必要があります。

  1. 候補の作成。 ロック付き folio を割り当てて挿入を試み、並列挿入の競合を処理する。
  2. キャッシュへの読み込み。 バックエンドへの読み込みを開始し、他のリクエストは読み込み中のオブジェクトを待機できる。
  3. 結果の公開。 I/O 完了後、有効状態を更新し、ロックを解除して待機者をウェイクアップさせる。

各リクエストがキャッシュ外で個別に読み込み完了し、最後に無条件で挿入すると、重複するキャッシュ読み込みを避けにくくなります。実際のパスでは、切り捨てや I/O エラーも処理する必要があります。

バックエンドが bio を生成する必要はありません。ローカルブロックファイルシステムはブロック層を介すことがあり、NFS はネットワークリクエストを使用し、tmpfs のバックアップ方法は異なります。file_accessed(file) はアクセス時間に関連しており、folio を受け取って LRU を更新するキャッシュアクセスマーク関数として記述してはいけません。

プレフェッチは次のアクセスを賭けている

シーケンシャルアクセスは、後の範囲を事前に読み込むのに適しており、バックエンド I/O とアプリケーションの計算を重叠させます。ランダムなジャンプや作業セットがメモリよりも大幅に大きい場合、過剰なプレフェッチは帯域幅を浪費し、有用なキャッシュを押し出す可能性があります。

「同期プレフェッチ」は通常、現在のキャッシュミスによってトリガーされるプレフェッチパスを指します。「非同期プレフェッチ」は、消費からプレフェッチウィンドウ内のトリガーマークまでウィンドウを進めることで推進されることが多いです。ここで命名されているからといって、同期パスがプレフェッチウィンドウ全体の完了を待たなければならないわけではなく、固定された 128 KiB のプレフェッチを行うわけでもありません。ウィンドウはアクセス履歴、ファイル境界、デバイス設定、実装戦略の影響を受けます。ページキャッシュとプレフェッチ API を参照してください。

3. 書き込みパス: まず局所的なコンテンツの正しさを保証し、次にダーティデータを管理する

バッファ付き書き込みは、対応するキャッシュ範囲を取得し、ユーザーデータをコピーし、サイズとダーティ状態を更新します。既存のファイルブロックの一部を上書きする場合、上書きされない古いコンテンツを事前に読み込む必要があるかもしれません。古いコンテンツがすでにキャッシュにある場合、または範囲が新規割り当てでホールでありゼロであるべきであれば、無理にディスクから事前に読み込む必要はありません。異なるファイルシステムは buffer-head や iomap などのパスを使用できるため、固定された関数チェーンをすべての実装に対して当てはめてはいけません。

バッファ書き込み: 可視性、ライトバック、永続化の完了点は異なる アプリケーション write() 新しいデータをカーネルに渡す ページキャッシュ dirty 通常の読み込みで新しいデータを観察可能 writeback 進行中 同時に再度 dirty にされる可能性あり バックエンド I/O 完了 永続化プロトコルを満たす必要がある fsync: 対応するデータ、メタデータ、ストレージの同期を要求し、待機する ファイルの同期は親ディレクトリの同期を自動的に代替せず、複数の書き込みをトランザクションに変えるものでもない dirty と writeback は重複可能であり、write の成功は電源断後の保持を保証するものではない。

dirty は、まだ書き戻しが必要な変更があることを示します。writeback は、書き戻しが進行中であることを示します。書き戻しを開始した後、アプリケーションは同じ folio を再度変更する可能性があるため、これらの 2 つの状態が同時に存在することがあります。今回の I/O 完了は、その後生成された新しいダーティデータを自動的にクリアしません。書き戻しコードは、範囲、状態、完了のタイミングを追跡する必要があり、詳細な契約は iomap デザイン を参照してください。

遅延割り当てにより、ファイルシステムは後の書き戻し時に物理ブロックを決定できるため、レイアウトの集約に有利ですが、これにより、空間不足などのエラーが最初の write よりも遅れて発生する可能性があります。カーネルは、書き戻しエラー記録メカニズムを使用して、関連するファイル記述子にエラーを返します。アプリケーションは、後続の fsync の失敗を無視してはいけません。close が成功しても、永続化されたことをそれだけで判断することはできません。

4. 書き込み側がダーティページによってレートリミングされる理由

まず、キャッシュの蓄積速度を計算します。入力と出力の速度が一定であると仮定します。

トラフィック速度
アプリケーションがダーティデータを生成1 GiB/s = 1024 MiB/s
バックエンドが排出200 MiB/s
一時差分824 MiB/s

1 秒ごとに、約 824 MiB の差分をさらに収容する必要があります。キャッシュは突発的な負荷を吸収できますが、この速度を無限に維持することはできません。ダーティページの予算が圧力を受けると、書き込み側はレートリミングを行う必要があります。短時間の高速 write は、単にこの予算を消費しただけかもしれません。

制御項目意味誤解されやすい点
dirty_background_ratio / dirty_background_bytesバックグラウンド書き戻し開始の閾値に関連データの永続性のデッドラインではない
dirty_ratio / dirty_bytes書き込み側のレートリミングに関連する閾値ratio の分母は単純な物理メモリ総量ではない
dirty_expire_centisecsダーティデータが一定の年齢に達すると周期書き戻しの対象となる各バイトが期限に達したからといって、即座にディスクに書き込まれるわけではない
dirty_writeback_centisecs周期書き戻しのウェイクアップ間隔唯一の書き戻しトリガーソースではない

ratio と bytes は、互いに置き換え可能なペアの構成方法です。書き込み側は、対応する他方をゼロにクリアします。実際の制御には、書き込み可能なダーティメモリの推定、各書き戻しドメインのシェア、cgroup サポートなどが関与します。現在の値は /proc/sys/vm/ から読み取るべきであり、あるグループのデフォルト値をすべてのマシンでの事実として記述してはいけません。バックグラウンド作業も、永久的に「1 つのディスクに対して 1 つの排他的な flusher スレッド」ではありません。VM sysctl ドキュメント を参照してください。

5. 3 つの完了セマンティクス

観察点何を意味するか何を意味しないか
バッファ付き write の成功返されたバイト数は今回の書き込みで受け入れられたすべての要求バイトが書き込まれた;電源断後も残っている
バックエンド書き込みリクエストの完了対応する I/O がそのプロトコルの完了条件を満たしたデバイスの揮発性キャッシュ、ディレクトリ更新、アプリケーショントランザクションの要件が自動的に満たされるわけではない
fsync の成功ファイルデータと必要なメタデータが同期契約に従って完了した親ディレクトリエントリが同期された;複数のファイルがアトミックトランザクションを形成した

fdatasync() は、ファイルサイズなど、データの回復に必要なメタデータも同期します。完全にメタデータを書き込まないわけではありません。作成や名前変更後、名前が永続的に存在することを保証するには、通常、関連する親ディレクトリの同期も必要です。具体的な障害プロトコルはファイルシステム能力に基づいて設計されるべきです。同期の失敗は、報告するか、明確な復旧フローに入る必要があります。fsync(2) を参照してください。

意図的に書き戻し進行中に再度書き込みを行い、古いリクエストを完了させます。以下では、キャッシュ、デバイスの揮発性状態、安定したコンテンツをそれぞれ保持し、電源断の結果を使用して、各完了ポイントで実際にどのバージョンが保持されたかを確認します。

図を準備しています
1 回の書き込み、4 つの異なる完了位置

読者が目にするキャッシュバージョン、1 回の I/O のスナップショット、デバイスの揮発性キャッシュ、安定したメディアは異なる場合があります。fsync のファイル完了契約は、ディレクトリ同期を代替するものではありません。

6. mmap とダイレクト I/O はそれぞれ何を変更するのか

通常のファイルの MAP_SHARED マッピングは通常、同じページキャッシュに接続され、書き込みはダーティページを形成する可能性があります。MAP_PRIVATE のコピーオンライトはプライベートな変更を形成し、これらの変更を元のファイルに書き戻しません。マッピングは、元の fd を閉じた後も存続する可能性があります。ファイルが並列に切り捨てられた後、新しい末尾を超えたページにアクセスすると、SIGBUS を受け取る可能性があります。msync(MS_SYNC) は共有マッピング範囲を同期できますが、ファイル間トランザクションやディレクトリ同期を代替するものではありません。mmap(2) を参照してください。

ダイレクト I/O: キャッシュパスを迂回し、永続化の約束を拡大しない

O_DIRECT は通常、ファイルデータ転送をバッファ I/O のキャッシュパスから迂回させるが、O_SYNC とは等価ではなく、ゼロコピー、非同期実行、または電源断後の永続性を自動的に約束するものでもない。バッファアドレス、長さ、ファイルオフセットのアライメント要件は、ファイルシステムとデバイスによって異なる。サポートされている場合、statx() の STATX_DIOALIGN を介して照会できるが、一律に 512 または 4096 バイトとしてハードコードしてはならない。制約を満たさない場合、エラーが返される可能性があり、一部のパスではフォールバックすることもある。

パスを混用する場合でも、キャッシュとバッファの寿命を調整する必要がある

ダイレクト I/O とバッファ付き書き込み、共有マッピングを混用する場合、カーネルは依然として一貫性を維持するためにキャッシュの書き戻しや無効化を行う必要がある可能性があり、パフォーマンスと並列性の推論はより複雑になる。非同期リクエストが完了する前に、ユーザーバッファは有効であり、アクセス制約を満たしている必要がある。fork とプライベートマッピングに関連するバッファについては、追加の制限に従う必要がある。インターフェースの制限については、open(2) の O_DIRECT 説明 を参照してください。

7. 小実験: 共有マッピングとプライベートコピー

ここでは、一時ファイルを 1 つだけ作成し、可視コンテンツを検証する。スループットや電源断復旧は測定しない。

import mmap
import os
import tempfile

with tempfile.TemporaryFile() as file:
    file.write(b"A" * mmap.PAGESIZE)
    file.flush()                    # まず Python ユーザー空間のバッファをクリア
    with mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_WRITE) as shared:
        shared[0:1] = b"S"
        shared.flush()              # Linux 上で共有マッピングを同期
    os.fsync(file.fileno())
    assert os.pread(file.fileno(), 1, 0) == b"S"
    with mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_COPY) as private:
        private[0:1] = b"P"
        assert private[0:1] == b"P"
        assert os.pread(file.fileno(), 1, 0) == b"S"
    assert os.pread(file.fileno(), 1, 0) == b"S"
    print("共有マッピング書き込みは可視;プライベート変更はファイルに書き戻されない:通過")

file.flush()、マッピング flush、fsync() はそれぞれ異なるバッファ/同期の境界を跨ぐ。1 つの Python メソッド名が同じだからといって、行われる作業が同じであるわけではない。

読み取り専用のトラブルシューティングでは、/proc/meminfo の Cached、Dirty、Writeback および /proc/vmstat の関連カウンターを確認できる。Cached は、いつでも無コストで解放できる通常のディスクファイルキャッシュとして直接見なすべきではなく、tmpfs/shmem なども統計に影響を与える。mincore() が報告する残留状態はスナップショットであり、次の読み込みレイテンシを保証するものではない。drop_caches はシステム全体での操作であり、他のプロセスのキャッシュ状態を乱すため、副作用のない「リフレッシュ」として日常の観測手順に入れるべきではない。

mm/filemap.c を沿ってキャッシュ読み込みを読み、mm/readahead.c を沿ってプレフェッチを読み、mm/page-writeback.c および fs/fs-writeback.c を沿ってレートリミングと書き戻しを読み込む。ファイルレイアウトとログメカニズムは ファイルシステム実装 に接続されている。