Articol tehnic

Deadlock WaitForIdle în randarea asincronă Delphi PDFium

O randare în lot îngheață la jumătate, pentru că executorul din PDFium Component nu consideră un task terminat până când răspunsul lui n-a fost expediat. Sub padSynchronize, acel răspuns rulează pe thread-ul principal. Dacă thread-ul principal se blochează fără să pompeze CheckSynchronize, worker-ul așteaptă pe thread-ul principal, în timp ce thread-ul principal așteaptă idle

Imaginea din debugger e de nerecunoscut greșit odată ce ai văzut-o. Pune pauză procesului înghețat, iar thread-ul principal stă într-o așteptare pe evenimentul idle, câteva frame-uri sub propria ta buclă de lot. Comută pe orice thread worker, și stă în interiorul lui TThread.Synchronize, ținând un rezultat terminat pe care nu-l poate preda. Nimic nu se învârte, niciun CPU nu arde, procesul e pur și simplu parcat. Acest articol e despre de ce există deloc acea stare, și despre trei reguli vecine care decid dacă un pool de workeri Delphi peste PDFium se poartă bine sau mușcă: ce mărginește de fapt QueueCapacity, în ce ordine trebuie să anuleze shutdown-ul, și ce nu-ți cumpără paralelismul acolo unde e vorba de ownership-ul obiectelor PDFium

De ce blochează WaitForIdle thread-ul principal?

Se blochează pentru că idle în TPdfAsyncExecutor e definit să includă expedierea răspunsului, nu doar finalizarea worker-ului. Numărul de rulări e incrementat în DequeueTask atunci când un worker preia un task, și e decrementat în TaskFinished, pe care worker-ul îl apelează doar după ce TPdfAsyncTaskOperation.Execute s-a întors. Acea metodă rulează corpul worker-ului, înregistrează rezultatul și apoi expediază răspunsul conform TPdfAsyncDispatchMode. Cu padSynchronize, expedierea e un apel TThread.Synchronize, așa că Execute nu se întoarce până când thread-ul principal nu l-a rulat

Delphi îți pune ție a doua jumătate a acelui contract. TThread.Synchronize adaugă metoda la o coadă globală și blochează thread-ul apelant pe un eveniment; ceva de pe thread-ul principal trebuie să apeleze CheckSynchronize înainte ca acel eveniment să fie vreodată semnalat. Bucla de mesaje VCL face asta pentru tine între mesaje, motiv exact pentru care bug-ul e invizibil în timpul folosirii interactive și apare în clipa în care scrii o buclă de lot blocantă. Un thread principal blocant e un thread principal care a părăsit bucla de mesaje, iar un thread principal în afara buclei de mesaje nu drenează pe nimeni

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.

Finalizarea task-ului și idle-ul executorului sunt două jaloane diferite

Sunt separate intenționat, iar a ști pe care dintre ele îl aștepți e întregul fix. IPdfAsyncTask.WaitFor e satisfăcut chiar în clipa în care rezultatul worker-ului e decis: Complete scrie TPdfAsyncTaskState finală și setează evenimentul de terminare înainte ca vreun răspuns să fie luat în considerare. TPdfAsyncExecutor.WaitForIdle e satisfăcut mai târziu, odată ce numărul din coadă și cel de rulări sunt ambele zero, iar numărul de rulări nu scade până când răspunsul n-a ajuns. Așa că un task poate fi patsSucceeded și observabil prin Snapshot în timp ce executorul e încă, în mod legitim, ocupat

// 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;

O consecință demnă de reținut: un răspuns care ridică o excepție nu rescrie istoria. DispatchReply prinde excepția și o stochează în ReplyErrorMessage, lăsând State, CancellationReason și ErrorMessage exact așa cum le-a determinat worker-ul. Un callback de UI care explodează în timp ce pictează o miniatură nu transformă deci niciodată o randare reușită într-una eșuată, iar telemetria ta continuă să raporteze ce a făcut de fapt motorul de randare. Dacă vrei API-ul în forma de callback în jurul unei singure operații, nu al unui pool, randarea în fundal cu future-uri anulabile acoperă acea cale

Mărginește QueueCapacity și workerii care rulează?

Nu. QueueCapacity din PDFium Component numără doar task-urile din coadă, niciodată pe cele deja în execuție pe un worker. Asta e deliberat: capacitatea e menită să exprime backpressure-ul real pe linia de așteptare, iar a împacheta sloturile fixe de concurență în același număr le-ar număra de două ori. Cu patru workeri și o capacitate de opt, poți avea douăsprezece task-uri în zbor, iar GetStats raportează cinstit despărțirea prin 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;

Cele patru culoare ale TPdfAsyncPriority sunt stricte, nu ponderate. DequeueTask parcurge de la papCritical în jos până la papLow și ia primul culoar ne-gol, păstrând ordinea FIFO în interiorul fiecăruia. Asta dă unei cereri interactive un mod curat de a sări înaintea unui lot care încă nu a pornit, dar nu întrerupe niciodată o muncă deja în curs, iar un apelant care tot alimentează papCritical poate înfometa papLow la nesfârșit. Rezervă primele două culoare pentru lucruri pe care un om le așteaptă vizibil, și lasă exportul în masă pe papNormal sau mai jos. Folosește Submit atunci când o coadă plină e o eroare de programare demnă de un EPdfAsyncQueueFull, și TrySubmit atunci când e o condiție normală pe care intenționezi s-o tratezi

