FileSystemWatcher実務ガイド - 取りこぼしと重複対策

· 更新日: · · FileSystemWatcher, C#, .NET, Windows開発, ファイル連携, 設計

更新履歴(3件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
冒頭に対象読者と前提、用語表を追加し、まず動く最小コードの節を新設しました。デバウンス時間の「100〜300ms」を運用経験からの初期値と明記し、走査時間と許容検知遅れから決める調整表を追加しました。USNチェンジジャーナルとの対比表も追加しています。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589603)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「FileSystemWatcher実務ガイド - 取りこぼしと重複対策」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589603 https://staging.comcomponent.com/blog/2026/03/10/000-filesystemwatcher-safe-basics/

DOI(最新版)
10.5281/zenodo.21589603
DOI(この版)
10.5281/zenodo.21732628

FileSystemWatcher は、Windows 上の .NET でファイル変更を監視するときにまず候補になる API です。ファイルやディレクトリの作成、変更、削除、名前変更をイベントで受け取れるので便利なのですが、CreatedChanged をそのまま完了通知だと思って使うと、取りこぼし、重複通知、途中ファイルの誤読でかなり普通に事故ります。

この記事では、FileSystemWatcher の使い方と注意点を、主に Windows 上の .NET によるファイル連携を前提に整理します。あわせて、前提となる排他制御の考え方は ファイル連携の排他制御の基礎知識 - ファイルロックと原子的 claim のベストプラクティス も参照できる形にしています。

実際、ファイルのコピー中に Created が先に飛ぶことはありますし、Changed も 1 回で済むとは限りません。短時間に変更が集中すると内部バッファがあふれて、個別の変更を取りこぼすこともあります。

なので、設計の芯はこうです。

  • 通知はきっかけ
  • 真実はディレクトリ再スキャン
  • 所有権は原子的な claim
  • 最後は idempotency で受け止める

本文では、この考え方で FileSystemWatcher をファイル連携に組み込むときのはまりどころを順に見ていきます。

なお、この記事に登場するコードは、ビルド・実行できるサンプル一式(ライブラリ、一時ディレクトリ上で動くコンソールデモ、実際にファイルを作成・変更してイベントを検証するユニットテスト)として GitHub で公開しています。

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

対象読者と前提

Windows 上の .NET で、受信ディレクトリを監視してファイルを取り込む処理を書く開発者 に向けて書いています。コード例は C# / .NET 8 以降を前提にしていますが、考え方自体は言語を問いません。

この記事は、上にリンクした前回の記事(ファイル連携の排他制御)と同じ用語をそのまま使います。claimidempotencymanifestbundle あたりは 4 章以降で説明なしに出てくるので、前回を読んでいなくても追えるよう、先に 1 行ずつまとめておきます。

先に押さえておく用語

用語 意味
claim 「このファイルは自分が処理する」という所有権を、他のワーカーに割り込まれない形で取ることです。実装としては incoming/ から processing/<worker>/ への rename を使い、rename に成功した 1 プロセスだけが所有者になります(4.3)
idempotency(冪等性) 同じ対象を 2 回以上処理しても、結果が 1 回処理したときと同じになる性質です。重複通知や再走査を前提にする以上、最後はここで受け止めることになります(4.5)
manifest 本体データと一緒に置く、中身を説明する小さなファイルです。件数、ハッシュ、IdempotencyKey などを入れておくと、受信側が「これは処理済みか」を判断できます
bundle 1 件分の連携をひとまとめにした単位です。本体 + manifest + 補助ファイルを 1 つのディレクトリに入れておくと、そのディレクトリごと 1 回の rename で claim できます(4.3)
full rescan イベントを当てにせず、監視対象ディレクトリを一から列挙し直して、処理してよい対象を洗い直すことです(4.4)
overflow FileSystemWatcher の内部バッファがあふれ、個別の通知を失うことです。Error イベントで通知されます(2.3)
ready 「もう読んでよい」と判断できた状態です。推測ではなく、final 名や done / manifest の存在で判定します(4.2)

