Articol Tehnic

Randarea PDF în Fundal în Delphi cu Futures Anulabile

Randarea unei pagini în PDFium este sincronă. Apelați biblioteca, aceasta rasterizează într-un bitmap pe care i l-ați înmânat, iar controlul revine când pixelii sunt scriși. Pentru o singură pagină de dimensiunea ecranului la un nivel de zoom, durează câteva milisecunde și nimeni nu observă. Pentru un export la 300 dpi al unui document de 200 de pagini, sau un strip de miniaturi care trebuie să rasterizeze fiecare pagină simultan, același apel costă secunde. Dacă faceți acel apel din firul principal, bucla de mesaje se oprește, fereastra încetează să se repicteze, iar Windows afișează temutul "Not Responding" deasupra barei de titlu. Munca este corectă. Locul în care ați rulat-o este greșit

Soluția este să mutați randarea lungă pe un fir de fundal și să aduceți rezultatul înapoi la firul principal, unde bitmap-ul poate fi înmânat unui control. PDFium în sine nu vă oprește să faceți acest lucru, dar binding-ul trebuie să facă handoff-ul sigur, deoarece suprafața de erori din jurul modelului "rulați pe un worker, răspundeți pe UI" este mare iar eșecurile sunt intermitente. Unitatea FPdfAsync din PDFiumPas există pentru a oferi acel tipar o singură implementare corectă, cu un model de anulare care se potrivește cu modul în care se comportă de fapt o randare lungă

Forma muncii

Trei operații domină cazurile în care o randare depășește un cadru. Randarea în lot parcurge un interval de pagini și rasterizează fiecare pagină, de obicei pe disc. Exportul multiplu de pagini face același lucru, dar asamblează ieșirea într-un singur fișier. Randarea paginii în fundal este ceea ce face un vizualizator când utilizatorul sare la o pagină care nu se află încă în cache, deci bitmap-ul este produs off-thread și afișat când este gata. Toate trei au aceleași constrângeri. Rulează suficient de mult timp încât firul UI nu le poate găzdui, produc un rezultat de care firul UI are nevoie în cele din urmă, iar utilizatorul le poate abandona. Închiderea documentului, derularea peste pagină sau apăsarea Cancel ar trebui să oprească munca în loc să forțeze utilizatorul să aștepte o ieșire pe care nu o mai doresc

Ultima constrângere este cea care modelează proiectarea. O randare care nu poate fi anulată este o randare care menține documentul deschis și consumă CPU după ce răspunsul nu mai contează. Deci unitatea este construită în jurul a două primitive care se compun: un future care transportă rezultatul înapoi, și un token care transportă cererea de anulare înainte

Un future de tip fire-and-forget

TPdfFuture<T>.Run ia un worker, un reply, și un token de anulare opțional. Pornește worker-ul pe un fir de fundal, iar când worker-ul termină, livrează reply-ul pe firul principal. Parametrul generic T este ceea ce produce randarea, adesea un handle de bitmap sau o înregistrare de stare. Worker-ul rulează off-thread; reply-ul rulează unde este sigur să atingeți VCL-ul

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

Omisiunea deliberată este orice fel de Wait. Nu există nicio metodă pentru a bloca apelantul până când future-ul se completează, și acesta nu este un accident. Un Wait apelat din firul principal este modul clasic de a bloca o UI: worker-ul are nevoie de firul principal pentru a rula reply-ul prin Synchronize, firul principal este parcat în interiorul lui Wait, și niciuna dintre părți nu poate continua. Refuzând să ofere primitiva, future-ul exclude tiparul care îi depășește cel mai adesea pe cei care încearcă să scrie asta ei înșiși. Codul care are cu adevărat nevoie să blocheze ar trebui să folosească un TThread simplu și să-și asume consecințele. Future-ul este pentru cazul fire-and-forget, care este ceea ce este de fapt randarea în fundal

Rezultatul este împachetat în TPdfFutureResult<T>, o înregistrare care îi spune reply-ului care dintre cele trei lucruri s-a întâmplat. IsSuccess înseamnă că worker-ul a returnat normal și Value conține randarea. IsCancelled înseamnă că token-ul s-a declanșat și worker-ul a ieșit la un punct de anulare. IsFailure înseamnă că worker-ul a aruncat o excepție, iar ErrorMessage poartă textul. Reply-ul inspectează starea o singură dată și ramifică, în loc să ghicească dintr-o valoare sentinel dacă un bitmap returnat este real

