Technisch artikel

PDF-rendering op de achtergrond in Delphi met annuleerbare futures

Het renderen van een pagina in PDFium is synchroon. U roept de bibliotheek aan, deze rasteriseert naar een bitmap die u eraan heeft overhandigd, en de controle komt terug wanneer de pixels zijn weggeschreven. Voor een enkele pagina op schermgrootte met één zoomniveau duurt dat een paar milliseconden en niemand merkt het. Voor een export op 300 dpi van een document met 200 pagina's, of een reeks miniaturen waarbij elke pagina tegelijk moet worden gerasteriseerd, kost dezelfde aanroep seconden. Als u die aanroep vanuit de hoofdthread doet, stopt de berichtenlus, stopt het venster met opnieuw tekenen en plaatst Windows het gevreesde "Reageert niet" over uw titelbalk. Het werk is correct. De plaats waar u het hebt uitgevoerd is verkeerd

De oplossing is om het langdurige renderen naar een achtergrondthread te verplaatsen en het resultaat terug te brengen naar de hoofdthread, waar de bitmap aan een besturingselement kan worden overhandigd. PDFium zelf houdt u hier niet van tegen, maar de binding moet de overdracht veilig maken, omdat het bug-oppervlak rond "uitvoeren op een werker, antwoorden op de UI" groot is en de fouten intermitterend zijn. De FPdfAsync eenheid in PDFiumPas bestaat om dat patroon één correcte implementatie te geven, met een annuleringsmodel dat past bij hoe een langdurige render zich daadwerkelijk gedraagt

De vorm van het werk

Drie bewerkingen domineren de gevallen waarin een weergave langer duurt dan een frame. Batchweergave doorloopt een paginabereik en rasteriseert elke pagina, meestal naar schijf. Multi-pagina-export doet hetzelfde, maar voegt de uitvoer samen tot één bestand. PDF-rendering op de achtergrond is wat een viewer doet wanneer de gebruiker naar een pagina springt die nog niet in de cache staat, dus de bitmap wordt off-thread geproduceerd en weergegeven wanneer deze klaar is. Alle drie delen ze dezelfde beperkingen. Ze duren lang genoeg zodat de UI-thread ze niet kan hosten, ze produceren een resultaat dat de UI-thread uiteindelijk nodig heeft, en de gebruiker kan ze stopzetten. Het sluiten van het document, voorbij de pagina scrollen of op Annuleren drukken zou het werk moeten stoppen in plaats van de gebruiker te dwingen te wachten op uitvoer die hij niet langer wil

Die laatste beperking bepaalt het ontwerp. Een rendering die niet kan worden geannuleerd, is een rendering die het document openhoudt en de CPU verbrandt nadat het antwoord er niet meer toe deed. Dus de eenheid is gebouwd rond twee primitieven die samenwerken: een 'future' die het resultaat terugbrengt, en een token dat het annuleringsverzoek doorstuurt

Een fire-and-forget future

TPdfFuture<T>.Run accepteert een werker (worker), een antwoord (reply) en een optioneel annuleringstoken. Het start de werker in een achtergrondthread, en wanneer de werker klaar is, levert het het antwoord af in de hoofdthread. De generieke parameter T is alles wat het renderen produceert, vaak een bitmap-handle of een statusrecord. De werker draait off-thread; het antwoord draait waar het veilig is om de VCL aan te raken

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

De opzettelijke weglating is elk soort Wait. Er is geen methode om de aanroeper te blokkeren totdat de future is voltooid, en dat is geen vergissing. Een Wait aangeroepen vanaf de hoofdthread is de klassieke manier om een UI in een impasse (deadlock) te brengen: de werker heeft de hoofdthread nodig om zijn antwoord door Synchronize te sturen, de hoofdthread is geparkeerd in Wait en geen van beide partijen kan verder. Door te weigeren deze primitief aan te bieden, sluit de future het patroon uit dat mensen die proberen dit zelf te schrijven het vaakst de das omdoet. Code die echt moet blokkeren, moet een gewone TThread gebruiken en de consequenties daarvan accepteren. De future is voor de fire-and-forget case, wat het renderen op de achtergrond feitelijk is

Het resultaat wordt verpakt in TPdfFutureResult<T>, een record die het antwoord vertelt welke van drie dingen er is gebeurd. IsSuccess betekent dat de werker normaal is teruggekeerd en Value bevat de gerenderde waarde. IsCancelled betekent dat het token werd geactiveerd en de werker het proces heeft verlaten bij een annuleringspunt. IsFailure betekent dat er een uitzondering is opgetreden in de werker, en ErrorMessage bevat de tekst. De callback controleert de status eenmalig en vertakt, in plaats van aan de hand van een controlewaarde te raden of een geretourneerde bitmap echt is

De v1.61.0 race die de levering van antwoorden veranderde

