Technický článek

Deadlock WaitForIdle v async PDFium pro Delphi

Dávkové vykreslování se uprostřed zasekne, protože exekutor v PDFium Component nepovažuje úlohu za dokončenou, dokud nebyla odeslána odpověď. Pod režimem padSynchronize tato odpověď běží na hlavním vlákně. Pokud hlavní vlákno blokuje, aniž by čerpalo frontu přes CheckSynchronize, worker čeká na hlavní vlákno, zatímco hlavní vlákno čeká na nečinnost

Obraz v debuggeru je jednoznačný, jakmile ho jednou uvidíte. Zastavte zamrzlý proces a hlavní vlákno sedí uvnitř čekání na událost nečinnosti, o několik rámců pod vaší vlastní dávkovou smyčkou. Přepněte na kterékoli worker vlákno a to sedí uvnitř TThread.Synchronize, drží dokončený výsledek, který nemůže předat dál. Nic se netočí, žádné CPU nehoří, proces je prostě zaparkovaný. Tento článek se zabývá tím, proč tento stav vůbec existuje, a třemi souvisejícími pravidly, která rozhodují, zda se worker pool nad PDFium v Delphi chová slušně, nebo kouše: co skutečně omezuje QueueCapacity, v jakém pořadí musí shutdown rušit úlohy, a co paralelismus nezaručuje, pokud jde o vlastnictví objektů PDFium

Proč WaitForIdle zablokuje hlavní vlákno?

Blokuje se to proto, že nečinnost v TPdfAsyncExecutor je definována tak, že zahrnuje i doručení odpovědi, nejen dokončení práce workeru. Počet běžících úloh se zvyšuje v DequeueTask, když si worker vyzvedne úlohu, a snižuje se v TaskFinished, kterou worker volá až poté, co se vrátí TPdfAsyncTaskOperation.Execute. Tato metoda spustí tělo workeru, zaznamená výsledek a poté odešle odpověď podle TPdfAsyncDispatchMode. Při padSynchronize je odeslání voláním TThread.Synchronize, takže Execute se nevrátí, dokud ji hlavní vlákno nespustí

Druhou polovinu této smlouvy nechává Delphi na vás. TThread.Synchronize přidá metodu do globální fronty a zablokuje volající vlákno na události; něco na hlavním vlákně musí zavolat CheckSynchronize, než je tato událost vůbec signalizována. Smyčka zpráv VCL to za vás dělá mezi jednotlivými zprávami, a přesně proto je chyba při interaktivním použití neviditelná a objeví se ve chvíli, kdy napíšete blokující dávkovou smyčku. Blokující hlavní vlákno je hlavní vlákno, které opustilo smyčku zpráv, a hlavní vlákno mimo smyčku zpráv nevyprazdňuje frontu nikomu

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čení úlohy a nečinnost exekutoru jsou dva různé milníky

Jsou oddělené záměrně a vědět, na který z nich vlastně čekáte, je celá oprava. IPdfAsyncTask.WaitFor je splněno okamžitě, jakmile je rozhodnut výsledek workeru: Complete zapíše finální TPdfAsyncTaskState a nastaví událost dokončení dřív, než je zvažována jakákoli odpověď. TPdfAsyncExecutor.WaitForIdle je splněno později, až jsou počty ve frontě i běžících úloh nulové, a počet běžících neklesne, dokud odpověď nedorazí. Úloha tedy může být patsSucceeded a pozorovatelná přes Snapshot, zatímco exekutor je stále legitimně zaneprázdněný

// 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ůsledek stojí za zapamatování: odpověď, která vyvolá výjimku, nepřepisuje historii. DispatchReply zachytí výjimku a uloží ji do ReplyErrorMessage, přičemž State, CancellationReason a ErrorMessage ponechá přesně tak, jak je určil worker. Zpětné volání UI, které spadne při kreslení náhledu, tedy nikdy nepromění úspěšné vykreslení v neúspěšné, a vaše telemetrie dál hlásí to, co vykreslovací engine skutečně udělal. Pokud chcete API ve tvaru callbacku kolem jediné operace místo poolu, cestu k tomu popisuje background rendering se zrušitelnými futures

Omezuje QueueCapacity i běžící workery?

Ne. QueueCapacity v PDFium Component počítá pouze úlohy ve frontě, nikdy ty, které už běží na workeru. Je to záměr: kapacita má vyjadřovat skutečný protitlak na čekací frontu, a sloučení pevných slotů souběžnosti do stejného čísla by je počítalo dvakrát. Se čtyřmi workery a kapacitou osm můžete mít dvanáct úloh současně v běhu, a GetStats tento rozdíl poctivě hlásí přes 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;

Čtyři pruhy TPdfAsyncPriority jsou striktní, nikoli vážené. DequeueTask prochází od papCritical dolů k papLow a vezme první neprázdný pruh, přičemž v rámci každého zachovává pořadí FIFO. To dává interaktivnímu požadavku čistý způsob, jak přeskočit před dávku, která ještě nezačala, ale nikdy to nepřeruší práci, která už běží, a volající, který soustavně krmí papCritical, může papLow vyhladovět donekonečna. Vyhraďte horní dva pruhy pro věci, na které viditelně čeká člověk, a hromadný export nechte na papNormal nebo níž. Použijte Submit, když je plná fronta programátorská chyba hodná EPdfAsyncQueueFull, a TrySubmit, když jde o normální stav, který hodláte ošetřit

