En batchrendering fryser halvvägs eftersom exekutorn i PDFium Component inte anser en uppgift klar förrän dess svar har skickats ut. Under padSynchronize körs det svaret på huvudtråden. Om huvudtråden blockerar utan att pumpa CheckSynchronize, väntar arbetartråden på huvudtråden medan huvudtråden väntar på inaktivitet
Debuggerbilden är omisskännlig när man väl har sett den. Pausa den frusna processen och huvudtråden sitter i en väntan på inaktivitetshändelsen, flera ramar under din egen batchloop. Växla till vilken arbetartråd som helst och den sitter inuti TThread.Synchronize, håller ett färdigt resultat den inte kan lämna över. Ingenting snurrar, ingen CPU brinner, processen är helt enkelt parkerad. Den här artikeln handlar om varför det tillståndet överhuvudtaget existerar, och om tre angränsande regler som avgör om en Delphi-arbetarpool ovanpå PDFium beter sig eller biter: vad QueueCapacity faktiskt begränsar, i vilken ordning avstängning måste avbryta i, och vad parallellism inte köper dig när det gäller PDFium-objektägande
Varför hänger WaitForIdle huvudtråden?
Den hänger eftersom inaktivitet i TPdfAsyncExecutor definieras som att inkludera svarsutskick, inte bara arbetarslutförande. Antalet körande ökas i DequeueTask när en arbetare plockar upp en uppgift, och det minskas i TaskFinished, som arbetaren bara anropar efter att TPdfAsyncTaskOperation.Execute har returnerat. Den metoden kör arbetarkroppen, registrerar utfallet, och skickar sedan ut svaret enligt TPdfAsyncDispatchMode. Med padSynchronize är utskicket ett TThread.Synchronize-anrop, så Execute returnerar inte förrän huvudtråden har kört det
Delphi lägger den andra halvan av det kontraktet på dig. TThread.Synchronize lägger till metoden i en global kö och blockerar den anropande tråden på en händelse; något på huvudtråden måste anropa CheckSynchronize innan den händelsen någonsin signaleras. VCL:s meddelandeloop gör detta åt dig mellan meddelanden, vilket är precis varför buggen är osynlig under interaktiv användning och dyker upp i samma stund du skriver en blockerande batchloop. En blockerande huvudtråd är en huvudtråd som har lämnat meddelandeloopen, och en huvudtråd utanför meddelandeloopen tömmer 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.
Uppgiftsslutförande och exekutorinaktivitet är två olika milstolpar
De är separerade avsiktligt, och att veta vilken du väntar på är hela fixen. IPdfAsyncTask.WaitFor uppfylls i samma stund arbetarresultatet är avgjort: Complete skriver det slutliga TPdfAsyncTaskState och sätter den klara-händelsen innan något svar övervägs. TPdfAsyncExecutor.WaitForIdle uppfylls senare, när både köade och körande antal är noll, och körande minskar inte förrän svaret har landat. Så en uppgift kan vara patsSucceeded och observerbar genom Snapshot medan exekutorn fortfarande legitimt är upptagen
// 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 att internalisera: ett svar som kastar ett undantag skriver inte om historien. DispatchReply fångar undantaget och lagrar det i ReplyErrorMessage, och lämnar State, CancellationReason och ErrorMessage exakt som arbetaren bestämde dem. En UI-callback som havererar medan den målar en miniatyrbild förvandlar därför aldrig en lyckad rendering till en misslyckad, och din telemetri fortsätter att rapportera vad rendermotorn faktiskt gjorde. Om du vill ha det callback-formade API:et kring en enskild operation snarare än en pool, täcker bakgrundsrendering med avbrytbara futures den vägen
Begränsar QueueCapacity även körande arbetare?
Nej. QueueCapacity i PDFium Component räknar bara köade uppgifter, aldrig de som redan körs på en arbetare. Det är avsiktligt: kapacitet är menad att uttrycka verklig bakåttryckning på väntelinjen, och att vika in de fasta samtidighetsplatserna i samma tal skulle räkna dem dubbelt. Med fyra arbetare och en kapacitet på åtta kan du ha tolv uppgifter i flykt, och GetStats rapporterar uppdelningen ärligt genom QueuedCount och 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 fyra körfälten i TPdfAsyncPriority är strikta, inte viktade. DequeueTask går från papCritical ner till papLow och tar det första icke-tomma fältet, och bevarar FIFO-ordning inom varje. Det ger en interaktiv förfrågan ett rent sätt att hoppa före en batch som ännu inte startat, men det avbryter aldrig arbete som redan körs, och en anropare som fortsätter mata papCritical kan svälta ut papLow på obestämd tid. Reservera de två översta fälten för saker en människa synligt väntar på, och lämna massexport på papNormal eller lägre. Använd Submit när en full kö är ett programmeringsfel värt ett EPdfAsyncQueueFull, och TrySubmit när det är ett normalt tillstånd du avser att hantera
Varför avbryter Shutdown utanför exekutorlåset?
Därför att att avbryta inuti det skulle invertera låsordningen och hänga avstängningen du försöker utföra. Shutdown(True) tar exekutorlåset, vänder avstängningsflaggan, och lägger till varje väntande uppgift i en lokal ögonblicksbildsarray genom AppendSnapshot. Sedan släpper den låset och går bara efteråt igenom ögonblicksbilden och anropar Cancel på varje post. Att avbryta en uppgift utlöser användarcallbacks registrerade på dess tokenkälla, och de callbackerna är vanlig applikationskod: de kan fråga GetStats, skicka in kompenserande arbete, eller vänta på inaktivitet. Var och en av dessa återinträder exekutorlåset, och en callback anropad medan det låset hålls skulle hamna i dödläge mot sig självt
Tokenkällan följer samma disciplin ett steg ner. CancelWithReason tar källåset, bestämmer den enda vinnande avbrytaren, skriver Reason, CancellationMessage och CancelledAtTick, och vänder först då den avbrutna flaggan atomiskt. Publicera före vändning är det som gör metadatan säker att läsa: vilken tråd som helst som observerar IsCancelled som True är garanterad att hitta en fullständig anledning bakom det, och senare anropare förlorar racet, returnerar False, och kan inte skriva över den första anledningen. De registrerade callbackerna tas i ögonblicksbild och rensas inuti låset men anropas utanför det, var och en inpackad så en misslyckad hanterare inte kan undertrycka de andra. Uppgifter som redan körs dödas aldrig; de avslutas kooperativt när deras arbetarkropp nästa gång anropar ThrowIfCancelled, vilket är varför Shutdown avslutar med en pumpande WaitForIdle innan trådarna sammanfogas
Slappnar parallella arbetare av på PDFium-objektägande?
Det gör de inte, och det här är gränsen som mest sannolikt misstolkas. TPdfAsyncExecutor schemalägger arbete; den gör inget anspråk på trådaffiniteten hos någonting du rör vid inuti det arbetet. En levande TPdf-instans blir inte samtidigt åtkomlig bara för att två arbetare råkar anropa in i den, och det interna renderlåset är en vakt mot överlappande renderanrop, inte en licens att dela ett dokument mellan trådar. Parallell rendering eller export betyder en TPdf per arbetare, skapad och förstörd inuti 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;
Kostnaden är verklig och värd att namnge: varje arbetare betalar sin egen parsning och sin egen sidcache, så minnet skalar med arbetarantal snarare än med dokumentantal. Det är priset för en modell där en arbetare kan avbrytas eller krascha utan att korrumpera någon annan. Om dina arbetare istället delar en visningssidas dokumentinstans, täcks låsningsreglerna kring det i renderlåset och anropen som missar det, och den avbrytbara enskilda-dokument-vägen finns i avbrytbar progressiv rendering
Att utöka ett publicerat gränssnitt utan att bryta vtable
IPdfCancellationToken och IPdfCancellationTokenSource är COM-liknande gränssnitt som externa binärer redan kan konsumera, så att lägga till en metod till någotdera skulle förskjuta varje senare plats i vtable och tyst felruta anrop kompilerade mot den gamla layouten. Diagnostik-, väntande-, borttagningsbara-callback- och atomisk-avbryt-förmågorna bor därför i IPdfCancellationTokenEx och IPdfCancellationTokenSourceEx, som ärver snarare än ändrar. New och Run behåller sin ursprungliga semantik för befintliga anropare; ny kod griper efter NewEx, NewTimeout och RunEx när den vill ha CancelWithReason, WaitForCancellation eller en hanterad IPdfCancellationRegistration. Arv är det enda säkra sättet att utöka ett publicerat gränssnitt, och det kostar en extra typ per generation
NewTimeout förtjänar en ärlig anmärkning. Varje timeout-källa äger en lättviktig tråd som väntar på avbrytandehändelsen eller deadline, vilket som kommer först. För en handfull eller några dussin deadlines är det enkelt, lågfördröjt och identiskt över Delphi, Lazarus och C++Builder. För tusentals korta deadlines är det fel form, och du bör driva avbrytande från en applikationsnivå-timer istället för att hålla tusentals väntande trådar
Ingen av de här reglerna är exotiska när de väl är nedskrivna, men var och en av dem är en produktionsincident när den inte är det. Vänta på rätt milstolpe och låt något pumpa synkroniseringskön, läs QueueCapacity som endast en gräns på väntelinjen, avbryt utanför dina lås, och ge varje arbetare sitt eget dokument. Asynklagret som beskrivs här levereras som en del av Delphi PDFium Component, tillsammans med rendering-, text- och formulär-API:erna det schemalägger