Tehnički članak

Pozadinsko renderiranje PDF-a u Delphiju pomoću otkazivih (cancellable) obećanja (futures)

Renderiranje stranice u PDFiumu je sinkrono (synchronous). Vi pozovete biblioteku, ona rasterizira sadržaj ravno u bitmapu koju ste joj predali, te se usput cjelokupna kontrola tek na samom kraju vraća natrag kada se uspješno ispišu sami zadani pikseli. Za samo neku jednu jedinu naizgled stranicu veličine ekrana te pri samo jednoj razini navedenog zumiranja to uzima jedva nekoliko milisekundi i nitko to usput ni ne primjećuje. Ali ipak za nekakav izvoz (export) s 300 dpi dokumenta od 200 stranica, ili za neku predočenu traku s minijaturama (thumbnail strip) koja mora odjednom rasterizirati svaku stranicu, potpuno isti poziv stoji doista nekoliko sekundi. Ako taj isti poziv napravite s glavne niti (main thread), petlja poruka se zaustavlja, prozor prestaje iscrtavati promjene (repainting), a Windows iscrtava zastrašujuće "Ne odgovara" (Not Responding) preko vaše naslovne trake (title bar). Posao je posve u redu. Mjesto gdje ste ga pokrenuli je pogrešno

Pravi izlaz iz navedene nezahvalne situacije obuhvaća premještanje dugačkog renderiranja na pozadinsku nit (background thread) i povratak rezultata na glavnu nit (main thread), gdje se bitmapa može predočiti te predati pod logičnu kontrolu (control). PDFium vas osobno nimalo ne sprječava da sve ovo učinite, no povezivanje (binding) mora učiniti primopredaju sigurnom (safe), zato što je opseg mogućih pogrešaka oko pristupa "pokrenuti na radniku (worker), dogovoriti odgovor na korisničkom sučelju (UI)" iznimno širok, a neuspjesi se javljaju povremeno (intermittent). Unit (jedinica) FPdfAsync unutar PDFiumPas postoji kako bi takvom obrascu ponudio jedno ispravno rješenje odnosno implementaciju uz pridružen model otkazivanja koji točno odgovara tome kako se jedno dugotrajno renderiranje zapravo ponaša

Oblici samog rada

Postoje pretežno tri operacije koje dominiraju situacijama gdje renderiranje nadživi okvir (frame). Grupno renderiranje (batch rendering) prolazi kroz jedan raspon stranica i rasterizira svaku od njih pojedinačno, najčešće na disk. Izvoz više stranica (multi-page export) radi istu stvar ali okuplja izlaz (output) unutar jedne datoteke. Pozadinsko renderiranje stranice jest zapravo ono što neki preglednik (viewer) radi kad korisnik naglo preskoči na neku novu stranicu koja se još uvijek ne nalazi unutar predmemorije (cache), pa se bitmapa izradi izvan niti (off-thread) te se prikaže čim bude spremna. Sve tri dijele iste ograničavajuće preduvjete (constraints). One traju dovoljno dugo da ih nit korisničkog sučelja (UI thread) ne može ugostiti (host), proizvode rezultat koji niti korisničkog sučelja konačno treba, a korisnik ih možda želi i napustiti. Zatvaranje dokumenta, listanje izvan te stranice ili pak samo pritiskanje gumba Odustani (Cancel) trebali bi zaustaviti posao umjesto da prisiljavaju korisnika da čeka izlaz koji mu više uopće ni ne treba

Ovo zadnje ograničenje je ono koje oblikuje dizajn. Renderiranje koje se ne može otkazati predstavlja renderiranje koje drži dokument otvorenim i troši procesor (CPU) i nakon što je sam odgovor prestao biti bitan. Stoga je i ova jedinica izgrađena oko dva jednostavna temeljna dijela (primitives) koja se preklapaju: obećanje (future) koje nosi rezultat natrag, i token koji prenosi zahtjev za otkazivanjem prema naprijed

Obećanje "ispali pa slobodno zaboravi" (fire-and-forget future)

TPdfFuture<T>.Run uzima radnika (worker), zatim odgovor (reply), kao i proizvoljni (optional) token za otkazivanje. Pokreće radnika na pozadinskoj niti (background thread), a kada radnik dovrši svoj posao on uredno isporučuje odgovor na glavnoj niti (main thread). Generički parametar T predstavlja ono što render proizvodi (produces), što je često rukovatelj bitmapom (bitmap handle) ili statusni zapis. Radnik djeluje izvan niti (off-thread); odgovor se događa ondje gdje je sasvim sigurno dotaknuti VCL

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

