Tehnični članak

Zastoj WaitForIdle pri asinhronem upodabljanju Delphi PDFium

Paketno upodabljanje se zamrzne na pol poti, ker izvajalnik v PDFium Component ne šteje naloge za zaključeno, dokler ni njen odgovor razposlan. Pod padSynchronize ta odgovor teče na glavni niti. Če se glavna nit blokira, ne da bi črpala CheckSynchronize, delavec čaka na glavno nit, medtem ko glavna nit čaka na prostost

Slika razhroščevalnika je nezamenljiva, ko jo enkrat vidite. Ustavite zamrznjen proces in glavna nit sedi znotraj čakanja na dogodek prostosti, več okvirjev pod vašo lastno paketno zanko. Preklopite na katerokoli delovno nit in sedi znotraj TThread.Synchronize, ter drži dokončan rezultat, ki ga ne more predati. Nič se ne vrti, noben CPE ne gori, proces je preprosto parkiran. Ta članek govori o tem, zakaj to stanje sploh obstaja, in o treh sosednjih pravilih, ki odločajo, ali se bazen delavcev Delphi nad PDFium obnaša ali grize: kaj QueueCapacity dejansko omeji, v katerem vrstnem redu mora zaustavitev preklicati, in kaj vzporednost ne kupi, kjer gre za lastništvo objektov PDFium

Zakaj WaitForIdle obesi glavno nit?

Obesi jo, ker je prostost v TPdfAsyncExecutor definirana tako, da vključuje razpošiljanje odgovora, ne le dokončanje delavca. Tekoče štetje se poveča v DequeueTask, ko delavec pobere nalogo, in zmanjša v TaskFinished, ki jo delavec pokliče šele, ko se je TPdfAsyncTaskOperation.Execute vrnil. Ta metoda izvede telo delavca, zabeleži izid in nato razpošlje odgovor glede na TPdfAsyncDispatchMode. Pri padSynchronize je razpošiljanje klic TThread.Synchronize, tako da se Execute ne vrne, dokler ga glavna nit ni izvedla

Delphi drugo polovico te pogodbe naloži vam. TThread.Synchronize metodo pripne globalni vrsti in blokira klicalno nit na dogodku; nekaj na glavni niti mora poklicati CheckSynchronize, preden je ta dogodek sploh signaliziran. Zanka sporočil VCL to naredi za vas med sporočili, kar je natanko razlog, da je hrošč neviden med interaktivno uporabo in se pojavi v trenutku, ko napišete blokirajočo paketno zanko. Blokirajoča glavna nit je glavna nit, ki je zapustila zanko sporočil, glavna nit zunaj zanke sporočil pa ne izprazni nikogar

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.

Dokončanje naloge in prostost izvajalnika sta dva različna mejnika

Namerno sta ločena, vedeti, na katerega čakate, pa je cel popravek. IPdfAsyncTask.WaitFor je izpolnjen v trenutku, ko je rezultat delavca odločen: Complete zapiše končni TPdfAsyncTaskState in nastavi dogodek dokončanja, preden je kakršen koli odgovor sploh obravnavan. TPdfAsyncExecutor.WaitForIdle je izpolnjen kasneje, ko sta tako čakajoče kot tekoče štetje nič, tekoče pa ne pade, dokler odgovor ni pristal. Naloga je torej lahko patsSucceeded in opazljiva prek Snapshot, medtem ko je izvajalnik še vedno legitimno zaposlen

// 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;

Ena posledica, vredna ponotranjenja: odgovor, ki vrže izjemo, ne prepiše zgodovine. DispatchReply ujame izjemo in jo shrani v ReplyErrorMessage, medtem ko State, CancellationReason in ErrorMessage pusti natanko takšne, kot jih je določil delavec. Povratni klic uporabniškega vmesnika, ki eksplodira med risanjem sličice, torej nikoli ne spremeni uspešnega upodabljanja v neuspešno, vaša telemetrija pa še naprej poroča, kaj je pogon upodabljanja dejansko naredil. Če želite API v obliki povratnega klica okoli ene same operacije namesto bazena, upodabljanje v ozadju s preklicljivimi prihodnostmi pokriva to pot

Ali QueueCapacity omeji tudi tekoče delavce?

Ne. QueueCapacity v PDFium Component šteje le čakajoče naloge, nikoli tistih, ki se že izvajajo na delavcu. To je namerno: zmogljivost naj bi izražala resničen protipritisk na čakalni vrsti, zvijanje fiksnih rež sočasnosti v isto številko pa bi jih štelo dvakrat. S štirimi delavci in zmogljivostjo osem imate lahko dvanajst nalog v letu, GetStats pa razcep pošteno poroča prek QueuedCount in RunningCount

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;

Štirje pasovi TPdfAsyncPriority so strogi, ne pa uteženi. DequeueTask prehodi od papCritical navzdol do papLow in vzame prvi nepraz pas, pri čemer znotraj vsakega ohrani vrstni red FIFO. To interaktivni zahtevi da čist način, da preskoči pred paketom, ki se še ni začel, vendar nikoli ne prekine dela, ki že teče, klicatelj, ki neprestano polni papCritical, pa lahko papLow stradra za nedoločen čas. Rezervirajte zgornja dva pasova za stvari, na katere človek vidno čaka, gručno izvoz pa pustite na papNormal ali nižje. Uporabite Submit, kadar je polna vrsta programerska napaka, vredna EPdfAsyncQueueFull, in TrySubmit, kadar je to normalno stanje, ki ga nameravate obravnavati

