Практическое руководство по FileSystemWatcher — защита от потерянных и повторяющихся уведомлений

· · FileSystemWatcher, C#, .NET, Разработка Windows, Интеграция файлов, Проектирование

FileSystemWatcher — первый API, который приходит на ум, когда нужно отслеживать изменения файлов в .NET на Windows. Он удобен тем, что доставляет создание, изменение, удаление и переименование файлов и каталогов в виде событий, но если воспринимать Created или Changed как сигнал о завершении, довольно легко напороться на потерянные уведомления, дублирующиеся оповещения и чтение недописанных файлов.

В этой статье мы разберём использование FileSystemWatcher и его подводные камни, ориентируясь прежде всего на интеграцию файлов в .NET на Windows. Заодно оформили статью так, чтобы можно было опираться и на базовые представления о взаимоисключающем доступе — см. «Основы взаимоисключающего доступа при интеграции файлов — лучшие практики блокировки файлов и атомарного захвата».

На практике Created действительно может сработать раньше, чем файл будет докопирован, а Changed отнюдь не гарантированно приходит один раз. Если изменения концентрируются в короткий промежуток времени, внутренний буфер может переполниться, и тогда отдельные изменения будут потеряны.

Поэтому в основе проектирования лежит вот что:

  • уведомление — это лишь повод;
  • истина — в повторном сканировании каталога;
  • владение определяется атомарным захватом;
  • в конце концов всё держится на идемпотентности.

В основной части статьи мы по порядку разберём подводные камни, с которыми сталкиваешься, встраивая FileSystemWatcher в интеграцию файлов, исходя именно из этой логики.

Код, встречающийся в этой статье, опубликован на GitHub в виде полного набора, который можно собрать и запустить (библиотека, консольная демонстрация, работающая на временном каталоге, и модульные тесты, которые реально создают и изменяют файлы, чтобы проверить события).

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

Содержание

  1. Сначала — вывод (в двух словах)
  2. Типичные заблуждения при использовании FileSystemWatcher (с диаграммами)
    • 2.1. Created принимают за сигнал о завершении
    • 2.2. Доверяют количеству и порядку событий Changed
    • 2.3. Изменения теряются из-за переполнения внутреннего буфера
  3. Антипаттерны
    • 3.1. Обработка данных прямо внутри обработчика событий
    • 3.2. Попытка восстановить истинное состояние по последовательности событий
    • 3.3. Остановка Changed воспринимается как завершение
    • 3.4. Мнение, что увеличение InternalBufferSize решает проблему
    • 3.5. Error только логируется и игнорируется
  4. Лучшие практики
    • 4.1. Сворачивайте уведомления в «запрос на пересканирование»
    • 4.2. Явно обозначайте условие завершения на стороне отправителя
    • 4.3. Получатель атомарно захватывает право владения (claim)
    • 4.4. Выполняйте full rescan при запуске, overflow и переподключении
    • 4.5. Исходите из идемпотентности
  5. Псевдокод (фрагменты)
    • 5.1. Типичный неудачный паттерн
    • 5.2. Пример в правильном направлении (набросок)
  6. Как выбирать (кратко)
  7. Итог
  8. Справочные материалы

1. Сначала — вывод (в двух словах)

  • События FileSystemWatcher — это не сигнал о завершении, а лишь признак изменения
  • Created / Changed / Renamed могут дублироваться, приходить в неожиданном порядке, а при overflow — теряться
  • Стабильнее, если обработчик событий не делает тяжёлой работы, а лишь ставит в очередь запрос на пересканирование
  • Определение завершения стоит делать явным через temp -> close -> rename / replace или через файл done / manifest
  • При наличии нескольких worker-ов перед чтением необходимо атомарно захватить право владения (claim)
  • Настройка InternalBufferSize — вспомогательная мера. В конечном счёте работают full rescan и идемпотентность

Иначе говоря, суть в том, чтобы не воспринимать FileSystemWatcher как «достоверный поток истории». Уведомление стабильнее держать исключительно в роли сигнала «пора сходить и посмотреть».

