Technický článek

Vykreslování PDF na pozadí v Delphi pomocí zrušitelných futures

Vykreslení stránky v PDFium je synchronní. Zavoláte knihovnu, ta rastrizuje do bitmapy, kterou jste jí předali, a řízení se vrátí, jakmile jsou pixely zapsány. Pro jedinou stránku o velikosti obrazovky na jedné úrovni přiblížení to trvá pár milisekund a nikdo si toho nevšimne. Pro export 200stránkového dokumentu v 300 dpi, nebo pro pruh náhledů, který musí rastrizovat každou stránku najednou, stojí totéž volání sekundy. Pokud to volání provedete z hlavního vlákna, smyčka zpráv se zastaví, okno se přestane překreslovat a Windows vám přes titulkový pruh vykreslí obávané „Neodpovídá“. Práce je správná. Místo, kde jste ji spustili, je špatné

Oprava spočívá v přesunutí dlouhého vykreslení na vlákno na pozadí a vrácení výsledku zpět do hlavního vlákna, kde lze bitmapu předat ovládacímu prvku. PDFium samo o sobě vám v tom nebrání, ale vazba musí to předání učinit bezpečným, protože povrch chyb kolem „spusť na pracovníkovi, odpověz na UI“ je široký a selhání jsou nepravidelná. Jednotka FPdfAsync v PDFiumPas existuje proto, aby tomuto vzoru poskytla jednu správnou implementaci, s modelem rušení, který odpovídá tomu, jak se dlouhé vykreslení skutečně chová

Podoba práce

Třem operacím dominují případy, kdy vykreslení přesáhne jeden snímek. Dávkové vykreslování projde rozsah stránek a rastrizuje každou stránku, obvykle na disk. Export více stránek dělá totéž, ale sestaví výstup do jednoho souboru. Vykreslování stránky na pozadí je to, co prohlížeč dělá, když uživatel skočí na stránku, která ještě není v cache, takže se bitmapa vytvoří mimo vlákno a zobrazí, jakmile je hotová. Všechny tři sdílejí stejná omezení. Běží dostatečně dlouho na to, aby je UI vlákno nemohlo hostit, vytvářejí výsledek, který UI vlákno nakonec potřebuje, a uživatel je může opustit. Zavření dokumentu, odrolování za stránku nebo stisknutí Zrušit by mělo práci zastavit, místo aby uživatele nutilo čekat na výstup, který už nechce

Právě to poslední omezení utváří návrh. Vykreslení, které nelze zrušit, je vykreslení, které drží dokument otevřený a pálí CPU poté, co odpověď přestala mít význam. Jednotka je proto postavena kolem dvou primitiv, která se skládají: future, která nese výsledek zpět, a token, který nese požadavek na zrušení dopředu

Future typu spusť a zapomeň

TPdfFuture<T>.Run přijímá pracovníka, odpověď a volitelný token pro zrušení. Spustí pracovníka na vlákně na pozadí, a když pracovník skončí, doručí odpověď na hlavním vlákně. Generický parametr T je cokoli, co vykreslení vytvoří, často handle bitmapy nebo stavový záznam. Pracovník běží mimo vlákno; odpověď běží tam, kde je bezpečné sahat na VCL

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

Záměrným vynecháním je jakýkoli druh Wait. Neexistuje metoda, která by zablokovala volajícího, dokud se future nedokončí, a to není opomenutí. Wait volané z hlavního vlákna je klasický způsob, jak zablokovat UI uváznutím: pracovník potřebuje hlavní vlákno, aby spustilo jeho odpověď přes Synchronize, hlavní vlákno je zaparkováno uvnitř Wait, a ani jedna strana nemůže pokročit. Tím, že odmítne toto primitivum nabídnout, future vylučuje vzor, který nejčastěji poráží ty, kdo se to pokoušejí napsat sami. Kód, který skutečně potřebuje blokovat, by měl použít prosté TThread a nést následky. Future je pro případ spusť a zapomeň, což je přesně to, čím vykreslování na pozadí ve skutečnosti je

Výsledek je zabalen v TPdfFutureResult<T>, záznamu, který odpovědi sdělí, která ze tří věcí se stala. IsSuccess znamená, že se pracovník vrátil normálně a Value drží vykreslení. IsCancelled znamená, že token vystřelil a pracovník se vyvázal v bodě zrušení. IsFailure znamená, že pracovník vyvolal výjimku, a ErrorMessage nese text. Odpověď stav jednou prozkoumá a větví se, místo aby z hodnoty strážce hádala, zda je vrácená bitmapa skutečná

Souběh ve verzi v1.61.0, který změnil doručování odpovědí

