Technical Article

PDF-rendering i bakgrunden i Delphi med avbrytbara terminer

Att rendera en sida i PDFium är synkront. Du gör ett anrop in i biblioteket, det rastreras till en bitmapp som du har överlämnat, och kontrollen kommer tillbaka när pixlarna har skrivits. För en enstaka sida i skärmstorlek på en zoomnivå tar det några millisekunder och ingen märker det. För en export av ett 200-sidigt dokument i 300 dpi, eller en miniatyrremsa som måste rastrera varje sida på en gång, kostar samma anrop sekunder. Om du gör det anropet från huvudtråden, stannar meddelandeslingan, fönstret slutar ritas om, och Windows målar det fruktade "Svarar inte" över din namnlist. Arbetet är korrekt. Platsen du körde det på är fel

Lösningen är att flytta den långa renderingen till en bakgrundstråd och föra tillbaka resultatet till huvudtråden, där bitmappen kan överlämnas till en kontroll. PDFium självt hindrar dig inte från att göra detta, men bindningen måste göra överlämningen säker, eftersom felzonen kring "kör på en arbetare, svara i gränssnittet" är vid och felen är intermittenta. Enheten FPdfAsync i PDFiumPas existerar för att ge det mönstret en korrekt implementation, med en avbrytningsmodell som passar hur en lång rendering faktiskt beter sig

Arbetets form

Tre operationer dominerar de fall där en rendering överlever en bildruta. Batchrendering går igenom ett intervall av sidor och rastrerar varje sida, vanligtvis till disk. Flersidig export gör detsamma men sätter ihop utmatningen till en enda fil. Rendering av sidor i bakgrunden är vad ett visningsprogram gör när användaren hoppar till en sida som ännu inte finns i cache, så bitmappen produceras utanför tråden och visas när den är klar. Alla tre delar samma begränsningar. De körs tillräckligt länge för att UI-tråden inte ska kunna vara värd för dem, de producerar ett resultat som UI-tråden i slutändan behöver, och användaren kan överge dem. Att stänga dokumentet, skrolla förbi sidan eller trycka på Avbryt bör stoppa arbetet i stället för att tvinga användaren att vänta på en utmatning de inte längre vill ha

Den sista begränsningen är den som formar designen. En rendering som inte kan avbrytas är en rendering som håller dokumentet öppet och förbrukar CPU efter att svaret upphört att spela någon roll. Så enheten är byggd kring två primitiver som samverkar: en termin som bär resultatet tillbaka, och en symbol som bär förfrågan om att avbryta framåt

En avfyra-och-glöm-termin

TPdfFuture<T>.Run tar en arbetare, ett svar, och ett valfritt avbrytningstoken. Den startar arbetaren på en bakgrundstråd, och när arbetaren är klar levererar den svaret på huvudtråden. Den generiska parametern T är vad än renderingen producerar, ofta ett bitmappshandtag eller en statuspost. Arbetaren körs utanför tråden; svaret körs där det är säkert att röra vid VCL

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

Den medvetna utelämningen är varje form av Wait. Det finns ingen metod för att blockera anroparen tills terminen är slutförd, och det är inte ett förbiseende. En Wait som anropas från huvudtråden är det klassiska sättet att låsa ett användargränssnitt i deadlock: arbetaren behöver huvudtråden för att köra sitt svar genom Synchronize, huvudtråden är parkerad inuti Wait, och ingen av sidorna kan fortsätta. Genom att vägra att erbjuda primitiven, utesluter terminen det mönster som oftast fäller dem som försöker skriva detta själva. Kod som genuint behöver blockera bör använda en vanlig TThread och ta konsekvenserna av det. Terminen är till för fallet med avfyra-och-glöm, vilket är vad bakgrundsrendering faktiskt är

Resultatet är inpackat i TPdfFutureResult<T>, en post som berättar för svaret vilken av tre saker som hände. IsSuccess betyder att arbetaren återvände normalt och Value håller renderingen. IsCancelled betyder att tokenet utlöstes och arbetaren hoppade av vid en avbrytningspunkt. IsFailure betyder att arbetaren kastade ett undantag, och ErrorMessage bär på texten. Svaret inspekterar statusen en gång och förgrenar sig, i stället för att gissa från ett sentinelvärde huruvida en returnerad bitmapp är verklig