目次

  1. まず結論(ひとことで)
    • 1.1. まず動く最小コード
  2. FileSystemWatcher で起きる勘違いパターン(図)
    • 2.1. Created を完了通知だと思う
    • 2.2. Changed の回数と順序を信じる
    • 2.3. 内部バッファあふれで変更を失う
  3. アンチパターン
    • 3.1. イベントハンドラの中でそのまま処理する
    • 3.2. イベント列から真実の状態を復元しようとする
    • 3.3. Changed が止まったら完了扱い
    • 3.4. InternalBufferSize を上げれば解決したと思う
    • 3.5. Error をログだけ出して無視する
  4. ベストプラクティス
    • 4.1. 通知は「再スキャン要求」に畳む
    • 4.2. 完了条件は送信側で明示する
    • 4.3. 受信側は claim を原子的に取る
    • 4.4. startup / overflow / 再接続時は full rescan する
    • 4.5. idempotency を前提にする
  5. 擬似コード(抜粋)
    • 5.1. 典型的な失敗パターン
    • 5.2. 正しい方向の例(雑に書くとこう)
  6. ざっくり使い分け
  7. まとめ
  8. 参考資料

この記事の知識マップ

この記事は、FileSystemWatcherのCreated/Changedイベントを完了通知として扱う誤りや、内部バッファのoverflowによる通知の取りこぼし、イベント列から状態を復元しようとする設計を避け、通知はすべて1種類の再スキャン要求に畳んでfull rescanで現物を確認する設計を提案する。完了は送信側がtemp->rename/done・manifestで明示し、受信側は再スキャンで見つけたready候補に対して原子的claimを取り、複数回見に行くことを前提にidempotencyで受け止める。常時起動できない、あるいは取りこぼしが許されない要件にはUSNチェンジジャーナルという選択肢もあると位置付けている。

FileSystemWatcher実務ガイドの知識マップFileSystemWatcherの通知が完了通知ではなく変化の気配に過ぎないこと、通知を再スキャン要求へ畳んでfull rescanとclaimと組み合わせること、内部バッファのoverflowによる通知の取りこぼしとUSNチェンジジャーナルという代替の関係を示す図原因になり得る利用する推奨される対応推奨される対応用いるのは非推奨原因になり得る原因になり得る原因になり得る推奨される対応利用する利用する推奨される対応推奨される対応推奨される対応原因になり得る防止する利用する利用する推奨される対応より先に行うべきより先に行うべき原因になり得る軽減する推奨される対応前提とする前提とするFileSystemWatcherfull rescan(ディレクトリの全体再走査)内部バッファのoverflowによる通知の取りこぼし定期的なディレクトリ列挙変更通知の取りこぼしInternalBufferSizeの調整Errorイベントをログだけ出して無視するアンチパターンCreatedを完了通知だと誤解する書き込み途中ファイルの読み込み事故送信側での完了条件の明示temp -> close -> rename/replaceでの公開done/manifestファイル通知を再スキャン要求に畳むChangedの回数・順序を信じるアンチパターンイベント列から状態を復元しようとするアンチパターン二重処理(二重計上・二重送信・更新の消失)原子的claimbundle(連携単位のディレクトリ)idempotency(冪等性)を前提にした処理watcher停止中の変更取りこぼしUSNジャーナル(変更ジャーナル)NTFS管理者権限

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全26件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

1. まず結論(ひとことで)

  • FileSystemWatcher のイベントは、完了通知 ではなく 変化の気配 です
  • Created / Changed / Renamed は、重複したり、想像と違う順序で来たり、overflow 時には取りこぼしたりします
  • イベントハンドラでは重い処理をせず、再スキャン要求を積む だけにした方が安定します
  • 完了判定は temp -> close -> rename / replacedone / manifest で 明示 するのが基本です
  • 複数ワーカーがいるなら、読む前に claim を原子的に取る 必要があります
  • InternalBufferSize の調整は補助です。最後は full rescan と idempotency が効きます