Condiția de cursă din v1.61.0 care a schimbat livrarea reply-ului

Cea mai instructivă parte a acestei unități este o schimbare pe o linie care a durat ceva timp pentru a fi înțeleasă. Prin versiunile timpurii, firul worker livra reply-ul cu TThread.Queue. Queue postează reply-ul în coada firului principal și returnează imediat, ceea ce pare exact ce vrea un future fire-and-forget. Era greșit, iar motivul merită explicat deoarece este tipul de eroare care trece fiecare test pe care vă gândiți să-l scrieți

Firul worker este creat cu FreeOnTerminate := True. Aceasta înseamnă că în momentul în care Execute returnează, firul se autodistruge, iar TThread.Destroy apelează RemoveQueuedEvents(Self) ca parte a curățeniei. RemoveQueuedEvents șterge orice metodă în coadă al cărei țintă este firul care moare. Deci secvența era: worker-ul termină, pune reply-ul în coadă față de el însuși, Execute returnează, firul se distruge, și RemoveQueuedEvents șterge reply-ul pe care firul principal nu îl rulase încă. Rezultatul pur și simplu dispărea. Mai rău, în fereastra îngustă în care firul principal scotea reply-ul din coadă și începea să-l ruleze în același moment în care firul era eliberat, reply-ul atingea câmpurile unui obiect pe jumătate distrus, ceea ce este un use-after-free

Soluția din v1.61.0 a fost să livreze reply-ul cu Synchronize în loc de Queue. Synchronize blochează firul worker până când firul principal a rulat reply-ul până la finalizare. Worker-ul este încă viu în timp ce reply-ul său se execută, deci nu există nimic de eliberat de sub el, iar firul nu returnează din Execute (și prin urmare nu începe să se distrugă) până când reply-ul a fost livrat. Livrarea este garantată, iar fereastra use-after-free este închisă

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;

Lecția generală depășește soluția specifică. Callback-urile asincrone fire-and-forget sunt cel mai ușor tipar de concurență de greșit subtil, deoarece calea fericită funcționează la prima încercare, iar eroarea trăiește în interacțiunea dintre ordinea de teardown a firului și coadă. Nu se reproduce la cerere. Depinde de dacă firul principal s-a întâmplat să golească coada înainte ca worker-ul să se fi întâmplat să termine distrugerea sa, care este un timing pe care planificatorul îl decide diferit la fiecare rulare. O primitivă care este corectă o singură dată, în binding, valorează mult mai mult decât același cod re-derivat în fiecare aplicație care are nevoie de o randare în fundal

De ce callback-urile sunt pointeri la metode

Worker-ul și reply-ul nu sunt metode anonime. Sunt tipuri procedure of object, TPdfFutureWorker<T> și TPdfFutureReply<T>, iar acea alegere este impusă de matricea compilatoarelor. PDFiumPas compilează pe Delphi XE5 și versiunile ulterioare și pe Free Pascal 3.2 în modul Delphi, iar FPC 3.2 în acel mod nu suportă metode anonime. Un callback de referință la procedură care captează variabile locale s-ar compila pe Delphi și ar eșua pe FPC, deci unitatea folosește cel mai mic numitor comun pe care ambele compilatoare îl acceptă

Consecința practică este unde trăiește starea. O metodă anonimă se închide peste variabile locale; un pointer la metodă nu. Deci orice stare de care are nevoie worker-ul, indexul paginii, zoom-ul, calea de ieșire, și orice stare pe care reply-ul trebuie să o actualizeze, controlul imaginii țintă sau eticheta de progres, trebuie să atârne de obiectul a cărui metodă este pasată. Într-un vizualizator, acel obiect este de obicei formularul sau un controler de randare pe care îl deține. Acesta nu este un workaround impus cu reluctanță; menține proprietatea acelei stări explicită și vizibilă pe obiectul receptor în loc să fie ascunsă în interiorul unui closure

Anulare cooperativă, nu o terminare forțată

Anularea aici este cooperativă. Nu există niciun API care ajunge în firul worker și îl termină, deoarece terminarea unui fir în mijlocul randării lasă PDFium să dețină lacăte și bitmap-uri parțial scrise, iar starea procesului după o terminare forțată nu este ceva despre care puteți raționa. În schimb, worker-ului i se înmânează un token read-only și se așteaptă să-l verifice, iar bucla de randare este scrisă să-l verifice între pagini sau între tile-uri, unde oprirea este curată