V1.61.0-kapplöpningen som ändrade svarsleveransen

Den mest lärorika delen av denna enhet är en enradsändring som det tog ett tag att förstå. Genom tidigare versioner levererade arbetstråden sitt svar med TThread.Queue. Queue lägger svaret i huvudtrådens kö och returnerar omedelbart, vilket läser som exakt vad en avfyra-och-glöm-termin vill ha. Det var fel, och anledningen är värd att stava ut eftersom det är den typen av bugg som klarar varje test du kan komma på att skriva

Arbetstråden skapas med FreeOnTerminate := True. Det betyder att i samma ögonblick som Execute returnerar, river tråden ner sig själv, och TThread.Destroy anropar RemoveQueuedEvents(Self) som en del av rensningen. RemoveQueuedEvents rensar varje köad metod vars mål är den döende tråden. Så sekvensen var: arbetaren blir klar, den köar svaret mot sig själv, Execute returnerar, tråden förstör sig själv, och RemoveQueuedEvents tar bort svaret som huvudtråden ännu inte hade kört. Resultatet försvann helt enkelt. Värre var att i det smala fönstret där huvudtråden drog av det köade svaret och började köra det i samma ögonblick som tråden frigjordes, rörde svaret vid fält i ett halvt förstört objekt, vilket är en use-after-free

Lösningen i v1.61.0 var att leverera svaret med Synchronize i stället för Queue. Synchronize blockerar arbetstråden tills huvudtråden har kört svaret till slutförande. Arbetaren lever fortfarande medan dess svar exekveras, så det finns inget att frigöra under fötterna på den, och tråden returnerar inte från Execute (och börjar därför inte förstöra sig själv) förrän svaret har blivit levererat. Leveransen är garanterad, och use-after-free-fönstret är stängt

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;

Den allmänna läxan överlever den specifika lösningen. Asynkrona avfyra-och-glöm-callbacks är det enklaste samtidighetsmönstret att subtilt göra fel på, eftersom den lyckliga vägen fungerar på första försöket och buggen lever i interaktionen mellan ordningen för trådens nedrivning och kön. Den reproducerar inte på begäran. Den beror på huruvida huvudtråden råkade tömma kön innan arbetaren råkade avsluta att förstöra sig själv, vilket är en tidsinställning som schemaläggaren beslutar om olika vid varje körning. En primitiv som är korrekt en gång, i bindningen, är värd mycket mer än samma kod återskapad i varje applikation som behöver en bakgrundsrendering

Varför callbacks är metodpekare

Arbetaren och svaret är inte anonyma metoder. De är typer av procedure of object, TPdfFutureWorker<T> och TPdfFutureReply<T>, och det valet är framtvingat av kompilatormatrisen. PDFiumPas kompilerar på Delphi XE5 och senare samt på Free Pascal 3.2 i Delphi-läge, och FPC 3.2 i det läget stöder inte anonyma metoder. Ett callback med en procedure-referens som fångar lokala variabler skulle kompilera i Delphi och misslyckas i FPC, så enheten använder den minsta gemensamma nämnaren som båda kompilatorerna accepterar

Den praktiska konsekvensen är var tillståndet lever. En anonym metod stänger över lokala variabler; en metodpekare gör det inte. Så allt tillstånd som arbetaren behöver, sidindex, zoomnivå, utmatningssökväg, och varje tillstånd som svaret behöver uppdatera, målbildskontrollen eller förloppsetiketten, måste hänga på objektet vars metod skickas in. I ett visningsprogram är det objektet oftast formuläret eller en renderingskontroller det äger. Detta är inte en lösning som pålagts motvilligt; det håller ägandeskapet över det tillståndet explicit och synligt på det mottagande objektet i stället för dolt inuti en closure

Samarbetsvilligt avbrytande, inte ett hårt avslut

Att avbryta här är samarbetsvilligt. Det finns inget API som sträcker sig in i arbetstråden och avslutar den, eftersom att avsluta en tråd mitt i en rendering lämnar PDFium med lås och delvis skrivna bitmappar, och processtillståndet efter ett påtvingat avslut är ingenting du kan resonera kring. I stället tilldelas arbetaren en skrivskyddad token och förväntas kontrollera den, och renderingsslingan skrivs för att kontrollera den mellan sidor eller mellan plattor, där ett stopp är rent