要するに、FileSystemWatcher を「真実の履歴ストリーム」として扱わないことです。 通知はあくまで、「そろそろ見に行け」の合図に留めた方が壊れにくくなります。

1.1. まず動く最小コード

FileSystemWatcher をまだ触ったことがない方向けに、正常系だけの最小形を置いておきます。ここから先の章は、この 10 行が「動いてしまう」ところから始まる落とし穴の話です。

// C# / .NET 8 コンソールアプリ。通知が届くことを確認するだけの最小形
using System.IO;

using var watcher = new FileSystemWatcher(@"C:\incoming")
{
    Filter = "*.csv",
    NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite,
};

watcher.Created += (_, e) => Console.WriteLine($"Created: {e.FullPath}");
watcher.Changed += (_, e) => Console.WriteLine($"Changed: {e.FullPath}");
watcher.Renamed += (_, e) => Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
watcher.Error += (_, e) => Console.WriteLine($"Error: {e.GetException().Message}");

watcher.EnableRaisingEvents = true; // ここで監視が始まる
Console.WriteLine("Enter キーで終了します");
Console.ReadLine();

最小形でも、次の 3 点は最初に押さえておくと迷いません。

  • EnableRaisingEvents = true にするまでイベントは 1 つも来ません。ハンドラを登録しただけでは動きません
  • watcher の寿命がアプリの寿命です。ローカル変数のスコープが切れて破棄されると、そこで通知は止まります。常駐させるなら、フィールドなど生き続ける場所に持ちます
  • NotifyFilter の既定値は LastWrite | FileName | DirectoryName の組み合わせです(8. 参考資料の FileSystemWatcher.NotifyFilter Property)。何を拾うかは明示しておいた方が、後から読み返したときに迷いません

そして重要なのは、このコードは「イベントが届くこと」しか確認していない ことです。Created の時点でファイルを読んでよいかも、通知を取りこぼしていないかも、この形では分かりません。ここから先が本題です。

2. FileSystemWatcher の使い方で起きやすい勘違いパターン(図)

2.1. Created を完了通知だと思う

これがいちばん分かりやすい地雷です。 コピーや転送では、ファイルが 作られた瞬間 に Created が飛び、そのあとに 1 回以上の Changed が続くことがあります。

受信側FileSystemWatcherwatched dir送信側受信側FileSystemWatcherwatched dir送信側まだコピー途中行数不足 / JSON破損 / ZIP破損orders.csv を作成CreatedOnCreatedorders.csv を開いて読む残りを書き込むChangedChanged

Created は「名前が見えた」を表しても、「もう読んでよい」を保証しません。 ここを同じ意味にすると、前回の記事の 2.1 を別ルートで踏むことになります。

2.2. Changed の回数と順序を信じる

Changed は 1 回だけ来るとは限りません。 移動や保存のような普通の操作でも、複数のイベントに分かれて見えることがあります。さらに、ウイルス対策ソフトやインデクサが触った分まで拾うこともあります。

FileSystemWatcherAV / indexerwatched dir保存するアプリFileSystemWatcherAV / indexerwatched dir保存するアプリ1回だけ・この順番とは限らないreport.xlsx を保存開始CreatedChanged一時ファイルから renameRenamedChangedスキャン / 属性参照Changed

Changed が 1 回来たら完了」「Renamed の次はもう触られない」という期待は、だいぶ危ういです。

補足:

  • ファイル rename で Changed が飛ぶことがあります
  • RenamedEventArgs.Name は、OS 側で old/new の対応が取れないと null になりえます
  • hidden file も無視されません。隠し temp 名だから見えないだろう、は通りません
  • 監視しているディレクトリそのものを rename しても、その変更は通知されません

2.3. 内部バッファあふれで変更を失う

FileSystemWatcher には内部バッファがあります。 短時間に変更が集中すると、ここがあふれて個別通知を取りこぼします。

はいいいえ短時間に大量の変更内部バッファに通知が溜まる処理が追いつく?個別イベントを順に処理overflowError イベント個別履歴の完全性を信用しないディレクトリを full rescan

