Техническая статья

Дедлок колбэка файлового монитора HotXLS в Delphi

HotXLS следит за книгой на диске через TXLSFileMonitor и поднимает OnFileChanged, когда другой процесс сохраняется поверх файла. Установка Monitor.Active := False изнутри этого хендлера раньше вешала всё навечно, потому что воркер-поток всё ещё был заблокирован в ожидании колбэка, который только что разослал. Фикс — дать объекту потока нести идентичность колбэка, который он сейчас выполняет

Почему остановка файлового монитора внутри его собственного хендлера вешает программу?

Потому что вызов деактивации и колбэк исполняются на двух разных потоках, и каждый ждёт, когда закончит другой. TXLSFileMonitor поставляется с SynchronizeEvent в True, а это значит, что опрашивающий поток сам ваш хендлер не вызывает. Он зовёт TThread.Synchronize, отдаёт метод главному потоку и блокируется, пока главный не выполнит его и не просигналит о завершении. Ровно то, что нужно хендлеру, перезагружающему грид или перерисовывающему форму, — и ровно то, что превращает очевидный вызов деактивации в ловушку

Два лица дедлока файлового монитора HotXLS: с SynchronizeEvent True главный поток блокируется в Worker.WaitFor, пока воркер блокируется в TThread.Synchronize, а с False хендлер исполняется на опрашивающем потоке, так что SetActive False ждёт сам поток, не давая OnFileChanged вернуться
С включённым SynchronizeEvent главный поток ждёт в Worker.WaitFor, пока воркер ждёт в Synchronize; с выключенным хендлер исполняется на опрашивающем потоке и ждёт собственного выхода — один замкнутый цикл, два режима доставки
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // Мы в главном потоке, спасибо TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // Пользователь хотел только одну перезагрузку, поэтому перестаём следить
  FMonitor.Active := False;   // раньше никогда не возвращалось
end;

Пройдите по двум стекам — и цикл очевиден. Главный поток сидит внутри SetActive(False), который терминирует воркера, а затем зовёт Worker.WaitFor, чтобы убедиться, что поток реально вышел до освобождения объекта. Воркер-поток тем временем припаркован внутри TThread.Synchronize и ждёт, когда главный вернётся из того самого хендлера, который зовёт SetActive. Ни одна сторона не может сдвинуться. Приложение не падает, не бросает исключение, оно просто перестаёт перерисовываться — худшая диагностическая сигнатура, какую только может иметь баг

Тот же дедлок носит второе лицо

Отключение синхронизации не убирает проблему, оно лишь переносит её. С SynchronizeEvent в False — разумный выбор в сервисе или консольной утилите без message loop — TXLSFileMonitor вызывает хендлер прямо на опрашивающем потоке. Ваш хендлер теперь исполняется на воркере, так что вызов Monitor.Active := False изнутри доходит до Worker.WaitFor на самом воркер-потоке. Поток не может ждать собственного выхода, поэтому блокируется, пока кто-то снаружи не убьёт процесс. Оба варианта — один и тот же баг: SetActive(False) предполагал, что его всегда зовут откуда-то извне колбэка. Для кнопки Close или деструктора формы это допущение держится, а для единственного места вызова, к которому люди тянутся первым, — нет, поэтому оно и прожило так долго. Любой компонент, который и владеет потоком, и рассылает пользовательский код из него, наследует ту же опасность, и решение в каждом случае одинаковое: путь teardown обязан уметь отличать, что его исполняют изнутри колбэка, который всё ещё держит поток, к которому собираются присоединиться

Пусть воркер-поток несёт идентичность колбэка

HotXLS решает это записью на самом объекте потока, какой OS-поток сейчас исполняет хендлер. TXLSFileMonitorThread.FireEvent штампует FCallbackThreadID значением GetCurrentThreadId непосредственно перед вызовом хендлера и чистит его в finally после. Этого одного поля достаточно, чтобы путь деактивации отличал «вызвано из постороннего кода» от «вызвано из моего собственного колбэка», и работает оно одинаково в обоих режимах доставки, потому что в синхронизированном случае GetCurrentThreadId внутри FireEvent уже сообщает главный поток

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // Поставленный в очередь колбэк может быть разослан WaitFor во время деактивации
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // Хендлер мог уничтожить Owner, но этот поток остаётся жить
    FCallbackThreadID := 0;
  end;
