FileSystemWatcher 的使用方法與注意事項 - 漏掉、重複通知、完成判定的陷阱

· · FileSystemWatcher, C#, .NET, Windows 開發, 檔案連動, 設計

FileSystemWatcher 是在 Windows 上的 .NET 中監視檔案變更時,首先會考慮的 API。 但是,如果把 CreatedChanged 直接當成完成通知使用,會因漏掉、重複通知、中途檔案的誤讀,相當常見地出事。

本文將 FileSystemWatcher 的使用方法與注意事項,主要以 Windows 上的 .NET 進行檔案連動為前提來整理。 此外,作為前提的互斥控制思考方式,也可以參考 檔案連動的互斥控制基礎知識 - 檔案鎖定與原子性 claim 的最佳實踐

FileSystemWatcher 很方便。可以用事件接收檔案或目錄的建立、變更、刪除、改名。 但是,如果在這裡把事件當成「完成通知」,會相當常見地出事。

例如,在檔案的複製中 Created 會先飛過來。Changed 未必只來 1 次。短時間內變更集中時,內部緩衝區會溢位,個別變更也會被漏掉。

所以設計的核心是這樣。

  • 通知是契機
  • 真相是目錄重新掃描
  • 所有權是原子性的 claim
  • 最後用 idempotency 承接

本文以這個思考方式,整理把 FileSystemWatcher 組入檔案連動時的陷阱。

1. 先講結論(一句話)

  • FileSystemWatcher 的事件不是完成通知而是變化的跡象
  • Created / Changed / Renamed 會重複,會以與想像不同的順序到來,在 overflow 時會漏掉
  • 事件處理器中不做沉重的處理,只堆疊重新掃描請求會比較穩定
  • 完成判定基本上以 temp -> close -> rename / replacedone / 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 的調整很重要,但這不是設計的本體。

  • 預設值是 8192 bytes
  • 不能小於 4096 bytes,也不能超過 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 / replacedone / manifest 明示
  • 所有權用原子性取得 claim 來決定

想避免的設計:

  • Created 立刻處理
  • 相信 Changed 的次數或順序
  • Changed 停止當成完成
  • 只靠 InternalBufferSize 安心
  • 看到 Error 卻不復原

實務上有效的對策:

  • 把通知摺疊為重新掃描請求
  • 在 startup / overflow / 重新連線時 full rescan
  • 用 claim rename 取得所有權
  • 用 idempotency 承接重複與重新掃描

也就是說,FileSystemWatcher 中不把「接收到事件」和「可以處理」視為相同是訣竅。 光是分開這裡,偶爾才壞的那種監視處理就會相當減少。

8. 參考資料

相關文章

共用相同標籤的最新文章。能以相近的主題延伸理解。

相關主題

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

與本主題相關的服務

本文連結到以下服務頁面,歡迎從最接近的入口查看。

回到部落格一覽