ここで大事なのは、「overflow が起きたら 1 件だけ失う」とは限らないことです。 個別イベント列の完全性そのものが怪しくなるので、素直に全体を見直したほうがよいです。

3. アンチパターン

3.1. イベントハンドラの中でそのまま処理する

これは、完了判定と所有権取得をイベントに背負わせすぎです。

watcher.Created += (_, e) =>
{
    using var stream = File.OpenRead(e.FullPath);
    Import(stream); // まだコピー中かもしれない
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException()); // 出すだけ
};

問題は 2 つあります。

  • Created の時点では内容が未完成かもしれない
  • 失敗や overflow の回復が無い

イベントハンドラは、再スキャン要求を立ててすぐ返す くらいがちょうどよいです。 ここで重い I/O や DB 更新まで始めると、バースト時に自分で自分の首を絞めます。

3.2. イベント列から真実の状態を復元しようとする

Created で辞書に追加、Changed で更新、Deleted で削除、Renamed でキー差し替え」という設計は、一見きれいです。 ただ、重複、分割、overflow、外乱が入ると、だんだん辻褄が怪しくなります。

switch (e.ChangeType)
{
    case WatcherChangeTypes.Created:
        state[e.FullPath] = Pending;
        break;
    case WatcherChangeTypes.Changed:
        state[e.FullPath] = Modified;
        break;
    case WatcherChangeTypes.Deleted:
        state.Remove(e.FullPath);
        break;
}

この方向で頑張るより、その都度 ディスク上の現物 を再確認した方が強いです。ファイル連携で重要なのは、今この瞬間に処理してよい対象を正しく見つけることであって、イベント履歴をきれいに再現することではないからです。

3.3. Changed が止まったら完了扱い

前回の「ファイルサイズが止まったら完了」と同じ匂いのする設計です。 便利そうですが、推測で完了を決めています。

if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
    return Ready;
}

これで困るのは、たとえばこういうケースです。

  • 大きいファイルのコピーが途中で一時停止する
  • 送信側アプリが複数段階で保存する
  • ネットワーク共有で通知が遅れて見える
  • 外部プロセスがあとから属性や時刻を書き換える

完了は 推測 ではなく 明示 した方が安定します。

3.4. InternalBufferSize を上げれば解決したと思う

InternalBufferSize の調整は大事ですが、これは設計の本体ではありません。

  • 既定値は 8192 バイト
  • 4096 バイト未満にはできず、64 KB を超えることもできない
  • バッファは non-paged memory を使うので、増やせば増やすほど気軽とは言えない

つまり、64 KB まで上げても、通知バーストがそれを超えたら終わりです。 しかも、完了通知かどうかの問題は 1 ミリも解決しません。

バッファを増やす前に、まず手を付けるべきことがあります。

  • Filter / Filters で監視対象を絞る
  • NotifyFilter を必要最小限にする
  • IncludeSubdirectories をむやみに true にしない
  • イベントハンドラを軽くする
  • full rescan と idempotency を入れる

3.5. Error をログだけ出して無視する

Error は「たまに出るけど気にしない」種類の通知ではありません。 buffer overflow や、監視継続に失敗した状況がここに出ます。

watcher.Error += (_, e) =>
{
    _logger.LogError(e.GetException(), "watcher error");
    // ここで終わると、取りこぼしに気づいたのに回復しない
};

最低限、ここまではやっておきたいところです。

  • full rescan を要求する
  • 監視継続が怪しいなら watcher の再生成も検討する
  • 取りこぼし前提で idempotent に再処理できるようにする

4. ベストプラクティス

4.1. 通知は「再スキャン要求」に畳む

Created / Changed / Deleted / Renamed / Error を、それぞれ別々の業務処理に直結させると見通しが悪くなります。まずは全部「見に行け」という 1 種類の信号に畳みます。

Created / Changed / Deleted / Renamedscan requestError / overflowstartupディレクトリ再スキャンready な候補を列挙claim を試す

