Műszaki cikk

WaitForIdle deadlock Delphi PDFium aszinkron renderelésben

Egy batch renderelés félúton lefagy, mert a PDFium Component-ben lévő végrehajtó nem tekint egy feladatot befejezettnek, amíg a válasza elküldésre nem kerül. A padSynchronize alatt ez a válasz a fő szálon fut. Ha a fő szál blokkol anélkül, hogy pumpálná a CheckSynchronize-t, a worker vár a fő szálra, miközben a fő szál a tétlenségre vár

A debugger-kép félreérthetetlen, ha egyszer láttad. Állítsd meg a lefagyott folyamatot, és a fő szál egy tétlenségi eseményre való várakozásban ül, több kerettel a saját batch-ciklusod alatt. Válts bármely worker szálra, és az a TThread.Synchronize-on belül ül, egy befejezett eredményt tartva, amit nem tud átadni. Semmi nem pörög, semmilyen CPU nem ég, a folyamat egyszerűen parkol. Ez a cikk arról szól, miért létezik egyáltalán ez az állapot, és három szomszédos szabályról, amelyek eldöntik, hogy egy Delphi worker-pool a PDFium fölött viselkedik-e, vagy harap: mit korlátoz valójában a QueueCapacity, milyen sorrendben kell a leállításnak megszakítania, és mit nem vásárol meg a párhuzamosság ott, ahol a PDFium objektum-tulajdonlásáról van szó

Miért akasztja meg a WaitForIdle a fő szálat?

Azért akasztja meg, mert a tétlenség a TPdfAsyncExecutor-ban úgy van definiálva, hogy magába foglalja a válasz-elküldést is, nem csak a worker befejezését. A futó számláló a DequeueTask-ban növekszik, amikor egy worker felvesz egy feladatot, és a TaskFinished-ben csökken, amelyet a worker csak azután hív, hogy a TPdfAsyncTaskOperation.Execute visszatért. Ez a metódus futtatja a worker törzsét, rögzíti az eredményt, majd elküldi a választ a TPdfAsyncDispatchMode szerint. A padSynchronize-val a küldés egy TThread.Synchronize hívás, így az Execute nem tér vissza, amíg a fő szál le nem futtatta azt

A Delphi rád bízza a szerződés második felét. A TThread.Synchronize hozzáfűzi a metódust egy globális sorhoz, és blokkolja a hívó szálat egy eseményen; valaminek a fő szálon hívnia kell a CheckSynchronize-t, mielőtt az az esemény valaha is jelezne. A VCL üzenethurok ezt megteszi neked az üzenetek között, ez pontosan az oka annak, hogy a hiba láthatatlan interaktív használat közben, és megjelenik abban a pillanatban, hogy egy blokkoló batch-ciklust írsz. Egy blokkoló fő szál olyan fő szál, amely elhagyta az üzenethurkot, és egy üzenethurkon kívüli fő szál nem szivattyúz senkinek

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.

A feladat-befejezés és a végrehajtó-tétlenség két különböző mérföldkő

Szándékosan vannak szétválasztva, és tudni, melyikre vársz, az egész javítás. Az IPdfAsyncTask.WaitFor abban a pillanatban teljesül, amikor a worker eredménye eldőlt: a Complete beírja a végleges TPdfAsyncTaskState-et, és beállítja a kész eseményt, mielőtt bármilyen választ megfontolnának. A TPdfAsyncExecutor.WaitForIdle később teljesül, amint a sorban álló és futó számlálók is nullák, és a futó nem csökken, amíg a válasz meg nem érkezett. Így egy feladat lehet patsSucceeded, és megfigyelhető a Snapshot-on keresztül, miközben a végrehajtó még jogosan elfoglalt

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

