Odborný článok

WaitForIdle deadlock v Delphi PDFium async renderovaní

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