FileSystemWatcher 的使用方法與注意事項 - 漏掉、重複通知、完成判定的陷阱
FileSystemWatcher 是在 Windows 上的 .NET 中監視檔案變更時,首先會考慮的 API。
但是,如果把 Created 或 Changed 直接當成完成通知使用,會因漏掉、重複通知、中途檔案的誤讀,相當常見地出事。
本文將 FileSystemWatcher 的使用方法與注意事項,主要以 Windows 上的 .NET 進行檔案連動為前提來整理。
此外,作為前提的互斥控制思考方式,也可以參考 檔案連動的互斥控制基礎知識 - 檔案鎖定與原子性 claim 的最佳實踐。
FileSystemWatcher 很方便。可以用事件接收檔案或目錄的建立、變更、刪除、改名。
但是,如果在這裡把事件當成「完成通知」,會相當常見地出事。
例如,在檔案的複製中 Created 會先飛過來。Changed 未必只來 1 次。短時間內變更集中時,內部緩衝區會溢位,個別變更也會被漏掉。
所以設計的核心是這樣。
- 通知是契機
- 真相是目錄重新掃描
- 所有權是原子性的 claim
- 最後用 idempotency 承接
本文以這個思考方式,整理把 FileSystemWatcher 組入檔案連動時的陷阱。
1. 先講結論(一句話)
FileSystemWatcher的事件不是完成通知而是變化的跡象Created/Changed/Renamed會重複,會以與想像不同的順序到來,在 overflow 時會漏掉- 事件處理器中不做沉重的處理,只堆疊重新掃描請求會比較穩定
- 完成判定基本上以
temp -> close -> rename / replace或done/ manifest 來明示 - 如果有多個 worker,讀取前必須原子性地取得 claim
InternalBufferSize的調整是輔助。最後是 full rescan 與 idempotency 起作用
簡言之,不要把 FileSystemWatcher 當成「真相的履歷串流」使用。
通知終究停留在「差不多該去看了」的信號上會比較不容易壞。
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 的調整很重要,但這不是設計的本體。
- 預設值是
8192bytes - 不能小於
4096bytes,也不能超過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 次
這樣一來,事件來 5 次還是 50 次,最終要做的都能統一為「看實物找 ready 的」。
4.2. 完成條件由傳送端明示
如果自己也握有傳送端,比起在 FileSystemWatcher 端努力做完成判定,修改公開協議會更有效。
王道還是這個。
- 把全部內容寫到
temp名稱 close- 在同一檔案系統上
rename / replace - 必要時最後放置
done/ manifest
flowchart TD
A[把全部內容寫到 data.tmp] --> B[flush / close]
B --> C[rename / replace 為 data.csv]
C --> D[放置 data.done / manifest.json]
D --> E[接收端只看 final 名稱或 done]
和上一篇文章相同,但這裡真的有效。
把 FileSystemWatcher 想成不是發明完成的工具,而是儘早發現已明示完成的工具,會比較好整理。
4.3. 接收端原子性地取得 claim
就算重新掃描找到 ready 的候選,直接去讀會被多個 worker 同時抓到。 所以處理前原子性地取得 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
這樣的話,只要 rename 1 次 bundle directory 就能取得所有權。
4.4. startup / overflow / 重新連線時進行 full rescan
這個相當重要。
- 應用程式啟動前就放著的檔案無法用事件撿到
- overflow 發生時,個別事件序列變得難以信任
- 網路共享或暫時斷線相關時,以「那段期間的某些東西」缺失為前提來看比較安全
所以至少以下時機加入 full rescan 會比較好。
- 啟動時
- 接收
Error時 - 重建 watcher 的直後
- 作為定期的保險,每一定間隔
這裡的思想是「watcher 是差分的提示,重新掃描是整合性的復原」。
4.5. 以 idempotency 為前提
使用 FileSystemWatcher 會對同一對象多次去看。
這不是 bug,作為設計接受會比較穩定。
例如以下。
- 在 manifest 中放入
IdempotencyKey - 如果已處理就不重新執行副作用
- 可以對照已封存 / 已記錄 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; // 其他 worker 先取得
}
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。
6. 大致的使用區分
-
單一接收 worker / 自己也能修改傳送端
首先是temp -> close -> rename和 startup scan。光這樣就相當穩定。 -
有多個接收 worker
在上述基礎上再加入incoming -> processing的 claim rename 會比較好。 -
高頻通知很多
縮小Filter/NotifyFilter/IncludeSubdirectories,讓事件處理器極小化。InternalBufferSize的調整在那之後。 -
overflow 造成困難 / 不允許漏掉
以 full rescan 為前提,就算這樣還是吃力的話,不賭在FileSystemWatcher單體會比較好。Windows 限定的話 USN change journal 也是選項。 -
無法控制對方系統的寫法
比起用推測補足完成條件,先考慮能否交涉公開協議會比較安全。不行的話就降低保證水準,往能以 idempotent 接收的設計靠攏。
最後 2 項是相當重要的撤退判斷。
FileSystemWatcher 雖然方便,但不是萬能的真相檢測器。
7. 總結
要掌握的點如下。
互斥控制與完成判定的本體:
FileSystemWatcher不是完成通知的替代- 真相不是事件序列,而是現在磁碟上能看到的狀態
- 完成以
temp -> close -> rename / replace或done/ manifest 明示 - 所有權用原子性取得 claim 來決定
想避免的設計:
- 用
Created立刻處理 - 相信
Changed的次數或順序 - 把
Changed停止當成完成 - 只靠
InternalBufferSize安心 - 看到
Error卻不復原
實務上有效的對策:
- 把通知摺疊為重新掃描請求
- 在 startup / overflow / 重新連線時 full rescan
- 用 claim rename 取得所有權
- 用 idempotency 承接重複與重新掃描
也就是說,FileSystemWatcher 中不把「接收到事件」和「可以處理」視為相同是訣竅。
光是分開這裡,偶爾才壞的那種監視處理就會相當減少。
8. 參考資料
- 相關文章:檔案連動的互斥控制基礎知識 - 檔案鎖定與原子性 claim 的最佳實踐
- FileSystemWatcher Class (System.IO)
- System.IO.FileSystemWatcher class - .NET
- FileSystemWatcher.InternalBufferSize 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
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
把 Generic Host / BackgroundService 帶進桌面應用程式的理由 - 啟動・壽命・graceful shutdown 的整理會輕鬆很多
整理把 .NET Generic Host 與 BackgroundService 帶進 WPF / WinForms 桌面應用程式的理由,把啟動、lifetime、graceful shutdown 集中於入口管理。透過 StartAsync / ExecuteAsync...
序列通訊應用的陷阱 - 先釐清 1 byte 單位、逾時、流控、重連、USB 轉換、UI 凍結
從設備整合與儀器控制的實作現場出發,整理序列通訊應用最容易踩到的陷阱。把訊息邊界、逾時語意、流控線設定、single writer、session 重連與 hex dump 日誌一一拆開,幫助讀者把「偶爾才壞」的 byte 序列處理改造成可預測且容易調查的結構。
將 .NET Framework 遷移到 .NET 之前該確認的事 - 在著手前就決定勝負的實戰檢核表
整理將 .NET Framework 業務應用程式遷移到現代 .NET 之前必須先盤點的論點。涵蓋著地版本、Windows 專用前提的取捨、不再支援的 API、共用函式庫切法、第三方部件、運營與 CI/CD,幫助在著手前釐清範圍並降低遷移風險。
.NET 的 Generic Host 是什麼 - 先整理 DI、設定、日誌、BackgroundService
本文整理 .NET Generic Host 的真面目,並把 DI、設定、日誌、IHostedService 與 BackgroundService 的關係,連同 Host.CreateApplicationBuilder 與 WebApplicationBuilder 的...
.NET 的 Native AOT 是什麼 - 先釐清與 JIT、ReadyToRun、trimming 的差異
把 .NET 的 Native AOT 與 JIT、ReadyToRun、self-contained、single-file、trimming、source generator 放在一起釐清,並從啟動、發布、相依性的角度整理它合適與不合適的情境,幫助讀者判斷該不該採用。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。