Start Debugging

.NET でファイルの書き込みが完了したことを検知する方法

FileSystemWatcher は書き込み側が終わる前に Changed を発火します。.NET 11 でファイルが完全に書き込まれたことを知るための信頼できる 3 つのパターン: FileShare.None でオープンする、サイズの安定化でデバウンスする、そして問題そのものを回避するプロデューサー側の rename トリックです。

FileSystemWatcher はファイルが「完了した」ことを教えてくれません。OS が変更を観測したことを教えてくれるだけです。Windows では各 WriteFile 呼び出しが Changed イベントを発火し、Created はファイルが現れた瞬間に発火します。多くの場合、まだ 1 バイトも書き込まれていません。信頼できるパターンは次のとおりです: (1) FileShare.None でファイルを開こうとし、IOException 0x20 / 0x21 を「まだ書き込み中」として扱い、バックオフしながらリトライする、(2) FileInfo.LengthLastWriteTimeUtc をポーリングし、両方が連続した 2 サンプルで安定するまで待つ、または (3) プロデューサーと協調し、name.tmp に書いてから File.Move で最終的な名前にする。これは同じボリューム上で原子的です。パターン 3 だけがレースコンディションなしに正しく動作します。パターン 1 と 2 は、プロデューサーを制御できない場合に生き延びるための方法です。

この記事は .NET 11 (preview 4) と Windows / Linux / macOS を対象としています。下記の FileSystemWatcher のセマンティクスはどのプラットフォームでも .NET Core 3.1 以降変わっておらず、協調的な rename トリックは POSIX と NTFS で同じです。

なぜ素朴なアプローチが間違っているのか

素朴なコードは次のように見え、あまりにも多くの場所で本番稼働しています:

// .NET 11 -- BROKEN, do not ship
var watcher = new FileSystemWatcher(@"C:\inbox", "*.csv");
watcher.Created += (_, e) =>
{
    var rows = File.ReadAllLines(e.FullPath); // throws IOException
    Process(rows);
};
watcher.EnableRaisingEvents = true;

Created は OS がディレクトリエントリの存在を報告した時点で発火します。書き込み側プロセスは 1 バイトも flush していない可能性があります。Windows ではファイルが FileShare.Read で開かれている場合があり (この場合、読み取りは部分ファイルを返します)、または FileShare.None で開かれている場合があります (この場合、読み取りは IOException: The process cannot access the file because it is being used by another process、HRESULT 0x80070020、win32 error 32 をスローします)。Linux ではデフォルトで強制ロックがないため、ほぼ常に部分読み取りになります。半分の CSV を黙って処理することになります。

Changed はもっと厄介です。プロデューサーの書き込み方によっては、WriteFile 呼び出しごとに 1 イベントが発生する場合があり、4 KB ブロックで書き込まれた 1 MB のファイルは 256 イベントを発火します。どれもライターが終わったことを教えてくれません。WriteFileLastTimeIPromise のような通知は存在しません。カーネルは書き込み側の意図を知らないからです。

3 つ目の問題: 多くのコピーツール (Explorer、robocopy、rsync) はまず隠しのテンポラリ名で書き込み、その後リネームします。テンポラリの Created、続いて最終ファイルの Renamed が見えます。これらのケースで反応すべきは Renamed イベントですが、FileSystemWatcher.NotifyFilter のデフォルトは .NET 11 で LastWrite を除外しており、一部のプラットフォームでは FileName を除外しているため、明示的にオプトインする必要があります。

パターン 1: FileShare.None で開きバックオフする

プロデューサーを制御できない場合、唯一の観測チャネルは「ファイルを排他的に開けるか」です。プロデューサーは書き込み中、開いたハンドルを保持しています。ハンドルを閉じれば、排他オープンが成功します。これは Windows、Linux、macOS で機能します (Linux は flock 経由でアドバイザリロックを提供しますが、通常の FileStream のロックなしオープンセマンティクスで十分です。書き込み側がいなくなったことを確認するためだけに読み取るからです)。

