Vykreslování stránky v PDFium je synchronní. Zavoláte knihovnu, ta provede rasterizaci do bitmapy, kterou jste jí předali, a řízení se vrátí, až když jsou pixely zapsány. Pro jednu stránku o velikosti obrazovky při jedné úrovni přiblížení to trvá několik milisekund a nikdo si toho nevšimne. Ale pro export 200stránkového dokumentu do 300 dpi nebo pás miniatur, který musí rasterizovat všechny stránky najednou, stojí stejné volání sekundy. Pokud toto volání provedete z hlavního vlákna, smyčka zpráv se zastaví, okno se přestane překreslovat a Windows přes váš záhlaví namaluje obávané "Neodpovídá". Práce je správná. Místo, kde jste ji spustili, je chybné
Řešením je přesunout dlouhé vykreslování do vlákna na pozadí a výsledek vrátit do hlavního vlákna, kde lze bitmapu předat ovládacímu prvku. Samotné PDFium vám v tom nebrání, ale vazba musí zajistit bezpečné předání, protože prostor pro chyby kolem "spusť na workeru, odpověz na UI" je široký a selhání jsou občasná. Jednotka FPdfAsync v PDFiumPas existuje proto, aby tomuto vzoru poskytla jednu správnou implementaci s modelem zrušení, který odpovídá tomu, jak se dlouhé vykreslování skutečně chová
Charakter práce
Třem operacím dominují případy, kdy vykreslování trvá déle než jeden snímek. Dávkové vykreslování prochází rozsah stránek a rasterizuje každou stránku, obvykle na disk. Vícestránkový export dělá totéž, ale sestavuje výstup do jednoho souboru. Vykreslování stránky na pozadí je to, co dělá prohlížeč, když uživatel přeskočí na stránku, která ještě není v mezipaměti, takže bitmapa je vytvořena mimo hlavní vlákno a zobrazena, jakmile je připravena. Všechny tři sdílejí stejná omezení. Běží dostatečně dlouho na to, aby je UI vlákno nemohlo hostit, produkují výsledek, který UI vlákno nakonec potřebuje, a uživatel je může opustit. Zavření dokumentu, posunutí mimo stránku nebo stisknutí tlačítka Zrušit by mělo práci zastavit, namísto toho, aby nutilo uživatele čekat na výstup, který už nechce
To poslední omezení je tím, co utváří design. Vykreslování, které nelze zrušit, je vykreslování, které udržuje dokument otevřený a pálí procesor i poté, co na odpovědi přestalo záležet. Jednotka je proto postavena na dvou primitivách, která se skládají: future, která nese výsledek zpět, a token, který nese požadavek na zrušení vpřed
Future typu "vystřel a zapomeň"
TPdfFuture<T>.Run přijímá workera, odpověď a volitelný token pro zrušení. Spustí workera ve vlákně na pozadí a po jeho dokončení doručí odpověď v hlavním vlákně. Generický parametr T je cokoliv, co vykreslování produkuje, často handle bitmapy nebo stavový záznam. Worker běží mimo hlavní vlákno; odpověď běží tam, kde je bezpečné manipulovat s VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Záměrně je vynechán jakýkoli druh Wait. Neexistuje žádná metoda, která by zablokovala volajícího, dokud se future nedokončí, a to není přehlédnutí. Wait volaný z hlavního vlákna je klasický způsob, jak uvíznout v mrtvém bodě v UI: worker potřebuje hlavní vlákno, aby spustil svou odpověď přes Synchronize, hlavní vlákno je zaparkované uvnitř Wait a ani jedna strana nemůže pokračovat. Tím, že future odmítá nabídnout toto primitivum, vylučuje vzor, který nejčastěji poráží lidi, kteří se to snaží napsat sami. Kód, který skutečně potřebuje blokovat, by měl použít obyčejný TThread a nést za to následky. Future je určena pro případ "vystřel a zapomeň", což je přesně to, čím vykreslování na pozadí je
Výsledek je obalen v TPdfFutureResult<T>, záznamu, který odpovědi říká, která ze tří věcí se stala. IsSuccess znamená, že worker se vrátil normálně a Value obsahuje vykreslení. IsCancelled znamená, že se aktivoval token a worker to zabalil v bodě zrušení. IsFailure znamená, že worker vyvolal výjimku a ErrorMessage nese její text. Odpověď zkontroluje stav jednou a rozvětví se, místo aby hádala ze sentinelové hodnoty, zda je vrácená bitmapa skutečná
Souběh z v1.61.0, který změnil doručování odpovědí
Nejpoučnější částí této jednotky je jednorádková změna, které chvíli trvalo porozumět. V dřívějších verzích doručovalo pracovní vlákno svou odpověď pomocí TThread.Queue. Funkce Queue pošle odpověď do fronty hlavního vlákna a okamžitě se vrátí, což zní přesně jako to, co chce future typu "vystřel a zapomeň". Bylo to špatně a důvod stojí za vysvětlení, protože to je ten druh chyby, který projde každým testem, který vás napadne napsat
Pracovní vlákno je vytvořeno s FreeOnTerminate := True. To znamená, že v okamžiku, kdy se vrátí Execute, se vlákno začne rušit a TThread.Destroy v rámci úklidu zavolá RemoveQueuedEvents(Self). RemoveQueuedEvents odstraní jakoukoli metodu ve frontě, jejímž cílem je umírající vlákno. Takže sekvence byla: worker dokončí práci, zařadí odpověď pro sebe do fronty, Execute se vrátí, vlákno se samo zničí a RemoveQueuedEvents smaže odpověď, kterou hlavní vlákno ještě nestihlo spustit. Výsledek prostě zmizel. A co hůř, v úzkém okně, kdy hlavní vlákno vytáhlo odpověď z fronty a začalo ji spouštět ve stejném okamžiku, kdy bylo vlákno uvolňováno, se odpověď dotkla polí napůl zničeného objektu, což je chyba typu use-after-free
Opravou ve verzi 1.61.0 bylo doručovat odpověď pomocí Synchronize namísto Queue. Synchronize blokuje pracovní vlákno, dokud hlavní vlákno neskončí se spouštěním odpovědi. Worker je stále naživu, zatímco se jeho odpověď provádí, takže se mu nemůže nic uvolnit pod rukama, a vlákno se nevrátí z Execute (a proto se nezačne samo ničit), dokud není odpověď doručena. Doručení je zaručeno a okno pro chybu use-after-free je uzavřeno
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;
Obecné ponaučení tuto konkrétní opravu přesahuje. Asynchronní zpětná volání typu "vystřel a zapomeň" jsou nejjednodušší vzor souběžnosti, který lze nenápadně zkazit, protože šťastná cesta funguje na první pokus a chyba žije v interakci mezi pořadím rušení vláken a frontou. Nereprodukuje se na vyžádání. Záleží na tom, zda hlavní vlákno náhodou vyprázdnilo frontu předtím, než se worker náhodou dokončil a zničil, což je načasování, o kterém plánovač rozhoduje při každém běhu jinak. Primitivum, které je správně implementováno jednou, ve vazbě, má mnohem větší hodnotu než stejný kód, který by byl znovu vymýšlen v každé aplikaci, která potřebuje vykreslování na pozadí
Proč jsou zpětná volání ukazateli na metody
Worker a odpověď nejsou anonymní metody. Jsou to typy procedure of object, TPdfFutureWorker<T> a TPdfFutureReply<T>, a tato volba je vynucena maticí překladačů. PDFiumPas se překládá v Delphi XE5 a novějších a ve Free Pascalu 3.2 v režimu Delphi, přičemž FPC 3.2 v tomto režimu nepodporuje anonymní metody. Zpětné volání typu odkaz na proceduru, které zachycuje lokální proměnné, by se přeložilo v Delphi a selhalo v FPC, takže jednotka používá nejmenšího společného jmenovatele, kterého akceptují oba překladače
Praktickým důsledkem je, kde stav žije. Anonymní metoda uzavírá lokální proměnné, ukazatel na metodu nikoliv. Takže jakýkoli stav, který worker potřebuje, jako je index stránky, přiblížení, výstupní cesta, a jakýkoli stav, který odpověď potřebuje aktualizovat, jako je cílový ovládací prvek obrázku nebo štítek postupu, musí viset na objektu, jehož metoda je předávána. V prohlížeči je tímto objektem obvykle formulář nebo ovladač vykreslování, který vlastní. To není neochotně vnucené řešení, ale udržuje to vlastnictví tohoto stavu explicitní a viditelné na přijímajícím objektu namísto toho, aby bylo skryto uvnitř uzávěru
Kooperativní zrušení, nikoli tvrdé zabití
Zrušení je zde kooperativní. Neexistuje žádné API, které by sáhlo do pracovního vlákna a ukončilo ho, protože ukončení vlákna uprostřed vykreslování by nechalo PDFium držet zámky a částečně zapsané bitmapy, a stav procesu po vynuceném zabití není něco, o čem by se dalo logicky uvažovat. Místo toho je workerovi předán token pouze pro čtení a očekává se, že jej zkontroluje, přičemž smyčka vykreslování je napsána tak, aby jej kontrolovala mezi stránkami nebo mezi dlaždicemi, kde je zastavení čisté
Token nabízí tři způsoby, jak pozorovat zrušení. IsCancelled je levný booleovský dotaz pro smyčku, která chce testovat a rozhodnout sama. ThrowIfCancelled je běžný případ: zavolejte ho v přirozeném bodě zrušení a pokud bylo požádáno o zrušení, vyvolá EPdfOperationCancelled, což odvine workera rovnou zpět do future. RegisterCallback připojí jednorázové upozornění, které se spustí jednou, když je zdroj zrušen, což je užitečné, když je worker zablokován v něčem, co může přerušit, namísto toho, aby seděl v těsné smyčce
Výjimka je místo, kde záleží na hranici vlákna. Když worker vyvolá EPdfOperationCancelled, future ji zachytí a promění na stav zrušení, takže odpověď vidí IsCancelled a nikoliv selhání. Samotný objekt výjimky není nikdy převeden do hlavního vlákna. Žije a umírá v pracovním vlákně; pouze jeho řetězec zprávy je zkopírován do ErrorMessage. Převedení živého objektu výjimky přes vlákna by znamenalo sahat do paměti vlastněné vláknem, které končí, což je stejná třída chyby, které má zabránit oprava se Synchronize. Stavový kód a řetězec překračují hranici čistě; objekt by to neudělal
Dvě rozhraní, takže worker nemůže zrušit sám sebe
Zrušení je záměrně rozděleno do dvou rozhraní. IPdfCancellationTokenSource je strana pro zápis: má Cancel a vlastník, který jej vytvoří, obvykle formulář, si jej ponechá a zavolá Cancel, když uživatel klikne na tlačítko nebo se formulář zavře. IPdfCancellationToken je strana pro čtení: má IsCancelled, ThrowIfCancelled a RegisterCallback, a to je vše, co worker kdy obdrží. Jeden konkrétní objekt implementuje obojí, ale workerovi je vždy předán pouze token, takže nemá jak zrušit operaci, kterou právě provádí. Toto rozdělení je ochranné zábradlí na úrovni API. Worker, který by mohl dosáhnout na Cancel prostřednictvím svého tokenu, by vyzýval zmatený kousek kódu ke zrušení sebe sama, a typový systém tuto možnost odstraňuje
Existuje odpovídající detail pro případ, kdy volající chce vykreslování, ale nikdy nemá v úmyslu jej zrušit. Namísto nutnosti vytvořit nový zdroj pro každé volání odhaluje jednotka PdfNoCancellationToken, singletonový token, který je trvale ve stavu nezrušeno. Run jej nahradí, když je argument tokenu ponechán jako nil. Tento singleton je sestrojen horlivě během inicializace jednotky namísto líně při prvním použití, a důvodem je opět souběžnost. Kdyby několik volání Run v různých pracovních vláknech sáhlo po líně vytvořeném singletonu najednou, mohla by závodit při jeho konstrukci, uniknout duplikát nebo krátce pozorovat napůl inicializovanou instanci. Jeho sestavení předtím, než může běžet jakýkoli worker, tento souběh zcela odstraňuje
Spuštění zrušitelného vykreslování
V praxi vytvoříte zdroj, ponecháte si ho na formuláři, předáte jeho Token do Run společně s metodou workera a metodou odpovědi a propojíte tlačítko Zrušit se zdrojem. Worker kontroluje token během vykreslování; odpověď aktualizuje UI, jakmile se vrátí výsledek. Vzhledem k tomu, že zpětná volání jsou ukazatelé na metody, worker a odpověď čtou cokoliv potřebují z polí formuláře
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;
Odpověď ošetřuje všechny tři výsledky, protože všechny tři jsou dosažitelné. Dokončené vykreslování hlásí úspěch, uživatel, který stiskl Zrušit, uvidí zrušenou větev a soubor, který nebylo možné zapsat, nebo stránka, kterou se nepodařilo rozparsovat, dorazí jako selhání se zprávou. Žádná z těchto větví neblokuje, žádná z nich se nedotýká pracovního vlákna a bitmapa nebo stav, který worker vyprodukoval, se čtou až poté, co je future doručí ve vlákně, které vlastní UI
Stejná disciplína při práci s vlákny se vyplácí i jinde v prohlížeči. Způsob, jakým jsou vykreslené bitmapy uchovávány a znovu používány při změnách přiblížení, je popsán v naší poznámce o mezipaměti vykreslování a výkonu přiblížení, a širší otázka udržování hranice PDFium v bezpečí pod Delphi je v článku Zpevnění VCL ABI PDFium pro paměťovou bezpečnost. Zde popsaná asynchronní infrastruktura je dodávána jako součást PDFium Component pro Delphi a C++Builder, společně s API pro vykreslování, text a formuláře, která jsou pokryta jinde na tomto blogu