Namjerni izuzetak jest bilo koja vrsta Wait metode. Ovdje dakle ne postoji način za blokadu pozivatelja dok se obećanje (future) ne dovrši, i to pritom nipošto ne predstavlja previd. Metoda Wait pozvana iz glavne niti posve je klasični način za trajno zaključavanje korisničkog sučelja (deadlock a UI): radniku je potrebna glavna nit kako bi putem funkcije Synchronize pokrenuo svoj odgovor, glavna je nit za to vrijeme usidrena unutar petlje Wait i nijedna strana pritom naprosto ne može samo ići naprijed i nastaviti. Odbijanjem ponude ovog baznog rješenja (primitive), takvo obećanje zbilja odbacuje klasični model koji inače redovito najviše i slomi brojne ljude prilikom njihovih pokušaja da ovakvo nešto izgrade povlačeći te ispisujući samostalno to isto. Bilo koji kod koji izistinski i nadasve baš nasušno zahtijeva kakvu preporučenu opciju za blokiranje jednostavno naprosto mora koristiti čisti TThread te preuzeti cjelovitu kontrolu nad pripadajućim posljedicama. Obećanje (future) prisutno je samo za ispunjavanje onog klasičnog "ispali pa zaboravi" primjera (case), a što pozadinsko renderiranje u srži i jest

Rezultat je umotan točno u TPdfFutureResult<T>, snimljeni podatak koji odgovoru poručuje koja od triju mogućih stvari se dogodila. IsSuccess označava da se radnik vratio normalno a Value čuva taj render. IsCancelled znači da je token ispucan i da je radnik odustao u samoj točki otkazivanja. IsFailure znači da je radnik to pokrenuo (raised), a ErrorMessage prenosi taj tekst. Odgovor analizira (inspects) status jednom i tada se grana (branches), umjesto da pogađa iz nadzirane (sentinel) vrijednosti je li uzvraćena bitmapa prava (real)

Utrkivanje (race) u v1.61.0 koje je promijenilo samu isporuku odgovora

Najpouzdaniji edukativni dio ove cjeline zapravo je sasvim obična promjena unutar samo jednog retka za čije je razumijevanje trebalo nešto više vremena. Kroz sve ranije verzije radnička je nit dostavljala svoj odgovor putem opcije TThread.Queue. Opcija Queue objavljuje odgovor na red glavne niti i vraća se odmah (immediately), što se čita točno kao ono što obećanje (future) za slučaj "ispali pa zaboravi" zapravo i želi. Ali to je bilo pogrešno, a navedeni razlog itekako zaslužuje detaljnije objašnjenje budući da je to vrsta pogreške (bug) koja uredno prolazi kroz svaki test koji samo zamislite da biste ga mogli napisati

Radnička nit se stvara uz opciju FreeOnTerminate := True. To znači da onog trenutka kada Execute uzvraća (returns), nit ruši samu sebe, a TThread.Destroy poziva RemoveQueuedEvents(Self) kao dio procesa čišćenja. RemoveQueuedEvents briše bilo koju metodu u redu (queued method) čiji je cilj upravo ta navedena nit u umiranju (dying thread). Stoga je niz (sequence) izgledao ovako: radnik završava svoj posao, on stavlja odgovor u red za čekanje naspram sebe samog, Execute se vraća, nit uništava samu sebe, a RemoveQueuedEvents briše odgovor koji glavna nit još nije bila ni pokrenula. Rezultat je naprosto iščeznuo. I još gore (worse), u uskom vremenskom prozoru u kojem bi glavna nit povukla stavljeni odgovor i počela ga pokretati u istom trenutku kada je nit upravo oslobođena (freed), odgovor je dodirnuo polja (fields) jednog napola uništenog objekta, što predstavlja upravo poznati "upotreba-nakon-oslobađanja" (use-after-free)

Rješenje u verziji v1.61.0 bilo je isporučivanje odgovora uz Synchronize umjesto Queue. Synchronize blokira radničku nit sve dok glavna nit ne izvede odgovor skroz do samog kraja (completion). Radnik je i dalje živ dok se njegov odgovor izvršava, tako da ovdje ne postoji ništa što bi se moglo osloboditi izravno iz pozadine samog radnika, a nit se ne vraća iz Execute (pa shodno tome ni ne započinje s uništavanjem same sebe) sve dok odgovor nije isporučen. Isporuka je time zajamčena (guaranteed), a spomenuti prozor tipa "use-after-free" konačno je zatvoren

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;

