Tekninen artikkeli

WaitForIdle-lukkiutuminen Delphi PDFium-asynkronirenderöinnissä

Eräajorenderöinti jäätyy puolivälissä, koska PDFium Componentin suorittaja ei pidä tehtävää valmiina ennen kuin sen vastaus on lähetetty. padSynchronize:n alla tuo vastaus ajaa pääsäikeessä. Jos pääsäie estyy pumppaamatta CheckSynchronize:a, työntekijä odottaa pääsäiettä samalla kun pääsäie odottaa joutenoloa

Debuggerikuva on erehtymätön, kun sen on kerran nähnyt. Pysäytä jäätynyt prosessi, ja pääsäie istuu odotustilassa joutenolotapahtumassa, useita kehyksiä oman eräajosilmukkasi alapuolella. Vaihda mihin tahansa työntekijäsäikeeseen, ja se istuu TThread.Synchronize:n sisällä, pitäen hallussaan valmista tulosta, jota se ei pysty luovuttamaan. Mikään ei pyöri, mikään suoritin ei pala, prosessi on yksinkertaisesti pysäköity. Tämä artikkeli käsittelee, miksi tuo tila on ylipäätään olemassa, ja kolmea naapuroivaa sääntöä, jotka päättävät, käyttäytyykö Delphi-työntekijäallas PDFiumin päällä vai puree se: mitä QueueCapacity oikeasti rajoittaa, missä järjestyksessä sammutuksen täytyy peruuttaa, ja mitä rinnakkaisuus ei osta sinulle PDFium-objektien omistajuuden osalta

Miksi WaitForIdle jumittaa pääsäikeen?

Se jumittaa, koska joutenolo TPdfAsyncExecutor:issa on määritelty sisältämään vastauksen lähetyksen, ei vain työntekijän valmistumisen. Käynnissä olevien määrää kasvatetaan DequeueTask:issa, kun työntekijä ottaa tehtävän, ja sitä vähennetään TaskFinished:issa, jonka työntekijä kutsuu vasta sen jälkeen, kun TPdfAsyncTaskOperation.Execute on palannut. Tuo metodi ajaa työntekijän rungon, tallentaa lopputuloksen, ja lähettää sitten vastauksen TPdfAsyncDispatchMode:n mukaisesti. padSynchronize:lla lähetys on TThread.Synchronize-kutsu, joten Execute ei palaa ennen kuin pääsäie on ajanut sen

Delphi laittaa tuon sopimuksen toisen puoliskon sinun vastuullesi. TThread.Synchronize liittää metodin globaaliin jonoon ja estää kutsuvan säikeen tapahtumalla; jonkin pääsäikeessä täytyy kutsua CheckSynchronize:a ennen kuin tuo tapahtuma koskaan signaloidaan. VCL-viestisilmukka tekee tämän puolestasi viestien välillä, mikä on täsmälleen syy, miksi bugi on näkymätön interaktiivisen käytön aikana ja ilmestyy heti, kun kirjoitat estävän eräajosilmukan. Estävä pääsäie on pääsäie, joka on jättänyt viestisilmukan, eikä viestisilmukan ulkopuolella oleva pääsäie tyhjennä ketään

uses
  System.Classes, FPdfAsync, PDFium;

// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // the main thread now parks in a kernel wait

// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.

Tehtävän valmistuminen ja suorittajan joutenolo ovat kaksi eri virstanpylvästä

Ne on erotettu tarkoituksella, ja sen tietäminen, kumpaa odotat, on koko korjaus. IPdfAsyncTask.WaitFor täyttyy sillä hetkellä, kun työntekijän tulos on päätetty: Complete kirjoittaa lopullisen TPdfAsyncTaskState:n ja asettaa valmis-tapahtuman ennen kuin yhtään vastausta harkitaan. TPdfAsyncExecutor.WaitForIdle täyttyy myöhemmin, kun jonossa olevien ja käynnissä olevien määrät ovat molemmat nolla, eikä käynnissä-luku laske ennen kuin vastaus on saapunut. Tehtävä voi siis olla patsSucceeded ja havaittavissa Snapshot:in kautta samalla kun suorittaja on yhä oikeutetusti kiireinen

// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // release any pending padSynchronize reply
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

