Technical Article

Fono PDF atvaizdavimas Delphi aplinkoje su atšaukiamais Futures

Puslapio atvaizdavimas (rendering) PDFium bibliotekoje yra sinchroninis. Jūs iškviečiate biblioteką, ji rasterizuoja į jūsų perduotą bitų žemėlapį (bitmap), ir valdymas grįžta tada, kai pikseliai yra įrašyti. Vienam ekrano dydžio puslapiui, esant vienam masteliui, tai trunka kelias milisekundes ir niekas to nepastebi. 300 dpi 200 puslapių dokumento eksportui, ar miniatiūrų juostai, kuri turi rasterizuoti kiekvieną puslapį vienu metu, tas pats iškvietimas kainuoja sekundes. Jei atliekate tą iškvietimą iš pagrindinės gijos (main thread), pranešimų ciklas sustoja, langas nustoja persipiešti, ir Windows jūsų pavadinimo juostoje nupiešia bauginantį „Not Responding“ (Neatsako). Darbas yra teisingas. Vieta, kur jį paleidote, yra neteisinga

Sprendimas yra perkelti ilgą atvaizdavimą į fono giją (background thread) ir grąžinti rezultatą į pagrindinę giją, kur bitų žemėlapis gali būti perduotas valdikliui (control). Pats PDFium netrukdo jums to daryti, tačiau susiejimas (binding) turi padaryti perdavimą saugų, nes klaidų paviršius aplink „vykdyk darbinėje gijoje (worker), atsakyk UI“ yra platus, o gedimai – nepastovūs. PDFiumPas komponente esantis FPdfAsync modulis egzistuoja tam, kad suteiktų šiam šablonui vieną teisingą realizaciją, su atšaukimo modeliu, atitinkančiu tai, kaip ilgas atvaizdavimas iš tikrųjų veikia

Darbo forma

Trys operacijos dominuoja atvejuose, kai atvaizdavimas trunka ilgiau nei vieną kadrą. Paketinis atvaizdavimas (batch rendering) pereina puslapių diapazoną ir rasterizuoja kiekvieną puslapį, paprastai į diską. Kelių puslapių eksportavimas daro tą patį, bet surenka išvestį į vieną failą. Fono puslapio atvaizdavimas yra tai, ką daro peržiūros programa (viewer), kai vartotojas peršoka į puslapį, kurio dar nėra talpykloje (cache), todėl bitų žemėlapis sukuriamas ne pagrindinėje gijoje (off-thread) ir parodomas, kai yra paruoštas. Visoms trims galioja tie patys apribojimai. Jos veikia pakankamai ilgai, kad UI gija negalėtų jų talpinti, jos sukuria rezultatą, kurio UI gijai galiausiai prireiks, ir vartotojas gali jas nutraukti. Dokumento uždarymas, puslapio praleidimas slenkant arba atšaukimo („Cancel“) paspaudimas turėtų sustabdyti darbą, užuot vertus vartotoją laukti išvesties, kurios jis daugiau nenori

Tas paskutinis apribojimas ir formuoja dizainą. Atvaizdavimas, kurio negalima atšaukti, yra atvaizdavimas, kuris laiko dokumentą atidarytą ir degina CPU po to, kai atsakymas nustoja būti svarbus. Todėl modulis yra sukurtas aplink du suderinamus primityvus: future objektą, kuris grąžina rezultatą atgal, ir žetoną (token), kuris perneša atšaukimo užklausą į priekį

„Iššauk ir pamiršk“ (fire-and-forget) future objektas

TPdfFuture<T>.Run priima darbinę giją (worker), atsakymą (reply) ir neprivalomą atšaukimo žetoną (cancellation token). Jis paleidžia darbinę giją fone, o kai darbinė gija baigia, pristato atsakymą į pagrindinę giją. Generinis parametras T yra bet kas, ką atvaizdavimas sukuria, dažnai tai būna bitų žemėlapio rankena (bitmap handle) arba būsenos įrašas. Darbinė gija veikia fone (off-thread); atsakymas veikia ten, kur saugu liesti VCL

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

