VFS 上級:ロック所有権、永続的更新、データ転送

このページの目次

設定ファイルの更新時、読者には古い版の完全版か新しい版の完全版のどちらかが見えるようにしたい。しかし、ロック、rename、fsync がそれぞれ保証することは異なる。本稿では、この更新を所有権、可視性、永続性の観点から分解し、データ転送におけるバッファの寿命について考察する。

説明すべき現象読むべき箇所
別の fd をクローズすると、なぜロックも消えるのか?第 1~2 節:inode とロック所有者
rename の成功はすでにクラッシュに耐えうるのか?第 3 節:ファイルとディレクトリの個別同期
sendfile 返却後、ソースの内容は変更可能か?第 4 節:コピーと参照の境界
遅延はロック、ログ、デバイスどちらにあるのか?第 5 節:完了ポイントごとの観測

1. inode フラグは単純な列挙状態マシンではない

i_state は合成可能な状態ビットの集合であり、I_NEW、I_DIRTY_SYNC などを 0 から始まる順序番号のように偽装してはならない。I_NEW は初期化の公開に関連し、dirty ビットは異なる書き戻し要件を記述し、I_FREEING などは回収の調整に関与する。具体的なビット値はターゲットカーネルの実装に属しており、バージョン横断的なチュートリアルの固定定数としては適さない。

I_DIRTY_DATASYNC は、fdatasync によって処理される必要がある inode メタデータの変更(データ読み取りに影響を与えるメタデータなど)を示し、単純な「データページの dirty」を意味するものではない。データページの dirty 状態は、mapping/folio レベルで別途追跡される。フィールド名が似ているからといって、保護範囲が同じであるわけではない。

参照カウントが 0 になっても、inode を直ちに破棄する必要はない。キャッシュ可能な未使用の inode はキャッシュ内に残しておき、回収時に追放できる。リンクされていない inode などの状況では異なる処理が行われる。初期化中の検索者、書き戻し者、回収者は調整を行う必要があり、unlock_new_inode() の役割は単なる「フラグのクリア」ではない。fs/inode.c と VFS ライフサイクルの説明 を合わせて読む方が、固定された関数チェーンを暗記するよりも信頼性が高い。

2. 3 種類のロックの所有者

ロック競合はファイル範囲で発生するが、ロックの所有者は異なる場合がある プロセス P:fd A、fd B 同じファイルを 2 回独立して open POSIX 範囲ロック 所有者:プロセス P OFD 範囲ロック 所有者:オープンインスタンス A 同一 inode の範囲 ロック間の競合チェックに使用 B をクローズすると、P によるこのファイルの POSIX ロックが解放される。独立したオープンインスタンス A の OFD ロックは解放されない。
ロック範囲と所有者dup / fork解放の境界
POSIX F_SETLK/F_SETLKWバイト範囲;プロセスに関連子プロセスは親プロセスのロックを継承しないそのプロセスが同一ファイルを指す任意の fd をクローズすると、そのファイルに対するこの種のロックが解放される
OFD F_OFD_SETLK/F_OFD_SETLKWバイト範囲;オープンインスタンスに関連オープンインスタンスを共有する記述子がロックを共有関連する最後のオープン参照がクローズされるか、明示的にロック解除される
Linux ローカル flock通常はファイル全体;オープンインスタンスに関連オープンインスタンスを共有すると、ロックも共有される関連する最後の参照がクローズされるか、明示的にロック解除される

独立した open で得られた OFD 所有者は異なるため、同一プロセス内でも競合が発生する。同一オープンインスタンスの dup は、独立した競合者を生成しない。OFD の struct flock.l_pid 入力には 0 を指定すべきであり、OFD ロックを検索した際の PID フィールドは、通常のプロセスロックのような意味を持たない。fcntl 範囲ロック を参照。

これらは通常協調的ロックである。他の呼び出し者が規約に従ってロックを追加しない場合、依然として内容を読み書きできる可能性がある。歴史的な Linux の mandatory locking は、現代の一般的な保証として扱ってはならない。ローカルの flock と POSIX ロックは通常独立したメカニズムだが、NFS/SMB の実装では範囲ロックをシミュレートして相互作用することがあり、ローカルでの振る舞いをネットワークファイルシステムにそのまま持ち込むことはできない。flock(2) を参照。

