Dávkové renderovanie zamrzne v polovici, pretože executor v PDFium Component nepovažuje úlohu za dokončenú, kým nie je jej odpoveď rozposlaná. Pod padSynchronize táto odpoveď beží na hlavnom vlákne. Ak sa hlavné vlákno zablokuje bez čerpania CheckSynchronize, worker čaká na hlavné vlákno, kým hlavné vlákno čaká na idle
Obraz v debuggeri je nezameniteľný, len čo ste ho raz videli. Pozastavte zamrznutý proces a hlavné vlákno sedí vnútri čakania na idle event, niekoľko frame-ov pod vaším vlastným batch loopom. Prepnite na akékoľvek worker vlákno a sedí vnútri TThread.Synchronize, držiac hotový výsledok, ktorý nemôže odovzdať. Nič sa netočí, žiadne CPU nehorí, proces je jednoducho zaparkovaný. Tento článok je o tom, prečo tento stav vôbec existuje, a o troch susediacich pravidlách, ktoré rozhodujú, či sa Delphi worker pool nad PDFium správa slušne, alebo hryzie: čo skutočne ohraničuje QueueCapacity, v akom poradí musí shutdown zrušiť, a čo vám paralelizmus nekúpi, pokiaľ ide o vlastníctvo objektov PDFium
Prečo WaitForIdle zavesí hlavné vlákno?
Zavesí ho, pretože idle v TPdfAsyncExecutor je definované tak, že zahŕňa rozposlanie odpovede, nie len dokončenie workera. Bežiaci počet sa zvýši v DequeueTask, keď si worker vyzdvihne úlohu, a zníži sa v TaskFinished, ktorú worker zavolá až po tom, čo TPdfAsyncTaskOperation.Execute vráti riadenie. Táto metóda spustí telo workera, zaznamená výsledok, a potom rozpošle odpoveď podľa TPdfAsyncDispatchMode. S padSynchronize je rozposlanie volanie TThread.Synchronize, takže Execute nevráti riadenie, kým hlavné vlákno nespustí toto volanie
Delphi dáva druhú polovicu tohto kontraktu na vás. TThread.Synchronize pripojí metódu do globálnej fronty a zablokuje volajúce vlákno na evente; niečo na hlavnom vlákne musí zavolať CheckSynchronize skôr, než sa tento event vôbec signalizuje. Message loop VCL toto robí za vás medzi správami, čo je presne dôvod, prečo je bug pri interaktívnom používaní neviditeľný a objaví sa v momente, keď napíšete blokujúci batch loop. Blokujúce hlavné vlákno je hlavné vlákno, ktoré opustilo message loop, a hlavné vlákno mimo message loop nič nečerpá
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.
Dokončenie úlohy a idle executora sú dva rôzne míľniky
Sú zámerne oddelené, a vedieť, na ktorý čakáte, je celá oprava. IPdfAsyncTask.WaitFor je splnené v okamihu, keď sa rozhodne o výsledku workera: Complete zapíše finálne TPdfAsyncTaskState a nastaví done event skôr, než sa vôbec uvažuje o odpovedi. TPdfAsyncExecutor.WaitForIdle je splnené neskôr, akonáhle sú vo fronte aj bežiace počty nula, a bežiaci sa nezníži, kým odpoveď nepristane. Takže úloha môže byť patsSucceeded a pozorovateľná cez Snapshot, kým je executor stále legitímne zaneprázdnený
// 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;
Jeden dôsledok stojí za zvnútornenie: odpoveď, ktorá vyvolá výnimku, neprepíše históriu. DispatchReply zachytí výnimku a uloží ju do ReplyErrorMessage, ponechávajúc State, CancellationReason a ErrorMessage presne také, aké ich určil worker. UI callback, ktorý vybuchne pri kreslení miniatúry, teda nikdy nezmení úspešné renderovanie na zlyhané, a vaša telemetria pokračuje v hlásení toho, čo render engine naozaj urobil. Ak chcete callback-shaped API okolo jednej operácie namiesto poolu, background renderovanie so zrušiteľnými futures pokrýva túto cestu
Ohraničuje QueueCapacity aj bežiacich workerov?
Nie. QueueCapacity v PDFium Component počíta len úlohy vo fronte, nikdy tie, ktoré už bežia na workeri. To je zámerné: kapacita má vyjadrovať skutočný backpressure na čakacom rade, a zahrnúť pevné konkurenčné sloty do toho istého čísla by ich počítalo dvakrát. So štyrmi workermi a kapacitou osem môžete mať dvanásť úloh v behu, a GetStats hlási toto rozdelenie poctivo cez QueuedCount a 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;
Štyri pruhy TPdfAsyncPriority sú prísne, nie vážené. DequeueTask prechádza od papCritical nadol po papLow a berie prvý neprázdny pruh, pričom zachováva FIFO poradie v rámci každého. To dáva interaktívnej požiadavke čistý spôsob, ako preskočiť dávku, ktorá ešte nezačala, ale nikdy neprerušuje prácu, ktorá už beží, a volajúci, ktorý neustále kŕmi papCritical, môže papLow vyhladovať na neurčito. Vyhraďte horné dva pruhy pre veci, na ktoré viditeľne čaká človek, a hromadný export ponechajte na papNormal alebo nižšie. Použite Submit, keď je plná fronta programátorská chyba hodná EPdfAsyncQueueFull, a TrySubmit, keď je to normálny stav, ktorý zamýšľate spracovať
Prečo Shutdown ruší mimo zámku executora?
Pretože rušenie vnútri neho by obrátilo poradie zámkov a zavesilo by shutdown, ktorý sa snažíte vykonať. Shutdown(True) zoberie zámok executora, preklopí príznak shutdownu, a pripojí každú čakajúcu úlohu do lokálneho snapshot poľa cez AppendSnapshot. Potom uvoľní zámok a až potom prejde snapshot a zavolá Cancel na každom zázname. Zrušenie úlohy spustí používateľské callbacky registrované na jej token source, a tie callbacky sú obyčajný aplikačný kód: môžu dopytovať GetStats, odoslať kompenzačnú prácu, alebo čakať na idle. Každý z nich znovu vstúpi do zámku executora, a callback vyvolaný, kým je tento zámok držaný, by deadlockoval sám proti sebe
Token source dodržiava rovnakú disciplínu o úroveň nižšie. CancelWithReason zoberie zámok source, rozhodne o jedinom víťaznom rušiteľovi, zapíše Reason, CancellationMessage a CancelledAtTick, a až potom atomicky preklopí zrušený príznak. Publikovať pred preklopením je to, čo robí metadáta bezpečnými na čítanie: akékoľvek vlákno, ktoré pozoruje IsCancelled ako True, má garantované, že za tým nájde kompletný dôvod, a neskorší volajúci prehrajú preteky, vrátia False, a nemôžu prepísať prvý dôvod. Registrované callbacky sa snapshotujú a vyčistia vnútri zámku, ale volajú sa mimo neho, každý zabalený tak, aby jeden zlyhávajúci handler nemohol potlačiť ostatné. Úlohy, ktoré už bežia, sa nikdy nezabijú; skončia kooperatívne, keď ich telo workera ďalej zavolá ThrowIfCancelled, čo je dôvod, prečo Shutdown končí čerpajúcim WaitForIdle pred spojením vlákien
Uvoľňuje paralelizmus workerov vlastníctvo objektov PDFium?
Neuvoľňuje, a toto je hranica, ktorú je najľahšie zle pochopiť. TPdfAsyncExecutor plánuje prácu; nerobí žiadne tvrdenie o vláknovej príslušnosti čohokoľvek, čoho sa v tejto práci dotknete. Živá inštancia TPdf sa nestane súbežne prístupnou len preto, že do nej náhodou volajú dvaja workeri, a interný render lock je ochrana proti prekrývajúcim sa render volaniam, nie licencia na zdieľanie dokumentu naprieč vláknami. Paralelné renderovanie alebo export znamená jeden TPdf na workera, vytvorený a zničený vnútri úlohy
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 reálna a stojí za pomenovanie: každý worker platí za vlastný parsing a vlastnú cache stránok, takže pamäť škáluje s počtom workerov, nie s počtom dokumentov. To je cena za model, kde môže byť worker zrušený alebo spadnúť bez poškodenia niekoho iného. Ak vaši workeri namiesto toho zdieľajú viewer-side dokument, pravidlá zamykania okolo toho sú pokryté v render locku a volaniach, ktoré ho míňajú, a jednodokumentová zrušiteľná cesta je v zrušiteľnom progresívnom renderovaní
Rozšírenie publikovaného interface bez pokazenia vtable
IPdfCancellationToken a IPdfCancellationTokenSource sú COM-štýlové interfacy, ktoré externé binárky možno už konzumujú, takže pripojenie metódy k jednému z nich by posunulo každý neskorší slot vo vtable a potichu presmerovalo volania skompilované voči starému rozloženiu. Diagnostické, čakacie, odstrániteľné-callback a atomické-cancel schopnosti preto žijú v IPdfCancellationTokenEx a IPdfCancellationTokenSourceEx, ktoré dedia namiesto úpravy. New a Run si ponechávajú svoju pôvodnú sémantiku pre existujúcich volajúcich; nový kód siahne po NewEx, NewTimeout a RunEx, keď chce CancelWithReason, WaitForCancellation alebo spravovanú IPdfCancellationRegistration. Dedenie je jediný bezpečný spôsob, ako rozšíriť publikovaný interface, a stojí jeden extra typ na generáciu
NewTimeout si zaslúži poctivú poznámku. Každý timeout source vlastní ľahké vlákno, ktoré čaká na cancellation event alebo deadline, podľa toho, čo príde prvé. Pre hrsť alebo pár desiatok deadlinov je to jednoduché, low-latency a identické naprieč Delphi, Lazarus a C++Builder. Pre tisíce krátkych deadlinov je to nesprávny tvar, a mali by ste riadiť zrušenie z jedného aplikačnej-úrovne timera namiesto držania tisícov čakajúcich vlákien
Žiadne z týchto pravidiel nie je exotické, keď sú raz napísané, ale každé z nich je produkčný incident, keď nie sú. Čakajte na správny míľnik a nechajte niečo čerpať synchronize frontu, čítajte QueueCapacity len ako hranicu čakacieho radu, rušte mimo svojich zámkov, a dajte každému workerovi vlastný dokument. Async vrstva tu popísaná sa dodáva ako súčasť Delphi PDFium Component, popri render, textových a form API, ktoré plánuje