Рендеринг сторінки у PDFium є синхронним. Ви викликаєте бібліотеку, вона растеризує в передану їй бітову карту, і керування повертається, коли пікселі записані. Для однієї сторінки розміром з екран на одному рівні масштабу це займає кілька мілісекунд, і ніхто нічого не помічає. Але для експорту 300 dpi документа на 200 сторінок або смуги мініатюр, яка має растеризувати кожну сторінку одразу, той самий виклик коштує секунди. Якщо зробити цей виклик з головного потоку, цикл повідомлень зупиняється, вікно перестає перемальовуватися, а 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, викликаний з головного потоку, — це класичний спосіб заблокувати 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 видаляє відповідь, яку головний потік ще не виконав. Результат просто зникав. Гірше того, у вузькому вікні, де головний потік витягнув поставлену в чергу відповідь і почав її виконувати рівно в той момент, коли потік звільнявся, відповідь торкалася полів напівзнищеного об'єкта, що є використанням після звільнення
Виправлення у v1.61.0 полягало в доставці відповіді за допомогою Synchronize замість Queue. Synchronize блокує робочий потік, доки головний потік не виконає відповідь повністю. Робоча процедура все ще жива, поки виконується її відповідь, тож немає нічого, що можна було б звільнити з-під неї, і потік не повертається з Execute (а отже, не починає знищувати себе), доки відповідь не доставлена. Доставка гарантована, а вікно використання після звільнення закрите
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, а FPC 3.2 у цьому режимі не підтримує анонімні методи. Зворотний виклик типу reference-to-procedure, який захоплює локальні змінні, скомпілювався б на 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; // поле, живе на формі
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 для Delphi та C++Builder, поряд з API рендерингу, тексту й форм, що розглянуті деінде в цьому блозі