Vykresľovanie stránky v PDFium je synchrónne. Zavoláte do knižnice, tá rastrizuje do bitmapy, ktorú ste jej odovzdali, a riadenie sa vráti po zapísaní pixelov. Pri jednej stránke veľkosti obrazovky pri jednej úrovni priblíženia to trvá niekoľko milisekúnd a nikto si to nevšimne. Pri exporte v 300 dpi z dokumentu so 200 stránkami, alebo pri prúžku miniatúr, ktorý musí naraz rastrizovať každú stránku, to isté volanie stojí sekundy. Ak toto volanie uskutočníte z hlavného vlákna, slučka správ sa zastaví, okno prestane prekresľovať a Windows namaľuje nad váš titulkový pruh obávané „Neodpovedá". Práca je správna. Miesto, kde ste ju spustili, je nesprávne
Riešením je presunúť dlhé vykresľovanie na vlákno na pozadí a priniesť výsledok späť do hlavného vlákna, kde môže byť bitmapa odovzdaná ovládaciemu prvku. PDFium samotný vám v tom nebráni, ale binding musí urobiť odovzdanie bezpečným, pretože plocha chýb okolo „spusti na pracovníkovi, odpovedz na UI" je rozsiahla a zlyhania sú prerušované. Modul FPdfAsync v PDFiumPas existuje preto, aby tomuto vzoru poskytol jednu správnu implementáciu, s modelom zrušenia, ktorý zodpovedá tomu, ako sa dlhé vykresľovanie skutočne správa
Tvar práce
Trom operáciám dominujú prípady, keď vykresľovanie trvá dlhšie ako jeden snímok. Dávkové vykresľovanie prechádza rozsahom stránok a rastrizuje každú stránku, zvyčajne na disk. Viacstránkový export robí to isté, ale zostavuje výstup do jedného súboru. Vykresľovanie stránky na pozadí je to, čo prehliadač robí, keď používateľ skočí na stránku, ktorá ešte nie je vo vyrovnávacej pamäti, takže bitmapa sa vytvorí mimo vlákna a zobrazí sa, keď je pripravená. Všetky tri zdieľajú rovnaké obmedzenia. Bežia dostatočne dlho na to, aby ich vlákno UI nemohlo hostiť, produkujú výsledok, ktorý vlákno UI nakoniec potrebuje, a používateľ ich môže opustiť. Zatvorenie dokumentu, posúvanie za stránku alebo stlačenie tlačidla Zrušiť by malo zastaviť prácu namiesto toho, aby nútilo používateľa čakať na výstup, ktorý už nechce
Práve toto posledné obmedzenie formuje návrh. Vykresľovanie, ktoré sa nedá zrušiť, je vykresľovaním, ktoré drží dokument otvorený a spaľuje CPU po tom, čo odpoveď prestala byť dôležitá. Modul je preto postavený okolo dvoch primitívov, ktoré sa skladajú: future, ktorá nesie výsledok späť, a token, ktorý nesie žiadosť o zrušenie dopredu
Future typu fire-and-forget
TPdfFuture<T>.Run prijíma pracovníka, odpoveď a voliteľný token zrušenia. Spustí pracovníka na vlákne na pozadí, a keď pracovník skončí, doručí odpoveď na hlavnom vlákne. Generický parameter T je to, čo vykresľovanie produkuje, často handle bitmapy alebo záznam stavu. Pracovník beží mimo vlákna; odpoveď beží tam, kde je bezpečné dotknúť sa VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Zámerné vynechanie je akýkoľvek druh Wait. Neexistuje žiadna metóda na zablokovanie volajúceho, kým future neskončí, a to nie je opomenutie. Wait volaný z hlavného vlákna je klasický spôsob, ako sa dostať do deadlocku v UI: pracovník potrebuje hlavné vlákno na spustenie svojej odpovede cez Synchronize, hlavné vlákno stojí vnútri Wait a ani jedna strana nemôže pokračovať. Tým, že tento primitív neponúka, future vylučuje vzor, ktorý najčastejšie poráža ľudí, čo sa to pokúšajú napísať sami. Kód, ktorý skutočne potrebuje blokovať, by mal použiť obyčajný TThread a prijať dôsledky. Future je pre prípad fire-and-forget, čím vykresľovanie na pozadí v skutočnosti je
Výsledok je zabalený do TPdfFutureResult<T>, záznamu, ktorý odpovedi povie, ktorá z troch vecí sa stala. IsSuccess znamená, že pracovník sa vrátil normálne a Value obsahuje vykreslený výstup. IsCancelled znamená, že token bol aktivovaný a pracovník opustil prácu na bode zrušenia. IsFailure znamená, že pracovník vyvolal výnimku, a ErrorMessage nesie text. Odpoveď skontroluje stav raz a rozvetví sa, namiesto toho, aby zo strážnej hodnoty hádala, či je vrátená bitmapa skutočná
Závodný stav vo v1.61.0, ktorý zmenil doručovanie odpovede
Najpoučnejšou časťou tohto modulu je jednoriadková zmena, ktorej pochopenie chvíľu trvalo. V raných verziách pracovné vlákno doručovalo svoju odpoveď pomocou TThread.Queue. Queue odošle odpoveď do frontu hlavného vlákna a okamžite sa vráti, čo vyzerá presne tak, ako chce future typu fire-and-forget. Bolo to nesprávne a dôvod stojí za to rozpísať, pretože ide o druh chyby, ktorá prejde každým testom, ktorý vás napadne napísať
Pracovné vlákno je vytvorené s FreeOnTerminate := True. To znamená, že v okamihu, keď sa Execute vráti, vlákno sa samo zruší a TThread.Destroy volá ako súčasť čistenia RemoveQueuedEvents(Self). RemoveQueuedEvents vyčistí každú metódu vo fronte, ktorej cieľom je zanikajúce vlákno. Sekvencia teda bola: pracovník skončí, zaradí odpoveď do frontu voči sebe, Execute sa vráti, vlákno sa samo zruší a RemoveQueuedEvents vymaže odpoveď, ktorú hlavné vlákno ešte nespustilo. Výsledok jednoducho zmizol. Ešte horšie, v úzkom okne, kde hlavné vlákno vybralo odpoveď z frontu a začalo ju spúšťať v rovnakom okamihu, keď sa vlákno uvoľňovalo, sa odpoveď dotkla polí napoly zničeného objektu, čo je use-after-free
Oprava vo v1.61.0 spočívala v doručení odpovede pomocou Synchronize namiesto Queue. Synchronize zablokuje pracovné vlákno, kým hlavné vlákno nedobehne odpoveď do konca. Pracovník je stále nažive, kým sa jeho odpoveď vykonáva, takže pod ním nie je čo uvoľňovať, a vlákno sa nevráti z Execute (a teda nezačne samo seba ničiť), kým odpoveď nebola doručená. Doručenie je zaručené a okno use-after-free je zatvorené
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;
Všeobecná lekcia pretrváva dlhšie ako konkrétna oprava. Asynchrónne spätné volania typu fire-and-forget sú najjednoduchším vzorom súbežnosti, ktorý sa dá subtílne pokaziť, pretože šťastná cesta funguje na prvý pokus a chyba žije v interakcii medzi poradím ukončovania vlákna a frontom. Nereprodukuje sa na požiadanie. Závisí od toho, či hlavné vlákno náhodou vyprázdnilo front skôr, ako pracovník náhodou dokončil svoje vlastné ničenie, čo je načasovanie, ktoré plánovač rozhoduje pri každom spustení inak. Primitív, ktorý je raz správny, v bindingu, má oveľa väčšiu hodnotu ako rovnaký kód odvodený znova v každej aplikácii, ktorá potrebuje vykresľovanie na pozadí
Prečo sú spätné volania ukazovateľmi na metódy
Pracovník a odpoveď nie sú anonymné metódy. Sú to typy procedure of object, TPdfFutureWorker<T> a TPdfFutureReply<T>, a túto voľbu vynucuje matica kompilátorov. PDFiumPas sa kompiluje na Delphi XE5 a novšom a na Free Pascal 3.2 v režime Delphi, a FPC 3.2 v tomto režime anonymné metódy nepodporuje. Spätné volanie typu reference-to-procedure, ktoré zachytáva lokálne premenné, by sa skompilovalo na Delphi a zlyhalo na FPC, takže modul používa najmenšieho spoločného menovateľa, ktorého oba kompilátory akceptujú
Praktickým dôsledkom je miesto, kde žije stav. Anonymná metóda sa uzaviera nad lokálnymi premennými; ukazovateľ na metódu nie. Takže akýkoľvek stav, ktorý pracovník potrebuje — index stránky, priblíženie, výstupná cesta — a akýkoľvek stav, ktorý odpoveď potrebuje aktualizovať — cieľový ovládací prvok obrázka alebo štítok priebehu — musí visieť na objekte, ktorého metóda sa odovzdáva. V prehliadači je týmto objektom zvyčajne formulár alebo radič vykresľovania, ktorý vlastní. Nie je to obchádzanie zavedené neochotne; udržiava vlastníctvo tohto stavu explicitné a viditeľné na prijímajúcom objekte namiesto toho, aby bolo skryté vnútri uzáveru
Kooperatívne zrušenie, nie tvrdé ukončenie
Zrušenie je tu kooperatívne. Neexistuje žiadne API, ktoré by siahlo do pracovného vlákna a ukončilo ho, pretože ukončenie vlákna uprostred vykresľovania zanechá PDFium s držanými zámkami a čiastočne zapísanými bitmapami, a stav procesu po vynútenom ukončení nie je niečo, o čom sa dá uvažovať. Namiesto toho pracovník dostane token iba na čítanie a očakáva sa od neho, že ho skontroluje, a slučka vykresľovania je napísaná tak, aby ho kontrolovala medzi stránkami alebo medzi dlaždicami, kde je zastavenie čisté
Token ponúka tri spôsoby pozorovania zrušenia. IsCancelled je lacný boolovský dotaz pre slučku, ktorá chce otestovať a rozhodnúť sa sama. ThrowIfCancelled je bežný prípad: zavolajte ho v prirodzenom bode zrušenia, a ak bolo zrušenie vyžiadané, vyvolá EPdfOperationCancelled, ktorá odvinie pracovníka rovno späť k future. RegisterCallback pripojí jednorazové upozornenie, ktoré sa spustí raz, keď je zdroj zrušený; je užitočné, keď je pracovník zablokovaný v niečom, čo sa dá prerušiť, namiesto toho, aby sedel v tesnej slučke
Výnimka je miestom, kde záleží na hranici vlákna. Keď pracovník vyvolá EPdfOperationCancelled, future ju zachytí a premení na stav zrušenia, takže odpoveď vidí IsCancelled, a nie zlyhanie. Samotný objekt výnimky sa nikdy neprenáša do hlavného vlákna. Žije a umiera na pracovnom vlákne; do ErrorMessage sa skopíruje iba jeho reťazec správy. Prenos živého objektu výnimky cez vlákna by znamenal siahnutie do pamäte vlastnenej vláknom, ktoré sa ukončuje, čo je rovnaká trieda chyby, ktorej má zabrániť oprava Synchronize. Stavový kód a reťazec prejdú hranicou čisto; objekt nie
Dve rozhrania, aby pracovník nemohol zrušiť sám seba
Zrušenie je zámerne rozdelené medzi dve rozhrania. IPdfCancellationTokenSource je strana zápisu: má Cancel, a vlastník, ktorý ho vytvorí — zvyčajne formulár — si ho ponechá a volá Cancel, keď používateľ klikne na tlačidlo alebo sa formulár zatvorí. IPdfCancellationToken je strana čítania: má IsCancelled, ThrowIfCancelled a RegisterCallback, a to je všetko, čo pracovník kedy dostane. Jeden konkrétny objekt implementuje obe, ale pracovníkovi sa vždy odovzdá iba token, takže nemá žiaden spôsob, ako zrušiť operáciu, ktorú vykonáva. Toto rozdelenie je ochranné zábradlie na úrovni API. Pracovník, ktorý by mohol cez svoj token dosiahnuť Cancel, by pozýval zmätený kúsok kódu, aby zrušil sám seba, a typový systém túto možnosť odstraňuje
Existuje zodpovedajúci detail pre prípad, keď volajúci chce vykresľovanie, ale nikdy nemá v úmysle ho zrušiť. Namiesto toho, aby vynucoval nový zdroj na každé volanie, modul sprístupňuje PdfNoCancellationToken, singleton token, ktorý je trvalo v nezrušenom stave. Run ho dosadí, keď je argument tokenu ponechaný nil. Tento singleton sa konštruuje dychtivo počas inicializácie modulu, a nie lenivo pri prvom použití, a dôvodom je opäť súbežnosť. Ak by niekoľko volaní Run na rôznych pracovných vláknach naraz siahlo po lenivo vytvorenom singletone, mohli by súťažiť o jeho konštrukciu, uniknúť duplikát alebo krátko pozorovať napoly inicializovanú inštanciu. Jeho zostavenie skôr, ako môže bežať akýkoľvek pracovník, závodný stav úplne odstraňuje
Spustenie odvolateľného vykresľovania
V praxi vytvoríte zdroj, ponecháte si ho na formulári, odovzdáte jeho Token do Run spolu s metódou pracovníka a metódou odpovede a prepojíte tlačidlo Zrušiť so zdrojom. Pracovník kontroluje token počas vykresľovania; odpoveď aktualizuje UI, len čo je výsledok späť. Keďže spätné volania sú ukazovateľmi na metódy, pracovník a odpoveď čítajú čokoľvek potrebujú z polí formulára
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;
Odpoveď spracúva všetky tri výsledky, pretože všetky tri sú dosiahnuteľné. Dokončené vykresľovanie hlási úspech, používateľ, ktorý stlačil Zrušiť, vidí vetvu zrušenia, a súbor, ktorý sa nedal zapísať, alebo stránka, ktorú sa nepodarilo analyzovať, prichádza ako zlyhanie so správou. Žiadna z týchto vetiev neblokuje, žiadna sa nedotýka pracovného vlákna a bitmapa alebo stav, ktorý pracovník vyprodukoval, sa prečíta až po tom, ako ho future doručila na vlákne, ktoré vlastní UI
Rovnaká disciplína vlákien sa vyplatí inde v prehliadači. Spôsob, akým sa vykreslené bitmapy uchovávajú a opakovane používajú naprieč zmenami priblíženia, je popísaný v našej poznámke o vyrovnávacej pamäti vykresľovania a výkone priblíženia, a širšia otázka bezpečného udržiavania hranice PDFium pod Delphi je v článku posilnenie ABI komponentu PDFium pre bezpečnosť pamäte. Asynchrónna infraštruktúra opísaná tu sa dodáva ako súčasť PDFium Component pre Delphi a C++Builder, spolu s API na vykresľovanie, text a formuláre, ktoré sú popísané inde na tomto blogu