Denna token erbjuder tre sätt att observera avbrott. IsCancelled är en billig boolesk omröstning för en loop som vill testa och bestämma själv. ThrowIfCancelled är det vanliga fallet: anropa det vid en naturlig avbrytningspunkt och, om en avbrytning har begärts, kastar den EPdfOperationCancelled, vilket rullar tillbaka arbetaren raka vägen till terminen. RegisterCallback fäster en avisering av typen one-shot som avfyras en gång när källan avbryts, vilket är användbart när en arbetare är blockerad i något den kan avbryta snarare än att sitta i en trång slinga

Undantaget är där trådgränsen spelar roll. När arbetaren kastar EPdfOperationCancelled fångar terminen upp det och omvandlar det till en avbruten status, så att svaret ser IsCancelled och inte ett fel. Själva undantagsobjektet överförs (marshaleras) aldrig till huvudtråden. Det lever och dör på arbetstråden; endast dess meddelandesträng kopieras in i ErrorMessage. Att överföra ett levande undantagsobjekt över trådar skulle innebära att sträcka sig in i minne som ägs av en tråd som håller på att avslutas, vilket är samma klass av misstag som Synchronize-rättningen finns till för att förhindra. En statuskod och en sträng korsar gränsen rent; ett objekt skulle inte göra det

Två gränssnitt, så att en arbetare inte kan avbryta sig själv

Avbrytande är medvetet uppdelat över två gränssnitt. IPdfCancellationTokenSource är skrivsidan: den har Cancel, och ägaren som skapar den, oftast formuläret, behåller den och anropar Cancel när användaren klickar på knappen eller formuläret stängs. IPdfCancellationToken är lässidan: den har IsCancelled, ThrowIfCancelled och RegisterCallback, och det är allt som arbetaren någonsin tar emot. Ett enda konkret objekt implementerar båda, men arbetaren tilldelas bara denna token, så den har inget sätt att avbryta operationen den kör. Uppdelningen är ett skyddsräcke på API-nivå. En arbetare som kunde nå Cancel genom sin token skulle inbjuda en förvirrad kodbit att avbryta sig själv, och typsystemet tar bort den möjligheten

Det finns en matchande detalj för fallet där en anropare vill ha en rendering men aldrig har för avsikt att avbryta den. I stället för att tvinga fram en färsk källa per anrop, exponerar enheten PdfNoCancellationToken, ett singleton-token som permanent befinner sig i tillståndet ej avbruten. Run sätter in det när token-argumentet lämnas som nil. Denna singleton konstrueras ivrigt under enhetens initiering snarare än lat vid första användningen, och anledningen är återigen samtidighet. Om flera Run-anrop på olika arbetstrådar alla sträckte sig efter en lat skapad singleton på en gång, skulle de kunna kapplöpa kring dess konstruktion, läcka en dubblett eller kortvarigt observera en halvinitierad instans. Att bygga den innan någon arbetare kan köra undanröjer kapplöpningen helt och hållet

Running a cancellable render

In practice you create a source, keep it on the form, pass its Token into Run alongside a worker method and a reply method, and wire the Cancel button to the source. The worker checks the token while it renders; the reply updates the UI once the result is back. Because the callbacks are method pointers, the worker and reply read whatever they need from the form's fields

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;

Svaret hanterar alla tre utfallen eftersom alla tre kan nås. En slutförd rendering rapporterar framgång, en användare som tryckte på Avbryt ser den avbrutna grenen, och en fil som inte kunde skrivas eller en sida som misslyckades med att tolkas anländer som ett fel med ett meddelande. Ingen av de grenarna blockerar, ingen av dem rör vid arbetstråden, och bitmappen eller statusen som arbetaren producerade läses först efter att terminen har levererat den på den tråd som äger gränssnittet

Samma tråddisciplin lönar sig på andra ställen i ett visningsprogram. Sättet som renderade bitmappar bevaras och återanvänds vid zoomändringar täcks i vår anteckning om renderingscachen och zoomprestanda, och den bredare frågan om att hålla PDFium-gränsen säker under Delphi finns i att härda PDFium VCL ABI för minnessäkerhet. Den asynkrona infrastrukturen som beskrivs här levereras som en del av PDFium Component för Delphi och C++Builder, vid sidan av API:erna för rendering, text och formulär som täcks på andra ställen på den här bloggen