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

Фоновый рендеринг PDF в Delphi с возможностью отмены задач

Рендеринг страницы в PDFium выполняется синхронно. Вы вызываете функцию библиотеки, она отрисовывает изображение в переданный вами битмап, и управление возвращается после записи пикселей. Для одной страницы размером с экран при одном масштабе это занимает несколько миллисекунд и проходит незаметно. Но для экспорта 200-страничного документа с разрешением 300 dpi или для ленты миниатюр, где нужно отрисовать все страницы сразу, тот же вызов требует секунд. Если сделать его из главного потока, цикл сообщений остановится, окно перестанет перерисовываться, а Windows покажет предупреждение «Не отвечает» в заголовке окна. Задача правильная, но место ее выполнения выбрано неверно

Решение — перенести длительный рендеринг в фоновый поток и вернуть результат в главный поток для отображения. PDFium не запрещает это делать, но биндинг должен обеспечить безопасность передачи данных, так какconcurrency-ошибки при взаимодействии рабочих потоков с интерфейсом сложны для диагностики и проявляются бессистемно. Модуль FPdfAsync в составе библиотеки PDFiumPas разработан для предоставления корректной реализации этого паттерна с моделью отмены, соответствующей поведению длительного рендеринга

Суть задачи

Три операции чаще всего занимают больше времени, чем длится один кадр интерфейса. Пакетный рендеринг обходит диапазон страниц и отрисовывает каждую из них (обычно на диск). Многостраничный экспорт делает то же самое, но собирает результат в один файл. Фоновый рендеринг страниц используется в окне просмотра, когда пользователь переходит к странице, которой еще нет в кэше — изображение формируется в фоновом потоке и отображается по готовности. Все три операции имеют общие ограничения: они длятся слишком долго для выполнения в потоке UI, возвращают результат, который в итоге нужен интерфейсу, и пользователь может отменить их в любой момент. Закрытие документа, прокрутка мимо страницы или нажатие кнопки «Отмена» должны останавливать вычисления, чтобы пользователю не приходилось ждать ненужных результатов

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

Асинхронный запуск задач

Метод TPdfFuture<T>.Run принимает рабочую процедуру (worker), процедуру обратного вызова (reply) и необязательный токен отмены. Он запускает задачу в фоновом потоке, и по ее завершении передает результат в метод обратного вызова в главном потоке. Обобщенный параметр T представляет собой возвращаемое значение рендеринга (обычно дескриптор битмапа или запись состояния). Задача выполняется вне потока интерфейса, а обратный вызов срабатывает там, где безопасно взаимодействовать с VCL

Диаграмма отменяемой фоновой отрисовки PDF в Delphi: главный поток запускает future с рабочим и ответом, фоновый поток опрашивает токен отмены между страницами, а ответ исполняется в главном потоке с исходами success, cancelled и failed
Future вручает воркеру токен только на чтение, ответ ложится в главный поток, который один раз ветвится по трём достижимым статусам
class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

В классе намеренно отсутствует метод Wait. Здесь нет возможности заблокировать вызывающий поток до завершения асинхронной задачи, и это сделано осознанно. Вызов блокирующего ожидания Wait из главного потока — классическая причина взаимоблокировок (deadlocks) в UI: фоновому потоку нужно синхронизироваться с главным потоком для передачи ответа через Synchronize, главный поток заблокирован в ожидании Wait, и ни одна сторона не может продолжить работу. Отсутствие этого метода исключает архитектурный паттерн, который чаще всего приводит к зависаниям. Если коду действительно требуется блокировка, следует использовать обычный класс TThread со всеми вытекающими последствиями. Наш класс спроектирован для асинхронного выполнения по принципу «запустил и забыл», что и представляет собой фоновый рендеринг

Результат оборачивается в запись TPdfFutureResult<T>, сообщающую обработчику один из трех вариантов исхода. IsSuccess означает, что задача завершилась штатно и поле Value содержит результат. Состояние IsCancelled указывает, что сработал токен отмены и задача завершилась в одной из контрольных точек. Состояние IsFailure говорит о возникновении исключения, текст которого передается в ErrorMessage. Обработчик один раз проверяет статус и выполняет нужное ветвление вместо попыток угадать состояние битмапа по косвенным признакам

Почему ответ идёт через Synchronize, а не Queue

