Batch render se zamrzne na pola puta jer izvršilac u PDFium Component ne smatra zadatak završenim dok njegov odgovor nije dispečovan. Pod padSynchronize taj odgovor se izvršava na glavnom thread-u. Ako se glavni thread blokira bez pumpanja CheckSynchronize, worker čeka na glavni thread dok glavni thread čeka na idle
Slika u debuggeru je nepogrešiva čim je jednom vidite. Pauzirajte zamrznuti proces i glavni thread sedi unutar čekanja na idle event, nekoliko frejmova ispod vaše sopstvene batch petlje. Prebacite se na bilo koji worker thread i on sedi unutar TThread.Synchronize, držeći gotov rezultat koji ne može predati. Ništa se ne vrti, nijedan CPU se ne troši, proces je jednostavno parkiran. Ovaj članak se bavi time zašto to stanje uopšte postoji, i sa tri susedna pravila koja odlučuju da li se Delphi worker pool preko PDFium-a ponaša ispravno ili ujeda: šta QueueCapacity zapravo ograničava, kojim redosledom gašenje mora otkazati, i šta paralelizam ne kupuje tamo gde je u pitanju vlasništvo PDFium objekata
Zašto WaitForIdle zaglavi glavni thread?
Zaglavi jer je idle u TPdfAsyncExecutor definisan da uključuje dispečovanje odgovora, ne samo završetak workera. Brojač koji je trenutno u toku se uvećava u DequeueTask kada worker pokupi zadatak, i umanjuje se u TaskFinished, koju worker poziva tek pošto se TPdfAsyncTaskOperation.Execute vratio. Ta metoda izvršava telo workera, beleži ishod, a zatim dispečuje odgovor prema TPdfAsyncDispatchMode. Sa padSynchronize dispečovanje je poziv TThread.Synchronize, pa se Execute ne vraća dok ga glavni thread nije izvršio
Delphi stavlja drugu polovinu tog ugovora na vas. TThread.Synchronize dodaje metodu u globalni red i blokira pozivajući thread na eventu; nešto na glavnom thread-u mora pozvati CheckSynchronize pre nego što se taj event ikada signalizuje. VCL petlja poruka to radi za vas između poruka, što je tačno razlog zašto je bag nevidljiv tokom interaktivne upotrebe, a pojavi se u trenutku kada napišete blokirajuću batch petlju. Blokiran glavni thread je glavni thread koji je napustio petlju poruka, a glavni thread van petlje poruka ne prazni nikoga
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.
Završetak zadatka i idle izvršioca su dva različita miljokaza
Namerno su razdvojeni, i znanje na koji čekate je cela popravka. IPdfAsyncTask.WaitFor se ispunjava u trenutku kada je rezultat workera odlučen: Complete upisuje konačan TPdfAsyncTaskState i postavlja gotov event pre nego što se ijedan odgovor razmotri. TPdfAsyncExecutor.WaitForIdle se ispunjava kasnije, kada su i broj u redu i broj u izvršenju oba nula, a izvršenje ne pada dok odgovor ne sleti. Tako zadatak može biti patsSucceeded i posmatran kroz Snapshot dok je izvršilac i dalje legitimno zauzet
// 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;
Jedna posledica koju vredi usvojiti: odgovor koji baci grešku ne prepisuje istoriju. DispatchReply hvata izuzetak i čuva ga u ReplyErrorMessage, ostavljajući State, CancellationReason i ErrorMessage tačno kako ih je worker odredio. UI callback koji eksplodira dok crta thumbnail zato nikad ne pretvara uspešan render u neuspeo, a vaša telemetrija nastavlja da prijavljuje šta je render engine zaista uradio. Ako želite API u obliku callbacka oko jedne operacije umesto pool-a, pozadinsko renderovanje sa otkazivim futures-ima pokriva tu putanju
Da li QueueCapacity ograničava i aktivne workere?
Ne. QueueCapacity u PDFium Component broji samo zadatke u redu čekanja, nikad one koji se već izvršavaju na workeru. To je namerno: kapacitet treba da izrazi stvaran pritisak na liniju čekanja, a uklapanje fiksnih slotova konkurentnosti u isti broj bi ih računalo dvaput. Sa četiri workera i kapacitetom osam možete imati dvanaest zadataka u letu, a GetStats iskreno prijavljuje podelu kroz QueuedCount i 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;
Četiri trake TPdfAsyncPriority su stroge, ne ponderisane. DequeueTask obilazi od papCritical naniže do papLow i uzima prvu nepraznu traku, čuvajući FIFO redosled unutar svake. To daje interaktivnom zahtevu čist način da preskoči ispred batcha koji još nije počeo, ali nikad ne prekida posao koji se već izvršava, a pozivalac koji stalno hrani papCritical može neograničeno izgladneti papLow. Rezervišite gornje dve trake za stvari na koje čovek vidljivo čeka, i ostavite bulk export na papNormal ili niže. Koristite Submit kada je pun red programerska greška vredna EPdfAsyncQueueFull, a TrySubmit kada je to normalno stanje koje nameravate da obradite
Zašto Shutdown otkazuje van brave izvršioca?
Zato što bi otkazivanje unutar nje obrnulo redosled brava i zaglavilo baš gašenje koje pokušavate da izvršite. Shutdown(True) uzima bravu izvršioca, prebacuje zastavicu gašenja, i dodaje svaki zadatak na čekanju u lokalni niz snapshot-a kroz AppendSnapshot. Zatim oslobađa bravu i tek posle obilazi snapshot pozivajući Cancel na svakoj stavci. Otkazivanje zadatka okida korisničke callbackove registrovane na njegovom izvoru tokena, a ti callbackovi su obična aplikaciona logika: mogu upitati GetStats, poslati kompenzujući posao, ili čekati na idle. Svaki od njih ponovo ulazi u bravu izvršioca, a callback pozvan dok je ta brava zadržana bi se zaglavio sam protiv sebe
Izvor tokena poštuje istu disciplinu jedan nivo niže. CancelWithReason uzima bravu izvora, odlučuje jedinog pobedničkog otkazivača, upisuje Reason, CancellationMessage i CancelledAtTick, i tek tada atomično prebacuje zastavicu otkazano. Objavi-pa-prebaci je ono što čini metapodatke bezbednim za čitanje: svaki thread koji vidi IsCancelled kao True garantovano nalazi kompletan razlog iza toga, a kasniji pozivaoci gube trku, vraćaju False, i ne mogu prepisati prvi razlog. Registrovani callbackovi se snimaju u snapshot i brišu unutar brave, ali se pozivaju van nje, svaki umotan tako da jedan handler koji otkaže ne može potisnuti ostale. Zadaci koji se već izvršavaju se nikad ne ubijaju; završavaju se kooperativno kada njihovo telo workera sledeći put pozove ThrowIfCancelled, zato se Shutdown završava pumpajućim WaitForIdle pre pridruživanja thread-ova
Da li paralelni workeri olabavljuju vlasništvo PDFium objekata?
Ne olabavljuju, i ovo je granica koju je najlakše pogrešno pročitati. TPdfAsyncExecutor raspoređuje posao; ne daje nikakvu tvrdnju o afinitetu thread-a bilo čega što dotaknete unutar tog posla. Živa instanca TPdf ne postaje konkurentno dostupna zato što dva workera slučajno pozivaju u nju, a interna brava renderovanja je čuvar protiv preklapajućih poziva renderovanja, ne dozvola za deljenje dokumenta preko threadova. Paralelno renderovanje ili export znači jedan TPdf po workeru, kreiran i uništen unutar posla
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;
Cena je stvarna i vredi je imenovati: svaki worker plaća sopstveni parsing i sopstveni keš stranica, pa memorija skalira sa brojem workera, a ne sa brojem dokumenata. To je cena modela u kom worker može biti otkazan ili pasti bez oštećenja bilo koga drugog. Ako vaši workeri ipak dele instancu dokumenta na strani viewera, pravila zaključavanja oko toga pokrivena su u bravi renderovanja i pozivima koji je promašuju, a putanja otkazivog jedinog dokumenta je u otkazivom progresivnom renderovanju
Proširivanje objavljenog interfejsa bez rušenja vtable-a
IPdfCancellationToken i IPdfCancellationTokenSource su COM-stila interfejsi koje eksterni binarni fajlovi možda već konsumiraju, pa bi dodavanje metode na bilo koji od njih pomerilo svaki kasniji slot u vtable-u i tiho pogrešno usmerilo pozive kompajlirane protiv starog rasporeda. Dijagnostičke, čekajuće, uklonjive-callback i atomske sposobnosti otkazivanja zato žive u IPdfCancellationTokenEx i IPdfCancellationTokenSourceEx, koji nasleđuju umesto da modifikuju. New i Run zadržavaju svoju originalnu semantiku za postojeće pozivaoce; novi kod poseže za NewEx, NewTimeout i RunEx kada želi CancelWithReason, WaitForCancellation ili upravljanu IPdfCancellationRegistration. Nasleđivanje je jedini bezbedan način da se raste objavljen interfejs, i košta jedan dodatan tip po generaciji
NewTimeout zaslužuje iskrenu napomenu. Svaki izvor timeouta poseduje lagan thread koji čeka na event otkazivanja ili rok, šta god stigne prvo. Za šačicu ili nekoliko desetina rokova to je jednostavno, niske latencije i identično preko Delphija, Lazarusa i C++Buildera. Za hiljade kratkih rokova to je pogrešan oblik, i otkazivanje bi trebalo da vodite iz jednog tajmera na nivou aplikacije umesto da držite hiljade čekajućih threadova
Nijedno od ovih pravila nije egzotično kad se jednom zapiše, ali svako od njih je produkcioni incident kad nije. Čekajte na pravi miljokaz i pustite nešto da pumpa synchronize red, čitajte QueueCapacity samo kao granicu linije čekanja, otkazujte van svojih brava, i dajte svakom workeru sopstveni dokument. Asinhroni sloj opisan ovde isporučuje se kao deo Delphi PDFium Component, zajedno sa API-jima renderovanja, teksta i formulara koje raspoređuje