En batch-rendering fryser halvvejs, fordi executoren i PDFium Component ikke betragter en opgave som færdig, før dens svar er blevet dispatchet. Under padSynchronize kører det svar på hovedtråden. Hvis hovedtråden blokerer uden at pumpe CheckSynchronize, venter workeren på hovedtråden, mens hovedtråden venter på idle
Debugger-billedet er umiskendeligt, når man først har set det. Pause den fastfrosne proces, og hovedtråden sidder inde i en vent på idle-hændelsen, flere frames under ens egen batch-loop. Skift til en hvilken som helst worker-tråd, og den sidder inde i TThread.Synchronize, holdende et færdigt resultat, den ikke kan overgive. Intet spinner, ingen CPU brænder, processen er simpelthen parkeret. Denne artikel handler om, hvorfor den tilstand overhovedet findes, og om tre nærliggende regler, der afgør, om en Delphi-worker-pool over PDFium opfører sig eller bider: hvad QueueCapacity faktisk begrænser, hvilken rækkefølge nedlukning skal annullere i, og hvad parallelisme ikke køber en, hvor PDFium-objektejerskab er angået
Hvorfor hænger WaitForIdle hovedtråden?
Den hænger, fordi idle i TPdfAsyncExecutor er defineret til at inkludere svar-dispatch, ikke kun worker-fuldførelse. Det kørende antal inkrementeres i DequeueTask, når en worker samler en opgave op, og det dekrementeres i TaskFinished, som workeren kun kalder, efter TPdfAsyncTaskOperation.Execute er returneret. Den metode kører worker-kroppen, registrerer udfaldet, og dispatcher så svaret i henhold til TPdfAsyncDispatchMode. Med padSynchronize er dispatchen et TThread.Synchronize-kald, så Execute returnerer ikke, før hovedtråden har kørt det
Delphi lægger anden halvdel af den kontrakt på dig. TThread.Synchronize tilføjer metoden til en global kø og blokerer den kaldende tråd på en hændelse; noget på hovedtråden skal kalde CheckSynchronize, før den hændelse nogensinde signaleres. VCL-beskedloopet gør dette for dig mellem beskeder, hvilket er præcis hvorfor fejlen er usynlig under interaktiv brug og optræder i det øjeblik man skriver en blokerende batch-loop. En blokerende hovedtråd er en hovedtråd, der har forladt beskedloopet, og en hovedtråd uden for beskedloopet dræner 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.
Opgavefuldførelse og executor-idle er to forskellige milepæle
De er adskilt med vilje, og at vide hvilken en man venter på, er hele rettelsen. IPdfAsyncTask.WaitFor er opfyldt i det øjeblik worker-resultatet er afgjort: Complete skriver den endelige TPdfAsyncTaskState og sætter done-hændelsen, før noget svar overvejes. TPdfAsyncExecutor.WaitForIdle er opfyldt senere, når køede og kørende antal begge er nul, og kørende falder ikke, før svaret er landet. Så en opgave kan være patsSucceeded og observerbar gennem Snapshot, mens executoren stadig legitimt er optaget
// 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;
En konsekvens værd at indarbejde: et svar, der rejser en fejl, omskriver ikke historien. DispatchReply fanger undtagelsen og gemmer den i ReplyErrorMessage, og lader State, CancellationReason og ErrorMessage forblive præcis som workeren afgjorde dem. Et UI-callback, der eksploderer, mens det maler en thumbnail, gør derfor aldrig en vellykket rendering til en fejlet en, og din telemetri fortsætter med at rapportere, hvad rendering-motoren faktisk gjorde. Hvis man vil have den callback-formede API omkring en enkelt operation frem for en pool, dækker baggrundsrendering med annullerbare futures den sti
Begrænser QueueCapacity også kørende workers?
Nej. QueueCapacity i PDFium Component tæller kun køede opgaver, aldrig dem der allerede eksekverer på en worker. Det er bevidst: kapacitet skal udtrykke reel modtryk på ventelinjen, og at folde de faste samtidighedspladser ind i det samme tal ville tælle dem to gange. Med fire workers og en kapacitet på otte kan man have tolv opgaver i flight, og GetStats rapporterer opdelingen ærligt gennem 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 baner i TPdfAsyncPriority er strikte, ikke vægtede. DequeueTask går fra papCritical ned til papLow og tager den første ikke-tomme bane, mens FIFO-rækkefølgen bevares inden for hver. Det giver en interaktiv forespørgsel en ren måde at springe foran en batch, der endnu ikke er startet, men det afbryder aldrig arbejde, der allerede kører, og en kalder, der bliver ved med at fodre papCritical, kan sulte papLow på ubestemt tid. Reservér de øverste to baner til ting et menneske synligt venter på, og lad bulk-eksport ligge på papNormal eller derunder. Brug Submit, når en fuld kø er en programmeringsfejl værd en EPdfAsyncQueueFull, og TrySubmit, når det er en normal tilstand man har tænkt sig at håndtere
Hvorfor annullerer Shutdown uden for executor-låsen?
Fordi at annullere inde i den ville vende låserækkefølgen om og hænge den nedlukning, man forsøger at udføre. Shutdown(True) tager executor-låsen, vender nedlukningsflaget, og tilføjer hver ventende opgave til et lokalt snapshot-array gennem AppendSnapshot. Så frigiver den låsen og går først derefter gennem snapshottet og kalder Cancel på hver post. At annullere en opgave affyrer brugerdefinerede callbacks registreret på dens token-kilde, og de callbacks er almindelig applikationskode: de kan forespørge GetStats, indsende kompenserende arbejde, eller vente på idle. Hver af dem genindtræder executor-låsen, og et callback kaldt mens den lås holdes, ville deadlocke mod sig selv
Token-kilden adlyder den samme disciplin ét niveau nede. CancelWithReason tager kilde-låsen, afgør den enkelte vindende annullerer, skriver Reason, CancellationMessage og CancelledAtTick, og vender først derefter det annullerede flag atomisk. Udgiv-før-vend er hvad der gør metadataene sikre at læse: enhver tråd, der observerer IsCancelled som True, er garanteret at finde en komplet grund bag den, og senere kaldere taber løbet, returnerer False, og kan ikke overskrive den første grund. De registrerede callbacks tages som snapshot og ryddes inde i låsen, men kaldes uden for den, hver pakket ind så én fejlende handler ikke kan undertrykke resten. Opgaver, der allerede kører, dræbes aldrig; de slutter kooperativt, når deres worker-krop næste gang kalder ThrowIfCancelled, hvilket er hvorfor Shutdown afslutter med en pumpende WaitForIdle før den samler tråde
Slækker parallelle workers PDFium-objektejerskab?
Det gør de ikke, og dette er den grænse, der oftest fejllæses. TPdfAsyncExecutor planlægger arbejde; den gør intet krav om trådaffiniteten af noget, man rører ved inden for det arbejde. En levende TPdf-instans bliver ikke samtidigt tilgængelig, fordi to workers tilfældigvis kalder ind i den, og den interne render-lås er en vagt mod overlappende render-kald, ikke en licens til at dele et dokument på tværs af tråde. Parallel rendering eller eksport betyder én TPdf per worker, oprettet og destrueret inde i jobbet
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;
Omkostningen er reel og værd at nævne: hver worker betaler sin egen parse og sin egen sidecache, så hukommelse skalerer med worker-antal frem for med dokumentantal. Det er prisen for en model, hvor en worker kan annulleres eller crashe uden at korrumpere nogen anden. Hvis dine workers i stedet deler et viewer-side-dokument, dækkes låsereglerne omkring det i render-låsen og de kald, der misser den, og den enkelt-dokument-annullerbare sti er i annullerbar progressiv rendering
At udvide en offentliggjort interface uden at ødelægge vtable'en
IPdfCancellationToken og IPdfCancellationTokenSource er COM-stil-interfaces, som eksterne binærfiler måske allerede konsumerer, så at tilføje en metode til nogen af dem ville forskyde hver senere plads i vtable'en og stille fejlrute kald kompileret mod det gamle layout. De diagnostiske, ventende, fjernbare-callback- og atomiske-annuller-kapaciteter bor derfor i IPdfCancellationTokenEx og IPdfCancellationTokenSourceEx, som arver frem for at ændre. New og Run beholder deres oprindelige semantik for eksisterende kaldere; ny kode rækker ud efter NewEx, NewTimeout og RunEx, når den vil have CancelWithReason, WaitForCancellation eller en administreret IPdfCancellationRegistration. Arv er den eneste sikre måde at udvide et offentliggjort interface på, og det koster én ekstra type per generation
NewTimeout fortjener en ærlig note. Hver timeout-kilde ejer en letvægts-tråd, der venter på annulleringshændelsen eller deadline'en, hvilken end kommer først. For en håndfuld eller nogle få dusin deadlines er det simpelt, lav-latens og identisk på tværs af Delphi, Lazarus og C++Builder. For tusindvis af korte deadlines er det den forkerte form, og man bør i stedet drive annullering fra én applikations-niveau-timer frem for at holde tusindvis af ventende tråde
Ingen af disse regler er eksotiske, når de først er skrevet ned, men hver eneste af dem er en produktionshændelse, når de ikke er. Vent på den rigtige milepæl og lad noget pumpe synchronize-køen, læs QueueCapacity som en grænse på ventelinjen alene, annullér uden for dine låse, og giv hver worker sit eget dokument. Async-laget beskrevet her leveres som del af Delphi PDFium Component, sammen med de rendering-, tekst- og formular-API'er den planlægger