Egy következmény, amit érdemes belsővé tenni: egy válasz, amely kivételt dob, nem írja át a történelmet. A DispatchReply elkapja a kivételt, és eltárolja a ReplyErrorMessage-ben, pontosan úgy hagyva a State-et, a CancellationReason-t és az ErrorMessage-et, ahogy a worker meghatározta őket. Egy UI callback, amely felrobban egy miniatűr festése közben, ezért sosem alakít át egy sikeres renderelést sikertelenre, és a telemetriád folytatja jelenteni, amit a render-motor ténylegesen tett. Ha egy callback-alakú API-t szeretnél egyetlen művelet köré egy pool helyett, a háttér-renderelés megszakítható jövőkkel tárgyalja azt az útvonalat

Korlátozza a QueueCapacity a futó workereket is?

Nem. A QueueCapacity a PDFium Component-ben csak a sorban álló feladatokat számolja, sosem azokat, amelyek már futnak egy workeren. Ez szándékos: a kapacitás célja, hogy valódi visszanyomást fejezzen ki a várakozási soron, és a fix párhuzamossági szeleteket ugyanabba a számba hajtani kétszer számolná őket. Négy workerrel és nyolcas kapacitással tizenkét feladatod lehet repülőben, és a GetStats őszintén jelenti a felosztást a QueuedCount és RunningCount révén

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;

A TPdfAsyncPriority négy sávja szigorú, nem súlyozott. A DequeueTask a papCritical-tól lefelé sétál a papLow-ig, és az első nem-üres sávot veszi, megőrizve a FIFO sorrendet minden sávon belül. Ez tiszta módot ad egy interaktív kérésnek, hogy egy még el nem indult batch elé ugorjon, de sosem szakítja meg a már futó munkát, és egy hívó, amely folyamatosan papCritical-t etet, korlátlanul kiéheztetheti a papLow-t. Tartsd fenn a felső két sávot azoknak a dolgoknak, amikre egy ember láthatóan vár, és hagyd a tömeges exportot a papNormal-on vagy alatta. Használd a Submit-et, amikor egy teli sor programozási hiba, amely megér egy EPdfAsyncQueueFull-t, és a TrySubmit-et, amikor ez normál állapot, amelyet kezelni szándékozol

Miért szakít meg a Shutdown a végrehajtó-zárolás kívül?

Mert a megszakítás azon belül megfordítaná a zárolási sorrendet, és megakasztaná a leállítást, amelyet éppen végre próbálsz hajtani. A Shutdown(True) megveszi a végrehajtó-zárolást, felbillenti a leállítás-jelzőt, és minden függőben lévő feladatot hozzáfűz egy helyi pillanatkép-tömbhöz az AppendSnapshot-on keresztül. Aztán elengedi a zárolást, és csak ezután sétál végig a pillanatképen, minden bejegyzésre Cancel-t hívva. Egy feladat megszakítása felhasználói callbackeket vált ki, amelyek annak token-forrásán vannak regisztrálva, és ezek hétköznapi alkalmazáskód: lekérdezhetik a GetStats-ot, kompenzáló munkát nyújthatnak be, vagy várhatnak a tétlenségre. Mindegyik újra belép a végrehajtó-zárolásba, és egy callback, amelyet miközben az a zár tartva van, hívnak, önmaga ellen deadlockolna

A token-forrás egy szinttel lejjebb ugyanazt a fegyelmet követi. A CancelWithReason megveszi a forrás-zárolást, eldönti az egyetlen nyertes megszakítót, beírja a Reason-t, a CancellationMessage-et és a CancelledAtTick-et, és csak ezután billenti fel a megszakított-jelzőt atomikusan. A közzététel-majd-billentés az, ami biztonságossá teszi a metaadatok olvasását: bármely szál, amely az IsCancelled-et True-ként figyeli meg, garantáltan teljes indokot talál mögötte, és a későbbi hívók elveszítik a versenyt, False-t adnak vissza, és nem tudják felülírni az első indokot. A regisztrált callbackek pillanatképként rögzülnek, és a zár belsejében törlődnek, de kívül hívódnak, mindegyik úgy becsomagolva, hogy egy elbukó kezelő ne tudja elnyomni a többit. A már futó feladatokat sosem ölik meg; kooperatívan érnek véget, amikor a worker törzsük legközelebb hívja a ThrowIfCancelled-et, ez az oka annak, hogy a Shutdown egy pumpáló WaitForIdle-lal fejeződik be, mielőtt csatlakozna a szálakhoz

