Randarea unei pagini în PDFium este o operațiune sincronă. Apelați biblioteca, aceasta realizează rasterizarea într-o imagine bitmap transmisă, iar controlul revine după ce pixelii au fost scriși. Pentru o singură pagină de dimensiunea ecranului la un singur nivel de zoom, acest proces durează câteva milisecunde și este insesizabil. Pentru un export la 300 dpi al unui document de 200 de pagini, sau pentru o bandă de miniaturi care trebuie să rasterizeze toate paginile simultan, același apel durează secunde. Dacă efectuați acest apel din firul de execuție principal (main thread), bucla de mesaje se blochează, fereastra nu se mai redesenează, iar Windows va afișa mesajul „Not Responding” pe bara de titlu. Activitatea este corectă, dar locul în care a fost executată este greșit
Soluția este mutarea randării lungi pe un fir de execuție secundar în fundal și transmiterea rezultatului înapoi pe firul principal, unde imaginea bitmap poate fi atribuită unui control vizual. PDFium permite acest lucru, dar legătura (binding) trebuie să securizeze transferul, deoarece erorile de tipul „rulează pe un thread worker, răspunde în interfața grafică UI” sunt diverse, iar eșecurile sunt intermitente. Unitatea FPdfAsync din PDFiumPas oferă o implementare corectă a acestui model, folosind un sistem de anulare adaptat comportamentului randărilor de lungă durată
Structura activității
Trei operațiuni sunt specifice situațiilor în care randarea durează mai mult de un cadru. Randarea în loturi (batch rendering) parcurge un interval de pagini și le rasterizează pe fiecare, de obicei pe disc. Exportul pe mai multe pagini face același lucru, dar asamblează rezultatul într-un singur fișier. Randarea paginii în fundal este utilizată de vizualizatoare când utilizatorul accesează o pagină care nu se află în memoria cache, imaginea bitmap fiind creată pe un fir secundar și afișată când este gata. Toate cele trei operațiuni au aceleași limitări. Rulează suficient de mult încât firul de execuție UI să nu le poată găzdui, produc un rezultat necesar ulterior în UI și pot fi abandonate de utilizator. Închiderea documentului, derularea peste pagină sau apăsarea butonului Anulare ar trebui să oprească procesul, în loc să oblige utilizatorul să aștepte un rezultat pe care nu îl mai dorește
Această ultimă limitare determină designul sistemului. O randare care nu poate fi anulată menține documentul deschis și consumă resurse CPU deși rezultatul nu mai este util. Prin urmare, unitatea este construită în jurul a două primitive corelate: o structură future care returnează rezultatul și un token care transmite solicitarea de anulare
O structură future de tip asincron
Metoda TPdfFuture<T>.Run primește un proces worker, un răspuns și, opțional, un token de anulare. Porneste procesul worker pe un fir de execuție secundar, iar când acesta se finalizează, transmite răspunsul pe firul principal. Parametrul generic T reprezintă rezultatul randării, adesea un handle de bitmap sau o structură de stare. Workerul rulează în fundal; răspunsul rulează acolo unde interacțiunea cu biblioteca VCL este sigură
Absența unei metode de tip Wait este intenționată. Nu există nicio metodă de blocare a apelantului până când operațiunea se finalizează, iar aceasta nu este o scăpare. Apelarea Wait din firul principal reprezintă modul clasic de a bloca interfața (deadlock): workerul are nevoie de firul principal pentru a-și rula răspunsul prin Synchronize, în timp ce firul principal este blocat în interiorul Wait, blocând ambele părți. Evitând oferirea acestei primitive, structura future exclude modelul care cauzează cele mai multe probleme la scrierea manuală a acestui cod. Codul care are cu adevărat nevoie de blocare ar trebui să utilizeze un TThread simplu și să își asume riscurile. Structura future este concepută pentru modelul asincron, caracteristic randării în fundal
Problema de sincronizare din v1.61.0 care a modificat livrarea răspunsului
Cea mai importantă parte a acestei unități este o modificare de o singură linie, care a fost necesară timp pentru a fi înțeleasă. În primele versiuni, firul de execuție worker transmitea răspunsul prin TThread.Queue. Metoda Queue adaugă răspunsul în coada firului principal și revine imediat, comportament ideal pentru o structură asincronă. Cu toate acestea, abordarea a fost greșită, iar motivul merită explicat, deoarece este genul de eroare care trece de toate testele obișnuite
Firul worker este creat cu setarea FreeOnTerminate := True. Aceasta înseamnă că imediat ce funcția Execute revine, firul se dezalocă, iar TThread.Destroy apelează RemoveQueuedEvents(Self) la curățare. RemoveQueuedEvents elimină orice metodă din coadă al cărei obiect țintă este firul care se închide. Astfel, ordinea era: workerul se finaliza, adăuga răspunsul în coadă referindu-se la el însuși, Execute revenea, firul se distrugea, iar RemoveQueuedEvents ștergea răspunsul pe care firul principal nu îl rulase încă. Rezultatul dispărea pur și simplu. Mai grav, în intervalul scurt în care firul principal prelua răspunsul din coadă și începea execuția exact când firul secundar se elibera, răspunsul accesa câmpuri ale unui obiect pe jumătate distrus, generând o eroare de tip use-after-free
Corecția din versiunea v1.61.0 a constat în trimiterea răspunsului prin Synchronize în loc de Queue. Metoda Synchronize blochează firul worker până când firul principal rulează complet răspunsul. Workerul rămâne activ în timp ce se execută răspunsul, evitându-se eliberarea memoriei sale, iar firul nu revine din Execute (deci nu începe autodistrugerea) până când răspunsul nu este livrat. Livrarea este garantată, iar eroarea use-after-free este eliminată
Lecția generală depășește această corecție specifică. Apelurile asincrone de tip asociază-și-uită (fire-and-forget) reprezintă cel mai simplu model de concurență care poate eșua subtil, deoarece execuția simplă funcționează de la prima încercare, iar eroarea apare în interacțiunea dintre ordinea de eliberare a firelor și coada de mesaje. Nu se reproduce la cerere. Depinde dacă firul principal a golit coada înainte ca workerul să se distrugă, o coordonare temporală pe care planificatorul (scheduler) o stabilește diferit la fiecare rulare. O primitivă corectă, integrată în legătură, este mai valoroasă decât rescrierea aceluiași cod în fiecare aplicație care necesită randare în fundal
De ce apelurile callback sunt pointeri de metode
Metodele worker și reply nu sunt metode anonime. Acestea sunt de tip procedure of object, respectiv TPdfFutureWorker<T> și TPdfFutureReply<T>, alegere impusă de matricea de compilatoare suportate. PDFiumPas se compilează pe Delphi XE5 și versiunile ulterioare, precum și pe Free Pascal 3.2 în modul Delphi, iar FPC 3.2 în acest mod nu acceptă metodele anonime. Un callback definit ca referință la procedură care capturează variabile locale s-ar compila în Delphi, dar ar eșua în FPC, astfel încât unitatea utilizează cel mai mic numitor comun acceptat de ambele compilatoare
Consecința practică vizează locul în care este stocată starea. O metodă anonimă capturează variabilele locale (closure); un pointer de metodă nu face acest lucru. Prin urmare, orice stare necesară workerului — indexul paginii, zoom-ul, calea de ieșire — și orice stare pe care reply-ul trebuie să o actualizeze — controlul de imagine țintă sau eticheta de progres — trebuie să fie definite în obiectul al cărui pointer de metodă este transmis. Într-un vizualizator, acest obiect este de obicei formularul sau un controler de randare deținut de acesta. Aceasta nu este o soluție de compromis, ci menține proprietatea stării explicită și vizibilă pe obiectul receptor, în loc să o ascundă într-o structură closure
Anulare cooperativă, nu oprire forțată
Anularea procesului este cooperativă. Nu există un API care să intervină în firul secundar pentru a-l opri forțat, deoarece oprirea unui fir în mijlocul randării lasă PDFium cu blocaje (locks) active și imagini parțial scrise, starea procesului devenind instabilă. În schimb, workerul primește un token read-only și trebuie să îl verifice, iar bucla de randare este configurată să facă această verificare între pagini sau între dale (tiles), unde oprirea se face în mod curat
Tokenul oferă trei moduri de a detecta anularea. IsCancelled este o verificare booleană rapidă pentru o buclă care dorește să testeze starea. Metoda ThrowIfCancelled este cazul cel mai comun: se apelează într-un punct natural de anulare și, dacă s-a solicitat anularea, generează excepția EPdfOperationCancelled, care oprește workerul direct. RegisterCallback asociază o notificare unică ce se declanșează când sursa este anulată, utilă când un worker este blocat într-o operațiune pe care o poate întrerupe, în loc să ruleze într-o buclă strânsă
Tratarea excepțiilor ține cont de limita dintre firele de execuție. Când workerul generează EPdfOperationCancelled, structura future o interceptează și o transformă într-o stare de anulare, astfel încât reply-ul detectează starea IsCancelled, nu un eșec. Obiectul excepției nu este niciodată transmis pe firul principal. Acesta este creat și eliberat pe firul worker; doar textul mesajului este copiat în ErrorMessage. Transmiterea unui obiect de excepție activ între fire ar însemna accesarea memoriei deținute de un fir care se închide, greșeală similară cu cea pe care corecția cu Synchronize o elimină. Un cod de stare și un șir de caractere traversează limita în siguranță; un obiect nu ar face acest lucru
Două interfețe, astfel încât un worker să nu se poată anula singur
Procesul de anulare este divizat intenționat în două interfețe. IPdfCancellationTokenSource reprezintă partea de scriere: conține metoda Cancel, iar obiectul părinte care o creează, de obicei formularul, o reține și apelează Cancel când utilizatorul face clic pe buton sau când formularul se închide. IPdfCancellationToken reprezintă partea de citire: conține proprietatea IsCancelled, ThrowIfCancelled și RegisterCallback, acesta fiind singurul element pe care îl primește workerul. Un singur obiect implementează ambele interfețe, dar workerului îi este transmis doar tokenul, neavând posibilitatea de a anula operațiunea aflată în derulare. Această divizare este o regulă de siguranță la nivel de API. Un worker care ar putea accesa Cancel prin intermediul tokenului său ar permite codului să se autoanuleze, iar sistemul de tipuri elimină această posibilitate
Există o abordare dedicată și pentru cazul în care un apelant dorește randarea, dar nu intenționează să o anuleze. Pentru a evita crearea unei surse noi la fiecare apel, unitatea oferă PdfNoCancellationToken, un token de tip singleton aflat permanent în starea de neanulare. Metoda Run îl utilizează când argumentul tokenului este nil. Acest singleton este creat la inițializarea unității, nu la prima utilizare, din motive ce țin tot de concurență. Dacă mai multe apeluri Run de pe fire worker diferite ar accesa simultan un singleton creat la cerere, ar apărea probleme de concurență la construcție, scurgeri de memorie sau instanțe parțial inițializate. Crearea lui înainte ca orice worker să ruleze elimină aceste riscuri
Rularea unei randări anulabile
În practică, creați o sursă, o păstrați pe formular, transmiteți elementul Token în Run alături de o metodă worker și o metodă reply și asociați butonul Cancel la sursă. Workerul verifică tokenul în timpul randării; reply-ul actualizează interfața grafică UI odată ce rezultatul este returnat. Deoarece apelurile callback sunt pointeri de metode, workerul și reply-ul citesc informațiile necesare din câmpurile formularului
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;
Metoda reply gestionează toate cele trei rezultate, deoarece toate sunt posibile. O randare finalizată indică succesul, un utilizator care a apăsat butonul Cancel activează ramura de anulare, iar un fișier care nu a putut fi scris sau o pagină care nu a putut fi parcată generează un eșec cu un mesaj corespunzător. Niciuna dintre aceste ramuri nu blochează execuția, niciuna nu interacționează cu firul worker, iar bitmap-ul sau starea produsă este citită doar după ce structura future a livrat-o pe firul de execuție care deține interfața UI
Aceeași disciplină a firelor de execuție aduce beneficii și în alte zone ale unui vizualizator. Modul în care imaginile bitmap randate sunt păstrate și reutilizate la modificarea zoom-ului este descris în nota noastră despre cache-ul de randare și performanța zoom-ului, iar aspectele mai largi legate de securizarea limitei PDFium în Delphi sunt tratate în articolul despre securizarea ABI-ului PDFium Component pentru siguranța memoriei. Infrastructura asincronă descrisă aici este livrată ca parte a pachetului PDFium Component pentru Delphi și C++Builder, alături de API-urile de randare, text și formulare prezentate pe acest blog