Renderovanje stranice u PDFium-u je sinhrono. Vi pozivate u biblioteku, ona rasterizuje u bitmapu koju ste joj predali, i kontrola se vraća kada su pikseli napisani. Za jednu stranicu veličine ekrana na jednom nivou zuma to traje nekoliko milisekundi i niko to ne primijeti. Za 300 dpi izvoz 200-straničnog dokumenta, ili traku sličica koja mora rasterizovati svaku stranicu odjednom, isti poziv košta sekunde. Ako taj poziv napravite s glavne niti, petlja poruka staje, prozor prestaje repaintovati, i Windows crta zastrašujući "Ne odgovara" preko vaše naslovne trake. Posao je ispravan. Mjesto na kojem ste ga pokrenuli je pogrešno
Rješenje je premjestiti dugo renderovanje na pozadinsku nit i donijeti rezultat natrag na glavnu nit, gdje se bitmapa može predati kontroli. PDFium sam vas ne sprečava da to radite, ali binding mora učiniti predaju sigurnom, jer je površina greške oko "pokrenuti na radniku, odgovoriti na UI" široka i kvarovi su intermitentni. Jedinica FPdfAsync u PDFiumPas postoji da tom uzorku da jednu ispravnu implementaciju, s modelom otkazivanja koji odgovara tome kako se dugo renderovanje zapravo ponaša
Oblik posla
Tri operacije dominiraju slučajevima gdje renderovanje nadživi jedan frame. Batch renderovanje hoda rasponom stranica i rasterizuje svaku stranicu, obično na disk. Višestraničan izvoz radi isto ali sklapa izlaz u jednu datoteku. Renderovanje pozadinske stranice je ono što preglednik radi kada korisnik skočí na stranicu koja još nije u kešu, pa se bitmapa proizvodi van niti i prikazuje kada je spremna. Sve tri dijele iste uvjete. Traju dovoljno dugo da UI nit ne može biti njihov domaćin, proizvode rezultat koji UI nit na kraju treba, i korisnik ih može napustiti. Zatvaranje dokumenta, skrolanje pored stranice, ili pritiskanje Otkazi treba zaustaviti posao umjesto da prisiljava korisnika da čeka na izlaz koji više ne želi
Taj posljednji uvjet je onaj koji oblikuje dizajn. Renderovanje koje ne može biti otkazano je renderovanje koje drži dokument otvorenim i troši CPU pošto je odgovor prestao biti bitan. Pa je jedinica izgrađena oko dva primitiva koji se komponuju: futur koji prenosi rezultat natrag, i token koji prenosi zahtjev za otkazivanjem naprijed
Futur koji se lansira i zaboravlja
TPdfFuture<T>.Run uzima radnika, odgovor, i opcioni token za otkazivanje. Pokreće radnika na pozadinskoj niti, i kada radnik završi isporučuje odgovor na glavnoj niti. Generički parametar T je ono što renderovanje proizvodi, često handle bitmape ili zapis statusa. Radnik se pokreće van niti; odgovor se pokreće gdje je sigurno dodirnuti VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Namjerno izostavljanje je ikakva vrsta Wait-a. Ne postoji metoda za blokiranje pozivaoca dok futur ne završi, i to nije previd. Wait pozvan s glavne niti je klasičan način za deadlock UI-a: radniku treba glavna nit da bi pokrenuo odgovor kroz Synchronize, glavna nit je parkirana unutar Wait-a, i nijedna strana ne može napredovati. Odbijanjem da ponudi primitiv, futur isključuje uzorak koji najčešće poražava ljude koji to pokušavaju sami napisati. Kod koji zaista treba blokirati treba koristiti običan TThread i snositi posljedice. Futur je za slučaj lansiranja i zaboravljanja, što pozadinsko renderovanje zapravo jest
Rezultat je omotan u TPdfFutureResult<T>, zapis koji govori odgovoru koja od tri stvari se dogodila. IsSuccess znači da je radnik vratio normalno i Value drži renderovanje. IsCancelled znači da je token okiden i radnik je iskočio na tački otkazivanja. IsFailure znači da je radnik izbacio iznimku, i ErrorMessage nosi tekst. Odgovor pregleda status jednom i grana, umjesto da pogađa iz sentinel vrijednosti da li je vraćena bitmapa stvarna
Utrka v1.61.0 koja je promijenila isporuku odgovora
Najpoučniji dio ove jedinice je promjena od jedne linije koja je potrajala da se razumije. Kroz rane verzije nit radnika je isporučivala odgovor s TThread.Queue. Queue postavlja odgovor u red glavne niti i odmah se vraća, što zvuči kao upravo ono što futur koji se lansira i zaboravlja želi. Bilo je pogrešno, i razlog je vrijedno objasniti jer je to vrsta greške koja prolazi svaki test koji mislite napisati
Nit radnika je kreirana s FreeOnTerminate := True. To znači da čim se Execute vrati, nit sama sebe ruši, i TThread.Destroy poziva RemoveQueuedEvents(Self) kao dio čišćenja. RemoveQueuedEvents prazni sve metode u redu čiji je cilj nit koja umire. Pa je redoslijed bio: radnik završi, postavi odgovor u red protiv sebe, Execute se vrati, nit sama sebe uništi, i RemoveQueuedEvents briše odgovor koji glavna nit još nije pokrenula. Rezultat je jednostavno nestao. Još gore, u uskom prozoru gdje je glavna nit povukla odgovor iz reda i počela ga pokretati u istom trenutku kada je nit bila oslobođena, odgovor je dodirivao polja napola uništenog objekta, što je use-after-free
Ispravak u v1.61.0 bio je isporučiti odgovor s Synchronize umjesto Queue. Synchronize blokira nit radnika dok glavna nit nije pokrenula odgovor do završetka. Radnik je još živ dok se odgovor izvršava, pa nema ničega što bi se oslobodilo ispod njega, i nit se ne vraća iz Execute-a (i stoga ne počinje sama sebe uništavati) dok odgovor nije isporučen. Isporuka je garantovana, i prozor use-after-free 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ća lekcija nadživi specifičan ispravak. Asinhrone povratne pozive koji se lansiraju i zaboravljaju najlakše je suptilno pogriješiti, jer sretna staza radi pri prvom pokušaju i greška živi u interakciji između redoslijeda rušenja niti i reda. Ne reproducira se na zahtjev. Ovisi o tome da li se glavna nit slučajno ispraznila red prije nego što je radnik slučajno završio samo-uništenje, što je tajming koji rasporeďivač odlučuje drugačije pri svakom pokretanju. Primitiv koji je jednom ispravan, u bindingu, vrijedi daleko više od istog koda ponovo izvedenog u svakoj aplikaciji koja treba pozadinsko renderovanje
Zašto su callback-i pokazivači metoda
Radnik i odgovor nisu anonimne metode. Oni su tipovi procedure of object, TPdfFutureWorker<T> i TPdfFutureReply<T>, i taj izbor je nametnut matricom kompajlera. PDFiumPas se kompajlira na Delphi XE5 i kasnije i na Free Pascal 3.2 u Delphi modu, a FPC 3.2 u tom modu ne podržava anonimne metode. Callback s referencom na proceduru koji hvata lokalne varijable bi se kompajlirao na Delphi-ju i padao na FPC-u, pa jedinica koristi najmanji zajednički nazivnik koji oba kompajlera prihvataju
Praktična posljedica je gdje stanje živi. Anonimna metoda zatvara lokalne varijable; pokazivač metode ne. Pa svako stanje koje radnik treba, indeks stranice, zum, putanja izlaza, i svako stanje koje odgovor treba ažurirati, ciljana kontrola slike ili oznaka napretka, mora visiti na objektu čija metoda se predaje. U preglednom programu taj objekt je obično forma ili kontroler renderovanja koji ona posjeduje. Ovo nije zaobilazno rješenje nametnuto nevoljko; drži vlasništvo tog stanja eksplicitnim i vidljivim na primajućem objektu umjesto skrivenim unutar closure-a
Kooperativno otkazivanje, ne tvrdo ubijanje
Otkazivanje ovdje je kooperativno. Ne postoji API koji doseže u nit radnika i terminira je, jer terminiranje niti usred renderovanja ostavlja PDFium s bravama i djelomično napisanim bitmapama, i stanje procesa nakon prisilnog ubijanja nije nešto o čemu možete rasuđivati. Umjesto toga radniku se predaje token samo za čitanje i očekuje se da ga provjeri, a petlja renderovanja je napisana da ga provjerava između stranica ili između ploča, gdje je zaustavljanje čisto
Token nudi tri načina za promatranje otkazivanja. IsCancelled je jeftino boolean ispitivanje za petlju koja želi testirati i sama odlučiti. ThrowIfCancelled je uobičajeni slučaj: pozovite ga na prirodnoj točki otkazivanja i, ako je otkazivanje zatraženo, izbacuje EPdfOperationCancelled, koji odmotava radnika pravo natrag u futur. RegisterCallback priključuje jednokratnu obavijest koja se okida jednom kada je izvor otkazan, korisno kada je radnik blokiran u nečemu što može prekinuti umjesto da sjedi u čvrstoj petlji
Iznimka je gdje granica niti ima značaj. Kada radnik izbaci EPdfOperationCancelled, futur ga hvata i pretvara u otkazani status, pa odgovor vidi IsCancelled a ne kvar. Sam objekt iznimke nikad nije marshalovan na glavnu nit. Živi i umire na niti radnika; samo njegova poruka stringa je kopirana u ErrorMessage. Marshalovanje živog objekta iznimke između niti bi značilo dosezanje u memoriju u vlasništvu niti koja završava, što je ista klasa greške za čije sprečavanje Synchronize ispravak postoji. Status kod i string prelaze granicu čisto; objekt ne bi
Dva interfejsa, pa radnik ne može sam sebe otkazati
Otkazivanje je namjerno podijeljeno na dva interfejsa. IPdfCancellationTokenSource je strana pisanja: ima Cancel, i vlasnik koji ga kreira, obično forma, drži ga i poziva Cancel kada korisnik klikne dugme ili se forma zatvori. IPdfCancellationToken je strana čitanja: ima IsCancelled, ThrowIfCancelled, i RegisterCallback, i to je sve što radnik ikad prima. Jedan konkretan objekt implementira oboje, ali radniku se uvijek predaje samo token, pa nema načina da otkaže operaciju koju izvodi. Podjela je zaštita na nivou API-ja. Radnik koji bi mogao dosegnuti Cancel kroz token bi pozvao konfuzan dio koda da sam sebe otkaže, i sistem tipova uklanja tu mogućnost
Postoji odgovarajući detalj za slučaj gdje pozivalac želi renderovanje ali nikad ne namjerava otkazati. Umjesto prisiljavanja novog izvora po pozivu, jedinica izlaže PdfNoCancellationToken, singleton token koji je trajno u stanju neotkazivanja. Run ga zamjenjuje kada se argument token ostavi nil. Taj singleton se konstruira požurno tokom inicijalizacije jedinice umjesto ljenivo pri prvoj upotrebi, i razlog je ponovo konkurentnost. Da bi nekoliko Run poziva na različitim nitima radnika sve dosegnulo za lijeno kreirani singleton odjednom, mogli bi se utrkivati u njegovoj konstrukciji, procuriti duplikat, ili kratko promatrati napola inicijaliziranu instancu. Izgradnjom ga prije nego što bilo koji radnik može pokrenuti uklanja se utrka u potpunosti
Pokretanje otkazivog renderovanja
U praksi kreirate izvor, držite ga na formi, predajete njegov Token u Run uz metodu radnika i metodu odgovora, i spajate dugme Otkazi s izvorom. Radnik provjerava token dok renderuje; odgovor ažurira UI kada je rezultat natrag. Jer su callback-i pokazivači metoda, radnik i odgovor čitaju što god trebaju iz polja forme
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 dostižna. Završeno renderovanje prijavljuje uspjeh, korisnik koji je pritisnuo Otkazi vidi otkazanu granu, i datoteka koja nije mogla biti napisana ili stranica koja nije mogla biti parsirana stiže kao kvar s porukom. Nijedna od tih grana ne blokira, nijedna ne dodiruje nit radnika, i bitmapa ili status koji je radnik proizveo čita se samo nakon što je futur isporučio na niti koja posjeduje UI
Ista nit-sigurnosna disciplina se isplati drugdje u preglednom programu. Način na koji su renderovane bitmape zadržavane i ponovo korištene kroz promjene zuma je obrađen u našoj napomeni o kešu renderovanja i performansama zuma, a šire pitanje sigurnog čuvanja PDFium granice pod Delphi-jem je u ojačavanju PDFium VCL ABI-ja za sigurnost memorije. Asinhrana infrastruktura opisana ovdje isporučuje se kao dio PDFium Component-a za Delphi i C++Builder, uz renderovanje, tekst i form API-je obrađene drugdje na ovom blogu