Å gjengi en side i PDFium er synkront. Du kaller inn i biblioteket, det rastreres inn i et punktgrafikkbilde du ga det, og kontrollen kommer tilbake når pikslene er skrevet. For en enkelt side i skjermstørrelse med ett zoomnivå tar det noen millisekunder, og ingen legger merke til det. For en 300 dpi-eksport av et dokument på 200 sider, eller en miniatyrbildestripe som må rastrere hver side samtidig, tar den samme samtalen sekunder. Hvis du gjør det kallet fra hovedtråden, stopper meldingssløyfen, vinduet slutter å male på nytt, og Windows maler den fryktede "Svarer ikke" over tittellinjen din. Arbeidet er riktig. Stedet du kjørte det er feil.
Løsningen er å flytte den lange gjengivelsen over til en bakgrunnstråd og bringe resultatet tilbake til hovedtråden, der punktgrafikkbildet kan overleveres til en kontroll. PDFium hindrer deg ikke i å gjøre dette, men bindingen må gjøre overleveringen trygg, fordi feiloverflaten rundt "kjør på en arbeider, svar på grensesnittet" er bred, og feilene er intermitterende. FPdfAsync-enheten i PDFiumPas eksisterer for å gi dette mønsteret én riktig implementering, med en avbrytelsesmodell som passer til hvordan en lang gjengivelse faktisk oppfører seg.
Arbeidets form
Tre operasjoner dominerer i tilfeller der en gjengivelse varer lenger enn en ramme. Batch-gjengivelse går gjennom et sideområde og rastrerer hver side, vanligvis til disk. Fleresidig eksport gjør det samme, men setter utdataene sammen i én fil. Bakgrunnsgjengivelse av sider er det en leser gjør når brukeren hopper til en side som ikke er i hurtigbufferen ennå, slik at punktgrafikkbildet produseres utenfor tråden og vises når det er klart. Alle tre deler de samme begrensningene. De kjører lenge nok til at grensesnitt-tråden ikke kan være vert for dem, de produserer et resultat som grensesnitt-tråden etter hvert trenger, og brukeren kan avbryte dem. Å lukke dokumentet, bla forbi siden eller trykke på Avbryt bør stoppe arbeidet i stedet for å tvinge brukeren til å vente på utdata de ikke lenger vil ha.
Den siste begrensningen er den som former designet. En gjengivelse som ikke kan avbrytes, er en gjengivelse som holder dokumentet åpent og brenner prosessorkraft etter at svaret sluttet å ha betydning. Så enheten er bygget rundt to primitiver som settes sammen: en future som bærer resultatet tilbake, og en token som bærer avbrytelsesforespørselen fremover.
En fire-and-forget-future
TPdfFuture<T>.Run tar en arbeider, et svar og en valgfri avbrytelsestoken. Den starter arbeideren på en bakgrunnstråd, og når arbeideren er ferdig, leverer den svaret på hovedtråden. Den generiske parameteren T er hva gjengivelsen produserer, ofte et bildehåndtak eller en statusoppføring. Arbeideren kjører utenfor tråden; svaret kjører der det er trygt å berøre VCL.
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Den bevisste utelatelsen er enhver form for Wait. Det er ingen metode for å blokkere innringeren til futuren er fullført, og det er ikke en forglemmelse. En Wait kalt fra hovedtråden er den klassiske måten å fastlåse et brukergrensesnitt på: arbeideren trenger hovedtråden for å kjøre svaret sitt gjennom Synchronize, hovedtråden er parkert inni Wait, og ingen av sidene kan fortsette. Ved å nekte å tilby primitivet, utelukker futuren mønsteret som oftest beseirer folk som prøver å skrive dette selv. Kode som genuint trenger å blokkere bør bruke en vanlig TThread og eie konsekvensene. Futuren er for fire-and-forget-tilfellet, som er det bakgrunnsgjengivelse faktisk er.
Resultatet er pakket inn i TPdfFutureResult<T>, en oppføring som forteller svaret hvilke av tre ting som skjedde. IsSuccess betyr at arbeideren kom tilbake normalt, og Value holder gjengivelsen. IsCancelled betyr at tokenet ble avfyrt, og arbeideren ga opp ved et avbrytelsespunkt. IsFailure betyr at arbeideren hevet et unntak, og ErrorMessage bærer teksten. Svaret inspiserer statusen én gang og forgrener seg, i stedet for å gjette fra en sentinelverdi om et returnert punktgrafikkbilde er ekte.
v1.61.0-kappløpet som endret svarlevering
Den mest lærerike delen av denne enheten er en enlinjes endring som det tok en stund å forstå. Gjennom tidlige versjoner leverte arbeider-tråden svaret sitt med TThread.Queue. Kø legger svaret inn i hovedtrådens kø og returnerer umiddelbart, noe som leses som akkurat det en fire-and-forget-future vil ha. Det var feil, og grunnen er verdt å stave ut fordi det er den typen feil som passerer hver test du tenker på å skrive.
Arbeider-tråden er opprettet med FreeOnTerminate := True. Det betyr at i det øyeblikket Execute returnerer, river tråden seg selv ned, og TThread.Destroy kaller RemoveQueuedEvents(Self) som en del av oppryddingen. RemoveQueuedEvents renser enhver satt-i-kø metode hvis mål er den døende tråden. Så sekvensen var: arbeideren blir ferdig, den setter svaret i kø mot seg selv, Execute returnerer, tråden ødelegger seg selv, og RemoveQueuedEvents sletter svaret som hovedtråden ikke hadde kjørt ennå. Resultatet forsvant rett og slett. Enda verre, i det smale vinduet der hovedtråden trakk det i-kø-satte svaret av og begynte å kjøre det i samme øyeblikk som tråden ble frigjort, berørte svaret felter i et halvødelagt objekt, som er en bruk-etter-frigjøring (use-after-free).
Rettelsen i v1.61.0 var å levere svaret med Synchronize i stedet for Queue. Synchronize blokkerer arbeider-tråden til hovedtråden har kjørt svaret til fullførelse. Arbeideren er fortsatt i live mens svaret utføres, så det er ingenting å frigjøre under den, og tråden returnerer ikke fra Execute (og begynner derfor ikke å ødelegge seg selv) før svaret er levert. Levering er garantert, og bruk-etter-frigjøring-vinduet er lukket.
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 generelle lærdommen overlever den spesifikke rettelsen. Fire-and-forget-asynkrone tilbakeringinger er det letteste samtidighetsmønsteret å gjøre subtilt feil, fordi den lykkelige stien fungerer ved første forsøk, og feilen lever i interaksjonen mellom nedrivingsrekkefølgen for tråder og køen. Den reproduserer seg ikke på kommando. Det avhenger av om hovedtråden tilfeldigvis tømte køen før arbeideren tilfeldigvis var ferdig med å ødelegge seg selv, som er en timing planleggeren bestemmer forskjellig hver gang. Et primitiv som er riktig én gang, i bindingen, er verdt mye mer enn den samme koden utledet på nytt i hver applikasjon som trenger en bakgrunnsgjengivelse.
Hvorfor tilbakeringingene er metodepekere
Arbeideren og svaret er ikke anonyme metoder. De er procedure of object-typer, TPdfFutureWorker<T> og TPdfFutureReply<T>, og det valget er tvunget av kompilatormatrisen. PDFiumPas kompilerer på Delphi XE5 og nyere, og på Free Pascal 3.2 i Delphi-modus, og FPC 3.2 i den modusen støtter ikke anonyme metoder. En referanse-til-prosedyre-tilbakeringing som fanger opp lokale variabler ville kompilere på Delphi og mislykkes på FPC, så enheten bruker den laveste fellesnevneren som begge kompilatorene aksepterer.
Den praktiske konsekvensen er hvor tilstanden lever. En anonym metode lukkes over lokalvariabler; en metodepeker gjør det ikke. Så all tilstand arbeideren trenger, sideindeksen, zoomen, utdatabanen, og all tilstand svaret trenger for å oppdatere, målbildekontrollen eller fremdriftsetiketten, må henge på objektet hvis metode blir sendt. I en leser er det objektet vanligvis skjemaet eller en gjengivelseskontroller som det eier. Dette er ikke en omvei pålagt motvillig; den holder eierskapet til den tilstanden eksplisitt og synlig på det mottakende objektet i stedet for skjult inni en lukning (closure).
Samarbeidende avbrytelse, ikke et hardt drap
Avbrytelse her er samarbeidende. Det er ingen API som strekker seg inn i arbeider-tråden og avslutter den, fordi avslutning av en tråd midt i gjengivelsen etterlater PDFium med låser og delvis skrevne punktgrafikkbilder, og prosesstilstanden etter et tvunget drap er ikke noe du kan resonnere om. I stedet får arbeideren utlevert et skrivebeskyttet token og forventes å sjekke det, og gjengivelsessløyfen skrives for å sjekke det mellom sider eller mellom fliser, der stopping er rent.
Tokenet tilbyr tre måter å observere avbrytelse på. IsCancelled er en billig boolsk polling for en løkke som vil teste og bestemme selv. ThrowIfCancelled er det vanlige tilfellet: kall den på et naturlig avbrytelsespunkt, og hvis avbrytelse er forespurt, kaster den EPdfOperationCancelled, som ruller arbeideren rett tilbake til futuren. RegisterCallback fester et enkeltskuddsvarsel som avfyres én gang når kilden avbrytes, nyttig når en arbeider er blokkert i noe den kan avbryte i stedet for å sitte i en tett løkke.
Unntaket er der trådgrensen spiller en rolle. Når arbeideren kaster EPdfOperationCancelled, fanger futuren det og gjør det om til en avbrutt status, slik at svaret ser IsCancelled og ikke en feil. Selve unntaksobjektet blir aldri samlet over til hovedtråden. Det lever og dør på arbeider-tråden; bare meldingsstrengen kopieres til ErrorMessage. Å samle (marshaling) et levende unntaksobjekt på tvers av tråder ville bety å strekke seg inn i minne eid av en tråd som fullføres, som er den samme klassen feil Synchronize-rettelsen eksisterer for å forhindre. En statuskode og en streng krysser grensen rent; et objekt ville ikke gjort det.
To grensesnitt, slik at en arbeider ikke kan avbryte seg selv
Avbrytelse er delt over to grensesnitt med vilje. IPdfCancellationTokenSource er skrivesiden: det har Cancel, og eieren som oppretter det, vanligvis skjemaet, beholder det og kaller Cancel når brukeren klikker på knappen eller skjemaet lukkes. IPdfCancellationToken er lesesiden: den har IsCancelled, ThrowIfCancelled og RegisterCallback, og det er alt arbeideren noen gang mottar. Ett konkret objekt implementerer begge, men arbeideren får bare utlevert tokenet, så den har ingen måte å avbryte operasjonen den kjører. Delingen er et sikkerhetsgjerde på API-nivå. En arbeider som kunne nå Cancel gjennom sitt token ville invitere en forvirret kodebit til å avbryte seg selv, og typesystemet fjerner muligheten.
Det er en matchende detalj for tilfellet der en innringer ønsker en gjengivelse, men aldri har til hensikt å avbryte den. I stedet for å tvinge en ny kilde per oppkall, eksponerer enheten PdfNoCancellationToken, et singleton-token som permanent er i ikke-avbrutt tilstand. Run erstatter det når token-argumentet står tomt. Singletonen konstrueres ivrig (eagerly) under enhetsinitialisering i stedet for dovent (lazily) ved første gangs bruk, og årsaken er samtidighet (concurrency) igjen. Hvis flere Run-kall på forskjellige arbeider-tråder alle strakte seg etter en dovent opprettet singleton på en gang, kunne de kappløpt (race) på konstruksjonen, lekket et duplikat, eller kortvarig observert en halvinitialisert forekomst. Å bygge det før noen arbeider kan kjøre fjerner kappløpet helt.
Kjøre en avbrytbar gjengivelse
I praksis oppretter du en kilde, beholder den på skjemaet, sender Token inn i Run sammen med en arbeidermetode og en svarmetode, og kobler Avbryt-knappen til kilden. Arbeideren sjekker tokenet mens den gjengir; svaret oppdaterer brukergrensesnittet når resultatet er tilbake. Fordi tilbakeringingene er metodepekere, leser arbeideren og svaret det de trenger fra skjemaets felt.
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 håndterer alle tre utfallene fordi alle tre er mulige å nå. En ferdig gjengivelse rapporterer suksess, en bruker som trykket Avbryt ser den avbrutte grenen, og en fil som ikke kunne skrives eller en side som ikke klarte å analyseres kommer som en feil med en melding. Ingen av disse grenene blokkerer, ingen av dem berører arbeider-tråden, og punktgrafikkbildet eller statusen arbeideren produserte, leses bare etter at futuren har levert den på tråden som eier brukergrensesnittet.
Den samme tråddisiplinen lønner seg andre steder i en leser. Måten gjengitte punktgrafikkbilder lagres og gjenbrukes på tvers av zoom-endringer dekkes i vårt notat om hurtigbuffer for gjengivelse og zoom-ytelse, og det bredere spørsmålet om å holde PDFium-grensen trygg under Delphi er i herding av PDFium VCL ABI for minnesikkerhet. Den asynkrone infrastrukturen beskrevet her leveres som en del av PDFium Component for Delphi og C++Builder, ved siden av gjengivelses-, tekst- og skjemasAPI-ene som dekkes andre steder på denne bloggen.