De ce anulează Shutdown în afara lock-ului executorului?

Pentru că anularea în interiorul lui ar inversa ordinea lock-urilor și ar bloca în deadlock chiar shutdown-ul pe care încerci să-l execuți. Shutdown(True) ia lock-ul executorului, comută flag-ul de shutdown și adaugă fiecare task în așteptare la un array local de snapshot prin AppendSnapshot. Apoi eliberează lock-ul și doar după aceea parcurge snapshot-ul, apelând Cancel pe fiecare intrare. Anularea unui task declanșează callback-uri de utilizator înregistrate pe token source-ul lui, iar acele callback-uri sunt cod obișnuit de aplicație: pot interoga GetStats, pot depune muncă compensatorie, sau pot aștepta idle. Fiecare dintre astea re-intră în lock-ul executorului, iar un callback invocat cât timp acel lock e ținut ar intra în deadlock cu el însuși

Token source-ul respectă aceeași disciplină un nivel mai jos. CancelWithReason ia lock-ul sursei, decide singurul canceller câștigător, scrie Reason, CancellationMessage și CancelledAtTick, și doar apoi comută flag-ul de anulare atomic. Publică-înainte-de-comutare e ceea ce face metadatele sigure de citit: orice thread care observă IsCancelled ca True are garantat că găsește un motiv complet în spate, iar apelanții ulteriori pierd cursa, returnează False și nu pot suprascrie primul motiv. Callback-urile înregistrate sunt puse în snapshot și șterse în interiorul lock-ului, dar invocate în afara lui, fiecare împachetat astfel încât un handler care eșuează să nu poată suprima restul. Task-urile deja în execuție nu sunt niciodată omorâte; se termină cooperant atunci când corpul worker-ului lor apelează următorul ThrowIfCancelled, motiv pentru care Shutdown se termină cu un WaitForIdle care pompează, înainte de a se alătura thread-urilor

Relaxează workerii paraleli ownership-ul obiectelor PDFium?

Nu, iar aceasta e granița cel mai probabil să fie citită greșit. TPdfAsyncExecutor programează muncă; nu face nicio afirmație despre afinitatea de thread a oricărui lucru pe care-l atingi în interiorul acelei munci. O instanță TPdf vie nu devine accesibilă concurent doar pentru că doi workeri se întâmplă s-o apeleze, iar lock-ul intern de randare e o gardă contra apelurilor de randare suprapuse, nu o licență de a partaja un document între thread-uri. Randarea sau exportul paralel înseamnă un TPdf per worker, creat și distrus în interiorul job-ului

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;

Costul e real și merită menționat: fiecare worker își plătește propriul parsing și propriul cache de pagini, așa că memoria scalează cu numărul de workeri, nu cu numărul de documente. Ăsta e prețul unui model în care un worker poate fi anulat sau poate crăpa fără să corupă pe altcineva. Dacă workerii tăi chiar partajează în schimb un document de partea viewer-ului, regulile de locking din jurul acelui caz sunt acoperite în lock-ul de randare și apelurile care-l ratează, iar calea anulabilă cu document unic e în randarea progresivă anulabilă

Extinderea unei interfețe publicate fără a rupe vtable-ul

IPdfCancellationToken și IPdfCancellationTokenSource sunt interfețe în stil COM pe care binare externe le pot consuma deja, așa că adăugarea unei metode la oricare dintre ele ar deplasa fiecare slot ulterior din vtable și ar redirecționa silențios greșit apelurile compilate contra layout-ului vechi. Capacitățile de diagnostic, așteptare, callback amovibil și anulare atomică trăiesc deci în IPdfCancellationTokenEx și IPdfCancellationTokenSourceEx, care moștenesc, nu modifică. New și Run își păstrează semantica originală pentru apelanții existenți; codul nou apelează NewEx, NewTimeout și RunEx atunci când vrea CancelWithReason, WaitForCancellation sau un IPdfCancellationRegistration gestionat. Moștenirea e singurul mod sigur de a extinde o interfață publicată, și costă un tip suplimentar pe generație

NewTimeout merită o notă onestă. Fiecare sursă de timeout deține un thread ușor care așteaptă pe evenimentul de anulare sau pe deadline, oricare vine primul. Pentru o mână sau câteva zeci de deadline-uri, asta e simplu, cu latență mică și identic pe Delphi, Lazarus și C++Builder. Pentru mii de deadline-uri scurte, e forma greșită, iar tu ar trebui să conduci anularea dintr-un singur timer la nivel de aplicație, în loc să ții mii de thread-uri în așteptare

Niciuna dintre aceste reguli nu e exotică odată scrisă, dar fiecare dintre ele e un incident de producție atunci când nu e scrisă. Așteaptă pe jalonul corect și lasă ceva să pompeze coada de synchronize, citește QueueCapacity doar ca o margine pe linia de așteptare, anulează în afara lock-urilor tale, și dă fiecărui worker propriul lui document. Nivelul asincron descris aici este livrat ca parte a Delphi PDFium Component, alături de API-urile de randare, text și formulare pe care le programează