Tekninen artikkeli

PDF-taustarenderöinti Delphissä peruutettavilla futuureilla (Cancellable Futures)

Sivun renderöinti (rendering) PDFiumissa on synkronista (synchronous). Kutsut kirjastoa (call into the library), se rasteroi (rasterises) sille antamaasi bittikarttaan (bitmap), ja hallinta (control) palaa takaisin, kun pikselit on kirjoitettu. Yhdelle näytön kokoiselle sivulle (screen-sized page) yhdellä zoomaustasolla se kestää muutaman millisekunnin (milliseconds) eikä kukaan huomaa sitä. 300 dpi:n vienti (export) 200-sivuisesta asiakirjasta tai pikkukuvakaista (thumbnail strip), jonka on rasteroitava jokainen sivu kerralla, vie samalla kutsulla sekunteja. Jos teet tuon kutsun pääsäikeestä (main thread), viestisilmukka (message loop) pysähtyy, ikkuna lopettaa uudelleenmaalauksen (repainting), ja Windows maalaa pelätyn "Ei vastaa" (Not Responding) -tekstin otsikkopalkkisi päälle. Työ (work) on oikein. Paikka, jossa sen ajoit, on väärin

Korjaus on siirtää pitkä renderöinti (long render) taustasäikeelle (background thread) ja tuoda tulos takaisin pääsäikeeseen (main thread), jossa bittikartta (bitmap) voidaan ojentaa kontrollille (control). PDFium itse ei estä sinua tekemästä tätä, mutta sidoksen (binding) on tehtävä luovutuksesta (handoff) turvallinen, koska bugipinta-ala (bug surface) "aja työläisessä (worker), vastaa käyttöliittymässä (UI)" -mallin ympärillä on laaja ja viat (failures) ovat ajoittaisia (intermittent). PDFiumPas-paketissa (PDFiumPas) oleva FPdfAsync-yksikkö on olemassa antaakseen tuolle mallille yhden oikean toteutuksen (implementation), sekä peruutusmallin (cancellation model), joka vastaa (fits) sitä, miten pitkä renderöinti oikeasti käyttäytyy

Työn (work) muoto

Kolme operaatiota dominoi tapauksia, joissa renderöinti (render) kestää pidempään kuin kehyksen (frame). Erärenderöinti (batch rendering) kävelee (walks) sivualueen läpi ja rasteroi (rasterises) jokaisen sivun, yleensä levylle. Monisivuinen vienti (multi-page export) tekee saman, mutta kokoaa (assembles) tulosteen yhdeksi tiedostoksi. Taustasivun renderöinti (background page rendering) on sitä, mitä katseluohjelma (viewer) tekee, kun käyttäjä hyppää sivulle, joka ei vielä ole välimuistissa (cache), jolloin bittikartta tuotetaan säikeen ulkopuolella (off-thread) ja näytetään, kun se on valmis. Kaikki kolme jakavat samat rajoitteet (constraints). Ne ovat käynnissä riittävän kauan (run long enough), ettei käyttöliittymäsäie (UI thread) voi isännöidä (host) niitä, ne tuottavat tuloksen, jota käyttöliittymäsäie lopulta (eventually) tarvitsee, ja käyttäjä saattaa hylätä (abandon) ne. Asiakirjan sulkemisen, sivun ohi vierittämisen (scrolling past) tai Peruuta-painikkeen (Cancel) painamisen pitäisi pysäyttää (stop) työ sen sijaan, että käyttäjä pakotettaisiin (forcing) odottamaan tulostetta (output), jota hän ei enää halua

Tuo viimeinen rajoite muokkaa (shapes) suunnittelua. Renderöinti, jota ei voida peruuttaa (cancelled), pitää asiakirjaa auki (holds the document open) ja polttaa (burns) CPU:ta senkin jälkeen, kun vastauksella (answer) ei ole enää merkitystä. Joten yksikkö rakentuu (built around) kahden yhdistettävän (compose) primitiivin (primitives) varaan: futuurin (future), joka kantaa (carries) tuloksen takaisin, ja tunnuksen (token), joka kantaa peruutuspyyntöä (cancellation request) eteenpäin

Ammu-ja-unohda -futuuri (fire-and-forget future)

