Мотивация

Поскольку Tresorit предлагает синхронизацию файлов, нам необходимо обнаруживать изменения в файловой системе. Чтобы сделать эту функцию действительно пригодной для использования, необходимо как можно быстрее информировать программное обеспечение об изменениях, внесенных другими приложениями. Мы могли бы периодически сканировать все синхронизированное дерево каталогов, но это медленно и потребляет много ресурсов, поэтому не обеспечивает адекватного взаимодействия с пользователем. Гораздо лучше обрабатывать только фактические изменения, и большинство операционных систем, включая Windows, могут предоставить эту информацию. Модуль, реализующий эту функцию, написан на C ++, и в прошлом для получения этих событий изменений использовалась популярная библиотека C, libuv. Но мы чувствовали, что напрямую использовать Windows API было бы лучше по нескольким причинам. Мы могли бы удалить библиотеку, что должно снизить расходы на обслуживание. Собственное решение также даст нам больше контроля над реализацией.

Чтобы снизить потребление ресурсов, мы договорились, что для всех путей, которые мы хотим прослушивать, следует использовать не более одного потока и поддерживать не менее 100 различных путей.

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

Возможные решения

Windows предлагает несколько способов отслеживать изменения в файловой системе. Давайте взглянем!

Есть очень простая функция: FindFirstChangeNotificationW(). Название говорит само за себя, он ждет, пока не произойдет первое изменение на заданном пути. Проблема в том, что изменения между вызовами будут потеряны, поэтому это не подходит для поставленной задачи.

Есть и более сложная функция: ReadDirectoryChangesW() функция (сокращенно RDC ()). Этот API может возвращать все изменения, поскольку он буферизует их между вызовами, поэтому события не будут потеряны. Однако имейте в виду, что в случаях, когда используемый буфер недостаточно велик, функция сообщит об ошибке, и последующие события будут потеряны. Это не является препятствием, поскольку в этом редком случае мы могли бы просто повторно просканировать весь путь.

ReadDirectoryChangesW ()

ReadDirectoryChangesW() возвращает изменения в структуре данных, называемые FILE_NOTIFY_INFORMATION. По сути, это настраиваемый односвязный список, в котором узлы содержат относительный путь и тип изменения (например, создание, удаление). Он не содержит никакой информации о отслеживаемом пути, поэтому он должен быть сохранен клиентским кодом. В нашем случае был создан класс с именем WatchInfo.

Существует версия этой функции * Ex (_ 5_), которая возвращает более подробную структуру (FILE_NOTIFY_EXTENDED_INFORMATION), но нам не нужна эта дополнительная информация для нашего внутреннего API. Также было бы проблематично использовать, потому что он доступен только с Windows 10 версии 1709.

RDC () требует следующих входных параметров:

  • HANDLE hDirectory: дескриптор контролируемого каталога
  • VOID* lpBuffer: буфер, в котором будет храниться FILE_NOTIFY_INFORMATION
  • DWORD nBufferLength: размер буфера
  • BOOL bWatchSubtree: смотреть ли все дерево каталогов - в нашем случае это всегда TRUE
  • DWORD dwNotifyFilter: фильтр по категориям событий. Прослушивая только подмножество событий, мы немного улучшили производительность нашего приложения и исправили незначительную проблему в процессе. Поскольку решение libuv отслеживает все виды событий, это было улучшением.

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

RDC () может сообщать об изменениях несколькими способами:

  1. Использование в качестве блокировки - здесь явно не применимо, поскольку мы не можем быть уверены, что будут изменения на каждом отслеживаемом пути.
  2. Использование функции GetOverlappedResult() с WaitForMultipleObjects().
  3. Используя процедуру завершения.
  4. Ожидание порта завершения ввода-вывода.

Способ GetOverlappedResult () / WaitForMultipleObjects ()

Мы могли бы создать своего рода объект события, чтобы сигнализировать потоку официантов, что были какие-то действия (например, изменение пути для наблюдения и т. Д.). Затем можно вызвать WaitForMultipleObjects() для этих объектов событий. Для реализации ознакомьтесь с модулем узла отслеживания пути, используемым редактором Atom.

Проблема с этим подходом заключается в том, что существует верхний предел в 64 объекта. Это ограничение связано со значением макроса MAXIMUM_WAIT_OBJECTS. Следовательно, у модуля Path Watcher Node есть открытый вопрос относительно этого ограничения. К сожалению, маловероятно, что будущая версия Windows увеличит этот предел, как это указано в ответе StackOverflow:

[…] Поскольку STATUS_ABANDONED_WAIT_63 определен как 0xBF, а STATUS_USER_APC определен как 0xC0, если вы увеличили MAXIMUM_WAIT_OBJECTS хотя бы на единицу, не было бы никакого способа определить разницу между 65-м дескриптором, который был брошен, и вашим ожиданием, завершенным APC. Правильное изменение MAXIMUM_WAIT_OBJECTS потребует перенумерации кодов состояния, что потребует перекомпиляции всех существующих программ Win32.

Кроме того, программа, скомпилированная с параметром MAXIMUM_WAIT_OBJECTS, определенным как 65, завершится ошибкой в ​​ОС, в которой определено как 64.