2. Типичные заблуждения при использовании FileSystemWatcher (с диаграммами)

2.1. Created принимают за сигнал о завершении

Это самая очевидная мина. При копировании или передаче файла Created срабатывает в момент его создания, а следом может прийти одно или несколько событий Changed.

ПолучательFileSystemWatcherwatched dirОтправительПолучательFileSystemWatcherwatched dirОтправителькопирование ещё не завершенонехватка строк / битый JSON / битый ZIPсоздаёт orders.csvCreatedOnCreatedоткрывает и читает orders.csvдописывает остальноеChangedChanged

Created может означать «имя стало видно», но не гарантирует «уже можно читать». Если приравнять эти два понятия, наступаешь на ту же мину, что и в разделе 2.1 предыдущей статьи, просто с другой стороны.

2.2. Доверяют количеству и порядку событий Changed

Changed не гарантированно приходит ровно один раз. Даже обычные операции вроде перемещения или сохранения могут распадаться на несколько событий. Более того, вы можете подхватить и то, что затронул антивирус или индексатор.

FileSystemWatcherAV / индексаторwatched dirСохраняющее приложениеFileSystemWatcherAV / индексаторwatched dirСохраняющее приложениене гарантировано ни то, что событие одно, ни этот порядокначинает сохранять report.xlsxCreatedChangedпереименовывает из временного файлаRenamedChangedсканирование / обращение к атрибутамChanged

Ожидания вроде «пришёл один Changed — значит готово» или «после Renamed файл больше никто не трогает» довольно рискованны.

Дополнительно:

  • переименование файла может порождать событие Changed;
  • RenamedEventArgs.Name может оказаться null, если ОС не смогла сопоставить старое и новое имя;
  • скрытые файлы не исключение — расчёт на то, что «это скрытое временное имя, поэтому его не увидят», не работает;
  • если переименовать сам отслеживаемое каталог, это изменение не будет доставлено.

2.3. Изменения теряются из-за переполнения внутреннего буфера

У FileSystemWatcher есть внутренний буфер. Если изменения концентрируются в короткий промежуток времени, буфер переполняется, и отдельные уведомления теряются.

ДаНетМного изменений за короткое времяУведомления накапливаются во внутреннем буфереОбработка успевает?Отдельные события обрабатываются по порядкуOverflowСобытие ErrorБольше не доверяем целостности отдельной историиПолное пересканирование каталога

Здесь важно, что 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.

Обработчику событий идёт на пользу, если он лишь выставляет запрос на пересканирование и сразу возвращает управление. Если начать здесь же тяжёлый ввод-вывод или обновление БД, при всплеске уведомлений вы сами себе перекроете кислород.

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 КБ — тоже;
  • буфер использует non-paged memory, поэтому увеличивать его бездумно не стоит.

Иными словами, даже подняв значение до 64 КБ, при всплеске уведомлений сверх этого предела всё закончится тем же. При этом вопрос о том, является ли уведомление сигналом завершения, не решается ни на миллиметр.

Прежде чем увеличивать буфер, стоит сделать следующее.

  • сузить область наблюдения через Filter / Filters;
  • свести NotifyFilter к необходимому минимуму;
  • не выставлять IncludeSubdirectories в true бездумно;
  • облегчить обработчик событий;
  • добавить full rescan и идемпотентность.

3.5. Error только логируется и игнорируется

Error — это не то уведомление, на которое можно «изредка взглянуть и пожать плечами». Именно здесь всплывают переполнение буфера и сбои продолжения наблюдения.

watcher.Error += (_, e) =>
{
    _logger.LogError(e.GetException(), "watcher error");
    // если остановиться здесь, вы заметили потерю, но не восстановились после неё
};

Как минимум стоит сделать следующее.

  • запросить full rescan;
  • если продолжение наблюдения под вопросом, рассмотреть пересоздание watcher-а;
  • сделать повторную обработку идемпотентной, исходя из того, что уведомления могли быть потеряны.

4. Лучшие практики