TPdfFuture<T>.Run ottaa vastaan työläisen (worker), vastauksen (reply) ja valinnaisen (optional) peruutustunnuksen (cancellation token). Se käynnistää työläisen taustasäikeellä (background thread), ja kun työläinen valmistuu, se toimittaa vastauksen pääsäikeelle (main thread). Geneerinen parametri (generic parameter) T on mitä tahansa renderöinti (render) tuottaakaan, usein bittikarttakahva (bitmap handle) tai tilatietue (status record). Työläinen (worker) ajetaan säikeen ulkopuolella (off-thread); vastaus (reply) ajetaan siellä, missä on turvallista koskettaa VCL:ää

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

Tarkoituksellinen (deliberate) puute (omission) on kaikenlainen (any kind of) Wait-metodi. Ei ole olemassa metodia, joka estäisi (block) kutsujaa (caller), kunnes futuuri suorittaa työnsä loppuun (completes), eikä se ole huomaamattomuus (oversight). Pääsäikeestä kutsuttu Wait on klassinen tapa saattaa (deadlock) käyttöliittymä umpikujaan: työläinen (worker) tarvitsee pääsäiettä ajaakseen vastauksensa (reply) Synchronize-kutsun kautta, pääsäie on pysäköity Wait-kutsun sisään, eikä kumpikaan puoli voi jatkaa. Kieltäytymällä (refusing) tarjoamasta primitiiviä, futuuri sulkee pois (rules out) mallin, joka useimmiten kaataa (defeats) ne, jotka yrittävät kirjoittaa tämän itse. Koodin, jonka todella (genuinely) tarvitsee estää (block), tulisi käyttää tavallista (plain) TThread-säiettä ja kantaa (own) seuraukset. Futuuri on ammu-ja-unohda -tapauksiin (fire-and-forget), jota taustarenderöinti (background rendering) todellisuudessa on

Tulos on kääritty (wrapped) TPdfFutureResult<T>-tietueeseen, joka kertoo vastaukselle (reply), mikä kolmesta asiasta tapahtui. IsSuccess tarkoittaa, että työläinen palautui (returned) normaalisti ja Value pitää (holds) renderöinnin (render). IsCancelled tarkoittaa, että tunnus (token) laukesi (fired) ja työläinen (worker) keskeytti (bailed out) peruutuspaikassa (cancellation point). IsFailure tarkoittaa, että työläinen nosti (raised) poikkeuksen, ja ErrorMessage kantaa tekstiä. Vastaus (reply) tarkastaa (inspects) tilan (status) kerran ja haarautuu (branches), sen sijaan että arvailisi palautetusta vartiosta (sentinel value), onko bittikartta (bitmap) todellinen

v1.61.0:n kilpajuoksu (race), joka muutti vastauksen toimittamisen (reply delivery)

Tämän yksikön opettavaisin (instructive) osa on yksirivinen (one-line) muutos, jonka ymmärtäminen vei aikaa. Varhaisissa (early) versioissa työläissäie (worker thread) toimitti vastauksensa (reply) komennolla TThread.Queue. Queue lähettää (posts) vastauksen pääsäikeen jonoon (queue) ja palaa (returns) välittömästi, mikä kuulostaa juuri siltä, mitä ammu-ja-unohda -futuuri haluaa. Se oli väärin, ja syy on syytä kirjata ylös (spelling out), koska se on sellainen bugi (bug), joka läpäisee (passes) jokaisen testin, jonka keksit (think to) kirjoittaa

Työläissäie (worker thread) luodaan asetuksella FreeOnTerminate := True. Se tarkoittaa, että heti kun Execute palaa, säie (thread) purkaa (tears down) itsensä, ja TThread.Destroy kutsuu komentoa RemoveQueuedEvents(Self) osana siivousta. RemoveQueuedEvents tyhjentää (purges) kaikki jonossa olevat (queued) metodit, joiden kohteena (target) on kuoleva (dying) säie. Joten sekvenssi oli: työläinen valmistuu, se jonottaa (queues) vastauksen (reply) itseään vastaan, Execute palaa, säie tuhoaa (destroys) itsensä, ja RemoveQueuedEvents poistaa (deletes) vastauksen, jota pääsäie ei ollut vielä ehtinyt ajaa (run). Tulos vain katosi (vanished). Vielä pahempaa, siinä kapeassa ikkunassa (narrow window), jossa pääsäie veti (pulled) jonossa olevan vastauksen ulos (off) ja aloitti sen ajamisen (running it) samalla hetkellä, kun säiettä oltiin vapauttamassa (freed), vastaus (reply) kosketti puoliksi tuhotun (half-destroyed) objektin (object) kenttiä, mikä on käyttö vapautuksen jälkeen (use-after-free)