実装上のポイント:

  • イベントハンドラでは dirty = true にして signal を出す程度にする
  • 走査は 1 本の worker に寄せる
  • バースト時は 100〜300ms ほどまとめてから 1 回走査する
  • 走査中に追加通知が来たら、終わったあとにもう 1 回走査する

3 つ目の 100〜300ms という値は、規格や公式ドキュメントに根拠のある数字ではなく、筆者の運用経験からの初期値 です。実際には、次の 2 つを測ってから決めた方が確実です。

見るもの 決め方
1 回の走査にかかる時間 待ち時間がこれより短いと、走査が終わる前に次の走査要求が溜まるだけになります。走査時間と同程度以上を下限の目安にします
許容できる検知遅れ 待ち時間はそのまま検知の遅れになります。「置かれてから n 秒以内に処理」という要件があるなら、その一部で収まる範囲に上限を置きます

たとえば、1 回の走査が 50ms で終わり、検知は 1 秒以内でよいなら、この 100〜300ms はちょうど収まります。逆に、ファイル数が多くて 1 回の走査に数秒かかるなら、待ち時間を伸ばすより先に走査の作り(対象の絞り込み、done だけを見る、サブディレクトリを分ける)を見直した方が効きます。

こうすると、イベントが 5 回来ても 50 回来ても、最終的にやることは「現物を見て ready なものを探す」に統一できます。

4.2. 完了条件は送信側で明示する

自分が送信側も握れるなら、FileSystemWatcher 側で完了判定を頑張るより、公開プロトコルを直した方が効きます。

王道は、やはりこれです。

  • temp 名に全内容を書く
  • close する
  • 同一ファイルシステム上で rename / replace する
  • 必要なら done / manifest を最後に置く
data.tmp に全内容を書くflush / closedata.csv に rename / replacedata.done / manifest.json を置く受信側は final 名や done だけを見る

前回の記事と同じですが、ここが本当に効きます。 FileSystemWatcher は完了を発明する道具ではなく、明示された完了を早めに見つける道具と捉えるのがしっくりきます。

4.3. 受信側は claim を原子的に取る

再スキャンで ready な候補が見つかっても、そのまま読みに行くと複数ワーカーが同時に掴めます。 なので、処理前に claim を原子的に取ります。

processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscanner先に成功した方だけが所有権を持つorder-123 を発見rename order-123rename order-123

前回の記事でも触れた通り、incoming -> processing/<worker>/ の rename が分かりやすいです。 特に 本体 + manifest + 補助ファイル を 1 つのディレクトリにまとめておくと、bundle 単位で claim できるので楽です。

incoming/
  order-123/
    payload.csv
    manifest.json

これなら、bundle directory を 1 回 rename するだけで所有権を取れます。

4.4. startup / overflow / 再接続時は full rescan する

これはかなり大事です。

  • アプリ起動前から置かれていたファイルは、イベントでは拾えません
  • overflow が起きたら、個別イベント列は信用しにくくなります
  • ネットワーク共有や一時切断が絡むと、「その間の何か」が抜ける前提で見た方が安全です

なので、少なくとも次のタイミングでは full rescan を入れた方がよいです。

  • 起動時
  • Error 受信時
  • watcher を作り直した直後
  • 定期的な保険として一定間隔ごと

ここでの思想は、「watcher は差分のヒント、再スキャンは整合性の回復」です。

4.5. idempotency を前提にする

FileSystemWatcher を使うと、同じ対象を複数回見に行くことになります。 これはバグではなく、設計として受け入れた方が安定します。

具体的には、こんな具合です。

  • manifest に IdempotencyKey を入れる
  • すでに処理済みなら副作用を再実行しない
  • archive 済み / DB 記録済み / 送信済み を照合できるようにする
  • full rescan しても「同じものをもう一度安全に見る」だけにする

exactly-once をイベントだけで作ろうとすると、だいぶ苦しくなります。 at-least-once を受け入れて、最後を idempotency で締めた方が実務では強いです。

5. 擬似コード(抜粋)

5.1. 典型的な失敗パターン

using var watcher = new FileSystemWatcher(incomingDir)
{
    Filter = "*.csv",
    IncludeSubdirectories = false,
    EnableRaisingEvents = true,
    InternalBufferSize = 64 * 1024
};