Yksi seuraus kannattaa sisäistää: vastaus, joka nostaa poikkeuksen, ei kirjoita historiaa uudelleen. DispatchReply nappaa poikkeuksen ja tallentaa sen muuttujaan ReplyErrorMessage, jättäen State:n, CancellationReason:in ja ErrorMessage:n täsmälleen sellaisiksi kuin työntekijä ne määritti. UI-takaisinkutsu, joka räjähtää maalatessaan pienoiskuvaa, ei siis koskaan muuta onnistunutta renderöintiä epäonnistuneeksi, ja telemetriasi jatkaa raportoimista siitä, mitä renderöintimoottori todella teki. Jos haluat takaisinkutsun muotoisen API:n yksittäisen operaation ympärille altaan sijaan, taustarenderöinti peruutettavilla futures-objekteilla kattaa tuon polun

Rajaako QueueCapacity myös käynnissä olevia työntekijöitä?

Ei. QueueCapacity PDFium Componentissa laskee vain jonossa olevat tehtävät, ei koskaan niitä, jotka jo suoritetaan työntekijällä. Se on tarkoituksellista: kapasiteetin on tarkoitus ilmaista todellista vastapainetta odotusjonolle, ja kiinteiden rinnakkaisuuspaikkojen taittaminen samaan lukuun laskisi ne kahteen kertaan. Neljällä työntekijällä ja kahdeksan kapasiteetilla voit saada kaksitoista tehtävää lennossa, ja GetStats raportoi jaon rehellisesti QueuedCount:in ja RunningCount:in kautta

var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit never raises: it returns False when the waiting line is full or
  // the executor is already shutting down, and bumps RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
    // WorkerCount and is never charged against the capacity.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

TPdfAsyncPriority:n neljä kaistaa ovat tiukkoja, ei painotettuja. DequeueTask kulkee papCritical:sta alas papLow:hon ja ottaa ensimmäisen ei-tyhjän kaistan, säilyttäen FIFO-järjestyksen kunkin sisällä. Se antaa interaktiiviselle pyynnölle puhtaan tavan hypätä eräajon ohi, joka ei ole vielä alkanut, mutta se ei koskaan keskeytä jo käynnissä olevaa työtä, ja kutsuja, joka jatkaa papCritical:in syöttämistä, voi näännyttää papLow:n loputtomiin. Varaa kaksi ylintä kaistaa asioille, joita ihminen näkyvästi odottaa, ja jätä massavienti papNormal:iin tai sen alle. Käytä Submit:ia, kun täysi jono on ohjelmointivirhe, joka ansaitsee EPdfAsyncQueueFull:n, ja TrySubmit:ia, kun se on normaali tila, jonka aiot käsitellä

Miksi Shutdown peruuttaa suorittajan lukon ulkopuolella?

Koska peruuttaminen sen sisällä kääntäisi lukkojärjestyksen ja jumittaisi juuri sen sammutuksen, jota yrität suorittaa. Shutdown(True) ottaa suorittajan lukon, kääntää sammutuslipun, ja liittää jokaisen odottavan tehtävän paikalliseen tilannekuvataulukkoon AppendSnapshot:in kautta. Sitten se vapauttaa lukon ja vasta sen jälkeen kulkee tilannekuvan läpi kutsuen Cancel:ia jokaiselle merkinnälle. Tehtävän peruuttaminen laukaisee käyttäjän takaisinkutsuja, jotka on rekisteröity sen tokenlähteelle, ja nuo takaisinkutsut ovat tavallista sovelluskoodia: ne saattavat kysyä GetStats:ia, lähettää kompensoivaa työtä, tai odottaa joutenoloa. Jokainen niistä palaa uudelleen suorittajan lukkoon, ja takaisinkutsu, joka kutsuttaisiin tuon lukon ollessa hallussa, lukkiutuisi itseään vasten

Tokenlähde noudattaa samaa kuria yhden tason alempana. CancelWithReason ottaa lähteen lukon, päättää yksittäisen voittavan peruuttajan, kirjoittaa Reason:in, CancellationMessage:n ja CancelledAtTick:in, ja vasta sitten kääntää peruutettu-lipun atomisesti. Julkaisu ennen kääntöä on se, mikä tekee metatiedosta turvallisen lukea: mikä tahansa säie, joka havaitsee IsCancelled:in True:ksi, on taattu löytävän täydellisen syyn sen takana, ja myöhemmät kutsujat häviävät kilpailun, palauttavat False:n eivätkä pysty ylikirjoittamaan ensimmäistä syytä. Rekisteröidyt takaisinkutsut otetaan tilannekuvaan ja tyhjennetään lukon sisällä mutta kutsutaan sen ulkopuolella, kukin käärittynä niin, että yksi epäonnistuva käsittelijä ei voi tukahduttaa muita. Jo käynnissä olevia tehtäviä ei koskaan tapeta; ne päättyvät yhteistyössä, kun niiden työntekijärunko seuraavan kerran kutsuu ThrowIfCancelled:ia, minkä vuoksi Shutdown päättyy pumppaavaan WaitForIdle:en ennen säikeiden liittämistä

