Технічна стаття

Фоновий рендеринг PDF у Delphi зі скасовуваними ф'ючерсами

Рендеринг сторінки у PDFium є синхронним. Ви викликаєте бібліотеку, вона виконує растеризацію у переданий їй растр (bitmap), і управління повертається після запису пікселів. Для однієї сторінки розміром з екран при одному рівні масштабування це займає кілька мілісекунд, і ніхто цього не помічає. Але для експорту документа на 200 сторінок із роздільною здатністю 300 dpi або для стрічки мініатюр, яка має растеризувати всі сторінки одночасно, той самий виклик коштує секунди. Якщо ви зробите цей виклик із головного потоку, цикл повідомлень зупиниться, вікно перестане перемальовуватися, і Windows відобразить сумнозвісне "Не відповідає" (Not Responding) у рядку заголовка. Робота виконана правильно. Але місце, де ви її запустили, неправильне

Рішення полягає у перенесенні тривалого рендерингу у фоновий потік та поверненні результату в головний потік, де растр можна передати елементу керування. Сам PDFium не заважає вам це зробити, але прив'язка (binding) повинна зробити передачу безпечною, оскільки поверхня для помилок навколо шаблону "запустити в робочому потоці, відповісти в UI" є широкою, а збої - періодичними. Модуль FPdfAsync у PDFiumPas існує для того, щоб надати цьому шаблону одну правильну реалізацію з моделлю скасування, яка відповідає фактичній поведінці тривалого рендерингу

Характер роботи

Три операції домінують у випадках, коли рендеринг триває довше за один кадр. Пакетний рендеринг проходить діапазон сторінок і растеризує кожну сторінку, як правило, на диск. Багатосторінковий експорт робить те саме, але збирає результат в один файл. Фоновий рендеринг сторінки - це те, що робить засіб перегляду (viewer), коли користувач переходить на сторінку, якої ще немає в кеші, тому растр створюється поза головним потоком і відображається, коли він готовий. Усі три мають однакові обмеження. Вони виконуються достатньо довго, щоб потік UI не міг їх розмістити, вони створюють результат, який зрештою знадобиться потоку UI, і користувач може їх скасувати. Закриття документа, прокручування сторінки або натискання кнопки Cancel має зупинити роботу замість того, щоб змушувати користувача чекати на результат, який йому більше не потрібен

Останнє обмеження визначає архітектуру. Рендеринг, який неможливо скасувати, це рендеринг, який утримує документ відкритим і витрачає ресурси процесора після того, як результат перестав мати значення. Тому модуль побудований навколо двох примітивів, які поєднуються: ф'ючерс (future), який повертає результат, і маркер (token), який передає запит на скасування

Ф'ючерс за принципом "запустив і забув" (fire-and-forget)

TPdfFuture<T>.Run приймає робочий метод (worker), метод відповіді (reply) та необов'язковий маркер скасування (cancellation token). Він запускає робочий метод у фоновому потоці, і коли той завершується, доставляє відповідь у головний потік. Узагальнений параметр T - це те, що генерує рендеринг, часто це дескриптор растра або запис стану. Робочий метод виконується поза головним потоком; відповідь виконується там, де безпечно взаємодіяти з VCL

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

Навмисним упущенням є будь-який вид Wait. Немає методу для блокування того, хто викликає, доки ф'ючерс не завершиться, і це не недогляд. Виклик Wait із головного потоку - це класичний спосіб викликати взаємне блокування (deadlock) UI: робочому потоку потрібен головний потік, щоб запустити свою відповідь через Synchronize, головний потік запаркований всередині Wait, і жодна зі сторін не може продовжити роботу. Відмовляючись від надання цього примітиву, ф'ючерс виключає шаблон, який найчастіше підводить розробників, що намагаються написати це самостійно. Код, якому справді потрібно блокування, повинен використовувати звичайний TThread і брати на себе всі наслідки. Ф'ючерс призначений для випадків "запустив і забув", чим насправді і є фоновий рендеринг

Результат загорнутий у TPdfFutureResult<T> - запис, який повідомляє методу відповіді, що саме з трьох подій сталося. IsSuccess означає, що робочий метод завершився нормально, і Value містить рендер. IsCancelled означає, що спрацював маркер скасування і робочий метод перервався в точці скасування. IsFailure означає, що в робочому потоці виник виняток, і ErrorMessage містить його текст. Метод відповіді перевіряє стан один раз і розгалужується, замість того, щоб вгадувати за сигнальним (sentinel) значенням, чи є повернутий растр справжнім

Стан гонитви (race condition) у версії v1.61.0, що змінив доставку відповідей

Найбільш повчальною частиною цього модуля є зміна в одному рядку, на розуміння якої пішов певний час. У ранніх версіях робочий потік доставляв свою відповідь за допомогою TThread.Queue. Queue надсилає відповідь у чергу головного потоку і негайно повертається, що звучить саме як те, чого вимагає ф'ючерс типу "запустив і забув". Це було помилкою, і причину варто пояснити, оскільки це той тип помилки, який проходить усі тести, що ви можете придумати

Робочий потік створюється з FreeOnTerminate := True. Це означає, що тієї ж миті, коли Execute повертає управління, потік завершує своє існування, і TThread.Destroy викликає RemoveQueuedEvents(Self) у рамках очищення. RemoveQueuedEvents видаляє будь-який поставлений у чергу метод, метою якого є потік, що завершується. Отже, послідовність була такою: робочий метод закінчується, він ставить відповідь у чергу до самого себе, Execute завершується, потік знищує себе, а RemoveQueuedEvents видаляє відповідь, яку головний потік ще не запустив. Результат просто зникав. Гірше того, у вузькому вікні, коли головний потік витягував відповідь із черги і починав її виконувати в той самий момент, коли потік звільнявся, відповідь зверталася до полів напівзнищеного об'єкта, що є помилкою використання після звільнення (use-after-free)