watcher.Created += (_, e) =>
{
    // Created = 完了通知、と思い込んでいる
    ProcessFile(e.FullPath);
};

watcher.Changed += (_, e) =>
{
    // 何度も来るので、とりあえずもう一回処理
    ProcessFile(e.FullPath);
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException());
    // 回復しない
};

問題点は 4 つあります。

  • Created / Changed をそのまま業務処理に結びつけている
  • 完了判定が無い
  • overflow 時に full rescan しない
  • 同じファイルを何回処理しても止める仕組みが無い

5.2. 正しい方向の例(雑に書くとこう)

private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;

void OnAnyChange(object? sender, FileSystemEventArgs e)
{
    RequestScan(full: false);
}

void OnRenamed(object? sender, RenamedEventArgs e)
{
    RequestScan(full: false);
}

void OnError(object? sender, ErrorEventArgs e)
{
    Log(e.GetException());
    RequestScan(full: true);
}

void RequestScan(bool full)
{
    if (full)
    {
        Interlocked.Exchange(ref _fullRescanRequested, 1);
    }

    if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
    {
        _scanSignal.Release();
    }
}

async Task ScannerLoopAsync(CancellationToken cancellationToken)
{
    RequestScan(full: true); // startup scan

    while (!cancellationToken.IsCancellationRequested)
    {
        await _scanSignal.WaitAsync(cancellationToken);

        // 通知バーストを少しまとめる
        await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);

        Interlocked.Exchange(ref _scanRequested, 0);
        bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;

        foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
        {
            var claimedPath = Path.Combine(processingDir, bundle.Name);

            if (!TryClaimByRename(bundle.Path, claimedPath))
            {
                continue; // 他ワーカーが先に取得
            }

            var manifest = ReadManifest(Path.Combine(claimedPath, "manifest.json"));

            if (AlreadyProcessed(manifest.IdempotencyKey))
            {
                MoveToArchive(claimedPath, archiveDir);
                continue;
            }

            ProcessBundle(claimedPath);
            RecordProcessed(manifest.IdempotencyKey);
            MoveToArchive(claimedPath, archiveDir);
        }

        if (Volatile.Read(ref _scanRequested) == 1)
        {
            _scanSignal.Release(); // 走査中に来た通知を取りこぼさない
        }
    }
}

この例で大事なのは、細かい API ではなく流れです。

  • 通知は scan request に畳む
  • 走査で ready を見つける
  • claim を取る
  • idempotency を確認する
  • 処理して記録し、archive に動かす

FileSystemWatcher のイベントは、ここでは trigger でしかありません。

なお、EnumerateReadyBundles / TryClaimByRename / ReadManifest / AlreadyProcessed などは、流れを見せるためにこの記事で名前を付けた関数で、.NET の標準 API ではありません。実際にビルドして動く形(ライブラリ、一時ディレクトリ上で動くコンソールデモ、イベントを検証するユニットテスト)は、冒頭にも挙げたサンプル一式に置いてあります。

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

6. ざっくり使い分け

  • 単一受信ワーカー / 自分で送信側も直せる まずは temp -> close -> rename と startup scan。これだけでもかなり安定します。

  • 複数受信ワーカーがいる 上に加えて incoming -> processing の claim rename を入れた方がよいです。

  • 高頻度で通知が多い Filter / NotifyFilter / IncludeSubdirectories を絞り、イベントハンドラを極小化します。InternalBufferSize の調整はそのあとです。

  • overflow して困る / 取りこぼしが許されない full rescan を前提にし、それでも厳しいなら FileSystemWatcher 単体に賭けない方がよいです。Windows 限定なら USN change journal も選択肢になります。

  • 相手システムの書き方を制御できない 完了条件を推測で補うより、公開プロトコルを交渉できないかを先に考えた方が安全です。無理なら、保証水準を下げた上で idempotent に受ける設計に寄せます。