Korjaus versiossa v1.61.0 oli toimittaa (deliver) vastaus (reply) komennolla Synchronize komennon Queue sijaan. Synchronize estää (blocks) työläissäiettä (worker thread), kunnes pääsäie on suorittanut vastauksen loppuun asti (run the reply to completion). Työläinen (worker) on edelleen elossa vastauksensa (reply) suorituksen (executes) ajan, joten mitään ei voi vapauttaa (free out) sen alta, ja säie ei palaa (returns) komennosta Execute (eikä siis aloita itsensä tuhoamista), ennen kuin vastaus on toimitettu (delivered). Toimitus on taattu (guaranteed), ja käyttö vapautuksen jälkeen -ikkuna (use-after-free window) on suljettu

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;

Yleinen opetus elää pidempään kuin (outlasts) spesifinen korjaus. Ammu-ja-unohda -tyyppiset asynkroniset takaisinkutsut (asynchronous callbacks) on vaikeinta saada täysin oikein, koska onnellinen polku (happy path) toimii ensimmäisellä yrittämällä, ja bugi elää säikeen purkujärjestyksen (thread teardown order) ja jonon välisessä vuorovaikutuksessa (interaction). Sitä ei voi toistaa pyydettäessä (reproduce on demand). Se riippuu siitä, sattuuko (happened to) pääsäie tyhjentämään (drain) jonon ennen kuin työläinen (worker) ehtii päättää (happened to finish) itsensä tuhoamisen (destroying itself), mikä on ajoitus (timing), jonka vuoronnusohjelma (scheduler) päättää eri tavalla joka kerta. Primitiivi, joka on oikein kerran (once), sidoksessa (binding), on paljon arvokkaampi (worth far more) kuin sama koodi uudelleen johdettuna (re-derived) jokaisessa sovelluksessa (application), joka tarvitsee taustarenderöintiä (background render)

Miksi takaisinkutsut (callbacks) ovat metodiosoittimia (method pointers)

Työläinen (worker) ja vastaus (reply) eivät ole anonyymejä metodeja (anonymous methods). Ne ovat procedure of object -tyyppejä, TPdfFutureWorker<T> ja TPdfFutureReply<T>, ja kääntäjämatriisi (compiler matrix) pakottaa (is forced by) tuohon valintaan (choice). PDFiumPas kääntyy (compiles) Delphi XE5:ssä ja uudemmissa (later) sekä Free Pascal 3.2:ssa Delphi-tilassa (mode), eikä FPC 3.2 tue kyseisessä tilassa anonyymejä metodeja. Viittaus-proseduuriin-takaisinkutsu (reference-to-procedure callback), joka sieppaa (captures) paikalliset muuttujat (local variables), kääntyisi Delphissä ja epäonnistuisi (fail) FPC:ssä, joten yksikkö käyttää pienintä yhteistä nimittäjää (lowest common denominator), jonka molemmat kääntäjät (compilers) hyväksyvät

Käytännön (practical) seuraus (consequence) on se, missä tila (state) elää. Anonyymi metodi (anonymous method) sulkee (closes over) paikalliset muuttujat (locals); metodiosoitin (method pointer) ei. Niinpä (so) mikä tahansa tila (state), jota työläinen (worker) tarvitsee – sivuindeksi (page index), zoomaus (zoom), tulostepolku (output path) – ja mikä tahansa tila, jota vastauksen (reply) pitää päivittää (update) – kohteen kuvakontrolli (target image control) tai edistymislappu (progress label) – on ripustettava (hang off) objektiin, jonka metodia välitetään (passed). Katseluohjelmassa (viewer) kyseinen objekti (object) on yleensä lomake (form) tai sen omistama renderöintiohjain (render controller). Tämä ei ole vastentahtoisesti (grudgingly) määrätty (imposed) kiertotie (workaround); se pitää (keeps) kyseisen tilan omistajuuden (ownership) eksplisiittisenä (explicit) ja näkyvänä (visible) vastaanottavassa (receiving) objektissa sen sijaan, että se olisi piilotettu sulkeuman (closure) sisään

