Sivun renderöinti PDFiumissa on synkronista. Teet kutsun kirjastoon, se rasteroi antamaasi bittikarttaan, ja hallinta palaa takaisin, kun pikselit on kirjoitettu. Yhdelle näytön kokoiselle sivulle yhdellä zoomaustasolla tämä vie muutaman millisekunnin eikä kukaan huomaa mitään. Mutta 200-sivuisen asiakirjan 300 dpi:n viennissä, tai pikkukuvien nauhassa, jonka on rasteroitava jokainen sivu kerralla, sama kutsu maksaa sekunteja. Jos teet tuon kutsun pääsäikeestä, viestisilmukka pysähtyy, ikkunan uudelleenpiirto lakkaa, ja Windows maalaa pelätyn "Ei vastaa" -tekstin otsikkopalkkiisi. Itse työ on oikein. Paikka, jossa ajoit sen, on väärä
Korjaus on siirtää pitkä renderöinti taustasäikeeseen ja tuoda tulos takaisin pääsäikeeseen, jossa bittikartta voidaan ojentaa kontrollille. PDFium itsessään ei estä sinua tekemästä tätä, mutta sidonnan (binding) on tehtävä siirrosta turvallinen, koska "aja työntekijäsäikeessä, vastaa käyttöliittymässä" -mallin bugipinta-ala on laaja ja virheet ovat satunnaisia. PDFiumPasin FPdfAsync-yksikkö on olemassa tarjotakseen tuolle mallille yhden oikean toteutuksen, peruutusmallilla, joka sopii siihen, miten pitkä renderöinti todellisuudessa käyttäytyy
Työn muoto
Kolme operaatiota hallitsevat tapauksia, joissa renderöinti kestää kauemmin kuin yhden ruudunpäivityksen verran. Erärenderöinti käy läpi sivualueen ja rasteroi jokaisen sivun, yleensä levylle. Monisivuinen vienti tekee saman, mutta kokoaa tulosteen yhteen tiedostoon. Taustasivun renderöinti on sitä, mitä katseluohjelma tekee, kun käyttäjä hyppää sivulle, jota ei vielä ole välimuistissa, jolloin bittikartta tuotetaan taustasäikeessä ja näytetään kun se on valmis. Näillä kolmella on samat rajoitteet. Ne kestävät niin kauan, ettei käyttöliittymäsäie voi isännöidä niitä, ne tuottavat tuloksen, jota käyttöliittymäsäie lopulta tarvitsee, ja käyttäjä saattaa hylätä ne. Asiakirjan sulkemisen, sivun ohi vierittämisen tai Peruuta-painikkeen painamisen tulisi pysäyttää työ sen sijaan, että käyttäjä pakotetaan odottamaan tulostetta, jota hän ei enää halua
Tuo viimeinen rajoite on se, joka muokkaa suunnittelua. Renderöinti, jota ei voida peruuttaa, on renderöinti, joka pitää asiakirjan auki ja polttaa suorittimen aikaa sen jälkeen, kun vastauksella ei ole enää väliä. Joten yksikkö on rakennettu kahden yhdistettävän primitiivin ympärille: futuren, joka kantaa tuloksen takaisin, ja tunnisteen (token), joka kantaa peruutuspyynnön eteenpäin
"Fire-and-forget" -future (ammu-ja-unohda -future)
TPdfFuture<T>.Run ottaa vastaan työntekijän (worker), vastauksen (reply) ja valinnaisen peruutustunnisteen (cancellation token). Se käynnistää työntekijän taustasäikeessä, ja kun työntekijä lopettaa, se toimittaa vastauksen pääsäikeessä. Geneerinen parametri T on se, mitä renderöinti tuottaa, usein bittikartan kahva tai tilatietue. Työntekijä suoritetaan taustasäikeessä; vastaus suoritetaan siellä, missä VCL:ään koskeminen on turvallista
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Tarkoituksellinen poisjättö on minkäänlainen Wait-kutsu (odota). Ei ole olemassa metodia, joka estäisi kutsujaa, kunnes future valmistuu, eikä tämä ole unohdus. Pääsäikeestä kutsuttu Wait on klassinen tapa saattaa käyttöliittymä lukkiutumistilaan (deadlock): työntekijä tarvitsee pääsäiettä ajaakseen vastauksensa Synchronize-kutsun kautta, pääsäie on pysäköity Wait-kutsun sisälle, ja kumpikaan osapuoli ei voi edetä. Kieltäytymällä tarjoamasta kyseistä primitiiviä, future sulkee pois mallin, joka useimmiten nujertaa ihmiset, jotka yrittävät kirjoittaa tämän itse. Koodin, jonka on aidosti estettävä suoritus (block), tulisi käyttää tavallista TThread-luokkaa ja kantaa siitä vastuu. Future on tarkoitettu "fire-and-forget" -tapauksiin, mitä taustarenderöinti todellisuudessa on
Tulos on kääritty TPdfFutureResult<T>-tietueeseen, joka kertoo vastaukselle, mikä kolmesta asiasta tapahtui. IsSuccess tarkoittaa, että työntekijä palasi normaalisti ja Value sisältää renderöinnin. IsCancelled tarkoittaa, että tunniste laukesi ja työntekijä keskeytti toimintansa peruutuspisteessä. IsFailure tarkoittaa, että työntekijä nosti poikkeuksen, ja ErrorMessage kantaa mukanaan tekstin. Vastaus tarkastaa tilan kerran ja haaroittuu, sen sijaan että se arvailisi vartija-arvon (sentinel value) perusteella, onko palautettu bittikartta aito
V1.61.0 kilpatilanne (race), joka muutti vastauksen toimituksen
Tämän yksikön opettavaisin osa on yksirivinen muutos, jonka ymmärtäminen vei aikansa. Varhaisissa versioissa työntekijäsäie toimitti vastauksensa TThread.Queue-kutsulla. Queue asettaa vastauksen pääsäikeen jonoon ja palaa välittömästi, mikä kuulostaa juuri siltä, mitä "fire-and-forget" -future haluaa. Se oli väärin, ja syy on syytä selittää yksityiskohtaisesti, koska se on sellainen ohjelmointivirhe, joka läpäisee jokaisen testin, jonka keksit kirjoittaa
Työntekijäsäie luodaan asetuksella FreeOnTerminate := True. Se tarkoittaa, että sillä hetkellä kun Execute palaa, säie ajaa itsensä alas, ja TThread.Destroy kutsuu RemoveQueuedEvents(Self) osana siivousta. RemoveQueuedEvents tyhjentää kaikki jonossa olevat metodit, joiden kohteena on kuoleva säie. Joten järjestys oli tämä: työntekijä lopettaa, se asettaa vastauksen jonoon itseään vasten, Execute palaa, säie tuhoaa itsensä, ja RemoveQueuedEvents poistaa vastauksen, jota pääsäie ei ollut vielä ehtinyt ajaa. Tulos yksinkertaisesti katosi. Vielä pahempaa, siinä kapeassa ikkunassa, jossa pääsäie veti jonossa olevan vastauksen ja alkoi ajaa sitä samalla hetkellä kun säiettä oltiin vapauttamassa, vastaus koski puoliksi tuhotun objektin kenttiin, mikä on use-after-free -virhe (käyttö vapautuksen jälkeen)
Korjaus versiossa v1.61.0 oli vastauksen toimittaminen Synchronize-kutsulla Queue-kutsun sijaan. Synchronize estää työntekijäsäiettä, kunnes pääsäie on suorittanut vastauksen loppuun asti. Työntekijä on edelleen elossa sen vastauksen suorituksen ajan, joten sen alta ei vapaudu mitään, eikä säie palaa Execute-kutsusta (eikä siksi ala tuhota itseään), ennen kuin vastaus on toimitettu. Toimitus on taattu, ja use-after-free -ikkuna 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 kestää kauemmin kuin tämä tietty korjaus. "Fire-and-forget" asynkroniset takaisinkutsut (callbacks) ovat helpoin rinnakkaisuusmalli, jonka voi saada hienovaraisesti väärin, koska onnellinen polku toimii ensimmäisellä yrittämällä ja bugi elää säikeen alasajojärjestyksen ja jonon välisessä vuorovaikutuksessa. Sitä ei voi toistaa pyynnöstä. Se riippuu siitä, sattuiko pääsäie tyhjentämään jonon ennen kuin työntekijä sattui lopettamaan itsensä tuhoamisen, mikä on ajoitus, jonka vuorontaja (scheduler) päättää eri tavalla jokaisella ajokerralla. Primitiivi, joka on oikein kerran, sidonnassa (binding), on paljon arvokkaampi kuin sama koodi uudelleenjohdettuna jokaisessa sovelluksessa, joka tarvitsee taustarenderöintiä
Miksi takaisinkutsut ovat metodiosoittimia
Työntekijä ja vastaus eivät ole anonyymejä metodeja. Ne ovat procedure of object -tyyppejä, TPdfFutureWorker<T> ja TPdfFutureReply<T>, ja kääntäjämatriisi pakottaa tämän valinnan. PDFiumPas kääntyy Delphi XE5:llä ja uudemmilla sekä Free Pascal 3.2:lla Delphi-tilassa, eikä FPC 3.2 kyseisessä tilassa tue anonyymejä metodeja. Reference-to-procedure -takaisinkutsu, joka sieppaa paikallisia muuttujia, kääntyisi Delphissä ja epäonnistuisi FPC:ssä, joten yksikkö käyttää alinta yhteistä nimittäjää, jonka molemmat kääntäjät hyväksyvät
Käytännön seuraus on se, missä tila (state) asuu. Anonyymi metodi sulkeutuu paikallisten muuttujien yli (closure); metodiosoitin ei sitä tee. Joten minkä tahansa tilan, jota työntekijä tarvitsee – sivuindeksin, zoomauksen, tulostepolun – ja minkä tahansa tilan, jota vastaus tarvitsee päivittää – kohdekuvakontrollin tai edistymisetiketin – on roikuttava siinä objektissa, jonka metodia välitetään. Katseluohjelmassa tuo objekti on yleensä lomake (form) tai sen omistama renderöintiohjain. Tämä ei ole vastahakoisesti pakotettu kiertotapa; se pitää tuon tilan omistajuuden eksplisiittisenä ja näkyvänä vastaanottavassa objektissa sen sijaan, että se olisi piilotettu sulkeuman (closure) sisään
Yhteistyöhön perustuva peruutus, ei kova pakotettu lopetus
Peruutus on tässä yhteistyöhön perustuva. Ei ole olemassa rajapintaa (API), joka kajoaisi työntekijäsäikeeseen ja lopettaisi sen, koska säikeen lopettaminen kesken renderöinnin jättäisi PDFiumin pitämään lukkoja ja osittain kirjoitettuja bittikarttoja, ja prosessin tila pakotetun tappamisen jälkeen ei ole jotain, mistä voisi vetää johtopäätöksiä. Sen sijaan työntekijälle ojennetaan vain luku -tunniste (read-only token) ja sen odotetaan tarkistavan se, ja renderöintiesilmukka on kirjoitettu tarkistamaan se sivujen tai ruutujen (tiles) välissä, joissa pysähtyminen on siistiä
Tunniste tarjoaa kolme tapaa havaita peruutus. IsCancelled on edullinen boolean-kysely silmukalle, joka haluaa testata ja päättää itse. ThrowIfCancelled on yleinen tapaus: kutsu sitä luonnollisessa peruutuspisteessä ja, jos peruutus on pyydetty, se nostaa EPdfOperationCancelled-poikkeuksen, joka kelaa työntekijän suoraan takaisin futureen. RegisterCallback liittää kertaluonteisen ilmoituksen (one-shot notification), joka laukeaa kerran, kun lähde peruutetaan, mikä on hyödyllistä, kun työntekijä on estynyt (blocked) jossain, minkä se voi keskeyttää sen sijaan, että se istuisi tiukassa silmukassa
Poikkeus on se, missä säierajalla on merkitystä. Kun työntekijä nostaa EPdfOperationCancelled-poikkeuksen, future ottaa sen kiinni ja muuttaa sen peruutettu-tilaksi, joten vastaus näkee arvon IsCancelled eikä virhettä (failure). Itse poikkeusobjektia ei koskaan siirretä (marshal) pääsäikeeseen. Se elää ja kuolee työntekijäsäikeessä; vain sen viestimerkkijono kopioidaan kenttään ErrorMessage. Elävän poikkeusobjektin siirtäminen säikeiden yli tarkoittaisi kajoamista päättyvän säikeen omistamaan muistiin, mikä on samaa virheluokkaa, jota Synchronize-korjaus on olemassa estääkseen. Tilakoodi ja merkkijono ylittävät rajan siististi; objekti ei ylittäisi
Kaksi rajapintaa, jottei työntekijä voi peruuttaa itseään
Peruutus on jaettu kahdelle rajapinnalle tarkoituksella. IPdfCancellationTokenSource on kirjoituspuoli: siinä on Cancel, ja sen luova omistaja, yleensä lomake, pitää sen itsellään ja kutsuu Cancel-metodia, kun käyttäjä napsauttaa painiketta tai lomake sulkeutuu. IPdfCancellationToken on lukupuoli: siinä on IsCancelled, ThrowIfCancelled ja RegisterCallback, ja se on kaikki mitä työntekijä koskaan saa. Yksi konkreettinen objekti toteuttaa molemmat, mutta työntekijälle ojennetaan vain tunniste, joten sillä ei ole mitään tapaa peruuttaa suorittamaansa operaatiota. Jako on rajapintatason suojakaide. Työntekijä, joka pääsisi käsiksi Cancel-kutsuun tunnisteensa kautta, voisi houkutella hämmentynyttä koodinpätkää peruuttamaan itsensä, ja tyyppijärjestelmä poistaa tämän mahdollisuuden
Tähän liittyy vastaava yksityiskohta tapaukseen, jossa kutsuja haluaa renderöinnin mutta ei koskaan aio peruuttaa sitä. Sen sijaan, että se pakottaisi uuden lähteen jokaista kutsua kohti, yksikkö tarjoaa PdfNoCancellationToken-tunnisteen, joka on singleton-tunniste ja pysyvästi ei-peruutettu-tilassa. Run korvaa sen, kun tunnisteargumentti jätetään nilliksi. Kyseinen singleton rakennetaan ahneesti (eagerly) yksikön alustuksen aikana sen sijaan, että se tehtäisiin laiskasti (lazily) ensimmäisellä käyttökerralla, ja syynä on jälleen rinnakkaisuus. Jos useat Run-kutsut eri työntekijäsäikeissä tavoittelisivat kaikki laiskasti luotua singletonia kerralla, ne voisivat joutua kilpatilanteeseen sen rakentamisessa, vuotaa kaksoiskappaleen, tai hetkellisesti havaita puoliksi alustetun instanssin. Sen rakentaminen ennen kuin yksikään työntekijä voi ajaa poistaa kilpatilanteen kokonaan
Peruutettavan renderöinnin suorittaminen
Käytännössä luot lähteen, pidät sen lomakkeella, välität sen Token-tunnisteen Run-kutsulle yhdessä työntekijämetodin ja vastausmetodin kanssa, ja yhdistät Peruuta-painikkeen lähteeseen. Työntekijä tarkistaa tunnisteen renderöidessään; vastaus päivittää käyttöliittymän heti, kun tulos on palannut. Koska takaisinkutsut ovat metodiosoittimia, työntekijä ja vastaus lukevat kaiken tarvitsemansa 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 käsittelee kaikki kolme lopputulosta, koska kaikki kolme ovat saavutettavissa. Valmis renderöinti raportoi onnistumisesta, Peruuta-painiketta painanut käyttäjä näkee peruutetun haaran, ja tiedosto, jota ei voitu kirjoittaa, tai sivu, jonka jäsennys epäonnistui, saapuu virheenä viestin kera. Mikään näistä haaroista ei estä suoritusta (block), mikään niistä ei koske työntekijäsäikeeseen, ja työntekijän tuottama bittikartta tai tila luetaan vasta sen jälkeen, kun future on toimittanut sen käyttöliittymän omistavassa säikeessä
Sama säiekuri maksaa itsensä takaisin muualla katseluohjelmassa. Tapa, jolla renderöidyt bittikartat säilytetään ja niitä käytetään uudelleen zoomausmuutosten yli, on käsitelty artikkelissa huomiomme renderöintivälimuistista ja zoomaussuorituskyvystä, ja laajempi kysymys PDFium-rajan pitämisestä turvallisena Delphissä on artikkelissa PDFiumin VCL ABI:n karkaisu muistiturvallisuutta varten. Tässä kuvattu asynkroninen infrastruktuuri toimitetaan osana PDFium-komponenttia Delphille ja C++Builderille, yhdessä renderöinti-, teksti- ja lomakeohjelmointirajapintojen kanssa, joita käsitellään muualla tässä blogissa