Höllentävätkö rinnakkaiset työntekijät PDFium-objektin omistajuutta?

Eivät, ja tämä on raja, joka luetaan todennäköisimmin väärin. TPdfAsyncExecutor aikatauluttaa työn; se ei väitä mitään minkään sen sisällä koskettamasi asian säie-affiniteetista. Elävästä TPdf-instanssista ei tule rinnakkain käytettävää, koska kaksi työntekijää sattuu kutsumaan sitä, ja sisäinen renderöintilukko on suoja päällekkäisiä renderöintikutsuja vastaan, ei lupa jakaa dokumenttia säikeiden välillä. Rinnakkainen renderöinti tai vienti tarkoittaa yhtä TPdf:ää työntekijää kohti, luotuna ja tuhottuna työn sisällä

type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // one document instance per worker, never shared
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ownership moves to the reply stage
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

Kustannus on todellinen ja ansaitsee maininnan: jokainen työntekijä maksaa oman jäsennyksensä ja oman sivuvälimuistinsa, joten muisti skaalautuu työntekijämäärän mukaan eikä dokumenttimäärän mukaan. Se on hinta mallista, jossa työntekijä voidaan peruuttaa tai kaataa turmelematta ketään muuta. Jos työntekijäsi sen sijaan jakavat katseluohjelmapuolen dokumentti-instanssin, sitä ympäröivät lukitussäännöt käsitellään artikkelissa renderöintilukosta ja kutsuista, jotka jättävät sen huomiotta, ja yksittäisen dokumentin peruutettava polku on artikkelissa peruutettavasta progressiivisesta renderöinnistä

Julkaistun rajapinnan laajentaminen rikkomatta vtablea

IPdfCancellationToken ja IPdfCancellationTokenSource ovat COM-tyylisiä rajapintoja, joita ulkoiset binäärit saattavat jo kuluttaa, joten metodin liittäminen kumpaankaan siirtäisi jokaisen myöhemmän paikan vtablessa ja reitittäisi kutsut hiljaa väärin vanhaa asettelua vasten käännetylle koodille. Diagnostiikka-, odotus-, poistettavat takaisinkutsu- ja atomiset peruutusominaisuudet elävät siis IPdfCancellationTokenEx:ssä ja IPdfCancellationTokenSourceEx:ssä, jotka periytyvät sen sijaan, että ne muokkaisivat. New ja Run pitävät alkuperäisen semantiikkansa olemassa oleville kutsujille; uusi koodi tavoittaa NewEx:n, NewTimeout:in ja RunEx:n, kun se haluaa CancelWithReason:in, WaitForCancellation:in tai hallitun IPdfCancellationRegistration:in. Periytyminen on ainoa turvallinen tapa kasvattaa julkaistua rajapintaa, ja se maksaa yhden ylimääräisen tyypin sukupolvea kohti

NewTimeout ansaitsee rehellisen huomautuksen. Jokainen aikakatkaisulähde omistaa kevyen säikeen, joka odottaa peruutustapahtumaa tai määräaikaa, kumpi tulee ensin. Kourallista tai muutamaa kymmentä määräaikaa varten se on yksinkertaista, matalan viiveen ratkaisu ja identtinen Delphissä, Lazaruksessa ja C++Builderissa. Tuhansille lyhyille määräajoille se on väärä muoto, ja sinun tulisi ajaa peruutus yhdestä sovellustason ajastimesta sen sijaan, että pitäisit tuhansia odottavia säikeitä

Mikään näistä säännöistä ei ole eksoottinen kirjoitettuna, mutta jokainen niistä on tuotantoinsidentti, kun sitä ei ole kirjoitettu. Odota oikeaa virstanpylvästä ja anna jonkin pumpata synkronointijonon, lue QueueCapacity rajaksi vain odotusjonolle, peruuta lukkojesi ulkopuolella, ja anna jokaiselle työntekijälle oma dokumenttinsa. Tässä kuvattu asynkroninen kerros toimitetaan osana Delphi PDFium Component -komponenttia, yhdessä renderöinti-, teksti- ja lomake-API:en kanssa, joita se aikatauluttaa