A PDFiumban az oldalmegjelenítés szinkron folyamat. Meghívja a könyvtárat, az raszterizál egy átadott bittérképbe (bitmap), és a vezérlés visszatér, amikor a pixelek kiírásra kerültek. Egyetlen képernyőméretű oldal egy adott nagyítási szintjénél ez néhány ezredmásodpercet vesz igénybe, és senki sem veszi észre. Egy 200 oldalas dokumentum 300 dpi felbontású exportálásánál, vagy egy bélyegkép-sávnál, amelynek egyszerre minden oldalt raszterizálnia kell, ugyanez a hívás másodpercekbe kerül. Ha ezt a hívást a fő szálról (main thread) indítja, az üzenetciklus leáll, az ablak nem frissül, és a Windows a rettegett "Nem válaszol" (Not Responding) feliratot festi a címsorra. A munka helyes. A hely, ahol lefuttatta, az a rossz
A megoldás az, hogy a hosszú megjelenítést áthelyezi egy háttérszálra, és az eredményt visszahozza a fő szálra, ahol a bittérkép átadható egy vezérlőnek. Maga a PDFium nem akadályozza meg ebben, de a kötésnek (binding) biztonságossá kell tennie az átadást, mert a "futtatás egy feldolgozón (worker), válasz az UI-n" körüli hibafelület széles, és a hibák szakaszosak. A PDFiumPas FPdfAsync egysége azért létezik, hogy ennek a mintának egyetlen helyes megvalósítást adjon, egy olyan megszakítási modellel (cancellation model), amely illeszkedik ahhoz, ahogyan egy hosszú megjelenítés valójában viselkedik
A munka formája
Három művelet uralja azokat az eseteket, amikor egy megjelenítés túléli a képkockát. A kötegelt megjelenítés (batch rendering) végigjár egy oldaltartományt, és minden oldalt raszterizál, általában a lemezre. A többoldalas exportálás (multi-page export) ugyanezt teszi, de a kimenetet egyetlen fájlba állítja össze. A háttérben történő oldalmegjelenítés (background page rendering) az, amit egy megjelenítő tesz, amikor a felhasználó egy olyan oldalra ugrik, amely még nincs a gyorsítótárban, így a bittérkép a fő szálon kívül készül el, és akkor jelenik meg, amikor kész. Mindhárom ugyanazon korlátokon osztozik. Elég sokáig futnak ahhoz, hogy a felhasználói felület szála ne tudja őket kiszolgálni, olyan eredményt hoznak létre, amelyre az UI-szálnak végül szüksége van, és a felhasználó el is hagyhatja őket. A dokumentum bezárásának, az oldalon való túlgörgetésnek vagy a Mégse gomb megnyomásának le kell állítania a munkát ahelyett, hogy a felhasználót olyan kimenetre várakoztatná, amire már nincs szüksége
Ez az utolsó korlát az, ami a tervezést alakítja. Egy olyan megjelenítés, amelyet nem lehet megszakítani, az olyan, amely nyitva tartja a dokumentumot, és égeti a CPU-t még azután is, hogy a válasz már nem számít. Az egység tehát két olyan primitív köré épül, amelyek kiegészítik egymást: egy jövő (future), amely visszahozza az eredményt, és egy token, amely előreviszi a megszakítási kérést
Egy "fire-and-forget" (tüzelj és felejtsd el) jövő
A TPdfFuture<T>.Run egy feldolgozót (worker), egy választ (reply) és egy opcionális megszakítási tokent (cancellation token) vár. Elindítja a feldolgozót egy háttérszálon, és amikor a feldolgozó befejezi, eljuttatja a választ a fő szálra. A generikus T paraméter az, amit a megjelenítés létrehoz, gyakran egy bittérkép-leíró (bitmap handle) vagy egy állapotrekord (status record). A feldolgozó a fő szálon kívül fut; a válasz ott fut, ahol biztonságosan hozzá lehet férni a VCL-hez
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Ami szándékosan hiányzik, az a Wait bármilyen formája. Nincs olyan metódus, amely blokkolná a hívót, amíg a jövő (future) befejeződik, és ez nem tévedés. A fő szálról hívott Wait a klasszikus módja annak, hogy holtpontra (deadlock) juttassunk egy UI-t: a feldolgozónak szüksége van a fő szálra, hogy a válaszát a Synchronize-on keresztül futtassa, a fő szál pedig a Wait-ben várakozik, és egyik oldal sem tud továbbhaladni. Azáltal, hogy nem hajlandó felajánlani ezt a primitívet, a future kizárja azt a mintát, amely a leggyakrabban legyőzi azokat, akik megpróbálják ezt maguk megírni. Az olyan kódnak, amelynek valóban blokkolnia kell, sima TThread-et kell használnia, és vállalnia a következményeket. A future a "tüzelj és felejtsd el" (fire-and-forget) esetekre való, ami valójában a háttérben történő megjelenítés
Az eredmény egy TPdfFutureResult<T> rekordba van csomagolva, amely megmondja a válasznak, hogy a három dolog közül melyik történt. Az IsSuccess azt jelenti, hogy a feldolgozó normálisan tért vissza, és a Value tartalmazza a megjelenítést. Az IsCancelled azt jelenti, hogy a token működésbe lépett, és a feldolgozó egy megszakítási ponton kiszállt. Az IsFailure azt jelenti, hogy a feldolgozó kivételt váltott ki, az ErrorMessage pedig hordozza a szöveget. A válasz egyszer megvizsgálja az állapotot, majd elágazik, ahelyett, hogy egy őrértékből próbálná kitalálni, hogy a visszaadott bittérkép valódi-e
A v1.61.0 versenyhelyzet (race), amely megváltoztatta a válasz kézbesítését
Ennek az egységnek a legtanulságosabb része egy egysoros változtatás, amelynek megértése eltartott egy ideig. A korai verziók során a feldolgozó szál (worker thread) a TThread.Queue segítségével szállította le a válaszát. A Queue elküldi a választ a fő szál (main thread) sorába, és azonnal visszatér, ami pontosan úgy hangzik, mint amit egy fire-and-forget jövő (future) akar. Ez téves volt, és az okot érdemes megfogalmazni, mert ez az a fajta hiba, amely átmegy minden olyan teszten, amit csak ki tud találni
A feldolgozó szálat FreeOnTerminate := True értékkel hozzák létre. Ez azt jelenti, hogy abban a pillanatban, amint az Execute visszatér, a szál lebontja önmagát, és a TThread.Destroy a tisztítás részeként meghívja a RemoveQueuedEvents(Self) függvényt. A RemoveQueuedEvents minden olyan sorba állított metódust kitisztít, amelynek célpontja a haldokló szál. A sorrend tehát a következő volt: a feldolgozó befejezi a munkát, saját maga ellen sorba állítja a választ, az Execute visszatér, a szál megsemmisíti önmagát, a RemoveQueuedEvents pedig törli a választ, amelyet a fő szál még nem futtatott le. Az eredmény egyszerűen eltűnt. Ami még rosszabb, abban a szűk ablakban, amikor a fő szál lehúzta a sorba állított választ, és elkezdte futtatni abban a pillanatban, amikor a szál felszabadult, a válasz egy félig megsemmisült objektum mezőit érintette, ami használat a felszabadítás után (use-after-free)
A javítás a v1.61.0 verzióban az volt, hogy a választ a Queue helyett a Synchronize segítségével kell leszállítani. A Synchronize blokkolja a feldolgozó szálat (worker thread), amíg a fő szál (main thread) végig nem futtatja a választ. A feldolgozó még él, amíg a válasza végrehajtódik, tehát nincs mit felszabadítani alóla, és a szál addig nem tér vissza az Execute metódusból (és így nem is kezdi el megsemmisíteni magát), amíg a választ kézbesítik. A kézbesítés garantált, és a use-after-free ablak bezárult
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // already cancelled? skip the worker
FResult.Value := FWorker(FToken);
except
on E: EPdfOperationCancelled do
begin
FResult.Status := pfsCancelled;
FResult.ErrorMessage := E.Message;
end;
on E: Exception do
begin
FResult.Status := pfsFailure;
FResult.ErrorMessage := E.Message;
end;
end;
if Assigned(FReply) then
// Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
// could be dropped by RemoveQueuedEvents before the main thread ran it.
Synchronize(DispatchReply);
end;
Az általános tanulság túléli a konkrét javítást. A fire-and-forget aszinkron visszahívások (callbacks) alkotják azt az egyidejűségi mintát (concurrency pattern), amelyet a legkönnyebb finoman elrontani, mert a "happy path" első próbálkozásra működik, a hiba pedig a szállebontás (thread teardown) sorrendje és a sor (queue) közötti interakcióban rejlik. Nem reprodukálható igény szerint. Attól függ, hogy a fő szálnak történetesen sikerült-e kiürítenie a sort, mielőtt a feldolgozó (worker) befejezte volna önmaga megsemmisítését; ez egy olyan időzítés, amelyet az ütemező minden egyes futtatáskor másképp dönt el. Egy olyan primitív, amely egyszer – a kötésben (binding) – helyes, sokkal többet ér, mint ugyanaz a kód újra levezetve minden egyes alkalmazásban, amelynek háttérbeli megjelenítésre (background render) van szüksége
Miért metódusmutatók a visszahívások
A feldolgozó (worker) és a válasz (reply) nem névtelen (anonymous) metódusok. Ezek procedure of object típusok, TPdfFutureWorker<T> és TPdfFutureReply<T>, és ezt a választást a fordító mátrix (compiler matrix) kényszeríti ki. A PDFiumPas lefordul Delphi XE5 és újabb verziókon, valamint Free Pascal 3.2-n Delphi módban, az FPC 3.2 ebben a módban azonban nem támogatja a névtelen metódusokat. Egy helyi változókat rögzítő (capture), eljárásra hivatkozó (reference-to-procedure) visszahívás (callback) lefordulna Delphin, de FPC-n elbukna, így az egység a legkisebb közös többszöröst használja, amelyet mindkét fordító elfogad
A gyakorlati következmény az, hogy hol is lakozik az állapot. Egy névtelen metódus bezár (close over) a helyi változók fölött; egy metódusmutató (method pointer) nem. Tehát bármilyen állapot, amire a feldolgozónak szüksége van (az oldalindex, a nagyítás, a kimeneti útvonal), és bármilyen állapot, amelyet a válasznak frissítenie kell (a cél képvezérlő vagy az előrehaladást jelző címke), azon az objektumon kell lógjon, amelynek a metódusát átadják. Egy megjelenítőben (viewer) ez az objektum általában az űrlap (form) vagy egy általa birtokolt megjelenítés-vezérlő (render controller). Ez nem egy vonakodva bevezetett áthidaló megoldás; ez kifejezetten a fogadó objektumon tartja az adott állapot tulajdonjogát, expliciten és láthatóan, ahelyett, hogy egy lezárásban (closure) rejtegetné azt
Kooperatív megszakítás, nem "hard kill"
A megszakítás (cancellation) itt kooperatív (együttműködő). Nincs olyan API, amely benyúlna a feldolgozó szálba (worker thread), és megszakítaná azt, mert egy szál megszakítása a megjelenítés közben úgy hagyja a PDFiumot, hogy zárakat (locks) és részben megírt bittérképeket tart fogva, és a folyamat (process) állapota egy kikényszerített leállítás (hard kill) után már nem kiszámítható. Ehelyett a feldolgozó kap egy csak olvasható (read-only) tokent, és elvárják, hogy ellenőrizze azt, a megjelenítési ciklus (render loop) pedig úgy van megírva, hogy az oldalak vagy a csempék között ellenőrizze, ahol a megállás már tiszta
A token háromféle módot kínál a megszakítás megfigyelésére. Az IsCancelled egy olcsó logikai (boolean) lekérdezés egy olyan ciklushoz, amely tesztelni akar és maga dönteni. A ThrowIfCancelled a leggyakoribb eset: hívja meg egy természetes megszakítási ponton, és ha kérték a megszakítást, akkor EPdfOperationCancelled kivételt vet, ami a feldolgozót egyenesen visszatekeri a future-höz. A RegisterCallback egy egyszeri értesítést (one-shot notification) csatol, amely egyszer sül el, amikor a forrást (source) megszakítják; ez akkor hasznos, ha a feldolgozó blokkolva van valamiben, amit képes megszakítani, ahelyett, hogy egy szoros ciklusban (tight loop) ülne
A kivétel az a hely, ahol a szál határa számít. Amikor a feldolgozó az EPdfOperationCancelled kivételt veti, a future elkapja azt, és megszakított (cancelled) állapotba fordítja, így a válasz (reply) a IsCancelled-et látja, nem pedig hibát. Maga a kivételobjektum soha nem kerül átirányításra (marshaled) a fő szálra. A feldolgozó szálon él és hal; csak az üzenetkarakterlánc (message string) másolódik be az ErrorMessage-be. Egy élő kivételobjektum átirányítása a szálak között azt jelentené, hogy be kellene nyúlni egy befejeződő szál által birtokolt memóriába, ami ugyanaz a hibakategória, amelynek megakadályozására a Synchronize javítás is született. Egy állapotkód (status code) és egy karakterlánc (string) tisztán átlépi a határt; egy objektum nem
Két interfész, így a feldolgozó nem tudja megszakítani önmagát
A megszakítás (cancellation) szándékosan van elosztva két interfész között. Az IPdfCancellationTokenSource az írási oldal: rendelkezik a Cancel metódussal, és a tulajdonos, aki létrehozza (általában a form), megtartja azt, és meghívja a Cancel-t, amikor a felhasználó a gombra kattint vagy a form bezáródik. Az IPdfCancellationToken az olvasási oldal: rendelkezik a IsCancelled, ThrowIfCancelled, és RegisterCallback metódusokkal, és ez minden, amit a feldolgozó valaha is megkap. Egyetlen konkrét objektum valósítja meg mindkettőt, de a feldolgozónak mindig csak a tokent adják át, így semmilyen módja nincs megszakítani az általa futtatott műveletet. Ez a szétválasztás egy API-szintű védőkorlát. Egy olyan feldolgozó, amely a tokenjén keresztül elérné a Cancel metódust, arra késztethetne egy zavart kódrészt, hogy megszakítsa önmagát; a típusrendszer (type system) pedig kizárja ezt a lehetőséget
Van egy ehhez illő részlet arra az esetre, amikor egy hívó (caller) megjelenítést kér, de soha nem szándékozik megszakítani. Ahelyett, hogy hívásonként egy friss forrást erőltetne, az egység a PdfNoCancellationToken-t, egy egyke (singleton) tokent teszi elérhetővé, amely állandóan nem-megszakított (not-cancelled) állapotban van. A Run helyettesíti azt, amikor a token argumentumot nullaként (nil) hagyják. Ez az egyke mohón (eagerly) jön létre az egység inicializálása során, nem pedig lustán (lazily) az első használatkor, és ennek az oka ismét az egyidejűség. Ha különböző feldolgozó szálakon több Run hívás is egyszerre nyúlna egy lustán létrehozott egykéhez, versenyezhetnének (race) a felépítésében, kiszivárogtathatnának (leak) egy másodpéldányt, vagy rövid ideig megfigyelhetnének egy félig inicializált példányt. Azzal, hogy még azelőtt felépíti, mielőtt bármelyik feldolgozó elindulna, teljesen megszünteti a versenyhelyzetet (race)
Megszakítható megjelenítés futtatása
A gyakorlatban létrehoz egy forrást (source), megtartja az űrlapon (form), a Token-jét a Run-ba adja át egy worker metódus és egy reply metódus mellett, majd a Mégse gombot a forráshoz köti. A feldolgozó a megjelenítés közben ellenőrzi a tokent; a válasz (reply) frissíti az UI-t, miután az eredmény visszatér. Mivel a visszahívások metódusmutatók (method pointers), a feldolgozó és a válasz beolvassa mindazt, amire szükségük van a form mezőiből
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // field, lives on the form
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // worker observes this at its next cancel point
end;
// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // clean stop between pages
RenderOnePage(PageIndex); // synchronous PDFium rasterisation
end;
Result := True;
end;
// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
if AResult.IsSuccess then
StatusLabel.Caption := 'Render complete'
else if AResult.IsCancelled then
StatusLabel.Caption := 'Cancelled'
else
StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;
A válasz (reply) mind a három kimenetet (outcome) kezeli, mert mindhárom elérhető. Egy befejezett megjelenítés sikert (success) jelent, a Mégse gombot megnyomó felhasználó a megszakított ágat (cancelled branch) látja, egy olyan fájl pedig, amelyet nem sikerült kiírni, vagy egy oldal, amelynek értelmezése sikertelen volt, üzenettel kísért hibaként (failure) érkezik. Ezeknek az ágaknak egyike sem blokkol, egyik sem érinti a feldolgozó szálat, és az a bittérkép vagy állapot, amelyet a feldolgozó készített, csak azután kerül kiolvasásra, hogy a future leszállította azt azon a szálon, amelyik az UI tulajdonosa
Ugyanez a szálkezelési fegyelem a megjelenítő (viewer) más részein is megtérül. A megjelenítési gyorsítótárról (render cache) és a nagyítási teljesítményről szóló jegyzetünk leírja, hogy miként őrzik meg és használják fel újra a renderelt bittérképeket a nagyítások során; míg a PDFium VCL ABI memóriabiztonsági megerősítése pedig tágabb kérdéskörben tárgyalja a PDFium határának biztonságban tartását Delphiben. Az itt ismertetett aszinkron infrastruktúra a Delphi és C++Builder rendszerekhez készült PDFium Component részeként érkezik, a blogon máshol tárgyalt megjelenítési, szöveges és űrlap API-k mellett