Практическое руководство по FileSystemWatcher — как не терять уведомления и что делать с дублями
· Обновлено: · Го Комура · FileSystemWatcher, C#, .NET, Разработка Windows, Интеграция файлов, Проектирование
История изменений (2 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Исправлен URL FileSystemWatcher в списке источников. Утверждения статьи не менялись.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619647)
Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.
Го Комура (2026). Практическое руководство по FileSystemWatcher — как не терять уведомления и что делать с дублями. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619647 https://comcomponent.com/ru/blog/2026/03/10/000-filesystemwatcher-safe-basics/
- DOI (последняя версия)
- 10.5281/zenodo.21619647
- DOI (эта версия)
- 10.5281/zenodo.21619648
FileSystemWatcher — первый API, который обычно рассматривают, когда в .NET на Windows нужно следить за изменениями файлов. Удобно, что создание, изменение, удаление и переименование файлов и каталогов приходят событиями. Но если принять Created или Changed за сигнал «файл уже готов», на практике довольно обычны потери уведомлений, дубли и чтение файла, который ещё дописывается.
В этой статье разбираем, как пользоваться FileSystemWatcher и где он подводит, в первую очередь на файловой интеграции в .NET на Windows. Базовые идеи взаимного исключения можно смотреть и в статье «Основы взаимного исключения при файловой интеграции — лучшие практики файловых блокировок и атомарного claim».
На деле Created действительно может прийти, пока файл ещё копируется, а Changed вовсе не обязан быть одним. Если изменения сыплются короткой пачкой, внутренний буфер переполняется, и отдельные изменения теряются.
Поэтому ядро проектирования такое:
- уведомление — только повод;
- источник истины — повторное сканирование каталога;
- владение берётся атомарным claim;
- в конце всё принимает на себя идемпотентность.
Дальше по этому принципу разбираем, где FileSystemWatcher ломается, когда его встраивают в файловую интеграцию.
Код из статьи опубликован на GitHub как полный набор, который можно собрать и запустить: библиотека, консольная демонстрация на временном каталоге и модульные тесты, которые реально создают и меняют файлы, чтобы проверить события.
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
Для кого эта статья и что предполагается
Статья для разработчиков, которые в .NET на Windows следят за входящим каталогом и забирают из него файлы. Примеры — на C# / .NET 8 и новее, но сами идеи от языка не зависят.
Термины те же, что в предыдущей статье про взаимное исключение при файловой интеграции. claim, idempotency, manifest, bundle с 4-й главы идут без пояснений, поэтому ниже — по одной строке на термин, чтобы можно было читать и без предыдущей статьи.
Термины, которые стоит зафиксировать заранее
| Термин | Смысл |
|---|---|
| claim | Атомарно взять владение «этот файл обрабатываю я», так чтобы другой воркер не вклинился. На практике это rename из incoming/ в processing/<worker>/: владельцем становится только тот процесс, у которого rename прошёл (4.3) |
| idempotency (идемпотентность) | Свойство: обработать один и тот же объект дважды и больше даёт тот же результат, что и один раз. Если дубли уведомлений и повторные проходы заложены в дизайн, в конце именно это их гасит (4.5) |
| manifest | Небольшой файл рядом с полезной нагрузкой, который её описывает. Число записей, хеш, IdempotencyKey и т. п. помогают получателю решить, обработан ли уже этот объект |
| bundle | Единица одной передачи. Полезная нагрузка + manifest + вспомогательные файлы лежат в одном каталоге, и claim берётся одним rename всего каталога (4.3) |
| full rescan | Не опираться на события, а заново перечислить наблюдаемый каталог и найти то, что уже можно обрабатывать (4.4) |
| overflow | Внутренний буфер FileSystemWatcher переполнился, отдельные уведомления потеряны. Об этом сообщает событие Error (2.3) |
| ready | Состояние «уже можно читать». Это не догадка, а вывод по финальному имени или по наличию 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. Делать full rescan при запуске, overflow и переподключении
- 4.5. Исходить из идемпотентности
- Псевдокод (фрагменты)
- 5.1. Типичный провал
- 5.2. Пример в правильную сторону (набросок)
- Как выбирать (кратко)
- Итог
- Справочные материалы
Карта знаний этой статьи
Статья предлагает не принимать события Created/Changed у FileSystemWatcher за сигнал завершения, не мириться с потерей уведомлений из-за overflow внутреннего буфера и не строить схему, которая пытается восстановить состояние из последовательности событий, а сворачивать все уведомления в один тип запроса повторного сканирования и проверять фактическое состояние через full rescan. Завершение явно обозначает отправитель через temp→rename или done/manifest; получатель берёт атомарный claim на кандидатов ready, найденных при повторном сканировании, и обрабатывает повторные проверки через идемпотентность. Если процесс нельзя держать постоянно запущенным или пропуски недопустимы, журнал USN (change journal) позиционируется как ещё один вариант.
flowchart LR
accTitle: Карта знаний: практическое руководство по FileSystemWatcher
accDescr: Схема показывает, что уведомления FileSystemWatcher — не сигнал завершения, а лишь признак изменения; что уведомления сворачивают в запрос повторного сканирования и сочетают с full rescan и claim; и связь потери уведомлений из-за overflow внутреннего буфера с альтернативой в виде журнала USN (change journal).
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["свёртка уведомлений в запрос rescan"]
changed_event_order_assumption["антипаттерн: полагаться на число и порядок событий Changed"]
event_log_state_reconstruction_antipattern["восстановление состояния из потока событий"]
duplicate_processing["двойная обработка (двойной учёт, повторная отправка, lost update)"]
atomic_claim["атомарный claim"]
bundle["bundle (каталог единицы интеграции)"]
idempotent_processing["обработка с расчётом на идемпотентность"]
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 и идемпотентность
Коротко: не считайте FileSystemWatcher «достоверным потоком истории».
Уведомление лучше оставить сигналом «пора сходить и посмотреть» — так конструкция ломается реже.
flowchart TB
accTitle: Ядро проектирования этой статьи
accDescr: Уведомление остаётся только поводом, источник истины проверяется повторным сканированием каталога, владение берётся атомарным claim, а дубли в конце принимает на себя идемпотентность.
notif["уведомление — повод"] --> rescan["истина — повторное сканирование каталога"]
rescan --> claim["владение — атомарный claim"]
claim --> idem["в конце — идемпотентность"]
Рис. 1: Ядро проектирования. События — не история истины, а сигнал «пора посмотреть».
1.1. Сначала — минимальный код, который уже «работает»
Если FileSystemWatcher ещё не трогали, ниже — минимальная форма только для нормального пути. Дальнейшие главы как раз про ловушки, которые начинаются с того, что эти десять строк «уже работают».
// Консольное приложение 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();
Даже в минимальной форме три вещи лучше зафиксировать сразу, чтобы потом не путаться.
- Пока нет
EnableRaisingEvents = true, не придёт ни одного события. Одной подписки обработчиков недостаточно - Время жизни
watcher— это время жизни приложения. Если локальная переменная выходит из области видимости и объект уничтожается, уведомления на этом прекращаются. Для постоянно работающего процесса держите его в поле или в другом месте, которое живёт вместе с приложением - Значение
NotifyFilterпо умолчанию — сочетаниеLastWrite | FileName | DirectoryName(см. FileSystemWatcher.NotifyFilter Property в разделе 8). Что именно ловите, лучше указать явно — потом при перечитывании кода меньше сюрпризов
И главное: этот код проверяет только то, что события доходят. Можно ли читать файл в момент Created и не теряются ли уведомления — из этой формы не видно. Дальше как раз об этом.
2. Типичные ошибки в работе с FileSystemWatcher (схема)
2.1. Считать Created сигналом о готовности
Это самая понятная ловушка.
При копировании или передаче Created приходит в момент создания файла, а затем может последовать одно или несколько 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 не обязан приходить ровно один раз.
Даже обычные операции вроде перемещения или сохранения могут выглядеть как несколько событий. Плюс в выборку попадают обращения антивируса или индексатора.
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: не обязательно один раз и не обязательно в этом порядке
Рис. 3: Обычное сохранение тоже распадается на несколько событий, и вперемешку идут обращения внешнего процесса. На число и порядок опираться нельзя.
Ожидания вроде «пришёл один Changed — значит готово» или «после Renamed файл уже никто не трогает» довольно шаткие.
Дополнительно:
- при rename файла иногда приходит и
Changed; RenamedEventArgs.Nameможет оказатьсяnull, если ОС не смогла сопоставить старое и новое имя;- скрытые файлы не отфильтровываются. Расчёт «это скрытое temp-имя, его не увидят» не работает;
- если переименовать сам наблюдаемый каталог, это изменение не придёт.
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 не значит «потеряли ровно одну запись». Под сомнением оказывается целостность самой последовательности отдельных событий, поэтому лучше честно пересмотреть картину целиком.
3. Антипаттерны
3.1. Обрабатывать данные прямо в обработчике события
Так на событие вешают слишком много: и определение готовности, и взятие владения.
watcher.Created += (_, e) =>
{
using var stream = File.OpenRead(e.FullPath);
Import(stream); // копирование, возможно, ещё не закончено
};
watcher.Error += (_, e) =>
{
Console.WriteLine(e.GetException()); // только вывод
};
Проблем две.
- в момент
Createdсодержимое может быть ещё не готово; - нет восстановления после сбоя или overflow.
Обработчику достаточно выставить запрос на повторное сканирование и сразу вернуть управление. Если здесь же запускать тяжёлый ввод-вывод или обновление БД, при всплеске вы сами себя загоните в узкое место.
flowchart TB
accTitle: Где проходит граница тяжести обработчика
accDescr: В обработчике события лучше выставить запрос на повторное сканирование и сразу вернуть управление. Если внутри запускать тяжёлый ввод-вывод или обновление БД, появляется риск прочитать ещё не готовое содержимое и не успевать при всплеске.
ev["обработчик события"] --> light["выставить запрос на сканирование и сразу вернуться"]
heavy["тяжёлый ввод-вывод или обновление БД в обработчике"] -.-> 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 КБ— тоже; - буфер занимает невыгружаемую память (non-paged memory), поэтому чем больше его раздувать, тем меньше это «просто так увеличить».
То есть даже до 64 КБ всплеск сверх этого предела всё равно обрывается.
И вопрос «это уведомление о готовности или нет» не решается ни на миллиметр.
Прежде чем увеличивать буфер, стоит заняться другим.
- сузить наблюдение через
Filter/Filters; - свести
NotifyFilterк необходимому минимуму; - не ставить
IncludeSubdirectoriesвtrueбез нужды; - облегчить обработчик события;
- добавить full rescan и идемпотентность.
flowchart TB
accTitle: Что делать раньше, чем увеличивать буфер
accDescr: Даже InternalBufferSize = 64 КБ не спасает, если всплеск его превосходит. Сначала сузьте наблюдение Filter и NotifyFilter, облегчите обработчик и заложите full rescan с идемпотентностью.
first["сначала заняться этим"] --> f1["сузить Filter и NotifyFilter"]
first --> f2["облегчить обработчик"]
first --> f3["full rescan и идемпотентность"]
buf["настройка InternalBufferSize"] -.-> aux["оставить последней подстраховкой"]
Рис. 8: Увеличение буфера — не ядро проектирования. Сначала сужение наблюдения и восстановление.
3.5. Только логировать Error и игнорировать его
Error — не то уведомление, на которое можно «иногда взглянуть и забыть».
Сюда попадают переполнение буфера и ситуации, когда наблюдение продолжить не удалось.
watcher.Error += (_, e) =>
{
_logger.LogError(e.GetException(), "watcher error");
// если остановиться здесь, потерю уже заметили, но не восстановились
};
Как минимум стоит сделать следующее.
- запросить full rescan;
- если продолжение наблюдения под вопросом, рассмотреть пересоздание watcher;
- сделать повторную обработку идемпотентной, исходя из того, что уведомления могли быть потеряны.
4. Практики, которые работают
4.1. Сворачивать уведомления в «запрос на повторное сканирование»
Если Created / Changed / Deleted / Renamed / Error сразу привязать каждое к своей бизнес-обработке, картина быстро мутнеет. Сначала сверните всё в один тип сигнала: «сходи посмотри».
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: И уведомления, и запуск сворачиваются в один scan request; повторное сканирование ищет ready-кандидатов и пробует claim.
Практические моменты реализации:
- в обработчике события достаточно выставить
dirty = trueи подать signal; - сканирование держите в одном воркере;
- при всплеске сначала соберите события примерно на 100–300 мс и только потом сканируйте один раз;
- если во время сканирования пришли новые уведомления, после него сделайте ещё один проход.
Третье число, 100–300 мс, не из стандарта и не из официальной документации: это начальное значение из опыта эксплуатации автора. На практике надёжнее сначала измерить два параметра и уже по ним выбрать задержку.
| На что смотреть | Как выбирать |
|---|---|
| Сколько занимает один проход сканирования | Если ждать меньше этого, к концу прохода уже копится следующий запрос. Нижнюю границу ориентируйте на время одного прохода или чуть больше |
| Какую задержку обнаружения ещё можно принять | Время ожидания сразу становится задержкой обнаружения. Если есть требование «обработать в течение n секунд после появления файла», верхнюю границу держите в пределах части этого бюджета |
Например, если один проход занимает 50 мс, а обнаружить файл достаточно за 1 секунду, 100–300 мс как раз укладываются. Наоборот, если файлов много и один проход занимает секунды, сначала пересмотрите само сканирование (сузить выборку, смотреть только done, разнести подкаталоги), а не растягивать ожидание.
Тогда неважно, пришло 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[получатель смотрит только на финальное имя или done]
Рис. 10: Отправитель пишет всё в temp, закрывает файл, публикует 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: владение получает только тот, у кого rename прошёл первым
Рис. 11: Даже если один и тот же кандидат видят несколько воркеров, владение получает только тот, у кого прошёл rename.
Как уже было в предыдущей статье, самый понятный способ — rename incoming -> processing/<worker>/.
Особенно удобно собрать полезную нагрузку, manifest и вспомогательные файлы в одном каталоге: тогда claim берётся сразу на весь bundle.
incoming/
order-123/
payload.csv
manifest.json
Тогда достаточно один раз переименовать каталог bundle, чтобы взять владение.
4.4. Делать full rescan при запуске, overflow и переподключении
Это довольно важный момент.
- файлы, которые лежали ещё до запуска приложения, событиями не подхватываются;
- после overflow отдельной последовательности событий доверять трудно;
- если в деле сетевой ресурс или кратковременный разрыв связи, безопаснее исходить из того, что «за это время что-то выпало».
Поэтому full rescan стоит вставлять как минимум в такие моменты:
- при запуске;
- при получении
Error; - сразу после пересоздания watcher;
- периодически, как подстраховка, через заданный интервал.
Идея здесь такая: «watcher — подсказка об изменениях, повторное сканирование — восстановление согласованности».
flowchart TB
accTitle: Когда вставлять full rescan
accDescr: Full rescan при запуске, при получении Error, сразу после пересоздания watcher и периодически как подстраховка восстанавливает изменения, которые событиями не подхватываются.
t1["при запуске"] --> fr["full rescan"]
t2["при получении Error"] --> fr
t3["сразу после пересоздания watcher"] --> fr
t4["периодически как подстраховка"] --> fr
fr --> heal["восстановление согласованности"]
Рис. 12: Watcher — подсказка об изменениях, full rescan — восстановление согласованности. Эти четыре момента стоит закладывать обязательно.
4.5. Исходить из идемпотентности
С FileSystemWatcher один и тот же объект неизбежно будут смотреть несколько раз.
Это не баг: стабильнее принять это как часть проектирования.
Конкретно это выглядит примерно так:
- класть
IdempotencyKeyв manifest; - если объект уже обработан, не повторять побочные эффекты;
- уметь сверять статусы «уже в архиве / уже записано в БД / уже отправлено»;
- добиться, чтобы даже после full rescan повторный просмотр «того же самого» был безопасным.
Строить exactly-once только на событиях довольно тяжело. На практике сильнее принять at-least-once и закрыть это идемпотентностью.
flowchart TB
accTitle: Как принимать повторные просмотры
accDescr: Несколько просмотров одного объекта принимаются как часть проектирования. По IdempotencyKey в manifest сверяют, что уже обработано, и не повторяют побочные эффекты — тогда повторное сканирование остаётся безопасным.
multi["один объект смотрят несколько раз"] --> accept["принимаем это как часть проектирования"]
accept --> key["сверяем обработанное по IdempotencyKey"]
key --> safe["не повторяем побочные эффекты"]
safe --> strong["даже full rescan — только безопасный повторный просмотр"]
Рис. 13: Не строить exactly-once на событиях. Принять at-least-once и закрыть это идемпотентностью.
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());
// без восстановления
};
Проблем четыре.
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); // сканирование при запуске
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;
- проверяется идемпотентность;
- объект обрабатывается, фиксируется и переносится в archive.
flowchart TB
accTitle: Поток обработки в правильную сторону
accDescr: Уведомления сворачиваются в scan request, сканирование находит ready-кандидатов, берётся claim, проверяется идемпотентность, затем обработка, фиксация и перенос в archive — это поток, который показывает псевдокод.
n["свернуть уведомления в scan request"] --> s["найти ready сканированием"]
s --> c["взять claim"]
c --> i["проверить идемпотентность"]
i --> p["обработать, зафиксировать и перенести в archive"]
Рис. 14: Ядро — этот поток, а не тонкости API. Событие здесь только trigger.
События FileSystemWatcher здесь — не больше чем trigger.
EnumerateReadyBundles / TryClaimByRename / ReadManifest / AlreadyProcessed — функции, названные в этой статье, чтобы показать поток; это не стандартный API .NET. Собираемый и запускаемый вид (библиотека, консольная демонстрация на временном каталоге, модульные тесты событий) лежит в наборе примеров, который указан в начале.
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
6. Как выбирать (кратко)
-
Один принимающий воркер / отправляющую сторону тоже правите вы Начните с
temp -> close -> renameи сканирования при запуске. Уже одно это даёт заметную стабильность. -
Несколько принимающих воркеров К предыдущему добавьте claim через rename
incoming -> processing. -
Высокая частота уведомлений Сузьте
Filter/NotifyFilter/IncludeSubdirectoriesи максимально облегчите обработчики. НастройкаInternalBufferSize— уже после этого. -
overflow мешает / потери недопустимы Стройте всё на full rescan, а если и этого мало — не ставьте только на
FileSystemWatcher. Если речь только о Windows, вариантом может стать USN change journal. -
Как пишет чужая система, вы не контролируете Прежде чем восполнять условие готовности догадками, сначала подумайте, можно ли согласовать протокол публикации. Если нет — снижайте уровень гарантий и склоняйтесь к идемпотентному приёму.
Последние два пункта — довольно важные критерии, когда лучше отойти от этого подхода.
FileSystemWatcher удобен, но это не всесильный детектор истины.
Чем USN change journal отличается
USN change journal — это запись изменений, которую NTFS ведёт на уровне тома. Уведомления каталога вроде FileSystemWatcher приложение получит, только если оно работало в момент изменения. Change journal остаётся на томе, поэтому изменения за время простоя приложения можно позже дочитать с той позиции (USN), на которой остановились в прошлый раз. В документации Microsoft среди слабостей уведомлений каталога как раз указано, что приложение нужно держать постоянно запущенным, и change journal объясняется как способ это обойти.
Но растёт и стоимость.
FileSystemWatcher |
USN change journal | |
|---|---|---|
| Единица наблюдения | заданный каталог (+ подкаталоги) | весь том; нужный диапазон сужаете сами |
| Пока приложение не работало | неизвестно; закрываете full rescan | можно дочитать из записи |
| Потери | случаются при overflow внутреннего буфера | старые записи исчезают, когда журнал упирается в свой предел |
| Что нужно | только API .NET | дескриптор тома и вызовы FSCTL_*. Для создания, удаления и других операций управления журналом нужны права администратора |
То есть это вариант, когда в требованиях появляются «нельзя держать постоянно запущенным» и «нужно подхватить изменения за время простоя». Если этого нет, FileSystemWatcher + full rescan реализуется проще.
flowchart TB
accTitle: Чем FileSystemWatcher отличается от USN change journal
accDescr: FileSystemWatcher не видит изменения за время простоя и закрывает пробел full rescan. USN change journal оставляет запись на томе, поэтому изменения за время простоя можно дочитать с предыдущей позиции USN.
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 и не восстанавливаться — всё это лучше обходить. Вместо этого сворачивайте уведомления в запрос на повторное сканирование, делайте full rescan при запуске, overflow и переподключении, берите владение через rename-claim, а дубли и повторные проходы принимайте идемпотентностью.
Иначе говоря, с 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
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Зачем использовать .NET Generic Host и BackgroundService в десктопных приложениях
Как с помощью Generic Host и BackgroundService собрать запуск, периодическую работу, завершение, логи, конфигурацию и DI в Windows-инстру...
Практические рекомендации по многопоточности: .NET — что решить до добавления потоков
Проверенные приёмы проектирования на .NET/C#, чтобы код не «иногда падал или зависал»: не создавать потоки вручную и опираться на Task, с...
CI/CD для WinForms / WPF: сборка, подпись и распространение в GitHub Actions
Практическое руководство по CI/CD для WinForms / WPF в GitHub Actions. Минимальный YAML сборки и тестов на windows-latest, нумерация верс...
Спящий режим, гибернация и Modern Standby: как не дать долгоживущему приложению остановиться ночью
Разбираем, почему долгоживущее Windows-приложение к утру оказывается остановленным: чем отличаются спящий режим S3, гибернация и Modern S...
Сетевые диски и UNC-пути: типичные ловушки ── как бизнес-приложению работать с файловым сервером (общей папкой)
Разбираем типичные сбои, когда бизнес-приложение пишет в общую папку или следит за ней. Почему службе не видна буква диска (Z:), какие пр...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Файловая интеграция и средства наблюдения на FileSystemWatcher — частая практическая тема в разработке Windows-приложений.
Технические консультации и ревью дизайна
Если нужно оформить защиту от потерь уведомлений, повторное сканирование и определение готовности как целостное проектное решение, это хорошо ложится на техническую консультацию и ревью архитектуры.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Можно ли читать файл в обработчике Created у FileSystemWatcher?
- Нет. Created означает только «имя стало видно» и не гарантирует «уже можно читать». При копировании или передаче Created может сработать в момент создания файла, а затем прийти одно или несколько Changed. Готовность должен явно обозначить отправитель через temp -> close -> rename/replace или через done / manifest; получателю стоит смотреть только на финальное имя или на done.
- Может ли FileSystemWatcher терять уведомления?
- Да. Внутренний буфер (по умолчанию 8192 байт, меньше 4096 байт выставить нельзя, верхний предел — 64 КБ) при переполнении теряет отдельные уведомления, и возникает событие Error. После overflow под сомнением оказывается целостность самой последовательности отдельных событий, поэтому безопаснее сделать full rescan каталога и заново оценить всю картину. Full rescan стоит закладывать при запуске, при получении Error, сразу после пересоздания watcher и периодически — как подстраховку.
- Почему Changed приходит много раз?
- Даже обычные операции вроде перемещения или сохранения могут распасться на несколько событий, плюс в выборку попадают обращения антивируса или индексатора. Строить логику на числе или порядке событий опасно. Уведомления лучше свернуть в один тип сигнала — «запрос на повторное сканирование», само сканирование вести одним воркером, а при всплеске сначала собирать события около 100–300 мс и только потом сканировать один раз — так стабильнее.
- Решает ли увеличение InternalBufferSize проблему потерянных уведомлений?
- Нет. Даже если поднять значение до 64 КБ, всплеск сверх этого предела снова приведёт к потерям, а вопрос «это уведомление о готовности или нет» не решается вообще. Буфер занимает невыгружаемую память (non-paged memory), поэтому увеличивать его без нужды тоже не стоит. Сначала сузьте наблюдение через Filter/NotifyFilter, пересмотрите IncludeSubdirectories, облегчите обработчик и добавьте full rescan вместе с идемпотентностью.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.