// .NET 11, C# 14
using System.IO;

static async Task<FileStream?> WaitForFileAsync(
    string path,
    TimeSpan timeout,
    CancellationToken ct)
{
    var deadline = DateTime.UtcNow + timeout;
    var delay = TimeSpan.FromMilliseconds(50);

    while (DateTime.UtcNow < deadline)
    {
        try
        {
            return new FileStream(
                path,
                FileMode.Open,
                FileAccess.Read,
                FileShare.None);
        }
        catch (IOException ex) when (IsSharingViolation(ex))
        {
            await Task.Delay(delay, ct);
            delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 1000));
        }
        catch (UnauthorizedAccessException)
        {
            // ACL problem, not a sharing problem -- do not retry
            throw;
        }
    }
    return null;
}

static bool IsSharingViolation(IOException ex)
{
    // ERROR_SHARING_VIOLATION = 0x20, ERROR_LOCK_VIOLATION = 0x21
    var hr = ex.HResult & 0xFFFF;
    return hr is 0x20 or 0x21;
}

3 つの細かいポイント:

このパターンは特定のケースで失敗します: プロデューサーが FileShare.Read | FileShare.Write で開く場合 (バグのあるアップローダーがそうします)。書き込みの途中で排他オープンが成功し、ゴミを読むことになります。これが疑われる場合は、パターン 1 とパターン 2 を組み合わせてください。

パターン 2: サイズの安定化によるデバウンス

ファイルロックに頼れない場合 (一部の Linux プロデューサー、一部の SMB シェア、一部のカメラのダンプ)、サイズと LastWriteTimeUtc をポーリングします。経験則: 妥当な間隔で 2 回連続のポーリングでサイズが変わらなければ、ライターは恐らく終わっています。

// .NET 11, C# 14
static async Task<bool> WaitForStableSizeAsync(
    string path,
    TimeSpan pollInterval,
    int requiredStableSamples,
    CancellationToken ct)
{
    var fi = new FileInfo(path);
    long lastSize = -1;
    DateTime lastWrite = default;
    int stable = 0;

    while (stable < requiredStableSamples)
    {
        await Task.Delay(pollInterval, ct);
        fi.Refresh(); // FileInfo caches; Refresh forces a fresh stat call
        if (!fi.Exists) return false;

        if (fi.Length == lastSize && fi.LastWriteTimeUtc == lastWrite)
        {
            stable++;
        }
        else
        {
            stable = 0;
            lastSize = fi.Length;
            lastWrite = fi.LastWriteTimeUtc;
        }
    }
    return true;
}

書き込み側について分かっていることに基づいて pollInterval を選びます:

落とし穴は FileInfo.Refresh() です。これがないと、FileInfo.LengthFileInfo を構築したときにキャッシュされた値を返し、ループは永遠に回ります。コンパイラの警告はありません。よくあるサイレントバグです。

本番ではパターン 1 と組み合わせてください: サイズが安定するまでポーリングし、その後、最終確認として排他オープンを試みます。この組み合わせは行儀のよいプロデューサーと悪いプロデューサーの両方を扱えます。

パターン 3: プロデューサーが協調する — 書いてから rename する

書き込み側を制御できるなら、何も検知する必要はありません。final.csv.tmp に書き、fsync し、閉じて、final.csv にリネームします。コンシューマーの FileSystemWatcherRenamed (または最終拡張子の Created) を観測して反応します。同じ NTFS または ext4 ボリュームでは、File.Move は原子的です: 宛先は完全なペイロードで存在するか、まったく存在しないかのいずれかです。