Yhteistoiminnallinen (cooperative) peruutus (cancellation), ei kova tappo (hard kill)

Peruutus (cancellation) on tässä yhteistoiminnallista (cooperative). Ei ole olemassa API:a, joka kurottuisi (reaches into) työläissäikeeseen (worker thread) ja lopettaisi (terminates) sen, koska säikeen lopettaminen (terminating a thread) kesken renderöinnin (mid-render) jättää PDFiumille lukkoja (locks) ja osittain kirjoitettuja (partially written) bittikarttoja, eikä prosessitilasta (process state) pakotetun tapon (forced kill) jälkeen voi enää päätellä (reason about) mitään. Sen sijaan työläiselle ojennetaan (handed) vain luku -tunnus (read-only token) ja sen odotetaan tarkistavan (check) se, ja renderöintisilmukka (render loop) kirjoitetaan tarkistamaan se sivujen (pages) tai tiilten (tiles) välissä, missä pysähtyminen (stopping) on siistiä (clean)

Tunnus (token) tarjoaa (offers) kolme tapaa havaita (observe) peruutus. IsCancelled on halpa totuusarvon (boolean) kysely (poll) silmukalle, joka haluaa testata ja päättää itse. ThrowIfCancelled on yleinen tapaus (common case): kutsu sitä luonnollisessa peruutuspaikassa (natural cancellation point), ja jos peruutus (cancellation) on pyydetty (requested), se nostaa EPdfOperationCancelled-poikkeuksen, joka kelaa (unwinds) työläisen (worker) suoraan takaisin (straight back) futuuriin (future). RegisterCallback kiinnittää (attaches) kertalaukaisuilmoituksen (one-shot notification), joka laukeaa (fires) kerran, kun lähde (source) peruutetaan, mikä on hyödyllistä (useful), kun työläinen (worker) on estettynä (blocked) jossain, minkä se voi keskeyttää (interrupt) tiukassa silmukassa istumisen sijaan

Poikkeus (exception) on se kohta, jossa säierajalla (thread boundary) on väliä (matters). Kun työläinen (worker) nostaa (raises) EPdfOperationCancelled-poikkeuksen, futuuri (future) ottaa sen kiinni (catches) ja muuttaa sen peruutettu-tilaksi (cancelled status), joten vastaus (reply) näkee IsCancelled-tilan, eikä epäonnistumista (failure). Itse poikkeusobjektia (exception object) ei koskaan (never) viedä (marshaled) pääsäikeelle. Se elää ja kuolee työläissäikeessä (worker thread); vain sen viestimerkkijono (message string) kopioidaan (copied) kohtaan ErrorMessage. Elävän (live) poikkeusobjektin (exception object) vieminen (marshaling) säikeiden yli tarkoittaisi (would mean) kurottumista (reaching into) muistiin (memory), jonka omistaa (owned by) päättyvä (finishing) säie, mikä on sama virheluokka (class of mistake), jonka Synchronize-korjaus on olemassa estääkseen. Tilakoodi (status code) ja merkkijono (string) ylittävät rajan puhtaasti; objekti (object) ei sitä tekisi (would not)

Kaksi rajapintaa (interfaces), jotta työläinen ei voi peruuttaa itseään

Peruutus on jaettu (split) kahdelle rajapinnalle tarkoituksella. IPdfCancellationTokenSource on kirjoituspuoli (write side): sillä on Cancel, ja sen luova (creates) omistaja, yleensä (usually) lomake (form), pitää sen ja kutsuu Cancel, kun käyttäjä napsauttaa painiketta tai lomake sulkeutuu. IPdfCancellationToken on lukupuoli (read side): sillä on IsCancelled, ThrowIfCancelled, ja RegisterCallback, ja tämä on kaikki, mitä työläinen (worker) koskaan vastaanottaa (receives). Yksi konkreettinen (concrete) objekti toteuttaa (implements) molemmat, mutta työläiselle ojennetaan (handed) vain tunnus (token), joten sillä ei ole mitään tapaa peruuttaa (cancel) ajamaansa (running) operaatiota. Jako (split) on API-tason kaide (guard rail). Työläinen, joka voisi saavuttaa (reach) Cancel-kutsun tunnuksensa kautta, kutsuisi hämmentynyttä (confused) koodinpätkää (piece of code) peruuttamaan itsensä, ja tyyppijärjestelmä (type system) poistaa (removes) tämän mahdollisuuden

