En batch-rendering fryser halvveis fordi eksekutøren i PDFium Component ikke anser en oppgave som ferdig før svaret dens er dispatchet. Under padSynchronize kjører det svaret på hovedtråden. Hvis hovedtråden blokkerer uten å pumpe CheckSynchronize, venter arbeideren på hovedtråden mens hovedtråden venter på inaktivitet
Debugger-bildet er utvetydig når du først har sett det. Pause den frosne prosessen og hovedtråden sitter inne i en venting på inaktivitetshendelsen, flere rammer under din egen batch-løkke. Bytt til en hvilken som helst arbeidertråd og den sitter inne i TThread.Synchronize, og holder et ferdig resultat den ikke kan levere over. Ingenting spinner, ingen CPU brenner, prosessen er ganske enkelt parkert. Denne artikkelen handler om hvorfor den tilstanden i det hele tatt finnes, og om tre nabo-regler som avgjør om en Delphi-arbeiderpool over PDFium oppfører seg eller biter: hva QueueCapacity faktisk begrenser, hvilken rekkefølge nedstenging må avbryte i, og hva parallellitet ikke kjøper deg der PDFium-objekteierskap er bekymret
Hvorfor henger WaitForIdle hovedtråden?
Den henger fordi inaktivitet i TPdfAsyncExecutor er definert til å inkludere svar-dispatching, ikke bare arbeider-fullføring. Det kjørende antallet økes i DequeueTask når en arbeider plukker opp en oppgave, og det senkes i TaskFinished, som arbeideren bare kaller etter at TPdfAsyncTaskOperation.Execute har returnert. Den metoden kjører arbeiderkroppen, registrerer utfallet, og dispatcher deretter svaret i henhold til TPdfAsyncDispatchMode. Med padSynchronize er dispatchingen et TThread.Synchronize-kall, så Execute returnerer ikke før hovedtråden har kjørt det
Delphi legger den andre halvparten av den kontrakten på deg. TThread.Synchronize legger metoden til en global kø og blokkerer den kallende tråden på en hendelse; noe på hovedtråden må kalle CheckSynchronize før den hendelsen noensinne signaliseres. VCL-meldingsløkken gjør dette for deg mellom meldinger, som er akkurat hvorfor feilen er usynlig under interaktiv bruk og dukker opp i det øyeblikket du skriver en blokkerende batch-løkke. En blokkerende hovedtråd er en hovedtråd som har forlatt meldingsløkken, og en hovedtråd utenfor meldingsløkken drenerer ingen
uses
System.Classes, FPdfAsync, PDFium;
// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
padSynchronize);
Task.WaitFor(High(Cardinal)); // the main thread now parks in a kernel wait
// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
// TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.
Oppgavefullføring og eksekutør-inaktivitet er to forskjellige milepæler
De er separert med hensikt, og å vite hvilken du venter på er hele fiksen. IPdfAsyncTask.WaitFor er tilfredsstilt i det øyeblikket arbeiderresultatet er avgjort: Complete skriver den endelige TPdfAsyncTaskState og setter ferdig-hendelsen før noe svar vurderes. TPdfAsyncExecutor.WaitForIdle er tilfredsstilt senere, når både køede og kjørende antall er null, og kjørende faller ikke før svaret har landet. Så en oppgave kan være patsSucceeded og observerbar gjennom Snapshot mens eksekutøren fortsatt er legitimt opptatt
// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
ReportBatchTimeout;
// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
ATimeoutMs: Cardinal): Boolean;
var
StartedAt: UInt64;
begin
StartedAt := PdfAsyncTick;
repeat
if ATask.WaitFor(10) then
Exit(True);
CheckSynchronize(0); // release any pending padSynchronize reply
Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
until not Result;
end;
Én konsekvens verdt å internalisere: et svar som kaster et unntak skriver ikke om historien. DispatchReply fanger unntaket og lagrer det i ReplyErrorMessage, og lar State, CancellationReason og ErrorMessage stå akkurat slik arbeideren avgjorde dem. Et UI-callback som eksploderer mens det maler en miniatyr gjør derfor aldri en vellykket rendering om til en mislykket, og telemetrien din fortsetter å rapportere hva rendermotoren faktisk gjorde. Hvis du vil ha den callback-formede API-en rundt en enkelt operasjon fremfor en pool, dekker bakgrunnsrendering med avbrytbare futures den veien
Begrenser QueueCapacity kjørende arbeidere også?
Nei. QueueCapacity i PDFium Component teller bare køede oppgaver, aldri de som allerede kjører på en arbeider. Det er bevisst: kapasitet er ment å uttrykke ekte motpress på venteslangen, og å folde de faste samtidighetsplassene inn i samme tall ville telt dem to ganger. Med fire arbeidere og en kapasitet på åtte kan du ha tolv oppgaver i flukt, og GetStats rapporterer skillet ærlig gjennom QueuedCount og RunningCount
var
Stats: TPdfAsyncExecutorStats;
Task: IPdfAsyncTask;
begin
// TrySubmit never raises: it returns False when the waiting line is full or
// the executor is already shutting down, and bumps RejectedCount.
if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
padSynchronize) then
begin
Stats := Executor.GetStats;
// QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
// WorkerCount and is never charged against the capacity.
LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
Stats.RejectedCount);
Exit;
end;
De fire feltene til TPdfAsyncPriority er strenge, ikke vektede. DequeueTask går fra papCritical ned til papLow og tar det første ikke-tomme feltet, og bevarer FIFO-rekkefølge innenfor hvert. Det gir en interaktiv forespørsel en ren måte å hoppe foran en batch som ikke har startet ennå, men den avbryter aldri arbeid som allerede kjører, og en kaller som fortsetter å mate papCritical kan sulte papLow på ubestemt tid. Reserver de to øverste feltene for ting et menneske synlig venter på, og la bulk-eksport ligge på papNormal eller lavere. Bruk Submit når en full kø er en programmeringsfeil verdt en EPdfAsyncQueueFull, og TrySubmit når det er en normal tilstand du har til hensikt å håndtere
Hvorfor avbryter Shutdown utenfor eksekutør-låsen?
Fordi å avbryte inne i den ville invertert låserekkefølgen og hengt opp nedstengingen du prøver å utføre. Shutdown(True) tar eksekutør-låsen, vender nedstengingsflagget, og legger til hver ventende oppgave til et lokalt øyeblikksbilde-array gjennom AppendSnapshot. Så slipper den låsen og går først deretter gjennom øyeblikksbildet og kaller Cancel på hver oppføring. Å avbryte en oppgave utløser brukercallbacks registrert på kildenominatorene dens, og de callbackene er ordinær applikasjonskode: de kan spørre GetStats, sende inn kompenserende arbeid, eller vente på inaktivitet. Hver eneste av dem går inn igjen i eksekutør-låsen, og et callback kalt mens den låsen holdes ville vranglåse seg selv
Tokenkilden adlyder samme disiplin ett nivå ned. CancelWithReason tar kildelåsen, avgjør den ene vinnende avbryteren, skriver Reason, CancellationMessage og CancelledAtTick, og først deretter vender den avbrutt-flagget atomisk. Publisér-før-vend er det som gjør metadataene trygge å lese: enhver tråd som observerer IsCancelled som True er garantert å finne en komplett grunn bak det, og senere kallere taper løpet, returnerer False, og kan ikke overskrive den første grunnen. De registrerte callbackene tas et øyeblikksbilde av og tømmes inne i låsen men kalles utenfor den, hver pakket inn slik at én feilende handler ikke kan undertrykke resten. Oppgaver som allerede kjører drepes aldri; de avsluttes samarbeidsvillig når arbeiderkroppen deres neste gang kaller ThrowIfCancelled, som er hvorfor Shutdown avslutter med en pumpende WaitForIdle før den slår seg sammen med tråder
Slapper parallelle arbeidere av på PDFium-objekteierskap?
Det gjør de ikke, og dette er grensen mest sannsynlig å bli mistolket. TPdfAsyncExecutor planlegger arbeid; den gjør ingen påstand om trådtilhørigheten til noe du rører inne i det arbeidet. En levende TPdf-instans blir ikke samtidig tilgjengelig fordi to arbeidere tilfeldigvis kaller inn i den, og den interne renderlåsen er en vakt mot overlappende renderkall, ikke en lisens til å dele et dokument på tvers av tråder. Parallell rendering eller eksport betyr én TPdf per arbeider, opprettet og ødelagt inne i jobben
type
TPageRenderJob = class
private
FFileName: string;
FPageIndex: Integer;
public
procedure Run(const AToken: IPdfCancellationToken);
end;
procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
LocalPdf: TPdf; // one document instance per worker, never shared
Bmp: TBitmap;
begin
LocalPdf := TPdf.Create(nil);
try
LocalPdf.FileName := FFileName;
LocalPdf.Active := True;
LocalPdf.PageNumber := FPageIndex;
AToken.ThrowIfCancelled;
Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
try
HandOffBitmap(FPageIndex, Bmp); // ownership moves to the reply stage
finally
Bmp.Free;
end;
finally
LocalPdf.Free;
end;
end;
Kostnaden er reell og verdt å nevne: hver arbeider betaler sin egen parsing og sin egen sidebuffer, så minne skalerer med arbeiderantall fremfor med dokumentantall. Det er prisen for en modell der en arbeider kan avbrytes eller krasje uten å korrumpere noen andre. Hvis arbeiderne dine i stedet deler et fremviser-side-dokument, er låsereglene rundt det dekket i renderlåsen og kallene som bommer på den, og den enkeltdokument-avbrytbare veien er i avbrytbar progressiv rendering
Å utvide et publisert grensesnitt uten å ødelegge vtable-en
IPdfCancellationToken og IPdfCancellationTokenSource er COM-stil-grensesnitt eksterne binærfiler kanskje allerede konsumerer, så å legge til en metode på hvilken som helst av dem ville forskjøvet hver senere plass i vtable-en og stille feilrutet kall kompilert mot det gamle oppsettet. De diagnostiske, ventende, fjernbare-callback og atomiske-avbryt-egenskapene bor derfor i IPdfCancellationTokenEx og IPdfCancellationTokenSourceEx, som arver fremfor å modifisere. New og Run beholder sin originale semantikk for eksisterende kallere; ny kode griper til NewEx, NewTimeout og RunEx når den vil ha CancelWithReason, WaitForCancellation eller en administrert IPdfCancellationRegistration. Arv er den eneste trygge måten å vokse et publisert grensesnitt på, og det koster én ekstra type per generasjon
NewTimeout fortjener en ærlig merknad. Hver timeout-kilde eier en lettvekts-tråd som venter på avbrytelseshendelsen eller fristen, hvilken som kommer først. For en håndfull eller noen dusin frister er det enkelt, lav-latens og identisk på tvers av Delphi, Lazarus og C++Builder. For tusenvis av korte frister er det feil form, og du bør drive avbrytelse fra én applikasjonsnivå-timer i stedet for å holde tusenvis av ventende tråder
Ingen av disse reglene er eksotiske når de først er skrevet ned, men hver eneste av dem er en produksjonshendelse når de ikke er det. Vent på riktig milepæl og la noe pumpe synchronize-køen, les QueueCapacity som en grense bare på venteslangen, avbryt utenfor låsene dine, og gi hver arbeider sitt eget dokument. Det asynkrone laget beskrevet her leveres som en del av Delphi PDFium Component, sammen med rendering-, tekst- og skjema-API-ene den planlegger