Het meest leerzame deel van deze eenheid is een verandering van één regel die een tijdje duurde om te begrijpen. In vroege versies leverde de worker-thread zijn antwoord af met TThread.Queue. Queue plaatst het antwoord in de wachtrij van de hoofdthread en keert onmiddellijk terug, wat precies leest als wat een fire-and-forget future wil. Het was fout, en de reden is het waard om uit te spellen omdat het het soort bug is dat elke test doorstaat die je kunt bedenken

De worker-thread is gemaakt met FreeOnTerminate := True. Dat betekent dat zodra Execute terugkeert, de thread zichzelf afbreekt en TThread.Destroy RemoveQueuedEvents(Self) aanroept als onderdeel van de opruiming. RemoveQueuedEvents wist elke in de wachtrij geplaatste methode waarvan het doelwit de stervende thread is. Dus de reeks was: de werker eindigt, hij plaatst het antwoord tegen zichzelf in de wachtrij, Execute keert terug, de thread vernietigt zichzelf, en RemoveQueuedEvents verwijdert het antwoord dat de hoofdthread nog niet had uitgevoerd. Het resultaat verdween gewoon. Erger nog, in de smalle tijdspanne waarin de hoofdthread het in de wachtrij geplaatste antwoord oppikte en het begon uit te voeren op hetzelfde moment dat de thread werd bevrijd, raakte het antwoord velden van een half verwoest object, wat een use-after-free is

De oplossing in v1.61.0 was om het antwoord af te leveren met Synchronize in plaats van Queue. Synchronize blokkeert de worker-thread totdat de hoofdthread het antwoord volledig heeft uitgevoerd. De werker leeft nog steeds terwijl zijn antwoord wordt uitgevoerd, dus er valt niets weg te halen, en de thread keert pas terug uit Execute (en begint zichzelf dus niet te vernietigen) totdat het antwoord is afgeleverd. Levering is gegarandeerd, en de use-after-free window is gesloten

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;

De algemene les gaat verder dan de specifieke oplossing. Fire-and-forget asynchrone callbacks zijn het gemakkelijkste concurrency-patroon om subtiel verkeerd te hebben, omdat het happy path werkt bij de eerste poging en de bug leeft in de interactie tussen de volgorde van het afbreken van de thread en de wachtrij. Het reproduceert niet op commando. Het hangt af van de vraag of de hoofdthread toevallig de wachtrij heeft leeggemaakt voordat de werker toevallig klaar was met het vernietigen van zichzelf, wat een timing is die de planner (scheduler) bij elke uitvoering anders beslist. Een primitief dat in één keer goed is, in de binding, is veel meer waard dan dezelfde code die opnieuw is afgeleid in elke applicatie die een achtergrondrendering nodig heeft

Waarom de callbacks methode-aanwijzers zijn

De werker en het antwoord zijn geen anonieme methoden. Het zijn procedure of object-typen, TPdfFutureWorker<T> en TPdfFutureReply<T>, en die keuze wordt afgedwongen door de compiler-matrix. PDFiumPas compileert op Delphi XE5 en nieuwer en op Free Pascal 3.2 in Delphi-modus, en FPC 3.2 in die modus ondersteunt geen anonieme methoden. Een reference-to-procedure-callback die lokale variabelen vangt, zou compileren op Delphi en falen op FPC, dus de eenheid gebruikt de kleinste gemene deler die beide compilers accepteren

De praktische consequentie is waar de status ('state') leeft. Een anonieme methode sluit zich over lokals; een methode-aanwijzer doet dat niet. Dus elke status die de werker nodig heeft, de pagina-index, de zoom, het uitvoerpad, en elke status die de antwoordmethode nodig heeft om bij te werken, de image-control voor het doelwit of het voortgangslabel, moet vastgelegd zijn aan het object waarvan de methode wordt doorgegeven. In een viewer is dat object meestal het formulier of een render-controller die het bezit. Dit is geen met tegenzin opgelegde tijdelijke oplossing; het houdt het eigenaarschap van die status expliciet en zichtbaar op het ontvangende object in plaats van verborgen in een sluiting (closure)

Coöperatieve annulering, geen harde kill

Annulering is hier coöperatief. Er is geen API die in de worker-thread reikt en deze beëindigt, omdat het halverwege renderen beëindigen van een thread ertoe leidt dat PDFium locks (vergrendelingen) vasthoudt en gedeeltelijk geschreven bitmaps achterlaat, en de processtatus na een geforceerde beëindiging ('kill') is niet iets waar u logischerwijs van op aan kunt. In plaats daarvan krijgt de werker een read-only token overhandigd en wordt verwacht dit te controleren, en de render-lus is zo geschreven dat deze gecontroleerd wordt tussen pagina's of tussen tegels (tiles), waar het stoppen netjes is