// .NET 11, C# 14 -- producer side
static async Task WriteAtomicallyAsync(
    string finalPath,
    Func<Stream, Task> writeBody,
    CancellationToken ct)
{
    var tmpPath = finalPath + ".tmp";

    await using (var fs = new FileStream(
        tmpPath,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true))
    {
        await writeBody(fs, ct);
        await fs.FlushAsync(ct);
        // FlushAsync flushes the .NET buffer; FlushToDisk forces fsync.
        // For most use cases FlushAsync + closing the handle is enough,
        // because Windows Cached Manager and the Linux page cache will
        // serialize the rename after the writes. If you must survive a
        // crash mid-write, also call:
        //   fs.Flush(flushToDisk: true);
    }

    // File.Move with overwrite=true uses MoveFileEx with MOVEFILE_REPLACE_EXISTING
    // on Windows and rename(2) on POSIX. Both are atomic on the same volume.
    File.Move(tmpPath, finalPath, overwrite: true);
}

2 つの非自明なルール:

これは Git が ref の更新に使うのと同じパターン、SQLite がジャーナルに使うのと同じパターン、原子的な設定リローダー (nginx、HAProxy) が使うのと同じパターンです。理由があります。プロデューサーを変更できるなら、これを採用して読むのを止めてください。

FileSystemWatcher への正しい接続

ハンドラは軽量で、キューに委ねるべきです。FileSystemWatcher はスレッドプールスレッド上でイベントを発火し、小さな内部バッファ (Windows ではデフォルト 8 KB) を持ちます。ハンドラ内でブロックするとバッファがあふれ、InternalBufferOverflowException を持つ Error イベントが発生し、イベントが静かに失われます。

// .NET 11, C# 14
using System.IO;
using System.Threading.Channels;

var channel = Channel.CreateUnbounded<string>(
    new UnboundedChannelOptions { SingleReader = true });

var watcher = new FileSystemWatcher(@"C:\inbox")
{
    Filter = "*.csv",
    NotifyFilter = NotifyFilters.FileName
                 | NotifyFilters.LastWrite
                 | NotifyFilters.Size,
    InternalBufferSize = 64 * 1024, // 64 KB, max is 64 KB on most platforms
};

watcher.Created += (_, e) => channel.Writer.TryWrite(e.FullPath);
watcher.Renamed += (_, e) => channel.Writer.TryWrite(e.FullPath);
watcher.EnableRaisingEvents = true;

// Dedicated consumer
_ = Task.Run(async () =>
{
    await foreach (var path in channel.Reader.ReadAllAsync())
    {
        if (path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) continue;
        if (!await WaitForStableSizeAsync(path, TimeSpan.FromMilliseconds(250), 2, default))
            continue;
        await using var fs = await WaitForFileAsync(path, TimeSpan.FromSeconds(30), default);
        if (fs is null) continue;
        await ProcessAsync(fs);
    }
});

このコードで人々が引っ掛かる 3 つの点:

ファイルがネットワーク共有上にある場合

SMB と NFS には独自のタイミングがあります。Windows での UNC パスへの FileSystemWatcher は共有に対して ReadDirectoryChangesW を使いますが、イベントは SMB リダイレクタによって統合されます。1 GB のファイルが連続的に書かれていても、Changed イベントは 1 分に 1 回しか見えないことがあります。パターン 1 と 2 は依然として機能しますが、pollInterval を 5-10 秒のオーダーに設定すべきです。100 ミリ秒ごとにリモートの FileInfo.Length をポーリングするとポーリングごとにメタデータラウンドトリップが発生し、リンクを飽和させます。

NFS はもっと厄介です: inotify は他のクライアントで行われた変更には発火せず、ローカルプロセスがローカルマウントに加えた変更にのみ発火します。コンシューマーがホスト A に、プロデューサーがホスト B にあって NFS 経由で書き込む場合、FileSystemWatcher は何も見えません。解決策はポーリングのみ — タイマー上で Directory.EnumerateFiles を実行し、各新規エントリにパターン 1 と 2 を適用します。ここで救ってくれるカーネルの通知パスはありません。

よくあるエッジケース

関連する読み物

出典

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る