end;

Ветка деактивации SetActive затем делает три вещи в продуманном порядке. Сначала отвязывает поток от монитора, обнуляя FThread, так что GetActive с этой секунды сообщает False, а повторный Active := False внутри того же хендлера выходит немедленно, вместо того чтобы дважды сносить поток. Затем зовёт Terminate. И только потом решает, как утилизировать объект потока

Как HotXLS разруливает teardown файлового монитора: TXLSFileMonitorThread.FireEvent штампует FCallbackThreadID значением GetCurrentThreadId перед вызовом хендлера и чистит его в finally, так что SetActive может отвязать FThread, вызвать Terminate и выбрать между самоосвобождением FreeOnTerminate внутри колбэка и классическими WaitFor плюс Free
FireEvent штампует FCallbackThreadID перед вызовом хендлера и чистит его в finally; SetActive затем выбирает между FreeOnTerminate при совпадении идентичности и классическими WaitFor плюс Free, когда вызывающий посторонний
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // монитор неактивен с этой точки
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // И прямой, и синхронизированный хендлеры обязаны вернуться до выхода
  // воркера; он освобождает себя после разматывания колбэка
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

При совпадении идентичности HotXLS не ждёт никогда. Он ставит FreeOnTerminate := True и возвращается, позволяя хендлеру доиграть, вызову Synchronize завершиться, Execute заметить Terminated, а RTL освободить объект потока после полного разматывания стека колбэка. Когда идентичность не совпадает — кнопка Close, деструктор, фоновая задача, — классические WaitFor плюс Free по-прежнему правильный выбор, потому что дают жёсткую гарантию: после возврата Active := False ни один колбэк больше не сработает. Два пути выхода, одно поле для выбора между ними. Одно следствие стоит проговорить: на пути самоосвобождения воркер переживает хендлер. Ничто в пути возврата FireEvent не должно больше трогать объект монитора, потому что хендлер вправе был его освободить. Именно поэтому FireEvent кэширует Owner и Handler в локальные переменные заранее и в finally трогает только своё поле. Случайный FOwner.Something после вызова хендлера превратил бы исправленный дедлок в use-after-free, а это строго худший исход

Почему FireEvent обязан проверять Terminated до любых действий?

Потому что WaitFor — не пассивное ожидание. В главном потоке реализация TThread.WaitFor в RTL продолжает дренировать очередь синхронизации, пока блокируется, — ровно для того, чтобы поток, припаркованный в Synchronize, мог завершиться и выйти, а не дедлочить против выключения. Это фича, и она означает, что колбэк, поставленный в очередь за микросекунды до вызова Active := False из кнопки Close, всё ещё может быть разослан во время teardown — уже после того, как монитор выбросил ссылку FThread. Гард в начале FireEvent превращает такую рассылку в no-op. Без него приложение, закрывающее главную форму, пока на наблюдаемый файл ложится сохранение, может прогнать хендлер перезагрузки против наполовину утилизированного состояния, и получившийся access violation выглядит совсем не как проблема порядка выключения. Если ваш компонент рассылает работу через Synchronize, правило обобщается: каждый синхронизированный метод нуждается в проверке Terminated первым оператором — не потому, что поток может исчезнуть, а потому, что очередь переживает решение остановиться

Та же форма бага в пути импорта CSV

