更新履歴(4件・最終更新 2026年08月22日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 設計の芯や再スキャンの流れを図でも追えるように、Mermaid図を9点追加しました(地の文500〜750字につき1図の規約に合わせたものです)。既存の図には通し番号付きのキャプションを付けました。本文の文章は変えていません。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- 冒頭に対象読者と前提、用語表を追加し、まず動く最小コードの節を新設しました。デバウンス時間の「100〜300ms」を運用経験からの初期値と明記し、走査時間と許容検知遅れから決める調整表を追加しました。USNチェンジジャーナルとの対比表も追加しています。
- 初版公開
この記事を引用する(DOI(登録済みアーカイブ): 10.5281/zenodo.21589603)
以下のDOIは過去に登録されたアーカイブを指しており、現在の本文とは一致しない場合があります。現在の本文を参照するときは、このページのURLを使用してください。
小村 豪(2026)「FileSystemWatcher実務ガイド - 取りこぼしと重複対策」合同会社小村ソフト. https://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. 正しい方向の例(雑に書くとこう)
- ざっくり使い分け
- まとめ
- 参考資料
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全26件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
1. まず結論(ひとことで)
FileSystemWatcherのイベントは、完了通知 ではなく 変化の気配 ですCreated/Changed/Renamedは、重複したり、想像と違う順序で来たり、overflow 時には取りこぼしたりします- イベントハンドラでは重い処理をせず、再スキャン要求を積む だけにした方が安定します
- 完了判定は
temp -> close -> rename / replaceやdone/ manifest で 明示 するのが基本です - 複数ワーカーがいるなら、読む前に claim を原子的に取る 必要があります
InternalBufferSizeの調整は補助です。最後は full rescan と idempotency が効きます
要するに、FileSystemWatcher を「真実の履歴ストリーム」として扱わないことです。
通知はあくまで、「そろそろ見に行け」の合図に留めた方が壊れにくくなります。
flowchart TB
accTitle: この記事の設計の芯
accDescr: 通知はきっかけにとどめ、真実はディレクトリ再スキャンで確かめ、所有権は原子的なclaimで取り、最後はidempotencyで重複を受け止めるという、この記事の設計の芯を示す。
notif["通知はきっかけ"] --> rescan["真実はディレクトリ再スキャン"]
rescan --> claim["所有権は原子的なclaim"]
claim --> idem["最後はidempotencyで受け止める"]
図1: 設計の芯。イベントを真実の履歴として扱わず、「そろそろ見に行け」の合図に留める。
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破損
図2: コピー途中でもCreatedは飛ぶ。届いた時点で読みに行くと、壊れたデータを掴む。
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回だけ・この順番とは限らない
図3: 普通の保存でもイベントは複数に分かれ、外部プロセスが触った分まで混ざる。回数も順序も当てにならない。
「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]
図4: 通知バーストが内部バッファを超えるとoverflowし、個別イベント列の完全性が崩れる。
ここで大事なのは、「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 更新まで始めると、バースト時に自分で自分の首を絞めます。
flowchart TB
accTitle: イベントハンドラの重さの分かれ目
accDescr: イベントハンドラでは再スキャン要求を立ててすぐ返すのがよく、ハンドラの中で重いI/OやDB更新まで始めると、内容が未完成のまま読む危険とバースト時に処理が追いつかなくなる問題を抱えることを示す。
ev["イベントハンドラ"] --> light["再スキャン要求を立ててすぐ返す"]
heavy["ハンドラ内で重いI/OやDB更新"] -.-> raw["未完成の内容を読む危険"]
heavy -.-> choke["バースト時に処理が追いつかない"]
図5: ハンドラは軽く。完了判定と所有権取得をイベントに背負わせない。
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;
}
この方向で頑張るより、その都度 ディスク上の現物 を再確認した方が強いです。ファイル連携で重要なのは、今この瞬間に処理してよい対象を正しく見つけることであって、イベント履歴をきれいに再現することではないからです。
flowchart TB
accTitle: イベント復元と現物確認の対比
accDescr: イベント列から状態を復元する設計は重複や分割やoverflowや外乱で辻褄が崩れるため、その都度ディスク上の現物を再確認して、今この瞬間に処理してよい対象を正しく見つける方が強いことを示す。
ev2["イベント列から状態を復元する"] -.-> broke["重複・分割・overflowで辻褄が崩れる"]
disk["その都度ディスク上の現物を確認"] --> goal["処理してよい対象を正しく見つける"]
図6: 目的はイベント履歴の再現ではなく、いま処理してよい対象を見つけること。
3.3. Changed が止まったら完了扱い
前回の「ファイルサイズが止まったら完了」と同じ匂いのする設計です。 便利そうですが、推測で完了を決めています。
if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
return Ready;
}
これで困るのは、たとえばこういうケースです。
- 大きいファイルのコピーが途中で一時停止する
- 送信側アプリが複数段階で保存する
- ネットワーク共有で通知が遅れて見える
- 外部プロセスがあとから属性や時刻を書き換える
完了は 推測 ではなく 明示 した方が安定します。
flowchart TB
accTitle: 静けさによる完了推測の危うさ
accDescr: Changedがしばらく止まったら完了とみなす推測は、コピーの一時停止や複数段階の保存、通知の遅延、あとからの属性書き換えで誤判定するため、完了は送信側が明示する方が安定することを示す。
guess["Changedが止まったら完了と推測"] -.-> c1["コピーの一時停止で誤判定"]
guess -.-> c2["多段保存・通知遅延で誤判定"]
fix["完了は送信側が明示する"] --> stable["推測に頼らず安定する"]
図7: 静けさは完了の証拠にならない。完了は推測ではなく明示で決める。
3.4. InternalBufferSize を上げれば解決したと思う
InternalBufferSize の調整は大事ですが、これは設計の本体ではありません。
- 既定値は
8192バイト 4096バイト未満にはできず、64 KBを超えることもできない- バッファは non-paged memory を使うので、増やせば増やすほど気軽とは言えない
つまり、64 KB まで上げても、通知バーストがそれを超えたら終わりです。
しかも、完了通知かどうかの問題は 1 ミリも解決しません。
バッファを増やす前に、まず手を付けるべきことがあります。
Filter/Filtersで監視対象を絞るNotifyFilterを必要最小限にするIncludeSubdirectoriesをむやみにtrueにしない- イベントハンドラを軽くする
- full rescan と idempotency を入れる
flowchart TB
accTitle: バッファ拡大より先にやること
accDescr: InternalBufferSizeを64KBまで上げてもバーストがそれを超えれば取りこぼすため、先にFilterやNotifyFilterで監視対象を絞り、ハンドラを軽くし、full rescanとidempotencyを入れるという順序を示す。
first["先に手を付けること"] --> f1["FilterとNotifyFilterで絞る"]
first --> f2["ハンドラを軽くする"]
first --> f3["full rescanとidempotency"]
buf["InternalBufferSizeの調整"] -.-> aux["最後の補助にとどめる"]
図8: バッファ拡大は設計の本体ではない。絞り込みと回復の仕組みが先。
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 を試す]
図9: どの通知もstartupも1種類のscan requestに畳み、再スキャンで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 を最後に置く
flowchart TD
A[data.tmp に全内容を書く] --> B[flush / close]
B --> C[data.csv に rename / replace]
C --> D[data.done / manifest.json を置く]
D --> E[受信側は final 名や done だけを見る]
図10: 送信側はtempに全内容を書いてcloseし、renameで公開し、必要ならdone / manifestを最後に置く。
前回の記事と同じですが、ここが本当に効きます。
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: 先に成功した方だけが所有権を持つ
図11: 同じ候補を複数ワーカーが見つけても、renameに成功した1つだけが所有権を持つ。
前回の記事でも触れた通り、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 は差分のヒント、再スキャンは整合性の回復」です。
flowchart TB
accTitle: full rescanを入れるタイミング
accDescr: 起動時、Error受信時、watcherを作り直した直後、定期的な保険としての一定間隔ごとという4つのタイミングでfull rescanを入れることで、イベントで拾えない変更を回復することを示す。
t1["起動時"] --> fr["full rescan"]
t2["Error受信時"] --> fr
t3["watcher再生成の直後"] --> fr
t4["定期的な保険として"] --> fr
fr --> heal["整合性の回復"]
図12: watcherは差分のヒント、full rescanは整合性の回復。この4つのタイミングで必ず入れる。
4.5. idempotency を前提にする
FileSystemWatcher を使うと、同じ対象を複数回見に行くことになります。
これはバグではなく、設計として受け入れた方が安定します。
具体的には、こんな具合です。
- manifest に
IdempotencyKeyを入れる - すでに処理済みなら副作用を再実行しない
- archive 済み / DB 記録済み / 送信済み を照合できるようにする
- full rescan しても「同じものをもう一度安全に見る」だけにする
exactly-once をイベントだけで作ろうとすると、だいぶ苦しくなります。 at-least-once を受け入れて、最後を idempotency で締めた方が実務では強いです。
flowchart TB
accTitle: 重複前提の受け止め方
accDescr: 同じ対象を複数回見に行くことをバグではなく設計として受け入れ、manifestのIdempotencyKeyで処理済みを照合して副作用を再実行しないことで、再走査しても安全になることを示す。
multi["同じ対象を複数回見に行く"] --> accept["設計として受け入れる"]
accept --> key["IdempotencyKeyで処理済みを照合"]
key --> safe["副作用を再実行しない"]
safe --> strong["full rescanしても安全に見るだけ"]
図13: 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 に動かす
flowchart TB
accTitle: 正しい方向の処理の流れ
accDescr: 通知をscan requestに畳み、走査でreadyな候補を見つけ、claimを取り、idempotencyを確認してから処理と記録を行いarchiveへ動かすという、擬似コードが表す一連の流れを示す。
n["通知をscan requestに畳む"] --> s["走査でreadyを見つける"]
s --> c["claimを取る"]
c --> i["idempotencyを確認する"]
i --> p["処理して記録しarchiveへ動かす"]
図14: 細かいAPIよりこの流れが本体。イベントはあくまでtrigger。
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 の方が実装は素直です。
flowchart TB
accTitle: FileSystemWatcherとUSN change journalの違い
accDescr: FileSystemWatcherはアプリ停止中の変更が分からずfull rescanで埋めるのに対し、USN change journalはボリューム側に記録が残るため前回読んだ位置から停止中の変更も読み直せるという違いを示す。
fsw["FileSystemWatcher"] -.-> gap["停止中の変更は分からない"]
gap --> fill["full rescanで埋める"]
usn["USN change journal"] --> keep["ボリューム側に記録が残る"]
keep --> resume["前回のUSNから読み直せる"]
図15: 常時起動できない・停止中の変更も拾いたい要件が出てきたら、change journalが選択肢になる。
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をどう使うかまとめます。
引数はなぜ壊れるか ── Windowsのコマンドライン引数の規則
Windowsでは引数の配列は存在せず、CreateProcessに渡るのは1本の文字列で、分割は受け取り側が行います。CommandLineToArgvW・CRT・.NETの分割規則と、.NETのArgumentList・C++での正しい組み立て方を解説します。
マルチスレッドの実務ベストプラクティス .NET編 ── スレッドを増やす前に決めておくこと
「スレッドを立てたら、たまに落ちる・固まる」を防ぐ設計の定石を.NET/C#向けに整理。スレッドを自分で作らずTaskに乗る、共有可変状態を減らす、ロックの規律、CancellationTokenでの停止設計、UIスレッドの扱いまで解説します。
WMI/CIMをC#・PowerShellから使う ── ハードウェア情報取得・プロセス監視・リモート照会の実務ガイド
PCのシリアル番号取得、ディスク空き監視、プロセス起動検知の定番がWMI/CIMです。Get-CimInstance等のCIMコマンドレットの使い方と旧Get-WmiObjectからの移行、C#のSystem.ManagementとCIM APIの使い分け、実例レシピと落と...
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
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を入れるのが順序です。