Рендеринг страницы в PDFium выполняется синхронно. Вы вызываете функцию библиотеки, она отрисовывает изображение в переданный вами битмап, и управление возвращается после записи пикселей. Для одной страницы размером с экран при одном масштабе это занимает несколько миллисекунд и проходит незаметно. Но для экспорта 200-страничного документа с разрешением 300 dpi или для ленты миниатюр, где нужно отрисовать все страницы сразу, тот же вызов требует секунд. Если сделать его из главного потока, цикл сообщений остановится, окно перестанет перерисовываться, а Windows покажет предупреждение «Не отвечает» в заголовке окна. Задача правильная, но место ее выполнения выбрано неверно
Решение — перенести длительный рендеринг в фоновый поток и вернуть результат в главный поток для отображения. PDFium не запрещает это делать, но биндинг должен обеспечить безопасность передачи данных, так какconcurrency-ошибки при взаимодействии рабочих потоков с интерфейсом сложны для диагностики и проявляются бессистемно. Модуль FPdfAsync в составе библиотеки PDFiumPas разработан для предоставления корректной реализации этого паттерна с моделью отмены, соответствующей поведению длительного рендеринга
Суть задачи
Три операции чаще всего занимают больше времени, чем длится один кадр интерфейса. Пакетный рендеринг обходит диапазон страниц и отрисовывает каждую из них (обычно на диск). Многостраничный экспорт делает то же самое, но собирает результат в один файл. Фоновый рендеринг страниц используется в окне просмотра, когда пользователь переходит к странице, которой еще нет в кэше — изображение формируется в фоновом потоке и отображается по готовности. Все три операции имеют общие ограничения: они длятся слишком долго для выполнения в потоке UI, возвращают результат, который в итоге нужен интерфейсу, и пользователь может отменить их в любой момент. Закрытие документа, прокрутка мимо страницы или нажатие кнопки «Отмена» должны останавливать вычисления, чтобы пользователю не приходилось ждать ненужных результатов
Последнее ограничение определяет архитектуру решения. Рендеринг, который нельзя отменить, продолжает держать документ открытым и тратить процессорное время, когда результат уже потерял актуальность. Поэтому модуль построен вокруг двух взаимодействующих примитивов: future, передающего результат обратно, и токена, передающего запрос отмены вперед
Асинхронный запуск задач
Метод TPdfFuture<T>.Run принимает рабочую процедуру (worker), процедуру обратного вызова (reply) и необязательный токен отмены. Он запускает задачу в фоновом потоке, и по ее завершении передает результат в метод обратного вызова в главном потоке. Обобщенный параметр T представляет собой возвращаемое значение рендеринга (обычно дескриптор битмапа или запись состояния). Задача выполняется вне потока интерфейса, а обратный вызов срабатывает там, где безопасно взаимодействовать с VCL
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. Обработчик один раз проверяет статус и выполняет нужное ветвление вместо попыток угадать состояние битмапа по косвенным признакам
Опасность состояния гонки в версии v1.61.0
Наиболее примечательной деталью этого модуля является изменение одной строки кода, суть которого прояснилась не сразу. В ранних версиях рабочий поток передавал свой ответ через метод TThread.Queue. Метод Queue помещает вызов в очередь главного потока и сразу возвращает управление, что выглядит идеально для асинхронного выполнения. Но это решение было ошибочным, и причину стоит разобрать подробно, так как подобные ошибки проходят большинство стандартных тестов
Рабочий поток создается с флагом FreeOnTerminate := True. Это означает, что сразу после выхода из метода Execute поток уничтожает сам себя, а деструктор TThread.Destroy вызывает метод RemoveQueuedEvents(Self) для очистки ресурсов. Метод RemoveQueuedEvents удаляет из очереди любые события, привязанные к уничтожаемому потоку. В результате возникала цепочка: рабочий поток завершал выполнение, ставил ответ в очередь к самому себе, выходил из метода Execute, уничтожался, и системный вызов RemoveQueuedEvents удалял ответ до того, как главный поток успевал его обработать. Ответ просто терялся. В худшем случае, если главный поток успевал извлечь ответ из очереди и начать его выполнение в тот же микросекундный интервал, когда поток уничтожался, происходило обращение к полям уже частично уничтоженного объекта (use-after-free)
В версии v1.61.0 это было исправлено заменой вызова Queue на Synchronize. Метод Synchronize блокирует рабочий поток до завершения обработки ответа в главном потоке. Рабочий поток гарантированно активен во время выполнения ответа, что исключает преждевременное освобождение ресурсов, а возврат из метода Execute (и самоликвидация потока) происходит только после гарантированной доставки ответа. Доставка обеспечена, а возможность ошибки use-after-free полностью исключена
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // already cancelled? skip the worker
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, not Queue: this thread is FreeOnTerminate, so a queued reply
// could be dropped by RemoveQueuedEvents before the main thread ran it.
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. Передача живого объекта исключения между потоками означала бы обращение к памяти завершающего свою работу потока, что является ошибкой того же класса, которую мы исправили заменой Queue на Synchronize. Код состояния и строка безопасно пересекают границу потоков, в отличие от объекта
Два интерфейса для защиты от самоотмены
Логика отмены разделена на два интерфейса. Интерфейс IPdfCancellationTokenSource предназначен для управления отменой: он содержит метод Cancel, и создающий его объект (обычно форма) хранит ссылку на него и вызывает метод при нажатии кнопки или закрытии формы. Интерфейс IPdfCancellationToken предназначен только для чтения состояния: он содержит свойства IsCancelled, ThrowIfCancelled и метод RegisterCallback. Это все, что получает рабочая процедура. Один реальный объект реализует оба интерфейса, но рабочему потоку передается только токен чтения, поэтому он физически не может вызвать отмену выполняемой им же задачи. Это разделение служит архитектурным барьером: рабочая задача, имеющая доступ к методу Cancel через свой токен, могла бы по ошибке отменить саму себя. Типизация исключает эту возможность
Также предусмотрено решение для сценариев, когда задача должна быть выполнена гарантированно без возможности отмены. Чтобы избавить разработчика от создания пустого источника отмены на каждый вызов, модуль предоставляет синглтон PdfNoCancellationToken, который всегда находится в состоянии «не отменен». Метод Run подставляет его автоматически, если параметр токена равен nil. Этот синглтон инициализируется при загрузке модуля, а не лениво при первом обращении, опять же из соображений многопоточности. Если бы несколько вызовов Run из разных потоков одновременно попытались инициализировать синглтон лениво, возникло бы состояние гонки, утечка дубликата или сбой при обращении к частично инициализированному экземпляру. Создание объекта до запуска потоков полностью решает проблему
Реализация отменяемого рендеринга
На практике вы создаете источник отмены, сохраняете его на форме, передаете его свойство Token в метод Run вместе с рабочим методом и методом обработки ответа, а кнопку отмены привязываете к источнику. Рабочий метод проверяет токен во время рендеринга, а метод ответа обновляет интерфейс по возвращении результатов. Поскольку обратные вызовы являются указателями методов, они могут читать любые необходимые поля формы
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // field, lives on the form
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // worker observes this at its next cancel point
end;
// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // clean stop between pages
RenderOnePage(PageIndex); // synchronous PDFium rasterisation
end;
Result := True;
end;
// Runs on the main thread. Safe to touch the VCL here.
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 рендеринга, текста и форм