4.1. Сворачивайте уведомления в «запрос на пересканирование»

Если напрямую привязать Created / Changed / Deleted / Renamed / Error каждое к своей отдельной бизнес-логике, читаемость резко падает. Сначала сворачиваем всё в один тип сигнала — «сходи посмотри».

Created / Changed / Deleted / Renamedзапрос на сканированиеError / overflowзапускПовторное сканирование каталогаПеречисление готовых кандидатовПопытка захвата

Практические моменты реализации:

  • в обработчике событий достаточно выставить dirty = true и подать сигнал;
  • сканирование стоит сосредоточить в одном worker-е;
  • при всплеске уведомлений сначала собрать их за 100–300 мс и лишь затем выполнить одно сканирование;
  • если во время сканирования пришли новые уведомления, после его завершения выполнить ещё одно.

Так, придёт ли 5 событий или 50, итоговое действие всегда сводится к одному: «посмотреть на реальное содержимое и найти готовое».

4.2. Явно обозначайте условие завершения на стороне отправителя

Если вы контролируете и отправляющую сторону, эффективнее не мучиться с определением завершения на стороне FileSystemWatcher, а поправить открытый протокол публикации.

Проверенный путь — по-прежнему такой:

  • записать всё содержимое под именем temp;
  • выполнить close;
  • сделать rename / replace в пределах одной файловой системы;
  • при необходимости в конце положить done / manifest.
Записываем всё содержимое в data.tmpflush / closerename / replace в data.csvКладём data.done / manifest.jsonПолучатель смотрит только на финальные имена или done

Как и в предыдущей статье, именно здесь эффект действительно ощутим. FileSystemWatcher стоит воспринимать не как инструмент, изобретающий завершение, а как инструмент, который раньше замечает уже явно объявленное завершение.

4.3. Получатель атомарно захватывает право владения (claim)

Даже если пересканирование нашло готового кандидата, если сразу пойти его читать, несколько worker-ов могут захватить его одновременно. Поэтому перед обработкой необходимо атомарно взять на себя владение — выполнить claim.

processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscannerвладением обладает только тот, кто успел первымобнаруживает order-123rename order-123rename order-123

Как уже упоминалось в предыдущей статье, самый понятный способ — переименование incoming -> processing/<worker>/. Особенно удобно собрать в одном каталоге основной файл, manifest и вспомогательные файлы — тогда захват можно выполнять сразу для целого пакета (bundle).

incoming/
  order-123/
    payload.csv
    manifest.json

В этом случае достаточно один раз переименовать каталог пакета, чтобы получить владение.

4.4. Выполняйте full rescan при запуске, overflow и переподключении

Это довольно важный момент.

  • файлы, положенные ещё до запуска приложения, событиями не подхватываются;
  • после overflow доверять отдельной последовательности событий становится трудно;
  • если задействованы сетевые диски или временные разрывы связи, безопаснее исходить из того, что «что-то за это время было упущено».

Поэтому full rescan стоит выполнять как минимум в такие моменты:

  • при запуске;
  • при получении Error;
  • сразу после пересоздания watcher-а;
  • периодически, на всякий случай, через определённый интервал.

Философия здесь такая: «watcher — это подсказка о разнице, а пересканирование — восстановление целостности».

4.5. Исходите из идемпотентности

При использовании FileSystemWatcher один и тот же объект неизбежно будет проверяться несколько раз. Это не баг — стабильнее принять это как часть проектирования.

Конкретно это выглядит примерно так:

  • вносить IdempotencyKey в manifest;
  • если объект уже обработан, не повторять побочные эффекты;
  • обеспечить возможность сверки статусов «архивировано / записано в БД / отправлено»;
  • добиться того, чтобы даже после full rescan повторный просмотр «того же самого» проходил безопасно.

Строить 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; // другой 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, а сам поток действий.

  • уведомления сворачиваются в запрос на сканирование;
  • сканирование находит готовое;
  • выполняется захват (claim);
  • проверяется идемпотентность;
  • объект обрабатывается, фиксируется и перемещается в архив.