Mukana on (There is) vastaava (matching) yksityiskohta sitä tapausta varten, kun kutsuja haluaa renderöinnin (render), mutta ei aio koskaan peruuttaa sitä (cancel it). Sen sijaan, että yksikkö (unit) pakottaisi uuden (fresh) lähteen (source) per kutsu (per call), se tarjoaa PdfNoCancellationToken-singleton-tunnuksen (singleton token), joka on pysyvästi ei-peruutettu-tilassa (not-cancelled state). Run korvaa (substitutes) sen, kun tunnusargumentti (token argument) jätetään tyhjäksi (nil). Tämä singleton rakennetaan heti yksikön (unit) alustuksen aikana (initialization) eikä vasta myöhemmin (lazily) ensimmäisellä käyttökerralla (first use), ja syy tähän on jälleen samanaikaisuus (concurrency). Jos useat Run-kutsut eri työläissäikeiltä (worker threads) pyytäisivät viiveellä (lazily) luotua singletonia yhtä aikaa (at once), ne voisivat kilpailla (race) sen rakentamisesta (construction), vuotaa (leak) kaksoiskappaleen (duplicate) tai lyhyesti tarkkailla (briefly observe) puoliksi alustettua (half-initialised) instanssia (instance). Sen rakentaminen (building it) ennen kuin yksikään työläinen (worker) voi ajaa (run), poistaa kilpailun (race) kokonaan

Peruutettavan (cancellable) renderöinnin ajaminen (running)

Käytännössä luot lähteen (source), säilytät sen lomakkeella (form), ohjaat (pass) sen Token-tunnuksen kohtaan Run yhdessä työläismetodin (worker method) ja vastausmetodin (reply method) kanssa, ja kytket Peruuta-painikkeen (Cancel button) lähteeseen (source). Työläinen (worker) tarkistaa (checks) tunnuksen (token) renderöinnin aikana; vastaus (reply) päivittää käyttöliittymän (UI) heti, kun tulos on saatu takaisin. Koska takaisinkutsut (callbacks) ovat metodiosoittimia (method pointers), työläinen ja vastaus lukevat kaiken tarvitsemansa (whatever they need) lomakkeen kentistä

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;

Vastaus (reply) käsittelee kaikki kolme lopputulosta (outcomes), koska kaikki kolme ovat saavutettavissa (reachable). Valmistunut (finished) renderöinti raportoi onnistumisen (success), Peruuta-painiketta painanut käyttäjä (user) näkee peruutettu-haaran (cancelled branch), ja tiedosto, jota ei voitu kirjoittaa, tai sivu, jonka jäsentäminen (parse) epäonnistui, saapuu (arrives) epäonnistumisena viestin (message) kera. Mikään näistä haaroista (branches) ei lukitse (block) suoritusta, mikään niistä ei kosketa työläissäiettä (worker thread), ja bittikarttaa (bitmap) tai työläisen tuottamaa tilaa (status) luetaan vasta sen jälkeen (after), kun futuuri (future) on toimittanut sen säikeelle (thread), joka omistaa käyttöliittymän (UI)

Sama säiekuri (threading discipline) tuottaa tulosta (pays off) muuallakin katseluohjelmassa (viewer). Se, miten renderöidyt bittikartat säilytetään ja käytetään uudelleen zoomausmuutosten yli, on käsitelty artikkelissa pdfium-component-render-cache-zoom-performance.html (huomautuksemme renderöintivälimuistista ja zoomaus-suorituskyvystä, render cache and zoom performance), ja laajempi kysymys PDFium-rajan pitämisestä turvallisena (safe) Delphissä on artikkelissa hardening-pdfium-vcl-abi-memory-safety-in-delphi.html (PDFium-komponenttisidoksen karkaisu muistiturvallisuutta varten). Tässä kuvattu asynkroninen infrastruktuuri (async infrastructure) toimitetaan (ships) osana PDFium Component -komponenttia Delphiä ja C++Builderia varten renderöinti- (rendering), teksti- (text) ja lomake-API-rajapintojen (form APIs) ohella, joita käsitellään muualla tässä blogissa