最後の 2 項目は、わりと大事な撤退判断です。 FileSystemWatcher は便利ですが、万能の真実検出器ではありません。

USN change journal は何が違うのか

USN change journal は、NTFS が ボリューム単位で持っている変更の記録 です。FileSystemWatcher のようなディレクトリ通知は、変更が起きた瞬間にアプリが動いていないと受け取れませんが、change journal はボリューム側に記録が残るので、アプリが止まっていた間の変更も、前回読んだ位置(USN)から後で読み直せます。Microsoft のドキュメントでも、ディレクトリ通知の弱点として「アプリを常時動かしておく必要がある」ことが挙げられ、その回避策として change journal が説明されています。

一方で、負担も増えます。

  FileSystemWatcher USN change journal
監視の単位 指定したディレクトリ(+ サブディレクトリ) ボリューム全体。必要な範囲は自分で絞り込む
アプリが止まっていた間 分からない。full rescan で埋める 記録から読み直せる
取りこぼし 内部バッファの overflow で起きる ジャーナルの上限を超えると古い記録から消える
必要なもの .NET の API だけ ボリュームハンドルと FSCTL_* の呼び出し。ジャーナルの作成・削除など管理操作には管理者権限が必要

つまり、「常時起動できない」「停止中の変更も拾いたい」が要件に入ってきたときの選択肢です。逆にそこが要らないなら、FileSystemWatcher + full rescan の方が実装は素直です。

7. まとめ

FileSystemWatcher は完了通知の代わりにはなりません。真実はイベント列ではなく、いまディスク上に見えている状態にあります。完了は temp -> close -> rename / replacedone / manifest で明示し、所有権は claim を原子的に取って決める。設計の本体はここにあります。

Created で即処理する、Changed の回数や順序を信じる、Changed が止まったら完了扱いにする、InternalBufferSize だけで安心する、Error を見たのに回復しない――どれも避けたい設計です。代わりに、通知は再スキャン要求に畳み、startup / overflow / 再接続では full rescan を入れ、claim rename で所有権を取り、重複と再走査は idempotency で受け止めます。

つまり、FileSystemWatcher では「イベントを受けたこと」と「処理してよいこと」を同じにしないのがコツです。 ここを分けるだけで、たまにだけ壊れるタイプの監視処理がかなり減ります。

8. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

FileSystemWatcherのCreatedイベントでファイルを読んでよいですか?
だめです。Createdは「名前が見えた」を表すだけで、「もう読んでよい」を保証しません。コピーや転送ではファイルが作られた瞬間にCreatedが飛び、そのあとに1回以上のChangedが続くことがあります。完了は送信側が temp -> close -> rename/replace や done/manifest で明示し、受信側はfinal名やdoneだけを見るのが基本です。
FileSystemWatcherで通知を取りこぼすことはありますか?
あります。内部バッファ(既定8192バイト、4096バイト未満にできず64KBが上限)があふれると個別通知を取りこぼし、Errorイベントが発生します。overflowが起きたら個別イベント列の完全性そのものが怪しくなるため、ディレクトリをfull rescanして全体を見直すのが安全です。起動時、Error受信時、watcher再生成直後、定期的な保険としてもfull rescanを入れるべきです。
Changedイベントが何度も来るのはなぜですか?
移動や保存のような普通の操作でも複数のイベントに分かれて見えることがあり、さらにウイルス対策ソフトやインデクサが触った分まで拾うためです。回数や順序を信じる設計は危険です。通知は「再スキャン要求」という1種類の信号に畳み、走査は1本のworkerに寄せ、バースト時は100〜300msほどまとめてから1回走査するのが安定します。
InternalBufferSizeを増やせば取りこぼしは解決しますか?
解決しません。64KBまで上げても通知バーストがそれを超えたら取りこぼしますし、完了通知かどうかの問題は何も解決しません。バッファは non-paged memory を使うため増やすほど気軽とも言えません。先にFilter/NotifyFilterで監視対象を絞り、IncludeSubdirectoriesを見直し、イベントハンドラを軽くし、full rescanとidempotencyを入れるのが順序です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る