Općenita pouka nadilazi specifično rješenje. Asinkroni povratni pozivi pod opcijom "ispali pa zaboravi" predstavljaju najlakši model podudaranja (concurrency pattern) da bi se pokazao suptilno pogrešnim, jer taj sretni put prolazi već iz prvog pokušaja a bug zapravo živi isključivo unutar interakcije između poretka rastavljanja same niti (thread teardown order) i reda (queue). On se nipošto ne reproducira na vaš zahtjev (on demand). Sve to ovisi o tome je li glavna nit slučajno ispraznila red prije nego što je radnik slučajno završio s uništavanjem samoga sebe, a to je tajming koji planer (scheduler) odlučuje drugačije pri svakom izvođenju (every run). Temelj (primitive) koji je jednom točan, u povezivanju (binding), vrijedi daleko više nego potpuno isti kod iznova izveden (re-derived) u svakoj aplikaciji kojoj treba takvo pozadinsko renderiranje

Zašto su povratni pozivi (callbacks) obični pokazivači na metode

Radnik i odgovor zapravo uopće ne predstavljaju anonimne metode. Oni su prvenstveno tek vrste procedure of object, uz TPdfFutureWorker<T> i TPdfFutureReply<T>, a takav izbor zapravo uvjetuje izabrana matrica kompajlera (compiler matrix). PDFiumPas se kompilira na Delphi XE5 i kasnijim verzijama, kao i na Free Pascal 3.2 unutar Delphi moda (Delphi mode), a FPC 3.2 u tom istom modu naprosto ne podržava anonimne metode. Povratni poziv koji se odnosi na proceduru (reference-to-procedure callback) a koji usputno preuzima lokalne varijable sasvim bi se uspješno iskompilirao na Delphi rješenju a potpuno bi srušio FPC, stoga jedinica koristi onaj najmanji zajednički nazivnik (lowest common denominator) kojeg oba kompajlera s lakoćom prihvaćaju

Praktična posljedica leži u tome gdje stanje živi. Anonimna metoda se zatvara preko lokalnih vrijednosti; pokazivač metode to ne čini. Stoga bilo koje stanje koje je potrebno radniku, poput indeksa stranice, zumiranja, izlazne putanje, kao i bilo koje stanje koje odgovor mora ažurirati, poput ciljane kontrole slike ili oznake napretka, mora ovisiti o objektu čija se metoda prosljeđuje. U pregledniku taj objekt obično je obrazac (form) ili kontroler renderiranja koji on posjeduje. Ovo nije zaobilazno rješenje (workaround) nametnuto preko volje; ono zadržava vlasništvo nad tim stanjem eksplicitnim i vidljivim na objektu primatelja umjesto da ostane skriveno unutar zatvaranja (closure)

Kooperativno otkazivanje, a ne samo uobičajeni surovi prekid procesa (hard kill)

Otkazivanje je ovdje isključivo kooperativno. Ne postoji API koji poseže u radničku nit i prekida je, jer prekidanje niti usred renderiranja ostavlja PDFium s netaknutim zaključavanjima (holding locks) i djelomično napisanima bitmapama, a stanje procesa nakon prisilnog prekida (forced kill) nije nešto o čemu možete mirno rasuđivati. Umjesto toga radniku se uručuje token samo za čitanje (read-only token) te se očekuje da ga on provjeri, a petlja za renderiranje napisana je tako da ga provjerava između stranica ili između pločica (tiles), gdje je zaustavljanje sasvim čisto (clean)

Token nudi tri načina za promatranje otkazivanja. IsCancelled je vrlo jeftina i brza booleova anketa (poll) za samu petlju koja samo želi to isprobati i onda donijeti odluku sama za sebe. ThrowIfCancelled predstavlja uobičajeni primjer: pozovite ga na nekoj sasvim prirodnoj točki otkazivanja i, ako je otkazivanje zatraženo, on pokreće (raises) EPdfOperationCancelled, što potom otpušta (unwinds) radnika ravno natrag u obećanje (future). RegisterCallback prilaže jednokratnu obavijest koja se ispaljuje jednom kada je izvor otkazan, a što je i više nego korisno kada je radnik blokiran u nečemu što može i prekinuti umjesto da on tamo samo sjedi u čvrstoj petlji