Token-ul oferă trei moduri de a observa anularea. IsCancelled este o sondare booleană ieftină pentru o buclă care vrea să testeze și să decidă singură. ThrowIfCancelled este cazul obișnuit: apelați-l la un punct natural de anulare și, dacă anularea a fost solicitată, ridică EPdfOperationCancelled, care derulează worker-ul direct înapoi la future. RegisterCallback atașează o notificare one-shot care se declanșează o dată când sursa este anulată, utilă când un worker este blocat în ceva ce poate întrerupe în loc să stea într-o buclă strânsă

Excepția este locul unde contează limita firului. Când worker-ul ridică EPdfOperationCancelled, future-ul o prinde și o transformă într-un status anulat, deci reply-ul vede IsCancelled și nu un eșec. Obiectul excepție în sine nu este niciodată marshaled la firul principal. Trăiește și moare pe firul worker; doar șirul său de mesaj este copiat în ErrorMessage. Marshaling-ul unui obiect excepție activ între fire ar însemna accesarea memoriei deținute de un fir care se termină, care este aceeași clasă de greșeală pe care soluția Synchronize există să o prevină. Un cod de stare și un șir trec granița în mod curat; un obiect nu ar face-o

Două interfețe, astfel încât un worker nu se poate anula singur

Anularea este împărțită intenționat între două interfețe. IPdfCancellationTokenSource este partea de scriere: are Cancel, iar proprietarul care o creează, de obicei formularul, o păstrează și apelează Cancel când utilizatorul face clic pe buton sau formularul se închide. IPdfCancellationToken este partea de citire: are IsCancelled, ThrowIfCancelled și RegisterCallback, și asta este tot ce primește vreodată worker-ul. Un singur obiect concret implementează ambele, dar worker-ul primește întotdeauna numai token-ul, deci nu are nicio modalitate de a anula operația pe care o rulează. Împărțirea este o barieră de protecție la nivel API. Un worker care ar putea ajunge la Cancel prin token-ul său ar invita un cod confuz să se anuleze pe sine, iar sistemul de tipuri elimină posibilitatea

Există un detaliu potrivit pentru cazul în care un apelant vrea o randare, dar nu intenționează niciodată să o anuleze. În loc să forțeze o sursă nouă per apel, unitatea expune PdfNoCancellationToken, un token singleton care este permanent în starea ne-anulată. Run îl substituie când argumentul token este lăsat nil. Acel singleton este construit devreme în timpul inițializării unității, nu leneș la prima utilizare, iar motivul este din nou concurența. Dacă mai multe apeluri Run pe fire worker diferite ar ajunge simultan la un singleton creat leneș, ar putea intra în condiție de cursă la construcția sa, ar putea scurge un duplicat sau ar putea observa temporar o instanță pe jumătate inițializată. Construindu-l înainte ca orice worker să poată rula, elimină condiția de cursă complet

Rularea unei randări anulabile

În practică creați o sursă, o păstrați pe formular, treceți Token-ul ei în Run alături de o metodă worker și o metodă reply, și conectați butonul Cancel la sursă. Worker-ul verifică token-ul în timp ce randează; reply-ul actualizează UI-ul odată ce rezultatul revine. Deoarece callback-urile sunt pointeri la metode, worker-ul și reply-ul citesc ce au nevoie 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;

Reply-ul gestionează toate cele trei rezultate deoarece toate trei sunt accesibile. O randare terminată raportează succes, un utilizator care a apăsat Cancel vede ramura anulată, iar un fișier care nu a putut fi scris sau o pagină care nu a putut fi analizată ajunge ca un eșec cu un mesaj. Niciuna dintre acele ramuri nu blochează, niciuna nu atinge firul worker, iar bitmap-ul sau starea pe care worker-ul le-a produs este citit(ă) numai după ce future-ul l-a livrat pe firul care deține UI-ul

Aceeași disciplină de threading este profitabilă și în altă parte într-un vizualizator. Modul în care bitmap-urile randate sunt păstrate și reutilizate la schimbările de zoom este acoperit în nota noastră despre cache-ul de randare și performanța la zoom, iar întrebarea mai largă despre menținerea limitei PDFium sigure sub Delphi este în întărirea ABI-ului PDFium VCL pentru siguranța memoriei. Infrastructura async descrisă aici este livrată ca parte a componentei PDFium Component pentru Delphi și C++Builder, alături de API-urile de randare, text și formulare acoperite în altă parte pe acest blog