Tyčinis praleidimas yra bet koks Wait (laukimo) metodas. Nėra metodo, kuris blokuotų iškviestąjį (caller), kol future objektas baigsis, ir tai nėra neapsižiūrėjimas. Wait, iškviestas iš pagrindinės gijos, yra klasikinis būdas sukelti UI aklavietę (deadlock): darbinei gijai reikia pagrindinės gijos, kad paleistų savo atsakymą per Synchronize, pagrindinė gija yra sustabdyta (parked) Wait viduje, ir nė viena pusė negali tęsti. Atsisakant siūlyti šį primityvą, future objektas atmeta šabloną, kuris dažniausiai nugali žmones, bandančius tai parašyti patiems. Kodas, kuriam tikrai reikia blokuoti, turėtų naudoti paprastą TThread ir prisiimti pasekmes. Future objektas skirtas „iššauk ir pamiršk“ (fire-and-forget) atvejui, o tai iš tikrųjų ir yra fono atvaizdavimas

Rezultatas yra įvyniotas į TPdfFutureResult<T> – įrašą, kuris pasako atsakymui, kuris iš trijų dalykų nutiko. IsSuccess reiškia, kad darbinė gija grįžo normaliai ir Value saugo atvaizdavimą. IsCancelled reiškia, kad žetonas suveikė ir darbinė gija nutraukė darbą atšaukimo taške. IsFailure reiškia, kad darbinė gija iššaukė išimtį (raised), o ErrorMessage neša tekstą. Atsakymas kartą patikrina būseną ir išsišakoja (branches), užuot spėliojęs iš kontrolinės reikšmės (sentinel value), ar grąžintas bitų žemėlapis yra tikras

v1.61.0 lenktynės (race), kurios pakeitė atsakymo pristatymą

Pati pamokomiausia šio modulio dalis yra vienos eilutės pakeitimas, kurį prireikė šiek tiek laiko suprasti. Ankstesnėse versijose darbinė gija pristatydavo savo atsakymą naudodama TThread.Queue. Queue (eilė) įkelia atsakymą į pagrindinės gijos eilę ir grįžta iškart, o tai skamba lygiai taip, ko nori „iššauk ir pamiršk“ future objektas. Tai buvo neteisinga, ir priežastį verta paaiškinti detaliai, nes tai yra tokia klaida, kuri praeina kiekvieną testą, kurį sugalvojate parašyti

Darbinė gija sukuriama su nustatymu FreeOnTerminate := True. Tai reiškia, kad tą pačią akimirką, kai grįžta Execute, gija save sunaikina (tears itself down), o TThread.Destroy iškviečia RemoveQueuedEvents(Self) kaip valymo dalį. RemoveQueuedEvents išvalo bet kokį eilėje laukiantį metodą, kurio taikinys yra mirštanti gija. Taigi, seka buvo tokia: darbinė gija baigia, ji įkelia atsakymą į eilę prieš save pačią, Execute grįžta, gija sunaikina save, ir RemoveQueuedEvents ištrina atsakymą, kurio pagrindinė gija dar nespėjo paleisti. Rezultatas tiesiog dingdavo. Dar blogiau, tame siaurame lange, kai pagrindinė gija paimdavo eilėje laukiantį atsakymą ir pradėdavo jį vykdyti tuo pačiu metu, kai gija buvo atlaisvinama, atsakymas liesdavo pusiau sunaikinto objekto laukus, o tai yra use-after-free (naudojimas po atlaisvinimo) klaida

V1.61.0 versijos sprendimas buvo pristatyti atsakymą su Synchronize vietoj Queue. Synchronize blokuoja darbinę giją tol, kol pagrindinė gija įvykdo atsakymą iki galo. Darbinė gija vis dar gyva, kol vykdomas jos atsakymas, todėl nėra ką atlaisvinti jai iš po kojų, ir gija negrįžta iš Execute (ir todėl nepradeda savęs naikinti), kol atsakymas nėra pristatytas. Pristatymas garantuotas, ir use-after-free langas uždarytas

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;

Bendra pamoka išlieka ilgiau nei konkretus pataisymas. „Iššauk ir pamiršk“ (fire-and-forget) asinchroniniai atgaliniai iškvietimai (callbacks) yra lengviausiai subtiliai sugadinamas lygiagretumo (concurrency) šablonas, nes laimingas kelias (happy path) suveikia iš pirmo karto, o klaida gyvena sąveikoje tarp gijos sunaikinimo tvarkos ir eilės. Ji nepasikartoja pagal pareikalavimą. Tai priklauso nuo to, ar pagrindinė gija atsitiktinai ištuštino eilę prieš darbinės gijos susinaikinimą, o šį laiką (timing) planuoklis (scheduler) kiekvieną kartą nusprendžia skirtingai. Primityvas, kuris yra teisingas vieną kartą, susiejime (binding), yra vertas kur kas daugiau nei tas pats kodas, iš naujo išvestas kiekvienoje programoje, kuriai reikia fono atvaizdavimo