Proč Shutdown ruší úlohy mimo zámek exekutoru?

Protože rušení uvnitř zámku by obrátilo pořadí zamykání a zablokovalo by samotný shutdown, který se snažíte provést. Shutdown(True) zabere zámek exekutoru, přepne příznak shutdownu a přes AppendSnapshot přidá každou čekající úlohu do lokálního snapshotového pole. Poté zámek uvolní a teprve poté prochází snapshot a volá Cancel na každém záznamu. Zrušení úlohy spouští uživatelská zpětná volání registrovaná na jejím token source, a tato zpětná volání jsou obyčejný aplikační kód: mohou dotazovat GetStats, zadávat kompenzační práci nebo čekat na nečinnost. Každé z nich znovu vstupuje do zámku exekutoru, a zpětné volání vyvolané v době, kdy je tento zámek držen, by se zaseklo samo o sobě

Token source dodržuje stejnou disciplínu o úroveň níž. CancelWithReason zabere zámek zdroje, rozhodne o jediném vítězném rušiteli, zapíše Reason, CancellationMessage a CancelledAtTick, a teprve poté atomicky přepne příznak zrušení. Publikování před přepnutím je to, co dělá metadata bezpečná ke čtení: jakékoli vlákno, které pozoruje IsCancelled jako True, má zaručeno, že za ním najde kompletní důvod, a pozdější volající prohrávají závod, vrací False a nemohou přepsat první důvod. Registrovaná zpětná volání jsou pořízena jako snapshot a vyčištěna uvnitř zámku, ale volána mimo něj, každé obalené tak, aby jeden selhávající handler nemohl potlačit ostatní. Úlohy, které už běží, nejsou nikdy zabity násilím; končí kooperativně, když jejich tělo workeru příště zavolá ThrowIfCancelled, a proto Shutdown končí čerpajícím WaitForIdle předtím, než se připojí k vláknům

Uvolňují paralelní workery vlastnictví objektů PDFium?

Neuvolňují, a tato hranice se čte nesprávně nejčastěji. TPdfAsyncExecutor plánuje práci; nijak netvrdí nic o vazbě na vlákno u čehokoli, čeho se v rámci té práce dotknete. Živá instance TPdf se nestane souběžně přístupnou jen proto, že do ní náhodou volají dva workery, a interní zámek vykreslování je pojistka proti překrývajícím se voláním vykreslení, nikoli licence ke sdílení dokumentu napříč vlákny. Paralelní vykreslování nebo export znamená jednu instanci TPdf na worker, vytvořenou a zrušenou uvnitř ú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álná a stojí za pojmenování: každý worker platí za svůj vlastní parsing a vlastní cache stránek, takže paměť škáluje s počtem workerů, ne s počtem dokumentů. To je cena modelu, ve kterém lze worker zrušit nebo nechat spadnout, aniž by tím poškodil kohokoli jiného. Pokud vaše workery místo toho sdílejí dokument na straně vieweru, pravidla zamykání kolem toho popisuje zámek vykreslení a volání, která ho vynechávají, a cesta se zrušitelným jedním dokumentem je v zrušitelné progresivní vykreslování

Rozšíření publikovaného rozhraní bez rozbití vtabulky

IPdfCancellationToken a IPdfCancellationTokenSource jsou rozhraní ve stylu COM, která už mohou konzumovat externí binárky, takže přidání metody do kteréhokoli z nich by posunulo každý pozdější slot ve vtabulce a tiše by přesměrovalo volání zkompilovaná proti starému rozvržení. Diagnostické a čekací schopnosti, odebíratelné callbacky a atomické rušení proto žijí v IPdfCancellationTokenEx a IPdfCancellationTokenSourceEx, které dědí, místo aby upravovaly. New a Run si pro stávající volající zachovávají původní sémantiku; nový kód sahá po NewEx, NewTimeout a RunEx, když chce CancelWithReason, WaitForCancellation nebo spravovanou IPdfCancellationRegistration. Dědičnost je jediný bezpečný způsob, jak rozšířit publikované rozhraní, a stojí jeden navíc typ na generaci

NewTimeout si zaslouží upřímnou poznámku. Každý timeout zdroj vlastní lehké vlákno, které čeká na událost zrušení nebo na deadline, podle toho, co nastane dřív. Pro hrstku nebo pár desítek deadlinů je to jednoduché, s nízkou latencí a identické napříč Delphi, Lazarus a C++Builder. Pro tisíce krátkých deadlinů je to špatný tvar a rušení byste měli řídit z jednoho časovače na úrovni aplikace, místo abyste drželi tisíce čekajících vláken

Žádné z těchto pravidel není exotické, jakmile je jednou sepsané, ale každé z nich je produkčním incidentem, když sepsané není. Čekejte na správný milník a nechte něco čerpat frontu synchronizace, čtěte QueueCapacity pouze jako omezení čekací fronty, rušte mimo své zámky a dejte každému workeru vlastní dokument. Asynchronní vrstva popsaná zde je součástí Delphi PDFium Component, vedle API pro vykreslování, text a formuláře, které plánuje