l_len=0 は、ロックがファイル末尾まで及び、その後の拡張範囲も含むことを意味し、空の範囲を意味するものではない。ブロッキングロックでは、シグナル、終了、デッドロックの処理も必要であり、F_GETLK が返すのはその瞬間に観察された競合のみであり、その後の状態は変化する可能性がある。「まずロックがないことを確認し、次にロックなしで書き込む」という手法で、実際のロック取得を代替してはならない。

OFD の最終参照境界の検証

以下の Linux C サンプルは一時ファイルを使用し、2 回の独立した open と 1 回の dup を行う。struct flock の手動バイナリレイアウトに依存しない:

#define _GNU_SOURCE
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static int set_lock(int fd) {
    struct flock lock = {
        .l_type = F_WRLCK, .l_whence = SEEK_SET,
        .l_start = 0, .l_len = 1, .l_pid = 0
    };
    return fcntl(fd, F_OFD_SETLK, &lock);
}
static void expect_conflict(int fd) {
    int rc = set_lock(fd);
    assert(rc == -1 && (errno == EAGAIN || errno == EACCES));
}
int main(void) {
    char path[] = "/tmp/ofd-demo-XXXXXX";
    int a = mkstemp(path);
    assert(a >= 0);
    int b = open(path, O_RDWR | O_CLOEXEC);
    assert(b >= 0);
    assert(unlink(path) == 0);
    int alias = dup(a);
    assert(alias >= 0);
    assert(set_lock(a) == 0);
    expect_conflict(b);
    assert(close(a) == 0);
    expect_conflict(b);            /* alias はまだオープンインスタンスを保持 */
    assert(close(alias) == 0);
    assert(set_lock(b) == 0);       /* 最終参照がクローズされてから取得可能 */
    assert(close(b) == 0);
    puts("OFD 独立所有者と最終参照:通過");
}

cc -std=c11 -Wall -Wextra -Werror ofd-demo.c -o ofd-demo でコンパイルできる。本例ではデモンストレーション検証のためにアサーションを使用しているが、実行時には -DNDEBUG を付けないこと。本番コードでは、各失敗分岐を明示的に処理すべきである。

3. 永続的置換:ファイル内容とディレクトリエントリの個別コミット

設定ファイルに対しては、読者が古い版の完全版か新しい版の完全版のどちらかしか見ないようにすることが一般的な目標である。元のファイルを直接 truncate してから書き込むと、並行して読み込んでいる読者に空ファイルや部分内容が見えてしまう。まず同一ディレクトリ内に一時ファイルを用意し、完全に書き込んで同期し、その後 rename でターゲットを置き換え、最後に親ディレクトリを同期する。

更新を 4 つの観測ポイントに分解する:

ステップここで確立される境界未完了のこと
一時ファイルへの書き込み完了データがインターフェースの受信側に書き込まれた同期完了の保証はない
一時ファイルの fsync内容と必要なメタデータが同期されたターゲットの名前がまだ置換されていない
rename によるターゲット置換ランタイム時の名前バインディングが変更されたディレクトリの更新はまだ同期が必要
親ディレクトリの fsync契約に従ってディレクトリ更新が同期されたファイル間トランザクションが自動的に形成されるわけではない

最後のステップ前にクラッシュした場合、rename の成功だけで再起動後の名前状態を保証することはできない。fsync(2) を参照。

以下では、ファイル内容の同期とディレクトリの同期を 2 つの独立したアクションとして扱う。任意の段階でクラッシュをシミュレートし、どの名前バインディングで回復し、どのバージョンが読まれるかを直接確認できる。

図を準備しています
名前の原子置換はされたが、内容も永続化されたか?

rename はランタイム時の名前バインディングを変更する。ファイル fsync とディレクトリ fsync は、それぞれ内容と名前の永続的境界を確立する。既存の fd は依然として古い inode を参照できる。

import os
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory(prefix="replace-demo-") as directory:
    root = Path(directory)
    target = root / "config"
    target.write_bytes(b"old\n")
    dirfd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
    oldfd = os.open(target, os.O_RDONLY)
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(dir=root, delete=False) as file:
            temporary = Path(file.name)
            file.write(b"new\n")
            file.flush()
            os.fsync(file.fileno())
        os.replace(temporary, target)
        temporary = None
        os.fsync(dirfd)
        assert target.read_bytes() == b"new\n"
        assert os.pread(oldfd, 4, 0) == b"old\n"
        print("新規オープンで新バージョンが見える;古いオープン参照は依然として旧ファイルを読む:通過")
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)
        os.close(oldfd)
        os.close(dirfd)