Nejpoučnější částí této jednotky je jednořádková změna, jejíž pochopení chvíli trvalo. V raných verzích pracovní vlákno doručovalo svou odpověď pomocí TThread.Queue. Queue zařadí odpověď do fronty hlavního vlákna a okamžitě se vrátí, což se čte přesně jako to, co future typu spusť a zapomeň chce. Bylo to špatně, a důvod stojí za vyslovení, protože je to druh chyby, která projde každým testem, jejž vás napadne napsat

Pracovní vlákno je vytvořeno s FreeOnTerminate := True. To znamená, že v okamžiku, kdy se Execute vrátí, se vlákno samo zbourá, a TThread.Destroy v rámci úklidu volá RemoveQueuedEvents(Self). RemoveQueuedEvents vyčistí jakoukoli zařazenou metodu, jejímž cílem je umírající vlákno. Sekvence tedy byla: pracovník skončí, zařadí odpověď proti sobě, Execute se vrátí, vlákno se samo zničí, a RemoveQueuedEvents smaže odpověď, kterou hlavní vlákno ještě nespustilo. Výsledek prostě zmizel. Hůře, v úzkém okně, kdy hlavní vlákno stáhlo zařazenou odpověď a začalo ji spouštět právě v okamžiku, kdy se vlákno uvolňovalo, odpověď sahala na pole napůl zničeného objektu, což je použití po uvolnění

Oprava ve verzi v1.61.0 spočívala v doručení odpovědi pomocí Synchronize namísto Queue. Synchronize blokuje pracovní vlákno, dokud hlavní vlákno nedokončí spuštění odpovědi. Pracovník je během provádění své odpovědi stále naživu, takže není co uvolnit zpod něj, a vlákno se nevrátí z Execute (a tedy se nezačne ničit), dokud nebyla odpověď doručena. Doručení je zaručeno a okno použití po uvolnění je uzavřeno

procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // už zrušeno? přeskoč pracovníka
    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, nikoli Queue: toto vlákno je FreeOnTerminate, takže
    // zařazená odpověď by mohla být zahozena pomocí RemoveQueuedEvents
    // dříve, než ji hlavní vlákno spustí.
    Synchronize(DispatchReply);
end;

Obecné ponaučení přežije konkrétní opravu. Asynchronní callbacky typu spusť a zapomeň jsou nejsnáze zákeřně pokazitelným vzorem souběžnosti, protože šťastná cesta funguje napoprvé a chyba žije v interakci mezi pořadím bourání vlákna a frontou. Nereprodukuje se na povel. Závisí na tom, zda hlavní vlákno náhodou vyprázdnilo frontu dříve, než pracovník náhodou dokončil své ničení, což je časování, které plánovač rozhoduje při každém běhu jinak. Primitivum, které je správné jednou, ve vazbě, má mnohem větší hodnotu než tentýž kód odvozený znovu v každé aplikaci, která potřebuje vykreslování na pozadí

Proč jsou callbacky ukazatele na metody

Pracovník a odpověď nejsou anonymní metody. Jsou to typy procedure of object, TPdfFutureWorker<T> a TPdfFutureReply<T>, a tato volba je vynucena maticí kompilátorů. PDFiumPas se kompiluje na Delphi XE5 a novějších a na Free Pascal 3.2 v režimu Delphi, a FPC 3.2 v tom režimu nepodporuje anonymní metody. Callback typu reference-to-procedure, který zachycuje lokální proměnné, by se zkompiloval na Delphi a selhal na FPC, takže jednotka používá nejnižší společný jmenovatel, který oba kompilátory přijímají

Praktickým důsledkem je, kde žije stav. Anonymní metoda uzavírá lokální proměnné; ukazatel na metodu nikoli. Takže jakýkoli stav, který pracovník potřebuje, index stránky, přiblížení, výstupní cesta, a jakýkoli stav, který odpověď potřebuje aktualizovat, cílový obrazový ovládací prvek nebo popisek průběhu, musí viset na objektu, jehož metoda je předávána. V prohlížeči je tím objektem obvykle formulář nebo řídicí prvek vykreslování, který formulář vlastní. Není to nerada uložené obejití; udržuje to vlastnictví tohoto stavu explicitní a viditelné na přijímajícím objektu místo skryté uvnitř uzávěru

Kooperativní rušení, nikoli tvrdé zabití