Kodėl atgaliniai iškvietimai (callbacks) yra metodų rodyklės (method pointers)

Darbinė gija ir atsakymas nėra anoniminiai metodai. Jie yra procedure of object tipo, TPdfFutureWorker<T> ir TPdfFutureReply<T>, ir šį pasirinkimą lėmė kompiliatorių matrica. PDFiumPas kompiliuojamas Delphi XE5 ir naujesnėse versijose bei Free Pascal 3.2 Delphi režimu, o FPC 3.2 tame režime nepalaiko anoniminių metodų. Rodyklės į procedūrą (reference-to-procedure) atgalinis iškvietimas, kuris fiksuoja vietinius kintamuosius (captures local variables), susikompiliuotų Delphi aplinkoje ir nepavyktų FPC, todėl modulis naudoja mažiausią bendrą vardiklį, kurį priima abu kompiliatoriai

Praktinė to pasekmė yra tai, kur gyvena būsena (state). Anoniminis metodas uždaro (closes over) vietinius kintamuosius; metodo rodyklė – ne. Todėl bet kokia būsena, kurios reikia darbinei gijai (puslapio indeksas, mastelis, išvesties kelias), ir bet kokia būsena, kurią atsakymui reikia atnaujinti (tikslinis vaizdo valdiklis ar progreso etiketė), turi kabėti ant objekto, kurio metodas yra perduodamas. Peržiūros programoje (viewer) tas objektas paprastai yra forma arba jos valdomas atvaizdavimo valdiklis (render controller). Tai nėra nenorom primestas apėjimas (workaround); tai išlaiko tos būsenos nuosavybę aiškią ir matomą priimančiame objekte, užuot slėpus ją uždarymo bloke (closure)

Kooperatyvus atšaukimas, o ne priverstinis nužudymas

Atšaukimas čia yra kooperatyvus. Nėra API, kuri pasiektų darbinę giją ir ją nutrauktų, nes gijos nutraukimas vidury atvaizdavimo palieka PDFium su užraktais (locks) ir iš dalies įrašytais bitų žemėlapiais, o proceso būsena po priverstinio nužudymo (forced kill) nėra kažkas, apie ką galite samprotauti. Vietoj to, darbinei gijai įteikiamas tik skaitymui skirtas žetonas (read-only token) ir tikimasi, kad ji jį patikrins, o atvaizdavimo ciklas parašytas taip, kad tikrintų jį tarp puslapių arba tarp plytelių (tiles), kur sustojimas yra švarus

Žetonas siūlo tris būdus stebėti atšaukimą. IsCancelled yra pigi loginė (boolean) apklausa ciklui, kuris nori pats patikrinti ir nuspręsti. ThrowIfCancelled yra įprastas atvejis: iškvieskite jį natūraliame atšaukimo taške ir, jei buvo paprašyta atšaukti, jis iššaukia EPdfOperationCancelled, kuris išvynioja (unwinds) darbinę giją tiesiai atgal į future objektą. RegisterCallback prijungia vienkartinį pranešimą, kuris suveikia vieną kartą, kai šaltinis yra atšaukiamas – naudinga, kai darbinė gija užblokuota kažkur, ką ji gali pertraukti, o ne sėdinti ankštame cikle (tight loop)

Išimtis (exception) yra ta vieta, kur gijų riba yra svarbi. Kai darbinė gija iššaukia EPdfOperationCancelled, future jį pagauna ir paverčia į atšauktą būseną (cancelled status), todėl atsakymas mato IsCancelled, o ne nesėkmę (failure). Pats išimties objektas niekada nėra maršrutizuojamas (marshaled) į pagrindinę giją. Jis gyvena ir miršta darbinėje gijoje; tik jo pranešimo eilutė nukopijuojama į ErrorMessage. Gyvo išimties objekto maršrutizavimas per gijas reikštų patekimą į atmintį, priklausančią baigiančiai gijai, o tai yra tos pačios klasės klaida, kurios išvengti egzistuoja Synchronize pataisymas. Būsenos kodas ir eilutė (string) ribą kerta švariai; objektas – ne

