Рендирането на страница в PDFium е синхронно. Вие извиквате библиотеката, тя растаризира в растерно изображение, което сте ѝ подали, и контролът се връща, когато пикселите бъдат записани. За единична страница с размерите на екрана при едно ниво на мащабиране това отнема няколко милисекунди и никой не забелязва. За експорт на документ от 200 страници с 300 dpi или лента с миниатюри, която трябва да растаризира всяка страница наведнъж, същото извикване струва секунди. Ако направите това извикване от основната нишка, цикълът на съобщения спира, прозорецът спира да се прерисува и Windows изписва страховитото "Не отговаря" над вашата заглавна лента. Работата е правилна. Мястото, където сте я стартирали, е грешно
Решението е да преместите дългото рендиране на фонова нишка и да върнете резултата обратно в основната нишка, където растерното изображение може да бъде предадено на контрола. Самият PDFium не ви спира да направите това, но свързването трябва да направи предаването безопасно, защото повърхността за грешки около "изпълни на работник, отговори в UI" е широка и отказите са периодични. Модулът FPdfAsync в PDFiumPas съществува, за да даде на този модел една правилна имплементация, с модел на отмяна, който отговаря на това как действително се държи едно дълго рендиране
Формата на работата
Три операции доминират в случаите, когато рендирането надживява един кадър. Партидното рендиране обхожда диапазон от страници и растаризира всяка страница, обикновено на диск. Многостраничният експорт прави същото, но сглобява изхода в един файл. Фоновото рендиране на страница е това, което прави програма за преглед, когато потребителят скочи на страница, която все още не е в кеша, така че растерното изображение се произвежда извън нишката и се показва, когато е готово. И трите споделят едни и същи ограничения. Те работят достатъчно дълго, за да не може основната нишка да ги хоства, те произвеждат резултат, от който основната нишка в крайна сметка се нуждае, и потребителят може да се откаже от тях. Затварянето на документа, превъртането покрай страницата или натискането на Отказ трябва да спре работата, вместо да принуждава потребителя да чака изход, който вече не иска
Това последно ограничение е това, което оформя дизайна. Рендиране, което не може да бъде отменено, е рендиране, което държи документа отворен и изгаря CPU, след като отговорът е престанал да има значение. Така че модулът е изграден около два примитива, които се комбинират: фючърс (future), който връща резултата, и токен, който предава заявката за отмяна напред
Фючърс изстреляй-и-забрави (fire-and-forget)
TPdfFuture<T>.Run приема работник, отговор и незадължителен токен за отмяна. Той стартира работника на фонова нишка и когато работникът приключи, доставя отговора на основната нишка. Общият параметър T е това, което произвежда рендирането, често манипулатор (handle) на растерно изображение или запис на състоянието. Работникът се изпълнява извън нишката; отговорът се изпълнява там, където е безопасно да се докосне VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Умишленото пропускане е какъвто и да е вид Wait. Няма метод за блокиране на извикващия, докато фючърсът не завърши, и това не е недоглеждане. Wait, извикан от основната нишка, е класическият начин за блокиране (deadlock) на потребителския интерфейс: работникът се нуждае от основната нишка, за да изпълни отговора си през Synchronize, основната нишка е паркирана в Wait и нито една от двете страни не може да продължи. Отказвайки да предложи примитива, фючърсът изключва модела, който най-често побеждава хората, които се опитват да напишат това сами. Код, който действително трябва да блокира, трябва да използва обикновен TThread и да поеме последствията. Фючърсът е за случая fire-and-forget, което всъщност представлява фоновото рендиране
Резултатът е обвит в TPdfFutureResult<T>, запис, който казва на отговора кое от три неща се е случило. IsSuccess означава, че работникът се е върнал нормално и Value съдържа рендирането. IsCancelled означава, че токенът се е задействал и работникът се е отказал в точка на отмяна. IsFailure означава, че работникът е хвърлил изключение, а ErrorMessage носи текста. Отговорът проверява състоянието веднъж и се разклонява, вместо да гадае от контролна стойност (sentinel value) дали върнатото растерно изображение е истинско
Състезанието във версия 1.61.0, което промени доставката на отговори
Най-поучителната част от този модул е промяна от един ред, чието разбиране отне известно време. В ранните версии работната нишка доставяше отговора си с TThread.Queue. Queue изпраща отговора към опашката на основната нишка и се връща незабавно, което се чете точно като това, което иска един фючърс fire-and-forget. Беше грешно и причината си струва да се обясни, защото това е видът грешка, която преминава всеки тест, който се сетите да напишете
Работната нишка се създава с FreeOnTerminate := True. Това означава, че в мига, в който Execute се върне, нишката се самоунищожава, и TThread.Destroy извиква RemoveQueuedEvents(Self) като част от почистването. RemoveQueuedEvents изчиства всеки метод на опашката, чиято цел е умиращата нишка. Така че последователността беше: работникът приключва, той поставя отговора в опашката срещу себе си, Execute се връща, нишката се самоунищожава и RemoveQueuedEvents изтрива отговора, който основната нишка все още не е изпълнила. Резултатът просто изчезваше. По-лошото е, че в тесния прозорец, където основната нишка изтегли отговора от опашката и започна да го изпълнява в същия момент, в който нишката се освобождаваше, отговорът докосна полета на полуунищожен обект, което е използване след освобождаване (use-after-free)
Поправката във версия 1.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;
Общият урок надживява конкретната поправка. Асинхронните обратни извиквания (callbacks) fire-and-forget са най-лесният модел на паралелност, който може да се обърка неусетно, защото щастливият път работи от първия опит, а грешката живее във взаимодействието между реда на разрушаване на нишката и опашката. Тя не се възпроизвежда по заявка. Зависи от това дали основната нишка случайно е източила опашката, преди работникът случайно да приключи с унищожаването си, което е време, което планировчикът (scheduler) решава различно при всяко изпълнение. Примитив, който е правилен веднъж, в свързването (binding), струва много повече от същия код, изведен отново във всяко приложение, което се нуждае от фоново рендиране
Защо обратните извиквания са указатели към методи
Работникът и отговорът не са анонимни методи. Те са типове procedure of object, TPdfFutureWorker<T> и TPdfFutureReply<T>, и този избор е наложен от матрицата на компилатора. PDFiumPas се компилира на Delphi XE5 и по-нови версии и на Free Pascal 3.2 в режим Delphi, а FPC 3.2 в този режим не поддържа анонимни методи. Обратно извикване от тип препратка към процедура, което улавя локални променливи, би се компилирало на Delphi и би се провалило на FPC, така че модулът използва най-малкото общо кратно, което и двата компилатора приемат
Практическото следствие е къде живее състоянието. Анонимен метод затваря върху локални променливи; указател към метод не го прави. Така че всяко състояние, от което се нуждае работникът, индексът на страницата, мащабът, пътят на изхода, и всяко състояние, което отговорът трябва да актуализира, целевият контрол за изображение или етикетът за напредък, трябва да виси от обекта, чийто метод се предава. В програма за преглед този обект обикновено е формата или контролер за рендиране, който тя притежава. Това не е заобиколно решение, наложено с неохота; то поддържа собствеността върху това състояние изрична и видима на приемащия обект, вместо скрита вътре в closure
Кооперативна отмяна, а не твърдо убиване
Отмяната тук е кооперативна. Няма API, което да достига до работната нишка и да я прекратява, защото прекратяването на нишка по време на рендиране оставя PDFium да държи ключалки (locks) и частично записани растерни изображения, а състоянието на процеса след принудително убиване не е нещо, за което можете да разсъждавате. Вместо това на работника се подава токен само за четене и се очаква да го проверява, а цикълът на рендиране е написан да го проверява между страниците или между плочките, където спирането е чисто
Токенът предлага три начина за наблюдение на отмяната. IsCancelled е евтина булева анкета за цикъл, който иска да тества и да реши сам за себе си. ThrowIfCancelled е често срещаният случай: извикайте го в естествена точка на отмяна и, ако е поискана отмяна, той хвърля EPdfOperationCancelled, което развива работника право обратно към фючърса. RegisterCallback прикрепя еднократно известие, което се задейства веднъж, когато източникът е отменен, полезно, когато работникът е блокиран в нещо, което може да прекъсне, вместо да седи в стегнат цикъл
Изключението е там, където границата на нишката има значение. Когато работникът хвърли EPdfOperationCancelled, фючърсът го улавя и го превръща в отменено състояние, така че отговорът вижда IsCancelled, а не повреда. Самият обект на изключението никога не се маршалира към основната нишка. Той живее и умира на работната нишка; само неговият низ на съобщение се копира в ErrorMessage. Маршалирането на жив обект на изключение през нишки би означавало достигане до памет, собственост на нишка, която приключва, което е същият клас грешка, която поправката със Synchronize съществува, за да предотврати. Кодът на състоянието и низът пресичат границата чисто; един обект не би го направил
Два интерфейса, така че работникът не може да отмени себе си
Отмяната е разделена през два интерфейса нарочно. IPdfCancellationTokenSource е страната за запис: има Cancel, и собственикът, който го създава, обикновено формата, го пази и извиква Cancel, когато потребителят щракне върху бутона или формата се затвори. IPdfCancellationToken е страната за четене: има IsCancelled, ThrowIfCancelled и RegisterCallback, и това е всичко, което работникът някога получава. Един конкретен обект имплементира и двете, но на работника се подава само токенът, така че няма начин да отмени операцията, която изпълнява. Разделянето е предпазна мантинела на ниво API. Работник, който може да достигне Cancel чрез своя токен, би поканил объркано парче код да отмени само себе си, а системата от типове премахва възможността
Има съвпадащ детайл за случая, когато извикващият иска рендиране, но никога не възнамерява да го отмени. Вместо да налага нов източник за всяко извикване, модулът излага PdfNoCancellationToken, сингълтън токен, който е постоянно в неотменено състояние. Run го замества, когато аргументът на токена е оставен nil. Този сингълтън се конструира нетърпеливо (eagerly) по време на инициализация на модула, а не мързеливо (lazily) при първа употреба, и причината отново е паралелността. Ако няколко извиквания на Run на различни работни нишки едновременно посегнат към мързеливо създаден сингълтън, те биха могли да се състезават при неговото конструиране, да изтекат дубликат или за кратко да наблюдават полуинициализиран екземпляр. Изграждането му, преди който и да е работник да може да се стартира, премахва изцяло състезанието
Изпълнение на анулируемо рендиране
На практика създавате източник, пазите го във формата, подавате неговия Token в Run заедно с метод за работник и метод за отговор, и свързвате бутона Cancel с източника. Работникът проверява токена, докато рендира; отговорът актуализира потребителския интерфейс, след като резултатът се върне. Тъй като обратните извиквания са указатели към методи, работникът и отговорът четат каквото им е необходимо от полетата на формата
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, вижда отменения клон, а файл, който не е могъл да бъде записан, или страница, която не е успяла да се парсне, пристига като повреда със съобщение. Нито един от тези клонове не блокира, нито един от тях не докосва работната нишка, и растерното изображение или състоянието, което работникът е произвел, се чете само след като фючърсът го е доставил в нишката, която притежава потребителския интерфейс
Същата дисциплина на нишките се отплаща и другаде в програмата за преглед. Начинът, по който рендираните растерни изображения се запазват и използват повторно при промени в мащаба, е обхванат в нашата бележка за кеша за рендиране и производителността на мащабиране, а по-широкият въпрос за запазване на безопасността на границата на PDFium под Delphi е в заздравяване на PDFium VCL ABI за безопасност на паметта. Асинхронната инфраструктура, описана тук, се доставя като част от компонента PDFium за Delphi и C++Builder, заедно с API-тата за рендиране, текст и формуляри, разгледани на други места в този блог