Rušení je zde kooperativní. Neexistuje API, které by sáhlo do pracovního vlákna a ukončilo je, protože ukončení vlákna uprostřed vykreslení zanechá PDFium s drženými zámky a částečně zapsanými bitmapami, a o stavu procesu po vynuceném zabití nelze uvažovat. Místo toho je pracovníkovi předán token jen pro čtení a očekává se, že jej zkontroluje, a smyčka vykreslení 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 laciný booleovský dotaz pro smyčku, která chce testovat a rozhodnout se sama. ThrowIfCancelled je běžný případ: zavolejte jej v přirozeném bodě zrušení, a pokud bylo zrušení vyžádáno, vyvolá EPdfOperationCancelled, což odvine pracovníka rovnou zpět do future. RegisterCallback připojí jednorázové oznámení, které vystřelí jednou, když je zdroj zrušen, užitečné, když je pracovník zablokován v něčem, co může přerušit, místo aby seděl v těsné smyčce

Výjimka je místo, kde záleží na hranici vláken. Když pracovník vyvolá EPdfOperationCancelled, future ji zachytí a promění ve stav zrušeno, takže odpověď vidí IsCancelled a nikoli selhání. Samotný objekt výjimky není nikdy maršálován do hlavního vlákna. Žije a umírá na pracovním vlákně; do ErrorMessage se zkopíruje pouze jeho zpráva. Maršálování živého objektu výjimky napříč vlákny by znamenalo sahat do paměti vlastněné vláknem, které končí, což je tatáž třída chyby, jíž oprava pomocí Synchronize existuje předejít. Stavový kód a řetězec překračují hranici čistě; objekt nikoli

Dvě rozhraní, takže pracovník nemůže zrušit sám sebe

Ruš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 pracovník kdy dostane. Jeden konkrétní objekt implementuje obojí, ale pracovníkovi je vždy předán pouze token, takže nemá žádný způsob, jak zrušit operaci, kterou provádí. Toto rozdělení je zábradlí na úrovni API. Pracovník, který by se mohl ke Cancel dostat přes svůj token, by lákal zmatený kus 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 vykreslení, ale nikdy je nehodlá zrušit. Místo vynucení čerstvého zdroje na každé volání jednotka vystavuje PdfNoCancellationToken, singleton token, který je trvale ve stavu nezrušeno. Run jej dosadí, když je argument tokenu ponechán nil. Tento singleton je konstruován dychtivě během inicializace jednotky, nikoli líně při prvním použití, a důvodem je opět souběžnost. Kdyby několik volání Run na různých pracovních vláknech sáhlo po líně vytvořeném singletonu najednou, mohla by se přetahovat o jeho konstrukci, vyplodit duplikát, nebo krátce pozorovat napůl inicializovanou instanci. Jeho sestavení dříve, než může spustit jakýkoli pracovník, souběh zcela odstraňuje

Spuštění zrušitelného vykreslení

V praxi vytvoříte zdroj, ponecháte jej na formuláři, předáte jeho Token do Run spolu s metodou pracovníka a metodou odpovědi, a tlačítko Zrušit propojíte se zdrojem. Pracovník token kontroluje, zatímco vykresluje; odpověď aktualizuje UI, jakmile je výsledek zpět. Protože callbacky jsou ukazatele na metody, pracovník i odpověď čtou cokoli potřebují z polí formuláře

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // pole, žije na formuláři
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // pracovník to zpozoruje v dalším bodě zrušení
end;

// Běží na vlákně na pozadí. Čte FPageRange / FOutputDir z formuláře.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // čisté zastavení mezi stránkami
    RenderOnePage(PageIndex);       // synchronní rastrizace PDFium
  end;
  Result := True;
end;

// Běží na hlavním vlákně. Zde je bezpečné sahat na 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;

Odpověď obsluhuje všechny tři výsledky, protože všechny tři jsou dosažitelné. Dokončené vykreslení hlásí úspěch, uživatel, který stiskl Zrušit, vidí větev zrušeno, a soubor, který nešlo zapsat, nebo stránka, kterou se nepodařilo naparsovat, dorazí jako selhání se zprávou. Žádná z těchto větví neblokuje, žádná z nich nesahá na pracovní vlákno, a bitmapa nebo stav, který pracovník vytvořil, se čte až poté, co jej future doručila na vlákně, které vlastní UI

Tatáž disciplína vláken se vyplatí i jinde v prohlížeči. Způsob, jakým jsou vykreslené bitmapy uchovávány a opětovně používány napříč změnami přiblížení, je pokryt v naší poznámce o cache vykreslování a výkonu přiblížení, a širší otázka udržení hranice PDFium bezpečnou pod Delphi je v zabezpečení ABI komponenty PDFium pro paměťovou bezpečnost. Asynchronní infrastruktura zde popsaná je dodávána jako součást PDFium Component pro Delphi a C++Builder, vedle API pro vykreslování, text a formuláře pokrytých jinde na tomto blogu