Vykresľovanie stránky v PDFium je synchrónne. Zavoláte do knižnice, tá rastruje do bitmapy, ktorú ste jej odovzdali, a riadenie sa vráti po zapísaní pixelov. Pre jednu stránku veľkosti obrazovky pri jednej úrovni priblíženia to trvá niekoľko milisekúnd a nikto si to nevšimne. Pre export pri 300 dpi z dokumentu so 200 stránkami, alebo pre prúžok miniatúr, ktorý musí naraz rastruvovať každú stránku, to isté volanie trvá sekundy. Ak toto volanie uskutočníte z hlavného vlákna, slučka správ sa zastaví, okno prestane prekresľovať a Windows zobrazí nad vašim titulkovým pruhom obávaný nápis „Neodpovedá". Práca je správna. Miesto, kde ste ju vykonali, je nesprávne
Riešením je presunúť dlhé vykresľovanie na vlákno na pozadí a vrátiť 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 väzba musí urobiť odovzdanie bezpečným, pretože plocha chýb okolo „spustí na pracovníkovi, odpovie na UI" je rozsiahla a zlyhania sú prerušované. Modul FPdfAsync v PDFiumPas existuje preto, aby poskytol tomuto vzoru jedno správne implementáciu, s modelom zrušenia, ktorý zodpovedá tomu, ako sa dlhé vykresľovanie skutočne správa
Tvar práce
Tri operácie dominujú prípadom, keď vykresľovanie trvá dlhšie ako jeden snímok. Dávkové vykresľovanie prechádza rozsahom stránok a rastruje 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ľ preskočí na stránku, ktorá ešte nie je v cache, 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é nemožno zrušiť, je vykresľovanie, 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 popisovač 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 blokovanie volajúceho, kým future neskončí, a to nie je opomenutie. Wait volaný z hlavného vlákna je klasický spôsob deadlocku UI: pracovník potrebuje hlavné vlákno na spustenie svojej odpovede cez Synchronize, hlavné vlákno stojí vo vnútri Wait a ani jedna strana nemôže pokračovať. Tým, že primitív neposkytuje, future vylučuje vzor, ktorý najčastejšie poráža ľudí, ktorí sa to pokúšajú napísať sami. Kód, ktorý skutočne potrebuje blokovanie, by mal použiť obyčajný TThread a prijať dôsledky. Future je pre prípad fire-and-forget, čím vykresľovanie na pozadí skutočne je
Výsledok je zabalený do TPdfFutureResult<T>, záznamu, ktorý hovorí odpovedi, ktorá z troch vecí sa stala. IsSuccess znamená, že pracovník sa vrátil normálne a Value obsahuje vykresľovanie. 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 hádala zo strážnej hodnoty, či vrátená bitmapa je skutočná
Závodný stav v1.61.0, ktorý zmenil doručovanie odpovedí
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 vysvetliť, pretože ide o druh chyby, ktorá prejde každým testom, na ktorý si pomyslíte
Pracovné vlákno je vytvorené s FreeOnTerminate := True. To znamená, že v okamihu, keď sa vráti Execute, vlákno sa samo zruší a TThread.Destroy volá RemoveQueuedEvents(Self) ako súčasť čistenia. RemoveQueuedEvents vyčistí všetky metódy vo fronte, ktorých 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í polobezpečné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 nespustí odpoveď do konca. Pracovník je stále nažive, kým sa jeho odpoveď vykonáva, takže nie je čo uvoľňovať spod neho, 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, pri ktorom sa dá subtilne chybiť, pretože šťastná cesta funguje na prvý pokus a chyba spočíva v interakcii medzi poradím ukončovania vlákien a frontom. Nereproducuje 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 inak pri každom spustení. Primitív, ktorý je raz správny, vo väzbe, má oveľa väčšiu hodnotu ako rovnaký kód odvodený znovu 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, konkrétne TPdfFutureWorker<T> a TPdfFutureReply<T>, a táto voľba je vynútená maticou 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 nepodporuje anonymné metódy. Spätné volanie s odkazom na procedúru, ktoré zachytáva lokálne premenné, by sa skompilovalo na Delphi a zlyhalo by na FPC, takže modul používa najmenšieho spoločného menovateľa, ktorý oba kompilátory akceptujú
Praktickým dôsledkom je miesto, kde žije stav. Anonymná metóda zatvára lokálne premenné; ukazovateľ na metódu nie. 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í byť pripojený k objektu, ktorého metóda sa odovzdáva. V prehliadači je týmto objektom zvyčajne formulár alebo radič vykresľovania, ktorý vlastní. Nejde o obchádzanie zavedené neochotne; udržiava vlastníctvo tohto stavu explicitné a viditeľné na prijímajúcom objekte namiesto toho, aby bolo skryté vo vnútri uzavretia
Kooperatívne zrušenie, nie tvrdé ukončenie
Zrušenie tu je 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 uzamknutiami a čiastočne zapísanými bitmapami, a stav procesu po vynútenom ukončení nie je niečo, o čom môžete uvažovať. Namiesto toho dostane pracovník token iba na čítanie a očakáva sa, ž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 sledovania zrušenia. IsCancelled je lacný boolovský dotaz pre slučku, ktorá chce otestovať a rozhodnúť sama. ThrowIfCancelled je bežný prípad: zavolajte ho v prirodzenom bode zrušenia a ak bolo zrušenie požadované, vyvolá EPdfOperationCancelled, čo odvinie pracovníka priamo späť k future. RegisterCallback pripojí jednorázové upozornenie, ktoré sa spustí raz, keď je zdroj zrušený; je to užitočné, keď je pracovník zablokovaný v niečom, čo môže 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 ho zachytí a premení na stav zrušenia, takže odpoveď vidí IsCancelled a nie zlyhanie. Samotný objekt výnimky sa nikdy neprenesie do hlavného vlákna. Žije a umiera na pracovnom vlákne; iba jeho reťazec správy sa skopíruje do ErrorMessage. 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 existuje oprava Synchronize. Stavový kód a reťazec prechádzajú hranicou čisto; objekt by 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 formulár sa zavrie. 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ík dostane vždy len token, takže nemá žiadny spôsob, ako zrušiť operáciu, ktorú vykonáva. Rozdelenie je ochranný záchytný bod na úrovni API. Pracovník, ktorý by mohol dosiahnuť Cancel cez svoj token, by pozýval zmätený kód, 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 prinútil nový zdroj na každé volanie, modul sprístupňuje PdfNoCancellationToken, singleton token, ktorý je trvalo v stave nezrušenia. Run ho dosadí, keď je argument tokenu ponechaný nil. Tento singleton sa konštruuje netrpezlivo počas inicializácie modulu a nie lenivo pri prvom použití; dôvodom je opäť súbežnosť. Ak by niekoľko volaní Run na rôznych pracovných vláknach siahlo naraz po lenivo vytvorenom singletone, mohli by zápasiť o jeho konštrukciu, uniknúť duplicitný výtlačok alebo krátko pozorovať čiastočne inicializovanú inštanciu. Konštrukcia pred tým, ako môže bežať akýkoľvek pracovník, odstraňuje závodný stav úplne
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 po tom, čo je výsledok k dispozícii. Pretož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ď spracová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 nepodarila analyzovať, prichádza ako zlyhanie so správou. Žiadna z týchto vetví neblokuje, žiadna sa nedotýka pracovného vlákna a bitmapa alebo stav, ktorý pracovník vyprodukoval, sa číta iba po tom, ako ju 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ú pri zmenách 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 v Delphi je v článku Posilnenie VCL ABI pre PDFium pre bezpečnosť pamäte. Asynchrónna infraštruktúra popísaná tu sa dodáva ako súčasť PDFium Component pre Delphi a C++Builder, spolu s API pre vykresľovanie, text a formuláre, ktoré sú popísané inde na tomto blogu