Het token biedt drie manieren om annulering in acht te nemen. IsCancelled is een goedkope boolean poll voor een lus die zelf wil testen en beslissen. ThrowIfCancelled is het algemene geval: roep het aan op een natuurlijk annuleringspunt en, als er om annulering is gevraagd, werpt het een EPdfOperationCancelled uitzondering, die de werker rechtstreeks naar de future afwikkelt. RegisterCallback voegt een eenmalige melding toe die eenmaal wordt geactiveerd wanneer de bron wordt geannuleerd, wat nuttig is wanneer een werker geblokkeerd is in iets dat het kan onderbreken in plaats van in een strakke lus (tight loop) te zitten

De uitzondering is waar de threadgrens er toe doet. Wanneer de werker EPdfOperationCancelled activeert, vangt de future deze op en zet deze om in een geannuleerde status, zodat de callback IsCancelled ziet en niet een foutmelding. Het uitzonderingsobject zelf wordt nooit naar de hoofdthread gemarshald. Het leeft en sterft op de worker-thread; alleen zijn berichtenreeks wordt naar ErrorMessage gekopieerd. Het overdragen van een live uitzonderingsobject over threads heen zou inhouden dat er in het geheugen moet worden gereikt dat eigendom is van een thread die aan het eindigen is, wat dezelfde klasse van fouten is als degene die de Synchronize oplossing dient te voorkomen. Een statuscode en een string gaan netjes de grens over; een object niet

Twee interfaces, zodat een werker zichzelf niet kan annuleren

Annulering is met opzet verdeeld over twee interfaces. IPdfCancellationTokenSource is de schrijfkant: het heeft Cancel, en de eigenaar die het aanmaakt, meestal het formulier, behoudt het en roept Cancel aan wanneer de gebruiker op de knop klikt of het formulier sluit. IPdfCancellationToken is de leeskant: het heeft IsCancelled, ThrowIfCancelled, en RegisterCallback, en dat is alles wat de werker ooit ontvangt. Eén concreet object implementeert beide, maar de werker krijgt alleen ooit het token overhandigd, dus het heeft geen manier om de bewerking te annuleren die het aan het uitvoeren is. De opsplitsing is een vangrail op API-niveau. Een werker die via zijn token bij Cancel zou kunnen komen, zou verwarrende stukken code uitnodigen om zichzelf te annuleren, en het typesysteem neemt die mogelijkheid weg

Er is een bijpassend detail voor het geval dat een aanroeper een rendering wil maar nooit van plan is deze te annuleren. In plaats van per aanroep een nieuwe bron af te dwingen, onthult de eenheid PdfNoCancellationToken, een singleton-token dat permanent in de niet-geannuleerde staat is. Run vervangt deze wanneer het token-argument als nil wordt achtergelaten. Dit singleton wordt gretig gebouwd tijdens de initialisatie van de eenheid in plaats van lui (lazy) bij het eerste gebruik, en de reden hiervoor is nogmaals gelijktijdigheid (concurrency). Als meerdere Run-aanroepen op verschillende worker-threads allemaal tegelijkertijd naar een lazy gemaakte singleton zouden grijpen, zouden ze kunnen wedijveren ('racen') om de constructie ervan, een duplicaat lekken of kort een half geïnitialiseerde instantie observeren. Het bouwen ervan voordat enige werker kan werken, verwijdert de race volledig

Een annuleerbare render uitvoeren

In de praktijk maakt u een bron (source) aan, behoudt u deze op het formulier, geeft u het bijbehorende Token door aan Run naast een werker-methode en een antwoord-methode, en koppelt u de knop Annuleren (Cancel) aan de bron. De werker controleert het token tijdens het renderen; de antwoord-methode updatet de UI zodra het resultaat terug is. Omdat de callbacks methode-aanwijzers zijn, lezen de werker en het antwoord alles wat ze nodig hebben uit de velden van het formulier

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;

Het antwoord verwerkt alle drie de uitkomsten omdat alle drie bereikbaar zijn. Een voltooide render meldt succes, een gebruiker die op Annuleren heeft gedrukt ziet de geannuleerde tak, en een bestand dat niet kon worden geschreven of een pagina die niet kon worden geparseerd, arriveert als een storing met een bericht. Geen van die takken blokkeert, geen van hen raakt de worker-thread aan, en de bitmap of status die de werker heeft geproduceerd, wordt pas gelezen nadat de future deze heeft afgeleverd op de thread die eigenaar is van de UI

Dezelfde threadingdiscipline werpt ook elders in een viewer zijn vruchten af. De manier waarop gerenderde bitmaps worden bewaard en hergebruikt tijdens zoomwijzigingen, wordt behandeld in onze notitie over de rendercache en zoomprestaties, en het bredere vraagstuk van het veilig houden van de PDFium-grens onder Delphi staat in het verharden van de PDFium VCL ABI voor geheugenveiligheid. De asynchrone infrastructuur die hier wordt beschreven, wordt geleverd als onderdeel van de PDFium Component voor Delphi en C++Builder, naast de weergave-, tekst- en formulier-API's die elders op deze blog worden behandeld