これはランタイム時の可視性と古い参照を検証するものであり、電源断をシミュレートするものではない。本番環境での更新では、新しいファイルの権限、所有者、ACL/xattr の設定、失敗後の状態の不確実性の処理を行う必要がある。ディレクトリをまたぐ移動には関連するディレクトリが含まれ、ファイルシステム横断の rename は通常この方法では完了できない。複数のファイルが各自の fsync を持ったからといって、自動的に同じトランザクションを形成するわけではない。

inode を置換しても、古いロックは自動的に移動しない

ロックと置換の組み合わせにはもう一つの罠がある。古い inode 上のロックは、置換後の新しい inode には自動的に移動しない。各プロセスが「現在のファイルを開いてロックする」場合、異なるバージョンにそれぞれロックがかかる可能性がある。更新の調整には、ID が安定しており、参加者が共通で遵守するロックオブジェクト、またはより高レベルのプロトコルが必要である。

4. sendfile / splice:どのデータ転送を減らすか

sendfile はユーザー空間の仲介を減らす。すべての層でコピーがないことを保証するものではない ファイル内容 / ページキャッシュ カーネル転送パス ページを参照するか、必要に応じてコピー socket / 下流オブジェクト ユーザー空間アプリケーション fd、範囲、リクエストを渡す 短距離転送、バックプレッシャー、ソースコンテンツの寿命は依然として処理が必要。システムコールの出入りがプロセスコンテキストスイッチに相当するわけではない。
インターフェース接続されるデータパス
sendfileアプリケーションがまず read でユーザーバッファに読み込み、その後 write で下流に書き込むのを回避
splice少なくとも一端が pipe であり、パイプバッファを介してサポートされるオブジェクトを接続
vmspliceユーザーメモリ範囲を pipe に接続

カーネルが元のページを参照するか、コピーするか、中間バッファを使用するかは、ファイルシステム、転送、暗号化、デバイス能力、および具体的なパスに依然として依存する。

「ゼロコピー」とは通常、特定 CPU のデータコピーを減らすことを指し、DMA がない、ページ参照コストがない、sendfile が固定で 1 回コピー、SG を追加すると固定で 0 回コピーであることを保証するものではない。1 回のシステムコールにおけるユーザー/カーネル変換は、別のプロセスへの切り替えに相当しない。ブロッキング、スケジューリング、割り込みはまた異なるオーバーヘッドを増加させる。sendfile(2) を参照。

インターフェース契約アプリケーションが維持する必要がある状態
正数を返すが、要求された長さより短い対応するオフセットを進め、残りの範囲を継続
ノンブロッキングで EAGAIN を返す書き込み可能/読み込み可能になるまで待機し、完了した進捗を保持して再試行
下流が依然としてソースページを参照しているコンシューマーが完了する前に、依然として参照されている内容を変更しない
pipe には容量制限がある生産/消費のバックプレッシャーを管理し、無限に詰め込めない

成功返却とソースメモリの再利用可能さは異なるイベント

vmsplice(SPLICE_F_GIFT) はメモリページ使用に対する厳格な約束であり、アラインメントなどの制限に従うことを要求し、その後贈与されたメモリを変更しないことを保証する。返却成功が自動的にユーザーマッピングを解除したり、アプリケーションに「即時再利用可能」であることを通知するものではない。通常の malloc バッファを呼び出し後に直ちに書き換えて、ネットワーク送信が完了したと仮定してはならない。vmsplice(2) を参照。

5. 観測はオブジェクトと完了ポイントに従う

/proc/locks、lslocks はロックの種類と範囲を観測できる。表示内容は PID namespace などの要因の影響を受け、OFD ロックは通常のロック保持 PID には対応しない。/proc/sys/fs/inode-state はシステムキャッシュの状態であり、現在アクティブなファイルの正確な数量ではない。同期遅延は、ダーティページ書き出し、ログコミット、デバイス完了、ネットワーク確認を区別する必要がある。単一の syscall の総所要時間から直接、ある層の破損を推論することはできない。

ソースコードのエントリポイントは、fs/inode.c、ファイルロック実装、fs/sync.c、fs/splice.c をそれぞれ沿って検索できる。ファイルロックのソースコードレイアウトはバージョンによって変化する可能性があるため、まずインターフェース名で位置特定を行う。ネットワークファイルの完了と再試行のセマンティクスは NFS に準拠する。