Zakaj Shutdown prekliče zunaj zaklepa izvajalnika?

Ker bi preklic znotraj njega obrnil vrstni red zaklepov in obesil zaustavitev, ki jo poskušate izvesti. Shutdown(True) vzame zaklep izvajalnika, preklopi zastavico zaustavitve in vsako čakajočo nalogo doda v lokalno polje posnetka prek AppendSnapshot. Nato sprosti zaklep in šele nato prehodi posnetek, pri čemer za vsak vnos pokliče Cancel. Preklic naloge sproži uporabniške povratne klice, registrirane na njenem viru žetona, ti povratni klici pa so navadna aplikacijska koda: lahko poizvedujejo GetStats, oddajo nadomestno delo ali čakajo na prostost. Vsak od njih ponovno vstopi v zaklep izvajalnika, povratni klic, poklican medtem ko je ta zaklep držan, pa bi zastal sam proti sebi

Vir žetona en nivo nižje uboga isto disciplino. CancelWithReason vzame zaklep vira, odloči edinega zmagovalnega preklicatelja, zapiše Reason, CancellationMessage in CancelledAtTick, šele nato pa atomarno preklopi zastavico preklicanosti. Objava pred preklopom je tisto, kar naredi metapodatke varne za branje: vsaka nit, ki opazi IsCancelled kot True, je zagotovljeno najde popoln razlog za njim, poznejši klicatelji pa izgubijo tekmo, vrnejo False in ne morejo prepisati prvega razloga. Registrirani povratni klici so posneti in počiščeni znotraj zaklepa, poklicani pa zunaj njega, vsak ovit tako, da en neuspel obravnavalnik ne more zatreti preostalih. Naloge, ki že tečejo, nikoli niso ubite; končajo se sodelovalno, ko njihovo telo delavca naslednjič pokliče ThrowIfCancelled, zato se Shutdown konča s črpajočim WaitForIdle, preden se pridruži nitim

Ali vzporedni delavci sprostijo lastništvo objektov PDFium?

Ne, to je meja, ki jo je najverjetneje napačno razumeti. TPdfAsyncExecutor razporeja delo; ne trdi ničesar o afiniteti niti karkoli, česar se dotaknete znotraj tega dela. Živa instanca TPdf ne postane sočasno dostopna, ker vanjo slučajno kličeta dva delavca, notranji zaklep upodabljanja pa je varovalo pred prekrivajočimi se klici upodabljanja, ne pa licenca za deljenje dokumenta med nitmi. Vzporedno upodabljanje ali izvoz pomeni en TPdf na delavca, ustvarjen in uničen znotraj opravila

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;

Cena je resnična in vredna poimenovanja: vsak delavec plača svoje lastno razčlenjevanje in svoj lastni predpomnilnik strani, tako da se pomnilnik skalira s številom delavcev, ne pa s številom dokumentov. To je cena modela, kjer je delavca mogoče preklicati ali kjer lahko sesuje, ne da bi pokvaril koga drugega. Če vaši delavci namesto tega vendarle delijo instanco dokumenta na strani pregledovalnika, so pravila zaklepanja okoli tega obravnavana v zaklepu upodabljanja in klicih, ki ga zgrešijo, pot za preklicljivo eno samo dokument pa je v preklicljivem progresivnem upodabljanju

Razširjanje izdanega vmesnika brez podiranja vtable

IPdfCancellationToken in IPdfCancellationTokenSource sta vmesnika v slogu COM, ki jih zunanje binarne datoteke morda že porabljajo, tako da bi dodajanje metode katerikoli od njiju premaknilo vsako poznejšo režo v vtable in tiho napačno usmerilo klice, prevedene proti stari postavitvi. Diagnostične, čakalne, odstranljive-povratno-klicne in atomarno-preklicne zmožnosti torej živijo v IPdfCancellationTokenEx in IPdfCancellationTokenSourceEx, ki dedujeta namesto da bi spreminjala. New in Run za obstoječe klicatelje ohranita svojo izvirno semantiko; nova koda seže po NewEx, NewTimeout in RunEx, kadar želi CancelWithReason, WaitForCancellation ali upravljan IPdfCancellationRegistration. Dedovanje je edini varen način za rast izdanega vmesnika, stane pa en dodaten tip na generacijo

NewTimeout si zasluži pošteno opombo. Vsak vir časovne omejitve poseduje lahkotno nit, ki čaka na dogodek preklica ali na rok, kar koli pride prej. Za peščico ali nekaj deset rokov je to preprosto, z nizko latenco in enako čez Delphi, Lazarus in C++Builder. Za tisoče kratkih rokov je to napačna oblika, preklic pa bi morali namesto tega gnati iz enega časovnika na ravni aplikacije, namesto da bi držali tisoče čakajočih niti

Nobeno od teh pravil ni eksotično, ko je enkrat zapisano, vendar je vsako od njih produkcijski incident, kadar ni. Čakajte na pravi mejnik in pustite, da nekaj črpa vrsto sinhronizacije, QueueCapacity berite le kot mejo na čakalni vrsti, prekličite zunaj svojih zaklepov in vsakemu delavcu dajte svoj lasten dokument. Tukaj opisan asinhroni sloj je izdan kot del Delphi PDFium Component, skupaj z API-ji za upodabljanje, besedilo in obrazce, ki jih razporeja