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;
// A deadlockoló alakzat: szinkronizált válasz plusz egy blokkoló fő szál
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
padSynchronize);
Task.WaitFor(High(Cardinal)); // a fő szál most egy kernel-várakozásban parkol
// Eközben a TPdfAsyncTaskOperation.Execute eljutott ide:
// TThread.Synchronize(AWorkerThread, DispatchReply);
// amely sorba állítja a DispatchReply-t, és vár, hogy a fő szál kiürítse.
// A fő szál semmit nem ürít, így mindkét oldal örökké vár.
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
// A TPdfAsyncExecutor.WaitForIdle már pumpál helyetted: az idle eseményre vár
// rövid szeletekben, és közben CheckSynchronize(0)-t hív.
if not Executor.WaitForIdle(30000) then
ReportBatchTimeout;
// Bármely kézzel írt fő szálas várakozásnak ugyanezt kell tennie kifejezetten.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
ATimeoutMs: Cardinal): Boolean;
var
StartedAt: UInt64;
begin
StartedAt := PdfAsyncTick;
repeat
if ATask.WaitFor(10) then
Exit(True);
CheckSynchronize(0); // engedje el a függőben lévő padSynchronize választ
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
// A TrySubmit soha nem dob kivételt: False-t ad vissza, ha a várakozási sor tele van vagy
// a végrehajtó már leáll, és növeli a RejectedCount-ot.
if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
padSynchronize) then
begin
Stats := Executor.GetStats;
// A QueuedCount azt méri, amit a QueueCapacity korlátoz. A RunningCount-ot a
// WorkerCount korlátozza, és soha nem számít bele a kapacitásba.
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; // workerenként egy dokumentumpéldány, soha nem megosztva
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); // a tulajdonjog átkerül a válasz-szakaszba
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