Lazítanak a párhuzamos workerek a PDFium objektum-tulajdonláson?

Nem, és ez a legvalószínűbb határ, amit félreértenek. A TPdfAsyncExecutor munkát ütemez; nem állít semmit a szál-affinitásáról bármely, amit a munkán belül érintesz. Egy élő TPdf példány nem válik egyidejűleg hozzáférhetővé attól, hogy két worker véletlenül belehívja azt, és a belső render-zárolás egy őrzés az átfedő render-hívások ellen, nem engedély egy dokumentum szálak közötti megosztására. A párhuzamos renderelés vagy export egy TPdf-et jelent workerenként, a munkán belül létrehozva és megsemmisítve

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;

A költség valós, és érdemes megnevezni: minden worker megfizeti a saját elemzését és a saját oldal-gyorsítótárát, így a memória a worker-számmal skálázódik, nem a dokumentumszámmal. Ez az ára egy olyan modellnek, ahol egy worker megszakítható vagy összeomolhat anélkül, hogy megrontana másokat. Ha a workereid ehelyett egy nézetoldali dokumentumot osztanak meg, az ehhez kapcsolódó zárolási szabályokat a a render-zárolás és a hívások, amelyek kihagyják tárgyalja, és az egyetlen-dokumentumos megszakítható útvonal a megszakítható progresszív renderelésben van

Egy publikált interfész kiterjesztése a vtábla eltörése nélkül

Az IPdfCancellationToken és az IPdfCancellationTokenSource COM-stílusú interfészek, amelyeket külső binárisok esetleg már fogyasztanak, így egy metódus hozzáfűzése bármelyikhez eltolná minden későbbi helyet a vtáblában, és csendben félre-irányítaná a régi elrendezés ellen fordított hívásokat. A diagnosztikai, várakozási, eltávolítható-callback és atomikus-megszakítási képességek ezért az IPdfCancellationTokenEx-ben és az IPdfCancellationTokenSourceEx-ben élnek, amelyek öröklődnek, nem módosítanak. A New és a Run megtartja eredeti szemantikáját a meglévő hívóknak; az új kód a NewEx-hez, NewTimeout-hoz és RunEx-hez nyúl, amikor CancelWithReason-t, WaitForCancellation-t vagy egy kezelt IPdfCancellationRegistration-t akar. Az öröklés az egyetlen biztonságos mód egy publikált interfész növelésére, és egy extra típusba kerül generációnként

A NewTimeout egy őszinte megjegyzést érdemel. Minden timeout-forrás birtokol egy könnyűsúlyú szálat, amely a megszakítási eseményre vagy a határidőre vár, amelyik előbb jön. Néhány tucat határidőnél ez egyszerű, alacsony-latenciájú, és azonos Delphi, Lazarus és C++Builder között. Több ezer rövid határidőnél ez rossz alak, és a megszakítást egy alkalmazás-szintű időzítőből kellene vezérelned, ahelyett hogy több ezer várakozó szálat tartanál

Ezen szabályok egyike sem egzotikus, ha egyszer leírjuk, de mindegyik éles incidens, ha nem tartják be. Várj a helyes mérföldkőre, és hagyd, hogy valami szivattyúzza a synchronize sort, olvasd a QueueCapacity-t csak a várakozási sor korlátjaként, szakítsd meg a zárolásaidon kívül, és adj minden workernek saját dokumentumot. Az itt leírt aszinkron réteg a Delphi PDFium Component részeként érkezik, a renderelő, szöveg- és űrlap-API-k mellett, amelyeket ütemez