Een batchrender bevriest halverwege omdat de executor in PDFium Component een taak pas als voltooid beschouwt zodra het antwoord is afgeleverd. Onder padSynchronize draait dat antwoord op de hoofdthread. Als de hoofdthread blokkeert zonder CheckSynchronize aan te sturen, wacht de worker op de hoofdthread terwijl de hoofdthread op idle wacht
Het debuggerbeeld is onmiskenbaar zodra je het hebt gezien. Pauzeer het bevroren proces en de hoofdthread zit in een wachtstand op de idle-event, meerdere frames onder je eigen batchlus. Schakel naar een willekeurige workerthread en die zit binnen TThread.Synchronize, met een voltooid resultaat vast dat hij niet kan overdragen. Niets draait, geen CPU brandt, het proces staat gewoon stil geparkeerd. Dit artikel gaat over waarom die toestand überhaupt bestaat, en over drie naburige regels die bepalen of een Delphi-workerpool over PDFium zich netjes gedraagt of bijt: wat QueueCapacity daadwerkelijk begrenst, in welke volgorde afsluiten moet annuleren, en wat parallellisme je niet oplevert waar het gaat om PDFium-objecteigendom
Waarom hangt WaitForIdle de hoofdthread op
Het hangt op omdat idle in TPdfAsyncExecutor is gedefinieerd inclusief antwoordaflevering, niet alleen workervoltooiing. De draaiende telling wordt verhoogd in DequeueTask wanneer een worker een taak oppakt, en verlaagd in TaskFinished, wat de worker pas aanroept nadat TPdfAsyncTaskOperation.Execute is teruggekeerd. Die methode voert de workerbody uit, registreert de uitkomst, en levert dan het antwoord af volgens TPdfAsyncDispatchMode. Met padSynchronize is de aflevering een TThread.Synchronize-aanroep, dus Execute keert pas terug nadat de hoofdthread hem heeft uitgevoerd
Delphi legt de tweede helft van dat contract bij jou. TThread.Synchronize voegt de methode toe aan een globale wachtrij en blokkeert de aanroepende thread op een event; iets op de hoofdthread moet CheckSynchronize aanroepen voordat die event ooit wordt gesignaleerd. De VCL-berichtenlus doet dit voor je tussen berichten door, wat precies is waarom de bug onzichtbaar is bij interactief gebruik en verschijnt zodra je een blokkerende batchlus schrijft. Een blokkerende hoofdthread is een hoofdthread die de berichtenlus heeft verlaten, en een hoofdthread buiten de berichtenlus draineert niemand
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.
Taakvoltooiing en executor-idle zijn twee verschillende mijlpalen
Ze zijn doelbewust gescheiden, en weten op welke je aan het wachten bent is de hele oplossing. IPdfAsyncTask.WaitFor is voldaan op het moment dat het workerresultaat is bepaald: Complete schrijft de definitieve TPdfAsyncTaskState en zet de done-event voordat enig antwoord in overweging wordt genomen. TPdfAsyncExecutor.WaitForIdle is pas later voldaan, zodra zowel de wachtrij- als de draaitelling nul zijn, en draaien daalt pas zodra het antwoord is geland. Dus een taak kan patsSucceeded zijn en observeerbaar via Snapshot terwijl de executor nog legitiem bezig is
// 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;
Eén gevolg dat het onthouden waard is: een antwoord dat een exception opwerpt, herschrijft de geschiedenis niet. DispatchReply vangt de exception op en slaat hem op in ReplyErrorMessage, waarbij State, CancellationReason en ErrorMessage precies blijven zoals de worker ze heeft vastgesteld. Een UI-callback die crasht tijdens het tekenen van een thumbnail verandert dus nooit een geslaagde render in een mislukte, en je telemetrie blijft rapporteren wat de render-engine daadwerkelijk deed. Als je de callback-vormige API rond een enkele bewerking wilt in plaats van een pool, behandelt achtergrondrendering met annuleerbare futures dat pad
Begrenst QueueCapacity ook draaiende workers
Nee. QueueCapacity in PDFium Component telt alleen taken in de wachtrij, nooit degene die al op een worker draaien. Dat is opzettelijk: capaciteit is bedoeld om echte backpressure op de wachtlijn uit te drukken, en de vaste concurrency-slots in datzelfde getal opnemen zou ze dubbel tellen. Met vier workers en een capaciteit van acht kun je twaalf taken in behandeling hebben, en GetStats rapporteert de verdeling eerlijk via QueuedCount en 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 vier banen van TPdfAsyncPriority zijn strikt, niet gewogen. DequeueTask loopt van papCritical naar beneden tot papLow en neemt de eerste niet-lege baan, waarbij FIFO-volgorde binnen elke baan behouden blijft. Dat geeft een interactief verzoek een schone manier om voor een batch te springen die nog niet is gestart, maar het onderbreekt nooit al draaiend werk, en een aanroeper die papCritical blijft voeden kan papLow onbeperkt laten verhongeren. Reserveer de bovenste twee banen voor dingen waar een mens zichtbaar op wacht, en laat bulkexport op papNormal of lager staan. Gebruik Submit wanneer een volle wachtrij een programmeerfout is die een EPdfAsyncQueueFull waard is, en TrySubmit wanneer het een normale conditie is die je van plan bent af te handelen
Waarom annuleert Shutdown buiten de executor-lock
Omdat annuleren erbinnen de lockvolgorde zou omkeren en de afsluiting die je probeert uit te voeren zou laten hangen. Shutdown(True) neemt de executor-lock, zet de afsluitvlag om, en voegt elke lopende taak toe aan een lokale snapshot-array via AppendSnapshot. Vervolgens geeft het de lock vrij en loopt pas daarna door de snapshot om Cancel op elke ingang aan te roepen. Een taak annuleren activeert gebruikers-callbacks die op zijn tokenbron zijn geregistreerd, en die callbacks zijn gewone applicatiecode: ze kunnen GetStats opvragen, compenserend werk indienen, of op idle wachten. Elk daarvan komt de executor-lock opnieuw binnen, en een callback die wordt aangeroepen terwijl die lock wordt vastgehouden zou tegen zichzelf deadlocken
De tokenbron volgt dezelfde discipline één niveau lager. CancelWithReason neemt de bronlock, bepaalt de enige winnende annuleerder, schrijft Reason, CancellationMessage en CancelledAtTick, en zet pas daarna de geannuleerde vlag atomisch om. Publiceren voor omzetten is wat de metadata veilig maakt om te lezen: elke thread die IsCancelled als True waarneemt, is gegarandeerd een volledige reden erachter te vinden, en latere aanroepers verliezen de race, geven False terug en kunnen de eerste reden niet overschrijven. De geregistreerde callbacks worden binnen de lock in een snapshot gezet en gewist, maar erbuiten aangeroepen, elk verpakt zodat één falende handler de rest niet kan onderdrukken. Taken die al draaien worden nooit gedood; ze eindigen coöperatief zodra hun workerbody de volgende keer ThrowIfCancelled aanroept, wat is waarom Shutdown eindigt met een pompende WaitForIdle voordat threads worden samengevoegd
Verslappen parallelle workers PDFium-objecteigendom
Nee, en dit is de grens die het vaakst verkeerd wordt gelezen. TPdfAsyncExecutor plant werk in; hij doet geen enkele uitspraak over de threadaffiniteit van iets dat je binnen dat werk aanraakt. Een levende TPdf-instantie wordt niet gelijktijdig toegankelijk omdat twee workers hem toevallig aanroepen, en de interne renderlock is een bescherming tegen overlappende renderaanroepen, geen licentie om een document tussen threads te delen. Parallel renderen of exporteren betekent één TPdf per worker, aangemaakt en vernietigd binnen de job
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;
De kosten zijn reëel en het vermelden waard: elke worker betaalt zijn eigen parse en zijn eigen paginacache, dus geheugen schaalt met het aantal workers in plaats van met het aantal documenten. Dat is de prijs van een model waarin een worker kan worden geannuleerd of kan crashen zonder iemand anders te corrumperen. Als je workers in plaats daarvan een viewer-zijdig document delen, worden de bijbehorende lockregels behandeld in de renderlock en de aanroepen die hem missen, en het annuleerbare pad voor een enkel document staat in annuleerbare progressieve rendering
Een gepubliceerde interface uitbreiden zonder de vtable te breken
IPdfCancellationToken en IPdfCancellationTokenSource zijn COM-achtige interfaces die externe binaries mogelijk al gebruiken, dus een methode toevoegen aan een van beide zou elke latere slot in de vtable verschuiven en aanroepen die tegen de oude indeling zijn gecompileerd stilzwijgend verkeerd routeren. De diagnostische, wachtende, verwijderbare-callback- en atomische-annuleermogelijkheden leven daarom in IPdfCancellationTokenEx en IPdfCancellationTokenSourceEx, die overerven in plaats van wijzigen. New en Run behouden hun oorspronkelijke semantiek voor bestaande aanroepers; nieuwe code grijpt naar NewEx, NewTimeout en RunEx wanneer het CancelWithReason, WaitForCancellation of een beheerde IPdfCancellationRegistration wil. Overerving is de enige veilige manier om een gepubliceerde interface te laten groeien, en het kost één extra type per generatie
NewTimeout verdient een eerlijke kanttekening. Elke timeoutbron bezit een lichtgewicht thread die wacht op de annulatie-event of de deadline, welke van de twee zich het eerst voordoet. Voor een handvol of enkele tientallen deadlines is dat eenvoudig, laag in latentie en identiek over Delphi, Lazarus en C++Builder. Voor duizenden korte deadlines is het de verkeerde vorm, en zou je annulering vanuit één applicatieniveau-timer moeten aansturen in plaats van duizenden wachtende threads aan te houden
Geen van deze regels is exotisch zodra ze zijn opgeschreven, maar elke ervan is een productie-incident wanneer dat niet gebeurt. Wacht op de juiste mijlpaal en laat iets de synchronize-wachtrij aansturen, lees QueueCapacity alleen als een grens op de wachtlijn, annuleer buiten je locks, en geef elke worker zijn eigen document. De hier beschreven asynchrone laag maakt deel uit van de Delphi PDFium Component, samen met de render-, tekst- en formulier-API's die het inplant