Рішенням у v1.61.0 було доставляти відповідь за допомогою Synchronize замість Queue. 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;

Загальний урок важливіший за конкретне виправлення. Асинхронні зворотні виклики (callbacks) за принципом "запустив і забув" - це шаблон паралелізму, в якому найлегше зробити приховану помилку, оскільки "щасливий шлях" працює з першої спроби, а помилка криється у взаємодії між порядком завершення потоків і чергою. Вона не відтворюється за запитом. Це залежить від того, чи встиг головний потік очистити чергу до того, як робочий потік завершив своє знищення, а цей час планувальник вирішує по-різному кожного запуску. Примітив, який правильно реалізований один раз у прив'язці, є набагато ціннішим, ніж той самий код, переписаний у кожній програмі, якій потрібен фоновий рендеринг

Чому зворотні виклики є вказівниками на методи

Робочий метод і відповідь не є анонімними методами. Вони є типами procedure of object, TPdfFutureWorker<T> та TPdfFutureReply<T>, і цей вибір продиктований матрицею компіляторів. PDFiumPas компілюється на Delphi XE5 і новіших версіях, а також на Free Pascal 3.2 у режимі Delphi, а FPC 3.2 у цьому режимі не підтримує анонімні методи. Зворотний виклик типу "вказівник на процедуру", який захоплює локальні змінні, скомпілювався б у Delphi і викликав би помилку у FPC, тому модуль використовує найменший спільний знаменник, який приймають обидва компілятори

Практичним наслідком є те, де зберігається стан. Анонімний метод замикає локальні змінні (замикання); вказівник на метод цього не робить. Тому будь-який стан, який потрібен робочому методу - індекс сторінки, масштаб, шлях виведення, і будь-який стан, який метод відповіді має оновити - цільовий елемент зображення або мітка прогресу, мають належати об'єкту, метод якого передається. У засобі перегляду цим об'єктом зазвичай є форма або контролер рендерингу, яким вона володіє. Це не вимушений обхідний шлях; він зберігає право власності на цей стан явним і видимим на об'єкті-отримувачі замість того, щоб ховати його всередині замикання

Кооперативне скасування замість жорсткого знищення

Скасування тут є кооперативним. Не існує API, який би втручався в робочий потік і переривав його, оскільки завершення потоку посеред рендерингу залишає PDFium із заблокованими ресурсами та частково записаними растрами, а стан процесу після примусового завершення стає непередбачуваним. Натомість робочому методу передається маркер (token) лише для читання, і очікується, що він буде його перевіряти, а цикл рендерингу написаний так, щоб перевіряти його між сторінками або між плитками (tiles), де зупинка є чистою

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

Виняток є місцем, де межа потоку має значення. Коли робочий метод генерує виняток EPdfOperationCancelled, ф'ючерс перехоплює його і перетворює на статус скасування, тому метод відповіді бачить IsCancelled, а не помилку. Сам об'єкт винятку ніколи не маршалізується у головний потік. Він живе і вмирає в робочому потоці; лише рядок його повідомлення копіюється у ErrorMessage. Маршалізація живого об'єкта винятку між потоками означала б звернення до пам'яті, що належить потоку, який завершується, що є тією ж помилкою, якій покликане запобігти виправлення Synchronize. Код стану та рядок перетинають межу чисто; об'єкт цього б не зробив

Два інтерфейси, тому робочий метод не може скасувати сам себе

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

Існує відповідна деталь для випадку, коли викликаючому потрібен рендеринг, але він ніколи не збирається його скасовувати. Замість того, щоб змушувати створювати нове джерело при кожному виклику, модуль відкриває PdfNoCancellationToken, маркер-одинак (singleton), який постійно перебуває в стані не-скасовано. Run підставляє його, коли аргумент маркера залишається рівним nil. Цей одинак створюється відразу (eagerly) під час ініціалізації модуля, а не відкладено (lazily) при першому використанні, і причиною цього знову є паралелізм. Якби кілька викликів Run у різних робочих потоках одночасно звернулися до створеного відкладеного одинака, вони могли б створити стан гонитви (race) під час його конструювання, спричинити витік дубліката або короткочасно спостерігати напівініціалізований екземпляр. Створення його до того, як зможе запуститися будь-який робочий потік, повністю усуває цю проблему

Запуск скасовуваного рендерингу

На практиці ви створюєте джерело, зберігаєте його у формі, передаєте його Token у Run разом із робочим методом та методом відповіді, і прив'язуєте кнопку Cancel до джерела. Робочий метод перевіряє маркер під час рендерингу; відповідь оновлює 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;

Відповідь обробляє всі три результати, оскільки всі три є досяжними. Завершений рендеринг повідомляє про успіх, користувач, який натиснув Cancel, бачить гілку скасування, а файл, який не вдалося записати, або сторінка, яку не вдалося проаналізувати, надходить як помилка з повідомленням. Жодна з цих гілок не блокується, жодна з них не торкається робочого потоку, а растр або стан, який створив робочий метод, зчитується лише після того, як ф'ючерс доставив його в потоці, який володіє інтерфейсом користувача (UI)

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