Аудит, породивший фикс монитора, вскрыл братский дефект в текстовом импортёре, и его стоит читать рядом, потому что лежащая в основе ошибка идентична: кусок конфигурации потребляется так, будто это черновое состояние. TXLSCSVLoader выставляет свойство SkipRows для баннерных строк над настоящим заголовком в экспортных отчётах. Парсеру нужен обратный отсчёт, пока он пробирается через эти строки, и исходная реализация считала вниз на самом бэкинг-поле свойства. Импортируйте один файл — и SkipRows молча стал нулём; переиспользуйте тот же лоадер для второго файла — и баннерные строки легли в грид

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // две баннерные строки над заголовком
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows здесь всё ещё 2: FSkipRemaining сбрасывается на каждом парсинге
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS теперь держит FSkipRows как объявленную настройку и сбрасывает отдельный счётчик FSkipRemaining в начале каждого парсинга — и в делимом, и в фиксированно-ширинном путях. Общее правило дёшево в применении и окупается: если цикл его декрементит — это не бэкинг-поле свойства. Та же партия затянула и XlsCsvTryParseISODateTime, который раньше был готов принять разбираемый префикс за целую дату. Теперь он валидирует всё поле — длину, позиции разделителей и каждую цифру, — так что значение вроде ссылки на заказ, лишь начинающееся с чего-то похожего на дату, остаётся текстом, а не превращается в неверный TDateTime. Молчаливое приведение типа на импорте отлаживать дальше по потоку куда дороже, чем отклонённое поле, — тема, которая проходит и через то, как HotXLS защищает книгу от конкурентных читателей и писателей

Дефект CSV-лоадера за аудитом HotXLS: обратный отсчёт SkipRows на бэкинг-поле свойства молча обнулял его после одного парсинга, так что второй файл протекал баннерными строками в грид, тогда как исправленная модель держит FSkipRows объявленной настройкой и сбрасывает отдельный счётчик FSkipRemaining в начале каждого вызова ParseFile
Обратный отсчёт на бэкинг-поле свойства превращает конфигурацию в черновое состояние: первый парсинг обнуляет SkipRows, а исправленная модель держит FSkipRows объявленным и сбрасывает FSkipRemaining в начале каждого парсинга

Что этот фикс даёт, а чего не даёт

Будьте точны насчёт гарантии. Изменение владения потоком делает безопасным ровно один ранее фатальный паттерн: деактивацию или освобождение TXLSFileMonitor изнутри его собственного хендлера OnFileChanged в любом из режимов доставки. Это не общий дизайн конкурентности, и оно не делает компонент безопасным для управления с произвольных потоков

  • Запись свойств по-прежнему не thread-safe. Ставьте FileName, IntervalMs и SynchronizeEvent с одного потока — обычно с того, что создал монитор
  • IntervalMs действует со следующей активации, а воркер спит короткими порциями, чтобы Terminate выполнялся оперативно даже при длинном периоде опроса
  • Детекция идёт по времени последней записи плюс размеру, так что изменение, сохраняющее оба значения, невидимо; файл, исчезнувший посреди сохранения, не срабатывает, а событие приходит, когда новое содержимое уже на месте
  • С SynchronizeEvent в False ваш хендлер исполняется на воркер-потоке, и все правила про касание VCL-контролов из фонового потока распространяются на то, что вы делаете внутри него
  • Перезагрузка книги из хендлера по-прежнему требует своей дисциплины, особенно если другой код держит ссылку на заменяемую книгу

На практике именно хендлер перезагрузки заслуживает максимум заботы, а не монитор. Событие изменения обычно приходит, пока пишущий процесс ещё устаканивается, так что загрузка, открывающая файл немедленно, может встретить наполовину закоммиченное сохранение; одна повторная попытка после короткой паузы надёжнее, чем вера в первую метку. Если наблюдаемый файл пишет ваш собственный код, поэтапный temp-файл и атомарный rename, описанные в том, как HotXLS делает каждое сохранение crash-safe, дают монитору чистое событие одного перехода для наблюдения вместо потока промежуточных размеров. А если несколько наблюдателей кормят одну рутину перезагрузки, следующим к прочтению будет про проблемы сериализации в read gate вокруг конкурентной ZIP-инфляции. Более широкий урок переживает конкретный компонент. Любому классу, который владеет потоком и колбэчит в пользовательский код, нужен путь teardown, умеющий распознавать собственный фрейм колбэка, потому что хендлер — самое естественное в мире место, где пользователь скажет «стоп». Запись идентичности потока колбэка стоит один DWORD и превращает необъяснимое зависание в обычный вопрос времени жизни объекта. Файловый мониторинг, атомарные сохранения и текстовый импорт поставляются в HotXLS Delphi Component для Delphi и C++Builder, и ни для чего из этого не нужна дополнительная конфигурация