Technical Article

Фоновый рендеринг PDF в Delphi с отменяемыми футурами

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

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

Форма работы

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

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

Футура типа «запустил и забыл»

TPdfFuture<T>.Run принимает рабочий метод, метод ответа и необязательный токен отмены. Он запускает рабочий метод в фоновом потоке, а когда тот завершается, доставляет ответ в главный поток. Универсальный параметр T - это то, что производит рендеринг, зачастую дескриптор битмапа или запись статуса. Рабочий метод выполняется вне потока; ответный метод выполняется там, где безопасно обращаться к VCL

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

Намеренное упущение - любой вид метода Wait. Метода для блокировки вызывающего потока до завершения футуры не существует, и это не оплошность. Вызов Wait из главного потока - классический способ вызвать взаимоблокировку интерфейса: рабочему потоку нужен главный поток для выполнения ответа через 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 удаляет ответ, который главный поток ещё не выполнил. Результат попросту исчезал. Хуже того, в узком окне, когда главный поток извлекал ответ из очереди и начинал его выполнять в тот самый момент, когда поток освобождался, ответ обращался к полям наполовину уничтоженного объекта, что представляло собой использование памяти после её освобождения

Исправление в v1.61.0 заключалось в доставке ответа с помощью Synchronize вместо Queue. Synchronize блокирует рабочий поток до тех пор, пока главный поток не выполнит ответ до конца. Рабочий поток ещё жив, пока выполняется его ответ, поэтому нечего освобождать из-под него, и поток не возвращает управление из Execute (и, следовательно, не начинает уничтожать себя) до тех пор, пока ответ не будет доставлен. Доставка гарантирована, и окно использования памяти после освобождения закрыто

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, и FPC 3.2 в этом режиме не поддерживает анонимные методы. Обратный вызов в виде ссылки на процедуру, захватывающей локальные переменные, компилировался бы на Delphi и не компилировался бы на FPC, поэтому модуль использует наименьший общий знаменатель, принимаемый обоими компиляторами

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

Кооперативная отмена, а не принудительное завершение

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

Токен предлагает три способа наблюдения отмены. IsCancelled - это дешёвый булев опрос для цикла, который хочет проверить и принять решение самостоятельно. ThrowIfCancelled - это наиболее распространённый случай: вызовите его в естественной точке отмены, и если отмена была запрошена, он генерирует EPdfOperationCancelled, которое разматывает рабочий метод прямо обратно в футуру. RegisterCallback прикрепляет одноразовое уведомление, срабатывающее один раз при отмене источника, что полезно, когда рабочий метод заблокирован в чём-то, что он может прервать, а не находится в тесном цикле

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

Два интерфейса, чтобы рабочий метод не мог отменить сам себя

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

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

Запуск отменяемого рендеринга

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

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 VCL для обеспечения безопасности памяти. Асинхронная инфраструктура, описанная здесь, поставляется в составе PDFium Component для Delphi и C++Builder вместе с API рендеринга, работы с текстом и формами, описанными в других статьях этого блога