Самая поучительная деталь этого модуля — одна строка: как ответ возвращается в главный поток. Очевидный выбор — TThread.Queue. Queue ставит ответ в очередь главного потока и немедленно возвращается, что выглядит ровно как то, чего хочет future типа «запустил и забыл». Здесь это неверно, и причину стоит проговорить подробно, потому что это тот сорт ошибки, который проходит все тесты, какие вы сумеете придумать

Рабочий поток создаётся с FreeOnTerminate := True. Это значит, что в момент возврата из Execute поток уничтожает сам себя, а TThread.Destroy в числе прочего вызывает RemoveQueuedEvents(Self). RemoveQueuedEvents вычищает любые события в очереди, чья цель — умирающий поток. Последовательность получается такой: воркер закончил, поставил ответ в очередь к самому себе, Execute вернулся, поток самоликвидировался, и RemoveQueuedEvents удалил ответ, который главный поток ещё не успел выполнить. Результат просто исчезает. Хуже того: в узком окне, когда главный поток вытаскивает ответ из очереди и начинает его исполнять ровно в тот момент, когда поток освобождается, ответ трогает поля наполовину разрушенного объекта — это use-after-free

Поэтому FPdfAsync доставляет ответ через Synchronize, а не Queue. Synchronize блокирует рабочий поток до тех пор, пока главный поток не выполнит ответ целиком. Воркер ещё жив, пока его ответ исполняется, — освобождать что-либо у него из-под ног нечему, и поток не возвращается из Execute (а значит, не начинает самоликвидацию), пока ответ не доставлен. Доставка гарантирована, и окна use-after-free нет

Хронология рядом: почему TThread.Queue мог молча потерять ответ future, пока FreeOnTerminate уничтожал рабочий поток Delphi, и как переход на Synchronize гарантирует доставку при фоновой отрисовке PDFium
Queue позволяла потоку с FreeOnTerminate вычистить собственный недоставленный ответ; Synchronize держит воркера живым, пока главный поток не закончит ответ
procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // уже отменено? пропускаем воркер
    FResult.Value := FWorker(FToken);
  except
    on E: EPdfOperationCancelled do
    begin
      FResult.Status := pfsCancelled;
      FResult.ErrorMessage := E.Message;
    end;
    on E: Exception do
    begin
      FResult.Status := pfsFailure;
      FResult.ErrorMessage := E.Message;
    end;
  end;

  if Assigned(FReply) then
    // Synchronize, а не Queue: этот поток FreeOnTerminate, поэтому поставленный в очередь ответ
    // мог быть отброшен RemoveQueuedEvents до того, как главный поток его выполнил.
    Synchronize(DispatchReply);
end;

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

Почему обратные вызовы являются указателями методов

Рабочие методы и обработчики ответов не реализованы как анонимные методы. Они используют типы procedure of object: TPdfFutureWorker<T> и TPdfFutureReply<T>. Этот выбор продиктован требованиями совместимости компиляторов. Библиотека PDFiumPas собирается на Delphi XE5 и новее, а также в компиляторе Free Pascal 3.2 в режиме Delphi, где поддержка анонимных методов отсутствует. Обратный вызов через анонимный метод с захватом локальных переменных компилировался бы в Delphi, но вызывал бы ошибку в FPC. Поэтому в модуле выбран минимальный общий знаменатель, поддерживаемый обоими компиляторами

На практике это влияет на место хранения данных состояния. Анонимный метод захватывает локальные переменные, а указатель метода — нет. Поэтому любые данные, необходимые задаче (индекс страницы, масштаб, путь к файлу), а также данные, обновляемые при ответе (компонент вывода изображения или индикатор прогресса), должны находиться в объекте, методы которого передаются. В визуальном приложении таким объектом обычно выступает форма или вспомогательный контроллер рендеринга. Это не просто вынужденный компромисс: такой подход делает владение данными состояния явным и видимым на уровне объекта-получателя вместо скрытия их внутри замыкания

Кооперативная отмена вместо жесткого завершения потоков

Отмена задач реализована по кооперативному принципу. В библиотеке нет методов для жесткого принудительного уничтожения рабочего потока, поскольку прерывание работы в процессе рендеринга оставляет PDFium с заблокированными ресурсами и частично записанными битовыми картами. Состояние процесса после такого завершения становится непредсказуемым. Вместо этого рабочий поток получает read-only токен и должен регулярно опрашивать его в цикле обработки страниц или фрагментов изображений, выполняя безопасную остановку

