更新履歴(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 です。ファイルやディレクトリの作成、変更、削除、名前変更をイベントで受け取れるので便利なのですが、Created や Changed をそのまま完了通知だと思って使うと、取りこぼし、重複通知、途中ファイルの誤読でかなり普通に事故ります。
この記事では、FileSystemWatcher の使い方と注意点を、主に Windows 上の .NET によるファイル連携を前提に整理します。あわせて、前提となる排他制御の考え方は ファイル連携の排他制御の基礎知識 - ファイルロックと原子的 claim のベストプラクティス も参照できる形にしています。
実際、ファイルのコピー中に Created が先に飛ぶことはありますし、Changed も 1 回で済むとは限りません。短時間に変更が集中すると内部バッファがあふれて、個別の変更を取りこぼすこともあります。
なので、設計の芯はこうです。
- 通知はきっかけ
- 真実はディレクトリ再スキャン
- 所有権は原子的な claim
- 最後は idempotency で受け止める
本文では、この考え方で FileSystemWatcher をファイル連携に組み込むときのはまりどころを順に見ていきます。
なお、この記事に登場するコードは、ビルド・実行できるサンプル一式(ライブラリ、一時ディレクトリ上で動くコンソールデモ、実際にファイルを作成・変更してイベントを検証するユニットテスト)として GitHub で公開しています。
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
対象読者と前提
Windows 上の .NET で、受信ディレクトリを監視してファイルを取り込む処理を書く開発者 に向けて書いています。コード例は C# / .NET 8 以降を前提にしていますが、考え方自体は言語を問いません。
この記事は、上にリンクした前回の記事(ファイル連携の排他制御)と同じ用語をそのまま使います。claim、idempotency、manifest、bundle あたりは 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. まず動く最小コード
FileSystemWatcherで起きる勘違いパターン(図)- 2.1.
Createdを完了通知だと思う - 2.2.
Changedの回数と順序を信じる - 2.3. 内部バッファあふれで変更を失う
- 2.1.
- アンチパターン
- 3.1. イベントハンドラの中でそのまま処理する
- 3.2. イベント列から真実の状態を復元しようとする
- 3.3.
Changedが止まったら完了扱い - 3.4.
InternalBufferSizeを上げれば解決したと思う - 3.5.
Errorをログだけ出して無視する
- ベストプラクティス
- 4.1. 通知は「再スキャン要求」に畳む
- 4.2. 完了条件は送信側で明示する
- 4.3. 受信側は claim を原子的に取る
- 4.4. startup / overflow / 再接続時は full rescan する
- 4.5. idempotency を前提にする
- 擬似コード(抜粋)
- 5.1. 典型的な失敗パターン
- 5.2. 正しい方向の例(雑に書くとこう)
- ざっくり使い分け
- まとめ
- 参考資料
この記事の知識マップ
この記事は、FileSystemWatcherのCreated/Changedイベントを完了通知として扱う誤りや、内部バッファのoverflowによる通知の取りこぼし、イベント列から状態を復元しようとする設計を避け、通知はすべて1種類の再スキャン要求に畳んでfull rescanで現物を確認する設計を提案する。完了は送信側がtemp->rename/done・manifestで明示し、受信側は再スキャンで見つけたready候補に対して原子的claimを取り、複数回見に行くことを前提にidempotencyで受け止める。常時起動できない、あるいは取りこぼしが許されない要件にはUSNチェンジジャーナルという選択肢もあると位置付けている。
flowchart LR
accTitle: FileSystemWatcher実務ガイドの知識マップ
accDescr: FileSystemWatcherの通知が完了通知ではなく変化の気配に過ぎないこと、通知を再スキャン要求へ畳んでfull rescanとclaimと組み合わせること、内部バッファのoverflowによる通知の取りこぼしとUSNチェンジジャーナルという代替の関係を示す図
filesystemwatcher["FileSystemWatcher"]
full_rescan["full rescan(ディレクトリの全体再走査)"]
buffer_overflow_event_loss["内部バッファのoverflowによる通知の取りこぼし"]
periodic_directory_listing["定期的なディレクトリ列挙"]
change_notification_loss["変更通知の取りこぼし"]
internal_buffer_size_tuning["InternalBufferSizeの調整"]
error_event_ignored_antipattern["Errorイベントをログだけ出して無視するアンチパターン"]
created_event_misinterpreted_as_complete["Createdを完了通知だと誤解する"]
partial_write_read["書き込み途中ファイルの読み込み事故"]
sender_side_completion_signaling["送信側での完了条件の明示"]
temp_then_rename_publish["temp -> close -> rename/replaceでの公開"]
done_manifest_file["done/manifestファイル"]
scan_request_coalescing["通知を再スキャン要求に畳む"]
changed_event_order_assumption["Changedの回数・順序を信じるアンチパターン"]
event_log_state_reconstruction_antipattern["イベント列から状態を復元しようとするアンチパターン"]
duplicate_processing["二重処理(二重計上・二重送信・更新の消失)"]
atomic_claim["原子的claim"]
bundle["bundle(連携単位のディレクトリ)"]
idempotent_processing["idempotency(冪等性)を前提にした処理"]
watcher_downtime_gap["watcher停止中の変更取りこぼし"]
usn_journal["USNジャーナル(変更ジャーナル)"]
ntfs["NTFS"]
admin_rights["管理者権限"]
filesystemwatcher -->|"原因になり得る"| buffer_overflow_event_loss
full_rescan -.->|"利用する"| periodic_directory_listing
full_rescan -->|"推奨される対応"| change_notification_loss
full_rescan -->|"推奨される対応"| buffer_overflow_event_loss
internal_buffer_size_tuning -->|"用いるのは非推奨"| buffer_overflow_event_loss
error_event_ignored_antipattern -.->|"原因になり得る"| change_notification_loss
filesystemwatcher -.->|"原因になり得る"| created_event_misinterpreted_as_complete
created_event_misinterpreted_as_complete -->|"原因になり得る"| partial_write_read
sender_side_completion_signaling -->|"推奨される対応"| created_event_misinterpreted_as_complete
sender_side_completion_signaling -->|"利用する"| temp_then_rename_publish
sender_side_completion_signaling -->|"利用する"| done_manifest_file
scan_request_coalescing -->|"推奨される対応"| changed_event_order_assumption
scan_request_coalescing -->|"推奨される対応"| event_log_state_reconstruction_antipattern
full_rescan -->|"推奨される対応"| event_log_state_reconstruction_antipattern
changed_event_order_assumption -->|"原因になり得る"| duplicate_processing
atomic_claim -->|"防止する"| duplicate_processing
bundle -->|"利用する"| atomic_claim
bundle -->|"利用する"| done_manifest_file
idempotent_processing -->|"推奨される対応"| duplicate_processing
full_rescan -->|"より先に行うべき"| atomic_claim
scan_request_coalescing -->|"より先に行うべき"| full_rescan
filesystemwatcher -->|"原因になり得る"| watcher_downtime_gap
full_rescan -->|"軽減する"| watcher_downtime_gap
usn_journal -->|"推奨される対応"| watcher_downtime_gap
usn_journal -->|"前提とする"| ntfs
usn_journal -.->|"前提とする"| admin_rights
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全26件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
1. まず結論(ひとことで)
FileSystemWatcherのイベントは、完了通知 ではなく 変化の気配 ですCreated/Changed/Renamedは、重複したり、想像と違う順序で来たり、overflow 時には取りこぼしたりします- イベントハンドラでは重い処理をせず、再スキャン要求を積む だけにした方が安定します
- 完了判定は
temp -> close -> rename / replaceやdone/ 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 が続くことがあります。
sequenceDiagram
participant 送信 as 送信側
participant 共有 as watched dir
participant W as FileSystemWatcher
participant 受信 as 受信側
送信->>共有: orders.csv を作成
共有-->>W: Created
W-->>受信: OnCreated
受信->>共有: orders.csv を開いて読む
Note over 受信: まだコピー途中
送信->>共有: 残りを書き込む
共有-->>W: Changed
共有-->>W: Changed
Note over 受信: 行数不足 / JSON破損 / ZIP破損
Created は「名前が見えた」を表しても、「もう読んでよい」を保証しません。
ここを同じ意味にすると、前回の記事の 2.1 を別ルートで踏むことになります。
2.2. Changed の回数と順序を信じる
Changed は 1 回だけ来るとは限りません。
移動や保存のような普通の操作でも、複数のイベントに分かれて見えることがあります。さらに、ウイルス対策ソフトやインデクサが触った分まで拾うこともあります。
sequenceDiagram
participant App as 保存するアプリ
participant Dir as watched dir
participant AV as AV / indexer
participant W as FileSystemWatcher
App->>Dir: report.xlsx を保存開始
Dir-->>W: Created
Dir-->>W: Changed
App->>Dir: 一時ファイルから rename
Dir-->>W: Renamed
Dir-->>W: Changed
AV->>Dir: スキャン / 属性参照
Dir-->>W: Changed
Note over W: 1回だけ・この順番とは限らない
「Changed が 1 回来たら完了」「Renamed の次はもう触られない」という期待は、だいぶ危ういです。
補足:
- ファイル rename で
Changedが飛ぶことがあります RenamedEventArgs.Nameは、OS 側で old/new の対応が取れないとnullになりえます- hidden file も無視されません。隠し temp 名だから見えないだろう、は通りません
- 監視しているディレクトリそのものを rename しても、その変更は通知されません
2.3. 内部バッファあふれで変更を失う
FileSystemWatcher には内部バッファがあります。
短時間に変更が集中すると、ここがあふれて個別通知を取りこぼします。
flowchart LR
A[短時間に大量の変更] --> B[内部バッファに通知が溜まる]
B --> C{処理が追いつく?}
C -- はい --> D[個別イベントを順に処理]
C -- いいえ --> E[overflow]
E --> F[Error イベント]
F --> G[個別履歴の完全性を信用しない]
G --> H[ディレクトリを 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 種類の信号に畳みます。
flowchart LR
A[Created / Changed / Deleted / Renamed] --> Q[scan request]
B[Error / overflow] --> Q
C[startup] --> Q
Q --> D[ディレクトリ再スキャン]
D --> E[ready な候補を列挙]
E --> F[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 を最後に置く
flowchart TD
A[data.tmp に全内容を書く] --> B[flush / close]
B --> C[data.csv に rename / replace]
C --> D[data.done / manifest.json を置く]
D --> E[受信側は final 名や done だけを見る]
前回の記事と同じですが、ここが本当に効きます。
FileSystemWatcher は完了を発明する道具ではなく、明示された完了を早めに見つける道具と捉えるのがしっくりきます。
4.3. 受信側は claim を原子的に取る
再スキャンで ready な候補が見つかっても、そのまま読みに行くと複数ワーカーが同時に掴めます。 なので、処理前に claim を原子的に取ります。
sequenceDiagram
participant Scan as scanner
participant IN as incoming
participant P1 as processing/worker1
participant P2 as processing/worker2
Scan->>IN: order-123 を発見
Scan->>P1: rename order-123
Scan->>P2: rename order-123
Note over P1,P2: 先に成功した方だけが所有権を持つ
前回の記事でも触れた通り、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 / replace や done / manifest で明示し、所有権は claim を原子的に取って決める。設計の本体はここにあります。
Created で即処理する、Changed の回数や順序を信じる、Changed が止まったら完了扱いにする、InternalBufferSize だけで安心する、Error を見たのに回復しない――どれも避けたい設計です。代わりに、通知は再スキャン要求に畳み、startup / overflow / 再接続では full rescan を入れ、claim rename で所有権を取り、重複と再走査は idempotency で受け止めます。
つまり、FileSystemWatcher では「イベントを受けたこと」と「処理してよいこと」を同じにしないのがコツです。
ここを分けるだけで、たまにだけ壊れるタイプの監視処理がかなり減ります。
8. 参考資料
- この記事のサンプルコード一式(ライブラリ、デモ、ユニットテスト) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/filesystemwatcher-safe-basics
- 関連記事: ファイル連携の排他制御の基礎知識 - ファイルロックと原子的 claim のベストプラクティス
- FileSystemWatcher Class (System.IO)
- System.IO.FileSystemWatcher class - .NET
- FileSystemWatcher.InternalBufferSize Property (System.IO)
- FileSystemWatcher.NotifyFilter Property (System.IO)
- FileSystemWatcher.Error Event (System.IO)
- FileSystemWatcher.Created Event (System.IO)
- FileSystemWatcher.Changed Event (System.IO)
- FileSystemWatcher.Renamed Event (System.IO)
- Change Journals - Win32 apps
- Creating, Modifying, and Deleting a Change Journal - Win32 apps
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
業務システムのコード設計 ── 商品コード・顧客コードの決め方とチェックディジット
商品コード・顧客コードなど業務システムのコード体系を決める実践ガイド。有意コードと無意味連番の判断表、JAN・Luhn等のチェックディジット算式とC#実装、Excelの0落ち対策、桁あふれと移行まで整理します。
.NET Generic HostとBackgroundServiceをデスクトップアプリで使う理由
Windows ツールや常駐アプリで起動、定期処理、終了処理、ログ、設定、DIを整理するために、Generic HostとBackgroundServiceをどう使うかまとめます。
マルチスレッドの実務ベストプラクティス .NET編 ── スレッドを増やす前に決めておくこと
「スレッドを立てたら、たまに落ちる・固まる」を防ぐ設計の定石を.NET/C#向けに整理。スレッドを自分で作らずTaskに乗る、共有可変状態を減らす、ロックの規律、CancellationTokenでの停止設計、UIスレッドの扱いまで解説します。
WMI/CIMをC#・PowerShellから使う ── ハードウェア情報取得・プロセス監視・リモート照会の実務ガイド
PCのシリアル番号取得、ディスク空き監視、プロセス起動検知の定番がWMI/CIMです。Get-CimInstance等のCIMコマンドレットの使い方と旧Get-WmiObjectからの移行、C#のSystem.ManagementとCIM APIの使い分け、実例レシピと落と...
業務アプリのDBスキーマをバージョン管理する ── 「客先ごとにDBが違う」を防ぐマイグレーションの実践
客先ごとに分散する業務アプリのDBスキーマをバージョン管理する実践ガイド。PRAGMA user_versionと前進マイグレーションのC#実装、EF Core Migrations・DbUp・自前実装の判断表、2段階リリースまで整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
FileSystemWatcher を使うファイル連携や監視ツールは、Windowsアプリ開発 の中でも実務でよく出るテーマです。
技術相談・設計レビュー
取りこぼし対策、再スキャン、完了判定を設計として整理したい場合は、技術相談・設計レビューとして相性がよいです。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- 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を入れるのが順序です。