Dvi sąsajos (interfaces), kad darbinė gija negalėtų atšaukti pati savęs

Atšaukimas specialiai padalintas į dvi sąsajas (interfaces). IPdfCancellationTokenSource yra rašymo pusė: ji turi Cancel, ir savininkas, kuris ją sukuria (paprastai forma), pasilieka ją ir iškviečia Cancel, kai vartotojas spusteli mygtuką arba forma užsidaro. IPdfCancellationToken yra skaitymo pusė: ji turi IsCancelled, ThrowIfCancelled ir RegisterCallback, ir tai yra viskas, ką darbinė gija kada nors gauna. Vienas konkretus objektas realizuoja abi dalis, tačiau darbinei gijai įteikiamas tik žetonas, todėl ji neturi galimybės atšaukti vykdomos operacijos. Dalijimas yra API lygio apsauginis turėklas. Darbinė gija, galinti pasiekti Cancel per savo žetoną, pakviestų painų kodo gabalą atšaukti patį save, o tipų sistema (type system) pašalina šią galimybę

Yra atitinkama detalė atvejui, kai iškviestasis (caller) nori atvaizdavimo, bet niekada neketina jo atšaukti. Užuot privertus kurti naują šaltinį (source) kiekvienam iškvietimui, modulis atskleidžia PdfNoCancellationToken – vienatinį (singleton) žetoną, kuris nuolat yra neatšauktoje būsenoje. Run jį pakeičia, kai žetono argumentas paliekamas tuščias (nil). Šis vienatinis objektas sukuriamas iš anksto (eagerly) modulio inicijavimo metu, o ne tingiai (lazily) pirmojo naudojimo metu, ir priežastis vėlgi yra lygiagretumas (concurrency). Jei keli Run iškvietimai skirtingose darbinėse gijose vienu metu kreiptųsi į tingiai sukurtą vienatinį objektą, jie galėtų lenktyniauti dėl jo sukūrimo, nutekinti dublikatą arba trumpam pamatyti pusiau inicijuotą egzempliorių (instance). Sukūrus jį prieš bet kuriai darbinei gijai pradedant veikti, lenktynės (race) visiškai pašalinamos

Atšaukiamo atvaizdavimo paleidimas

Praktikoje jūs sukuriate šaltinį (source), pasiliekate jį formoje, perduodate jo Token į Run kartu su darbinės gijos metodu ir atsakymo metodu, bei prijungiate atšaukimo mygtuką („Cancel“) prie šaltinio. Darbinė gija tikrina žetoną atvaizdavimo metu; atsakymas atnaujina UI, kai tik grįžta rezultatas. Kadangi atgaliniai iškvietimai (callbacks) yra metodų rodyklės, darbinė gija ir atsakymas skaito viską, ko jiems reikia, iš formos laukų

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;

Atsakymas apdoroja visas tris baigtis, nes visos trys yra pasiekiamos. Baigtas atvaizdavimas praneša apie sėkmę, vartotojas, paspaudęs Atšaukti (Cancel), mato atšauktą atšaką, o failas, kurio nepavyko įrašyti, arba puslapis, kurio nepavyko išnagrinėti (parse), atkeliauja kaip nesėkmė su pranešimu. Nė viena iš tų atšakų neblokuoja, nė viena iš jų neliečia darbinės gijos, o darbinės gijos sukurtas bitų žemėlapis ar būsena yra perskaitomi tik tada, kai future jį pristato gijai, kuriai priklauso UI

Ta pati gijų valdymo disciplina (threading discipline) pasiteisina ir kitose peržiūros programos dalyse. Būdas, kaip atvaizduoti bitų žemėlapiai yra saugomi ir pakartotinai naudojami keičiant mastelį, aprašytas mūsų užrašuose apie atvaizdavimo talpyklą (render cache) ir mastelio keitimo našumą, o platesnis klausimas, kaip išlaikyti PDFium ribą saugią Delphi aplinkoje, aptariamas straipsnyje PDFium VCL ABI stiprinimas dėl atminties saugumo. Čia aprašyta asinchroninė infrastruktūra pateikiama kaip PDFium komponento, skirto Delphi ir C++Builder, dalis kartu su atvaizdavimo, teksto ir formų API, aprašytais kitur šiame tinklaraštyje