Токен предлагает три способа отслеживания отмены. Свойство IsCancelled — простое логическое поле для проверки в цикле. Метод ThrowIfCancelled используется наиболее часто: вызовите его в контрольной точке, и при наличии запроса отмены он сгенерирует исключение EPdfOperationCancelled, которое прервет выполнение задачи. Метод RegisterCallback позволяет зарегистрировать однократное уведомление, срабатывающее при отмене источника, что полезно, когда поток заблокирован на операции, которую можно прервать извне, а не крутится в цикле

Исключение — вот где граница потока становится важной. Когда воркер бросает EPdfOperationCancelled, future перехватывает его и превращает в статус отмены, так что ответ видит IsCancelled, а не сбой. Сам объект исключения никогда не переправляется в главный поток: он живёт и умирает на рабочем потоке, и в ErrorMessage копируется лишь строка сообщения. Переправа живого объекта исключения между потоками означала бы лазание в память потока, который завершается, — ошибку того же класса, от которой защищает доставка через Synchronize. Код состояния и строка пересекают границу чисто; объект — нет

Диаграмма модели кооперативной отмены в PDFium Component для Delphi: форма владеет записываемым источником токена отмены, фоновый рабочий получает только read-only токен, опрашиваемый в чистых точках, а исходы становятся success, cancelled или failure без завершения потоков
Отмена кооперативна: форма хранит записываемый источник, воркер видит только опрашиваемые члены, а исключение никогда не пересекает границу потока

Два интерфейса для защиты от самоотмены

Логика отмены разделена на два интерфейса. Интерфейс IPdfCancellationTokenSource предназначен для управления отменой: он содержит метод Cancel, и создающий его объект (обычно форма) хранит ссылку на него и вызывает Cancel при нажатии кнопки или закрытии формы. Интерфейс IPdfCancellationToken предназначен только для чтения состояния: он содержит свойства IsCancelled, ThrowIfCancelled и метод RegisterCallback. Это все, что получает рабочая процедура. Один реальный объект реализует оба интерфейса, но рабочему потоку передается только токен чтения, поэтому он физически не может вызвать отмену выполняемой им же задачи. Это разделение служит архитектурным барьером: рабочая задача, имеющая доступ к методу Cancel через свой токен, могла бы по ошибке отменить саму себя. Типизация исключает эту возможность

Также предусмотрено решение для сценариев, когда задача должна быть выполнена гарантированно без возможности отмены. Чтобы избавить разработчика от создания пустого источника отмены на каждый вызов, модуль предоставляет синглтон PdfNoCancellationToken, который всегда находится в состоянии «не отменен». Метод Run подставляет его автоматически, если параметр токена равен nil. Этот синглтон инициализируется при загрузке модуля, а не лениво при первом обращении, опять же из соображений многопоточности. Если бы несколько вызовов Run из разных потоков одновременно попытались инициализировать синглтон лениво, возникло бы состояние гонки, утечка дубликата или сбой при обращении к частично инициализированному экземпляру. Создание объекта до запуска потоков полностью решает проблему

Реализация отменяемого рендеринга

На практике вы создаете источник отмены, сохраняете его на форме, передаете его свойство Token в метод Run вместе с рабочим методом и методом обработки ответа, а кнопку отмены привязываете к источнику. Рабочий метод проверяет токен во время рендеринга, а метод ответа обновляет интерфейс по возвращении результатов. Поскольку обратные вызовы являются указателями методов, они могут читать любые необходимые поля формы

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // поле, живёт на форме
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // воркер замечает это в следующей точке отмены
end;

// Выполняется в фоновом потоке. Читает FPageRange / FOutputDir с формы.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // чистая остановка между страницами
    RenderOnePage(PageIndex);       // синхронная растеризация PDFium
  end;
  Result := True;
end;

// Выполняется в главном потоке. Здесь безопасно обращаться к VCL.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
  if AResult.IsSuccess then
    StatusLabel.Caption := 'Render complete'
  else if AResult.IsCancelled then
    StatusLabel.Caption := 'Cancelled'
  else
    StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;

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

Аналогичные правила многопоточности применяются и в других частях программы просмотра. Особенности кэширования и повторного использования битмапов при масштабировании описаны в статье о кэше рендеринга и производительности масштабирования, а общие принципы безопасной интеграции PDFium в Delphi изложены в материале об обеспечении безопасности памяти и ABI биндинга PDFium. Асинхронная инфраструктура поставляется в составе компонента PDFium Component для Delphi и C++Builder вместе с API рендеринга, текста и форм