Iznimka je upravo tamo gdje je granica niti zbilja važna (matters). Kada radnik pokrene EPdfOperationCancelled, obećanje ga hvata i pretvara u status otkazivanja (cancelled status), tako da odgovor vidi IsCancelled a ne i neuspjeh (failure). Sam objekt iznimke nikada se ne uređuje odnosno ne prenosi (marshals) u glavnu nit. On živi i umire na radničkoj niti; samo se njegov niz poruka (message string) kopira u ErrorMessage. Uređivanje (marshaling) objekta žive iznimke preko niti značilo bi i posezanje u memoriju koja je u vlasništvu niti koja upravo završava, što predstavlja sasvim istu klasu pogrešaka koje popravak Synchronize nastoji spriječiti. Statusni kod i jedan string sasvim čisto prelaze navedenu granicu; objekt to ne bi napravio

Dva zasebna sučelja tako da radnik naprosto ne može poništiti samoga sebe

Otkazivanje je namjerno podijeljeno na dva sučelja. IPdfCancellationTokenSource predstavlja stranu za pisanje (write side): on ima Cancel, a vlasnik koji ga stvara, obično sam obrazac (form), čuva ga i poziva Cancel kada korisnik klikne na gumb ili se obrazac zatvori. IPdfCancellationToken predstavlja stranu za čitanje (read side): ima IsCancelled, ThrowIfCancelled i RegisterCallback, a to je sve što radnik ikada i prima. Jedan konkretan objekt implementira oba, ali radniku se uručuje samo token, tako da on nema nikakvog načina da poništi samu operaciju koju trenutno pokreće. Ova podjela predstavlja ogradu za zaštitu na razini samog API-ja (API-level guard rail). Radnik koji bi mogao dohvatiti Cancel putem svog tokena pozvao bi jedan zbunjeni komad koda da otkaže samog sebe, a sustav tipova (type system) u potpunosti uklanja takvu mogućnost

Postoji i odgovarajući detalj za slučaj kada pozivatelj želi render, ali nikada ga ne namjerava otkazati. Umjesto da prisiljava na novi izvor po pozivu, jedinica izlaže PdfNoCancellationToken, jedan jedinstveni token (singleton token) koji se trajno nalazi u neotkazanom stanju (not-cancelled state). Run ga zamjenjuje kada je argument tokena ostavljen kao nil. Taj se jedinstveni token konstruira revno i željno tijekom inicijalizacije same jedinice umjesto tek lijeno i polako pri prvoj upotrebi (lazily on first use), a razlog leži još jednom u podudaranju (concurrency). Ako se nekoliko Run poziva na različitim radničkim nitima odjednom dočepaju onog lijeno kreiranog jedinstvenog tokena, oni bi se mogli početi utrkivati (race) oko njegove konstrukcije, ispustiti duplikat, ili ukratko promotriti napola inicijaliziranu instancu. Izgradnja samog tokena prije nego što bilo koji radnik može raditi u potpunosti uklanja navedenu utrku (race)

Pokretanje otkazivog renderiranja (cancellable render)

U samoj praksi vi stvarate izvor, čuvate ga na obrascu (form), prosljeđujete njegov Token u Run zajedno uz metodu radnika i metodu odgovora, te povezujete gumb Odustani (Cancel) s navedenim izvorom. Radnik provjerava token dok on renderira; odgovor ažurira korisničko sučelje (UI) jednom kada se rezultat vrati natrag. Budući da su povratni pozivi zapravo pokazivači metoda, radnik i odgovor čitaju što god im treba iz polja obrasca

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;

Odgovor obrađuje sva tri ishoda jer su sva tri i te kako dostižna. Završen render prijavljuje uspjeh, korisnik koji je pritisnuo Odustani (Cancel) vidi otkazanu granu, a datoteka koja se nije mogla zapisati ili pak stranica koja nije uspjela raščlaniti pristiže kao neuspjeh s odgovarajućom porukom. Nijedna od tih grana ne blokira, nijedna ne dotiče radničku nit, a bitmapa ili pak onaj status koji je radnik proizveo očitavaju se isključivo samo onda nakon što ga je obećanje (future) već sasvim dostavilo na nit koja u potpunosti posjeduje korisničko sučelje (UI)

Posve ista disciplina niti itekako se isplati i na drugim mjestima u samom pregledniku. Način na koji se renderirane bitmape čuvaju i iznova koriste kroz promjene zumiranja pokriven je u našoj bilješci o predmemoriji renderiranja i izvedbi zumiranja, a puno šire pitanje očuvanja PDFium granice sigurnom pod Delphijem nalazi se u dijelu očvršćavanje PDFium VCL ABI-ja za memorijsku sigurnost. Asinkrona infrastruktura koja je ovdje opisana isporučuje se kao sastavni dio PDFium komponente za Delphi i C++Builder, zajedno uz API-je za renderiranje, tekst i obrazac koji su pokriveni na drugim mjestima na ovom istom blogu