Хотя с помощью этого метода можно отслеживать более 64 путей, для этого требуется дополнительная логика, поскольку необходимо создать несколько потоков, которые ожидают друг друга, организованные в виде дерева. Из-за дополнительной сложности мы не использовали этот подход.

Использование процедуры завершения

Другой способ - указать процедуру завершения. Он будет работать как асинхронный вызов процедуры (APC). Наша проблема с этим подходом заключалась в том, что приложение в настоящее время не использует APC для каких-либо других функций, поэтому для них нет пула потоков. В настоящее время, когда поток находится в спящем режиме, он переходит в состояние ожидания, требующее предупреждения, что означает, что для него может быть запланирован APC, и функция SleepEx() вернется после его завершения. Это может означать, что поток, который намеревается засыпать на 5 секунд, внезапно вернется через 200 мс. Хотя в целом код написан с этим предположением, он может вызвать некоторые трудные для отладки проблемы.

Завершение работы также создает некоторые проблемы. Я не нашел способа ни отменить APC, ни определить, запущен он или нет. Конечно, вы можете добавить эту функциональность с помощью подсчета экземпляров или другими способами, но это добавит сложности без какого-либо заметного выигрыша.

Путь порта завершения ввода / вывода

Порт завершения ввода-вывода (обычно сокращенно IOCP) в основном представляет собой очередь сообщений ввода-вывода. Для получения этих сообщений можно использовать функцию GetQueuedCompletionStatusEx(). В нашем случае для каждого уведомления об изменении будет «пакет». Следующая проблема, которую необходимо решить, - определить каталог, в котором произошло изменение. Эти «пакеты» имеют довольно жесткую структуру, они содержат только следующую информацию:

  • DWORD* lpNumberOfBytesTransferred: Не контролируется клиентским кодом, он описывает длину структуры данных FILE_NOTIFY_INFORMATION.
  • ULONG_PTR* lpCompletionKey: Следует использовать для этой цели, но он не управляется вызовом RDC () - его можно установить с помощью другой функции: CreateIoCompletionPort().
  • OVERLAPPED** lpOverlapped: Хотя структура OVERLAPPED содержит не относящуюся к нам информацию, поскольку она в основном предназначена для операций асинхронного ввода-вывода, сам указатель может использоваться для однозначной идентификации источника изменения. Экземпляр может быть предоставлен клиентским кодом при вызове ReadDirectoryChangesW().

Хотя вы также можете использовать CompletionKey, мы решили использовать структуру OVERLAPPED для идентификации. Мы сохраняем информацию в std::map, где OVERLAPPED* является ключом, и ищем дополнительные данные (буфер FILE_NOTIFY_INFORMATION, исходный путь и т. Д.) Каждый раз, когда сообщается об изменении.

Итак, мы создаем новый поток, который запускает этот небольшой цикл событий, который просто прослушивает сообщения в IOCP. Завершение работы выполняется путем отправки специального пакета в IOCP, где заполняется CompletionKey, и это останавливает цикл обработки событий.

Чтобы удалить путь, который мы больше не хотим слушать, можно закрыть его дескриптор каталога. Как только вы это сделаете, RDC () отправит уведомление о закрытии. В большинстве случаев это пустое «сообщение» длиной 0 байт, но если на пути есть изменения, это может быть фактическое событие изменения. Это означает, что вы не можете освободить буфер, пока не получите уведомление о закрытии. Вот почему цикл событий не закрывается сразу после получения пакета выключения: он должен проверить, все ли уведомления о закрытии прибыли, и уничтожить ли соответствующие WatchInfo экземпляры. Это поведение явно не задокументировано, и я думаю, что это связано с тем, что документация Microsoft по API очень функционально ориентирована. Следуя этой логике, это должно быть упомянуто в документации для CloseHandle(), но, честно говоря, это не имеет ничего общего с RDC (), это очень общая функция. Сама статья RDC () объясняет много связанных вещей, но не много о том, как остановить прослушивание, что понятно, учитывая, что эта функция инициирует прослушивание.

Хороший ресурс об использовании RDC () с IOCP можно найти в исходниках 0 A.D., игры RTS с открытым исходным кодом. К сожалению, я нашел его только тогда, когда наше решение было отчасти законченным. Это очень хорошо документированный код, если пример кода непонятен, вы также можете проверить это. Версия, которую я нашел изначально, довольно старая, ее нынешнее воплощение несколько иное.

Имейте в виду, что указанный относительный путь может содержать короткие имена (имена 8.3, например RDC_FS~1.CPP). В документации неясно, когда это происходит:

Если для файла есть как короткое, так и длинное имя, функция вернет одно из этих имен, но не указано, какое именно.

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

Резюме

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

Хорошей новостью является то, что реализация доступна на GitHub, поэтому, если в моем объяснении чего-то не хватает, вы всегда можете проверить рабочий пример.

В конце концов, я думаю, была создана лучшая альтернатива, которая лучше работает и ее легче поддерживать. Кроме того, я много узнал о Win32 API, что всегда приятно, учитывая вездесущность платформы.