События FileSystemWatcher здесь — не более чем триггер.

6. Как выбирать (кратко)

  • Один принимающий worker / вы же контролируете отправителя Начните с temp -> close -> rename и сканирования при запуске. Уже одно это даёт неплохую стабильность.

  • Несколько принимающих worker-ов Добавьте к вышеперечисленному захват через переименование incoming -> processing.

  • Высокая частота уведомлений Сузьте Filter / NotifyFilter / IncludeSubdirectories, максимально облегчите обработчики событий. Настройка InternalBufferSize — уже после этого.

  • overflow мешает / потери недопустимы Стройте всё на full rescan, а если и этого мало — не полагайтесь на один только FileSystemWatcher. Если речь только о Windows, вариантом может стать USN change journal.

  • Вы не контролируете, как пишет другая система Прежде чем восполнять условие завершения догадками, стоит сначала подумать, можно ли согласовать открытый протокол публикации. Если нет — снижайте уровень гарантий и склоняйтесь к идемпотентному приёму.

Последние два пункта — довольно важные критерии для отступления. FileSystemWatcher удобен, но не является всемогущим детектором истины.

7. Итог

FileSystemWatcher не заменяет собой сигнал о завершении. Истина — не в последовательности событий, а в том, что прямо сейчас видно на диске. Завершение обозначайте явно через temp -> close -> rename / replace или через done / manifest, а владение определяйте, атомарно захватывая claim. Именно в этом суть проектирования.

Обрабатывать сразу по Created, доверять количеству или порядку Changed, считать остановку Changed завершением, успокаиваться одним лишь InternalBufferSize, видеть Error и не восстанавливаться после него — всё это стоит обходить стороной. Вместо этого сворачивайте уведомления в запрос на пересканирование, выполняйте full rescan при запуске, overflow и переподключении, забирайте владение через переименование-захват, а дубли и повторные проходы принимайте за счёт идемпотентности.

Иначе говоря, с FileSystemWatcher хитрость в том, чтобы не приравнивать «получение события» к «разрешению обработать». Одно только это разделение заметно сокращает число мониторинговых решений, которые ломаются лишь изредка.

8. Справочные материалы

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Если ваше Windows-приложение приняли за вирус — как реагировать на ложные срабатывания Microsoft Defender и жить с влиянием на производительность

Разбираем правильный порядок действий, если Microsoft Defender ложно определяет ваше Windows-приложение как вредоносное: как устроена сов...

Спящий режим, гибернация, Modern Standby и долго работающие приложения — как проектированием предотвратить «остановилось ночью»

Разбираем, почему долго работающее Windows-приложение оказывается «остановленным к утру», начиная с различий между спящим режимом S3, гиб...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Технические консультации и ревью дизайна

Если нужно оформить меры против потерь уведомлений, повторное сканирование и определение завершения в виде цельного проектного решения, это хорошо ложится на техническую консультацию и ревью архитектуры.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Можно ли читать файл в обработчике события 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 приходит помногу раз?
Даже обычные операции вроде перемещения или сохранения файла могут распадаться на несколько событий, а кроме того, регистрируются и действия антивируса или индексатора. Проектировать логику, полагаясь на количество или порядок событий, опасно. Уведомления стоит сворачивать в один тип сигнала — «запрос на пересканирование», направлять сканирование в единственный worker, а при всплесках уведомлений сначала собирать их в течение примерно 100–300 мс и лишь затем выполнять одно сканирование — так стабильнее.
Решает ли увеличение InternalBufferSize проблему потерянных уведомлений?
Нет. Даже если поднять значение до 64 КБ, всплеск уведомлений, превышающий этот предел, снова приведёт к потерям, а вопрос о том, является ли уведомление признаком завершения, вообще никак не решается. К тому же буфер использует non-paged memory, так что увеличивать его бездумно тоже не стоит. Правильный порядок действий — сначала сузить область наблюдения через Filter/NotifyFilter, пересмотреть IncludeSubdirectories, облегчить обработчик событий и добавить full rescan вместе с идемпотентностью.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог