Techninis straipsnis

WaitForIdle aklavietė Delphi PDFium asinchroniniame atvaizdavime

Paketinis atvaizdavimas užsišaldo pusiaukelėje, nes vykdytuvas PDFium Component nelaiko užduoties baigta, kol jos atsakymas nebuvo išsiųstas. Su padSynchronize tas atsakymas vyksta pagrindinėje gijoje. Jei pagrindinė gija blokuojasi, neleisdama CheckSynchronize, darbinė gija laukia pagrindinės gijos, kol pagrindinė gija laukia laisvos būsenos

Derintuvo vaizdas nesuklystamas, kai jį pamatai. Pristabdykite užšalusį procesą, ir pagrindinė gija sėdi laukime laisvos būsenos įvykio, keliais kadrais žemiau jūsų pačios paketinio ciklo. Perjunkite į bet kurią darbinę giją, ir ji sėdi viduje TThread.Synchronize, laikydama baigtą rezultatą, kurio negali perduoti. Niekas nesisuka, joks CPU nedega, procesas tiesiog stovi. Šis straipsnis yra apie tai, kodėl ta būsena apskritai egzistuoja, ir apie tris gretimas taisykles, nulemiančias, ar Delphi darbininkų telkinys virš PDFium elgiasi, ar kandžiojasi: ką iš tikrųjų riboja QueueCapacity, kokia tvarka turi vykti atšaukimas išjungimo metu, ir ko lygiagretumas nesuteikia PDFium objektų nuosavybės atžvilgiu

Kodėl WaitForIdle pakabina pagrindinę giją?

Jis pakabina todėl, kad laisva būsena TPdfAsyncExecutor apibrėžiama kaip apimanti atsakymo išsiuntimą, ne tik darbo užbaigimą. Veikiančių skaičius padidinamas DequeueTask, kai darbininkas paima užduotį, ir sumažinamas TaskFinished, kurį darbininkas kviečia tik po to, kai TPdfAsyncTaskOperation.Execute grįžo. Tas metodas vykdo darbininko kūną, įrašo rezultatą, o tada išsiunčia atsakymą pagal TPdfAsyncDispatchMode. Su padSynchronize siuntimas yra TThread.Synchronize iškvietimas, todėl Execute negrįžta, kol pagrindinė gija jo nepavykdė

Delphi antrą sutarties pusę uždeda ant jūsų. TThread.Synchronize prideda metodą prie globalios eilės ir blokuoja kviečiančią giją įvykiu; kažkas pagrindinėje gijoje turi iškviesti CheckSynchronize, prieš tam įvykiui kada nors signalizuojant. VCL pranešimų ciklas tai daro už jus tarp pranešimų, ir būtent todėl klaida nematoma interaktyvaus naudojimo metu ir pasirodo tą akimirką, kai parašote blokuojantį paketinį ciklą. Blokuojanti pagrindinė gija yra pagrindinė gija, palikusi pranešimų ciklą, o pagrindinė gija už pranešimų ciklo ribų nieko neišvalo

WaitForIdle užstrigimo Delphi PDFium darbuotojų baseine diagrama, kur pagrindinė gija laukia ramybės, kol darbuotojas užstringa TThread.Synchronize viduje
Kiekviena gija laukia sąlygos, kurią gali atlaisvinti tik kita, todėl visas paketas pastatytas be jokių CPU kaštų
uses
  System.Classes, FPdfAsync, PDFium;

// Forma, kuri sukelia aklavietę: sinchronizuotas atsakymas plius blokuojanti pagrindinė gija
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // pagrindinė gija dabar sustoja branduolio laukime

// Tuo tarpu TPdfAsyncTaskOperation.Execute pasiekė:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// kuris įtraukia DispatchReply į eilę ir laukia, kol pagrindinė gija ją ištuštins.
// Pagrindinė gija nieko neištuština, todėl abi pusės laukia amžinai.

Užduoties užbaigimas ir vykdytuvo laisva būsena yra du skirtingi etapai

Jie atskirti sąmoningai, ir žinojimas, kurio iš jų laukiate, yra visas taisymas. IPdfAsyncTask.WaitFor patenkinamas tą akimirką, kai darbininko rezultatas nuspręstas: Complete įrašo galutinę TPdfAsyncTaskState ir nustato baigto įvykį prieš svarstant bet kokį atsakymą. TPdfAsyncExecutor.WaitForIdle patenkinamas vėliau, kai ir eilėje esančių, ir vykdomų skaičiai yra nulis, o vykdomų skaičius nesumažėja, kol atsakymas nepasiekė. Todėl užduotis gali būti patsSucceeded ir stebima per Snapshot, kol vykdytuvas vis dar teisėtai užimtas

// TPdfAsyncExecutor.WaitForIdle jau siurbia už jus: jis laukia laisvos būsenos
// įvykio trumpais gabalais ir tarp jų kviečia CheckSynchronize(0).
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Bet koks rankomis parašytas pagrindinės gijos laukimas turi tą patį daryti aiškiai.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // atlaisvina bet kokį laukiantį padSynchronize atsakymą
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

Viena pasekmė, verta įsisavinti: atsakymas, kuris kelia klaidą, nepersirašo istorijos. DispatchReply pagauna išimtį ir ją saugo ReplyErrorMessage, palikdamas State, CancellationReason ir ErrorMessage lygiai tokius, kokius nustatė darbininkas. UI atgalinis iškvietimas, sprogstantis piešiant miniatiūrą, todėl niekada nepaverčia sėkmingo atvaizdavimo nepavykusiu, o jūsų telemetrija toliau praneša, ką atvaizdavimo variklis iš tikrųjų padarė. Jei norite atgalinio-iškvietimo formos API vienai operacijai, o ne telkiniui, foninis atvaizdavimas su atšaukiamais ateities objektais apima tą kelią

Ar QueueCapacity riboja ir veikiančius darbininkus?

Ne. QueueCapacity PDFium Component skaičiuoja tik eilėje esančias užduotis, niekada tas, kurios jau vykdomos darbininke. Tai sąmoninga: talpa skirta išreikšti realų atgalinį spaudimą laukimo eilei, o fiksuotų lygiagretumo vietų sulankstymas į tą patį skaičių jas skaičiuotų du kartus. Su keturiais darbininkais ir talpa aštuoni galite turėti dvylika užduočių skrydyje, o GetStats sąžiningai praneša padalinį per QueuedCount ir RunningCount

Diagrama, rodanti, kad QueueCapacity riboja aštuonias eilėje esančias PDFium užduotis laukiančiųjų linijoje, kol keturios Delphi darbuotojos dirba kaip atskiras biudžetas
Talpa matuoja tik laukiančiųjų eilę, o GetStats praneša eilės ir vykstančiųjų skaičius atskirai
var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit niekada nekelia išimties: jis grąžina False, kai laukimo eilė pilna arba
  // vykdytuvas jau vyksta išjungimas, ir padidina RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount yra tai, ką riboja QueueCapacity. RunningCount ribojamas
    // WorkerCount ir niekada neįskaičiuojamas į talpą.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

Keturios TPdfAsyncPriority juostos yra griežtos, ne svertinės. DequeueTask eina nuo papCritical žemyn iki papLow ir ima pirmą netuščią juostą, išsaugodama FIFO tvarką kiekvienoje juostoje. Tai suteikia interaktyviam prašymui švarų būdą peršokti dar nepradėtą paketą, tačiau niekada nepertraukia jau vykstančio darbo, o iškvietėjas, nuolat maitinantis papCritical, gali neribotai badauti papLow. Rezervuokite dvi viršutines juostas dalykams, kurių žmogus matomai laukia, ir palikite masinį eksportą papNormal ar žemiau. Naudokite Submit, kai pilna eilė yra programavimo klaida, verta EPdfAsyncQueueFull, ir TrySubmit, kai tai normali sąlyga, kurią ketinate tvarkyti

Kodėl Shutdown atšaukia už vykdytuvo užrakto ribų?

Todėl, kad atšaukimas jo viduje apverstų užrakto tvarką ir pakabintų jūsų vykdomą išjungimą. Shutdown(True) paima vykdytuvo užraktą, perjungia išjungimo vėliavėlę ir prideda kiekvieną laukiančią užduotį prie vietinio nuotraukos masyvo per AppendSnapshot. Tada jis atlaisvina užraktą ir tik po to vaikšto per nuotrauką, kviesdamas Cancel kiekvienam įrašui. Užduoties atšaukimas iškelia vartotojo atgalinius iškvietimus, registruotus jos žetono šaltinyje, o tie atgaliniai iškvietimai yra įprastas programos kodas: jie gali paklausti GetStats, pateikti kompensuojantį darbą ar laukti laisvos būsenos. Kiekvienas iš jų pakartotinai patenka į vykdytuvo užraktą, o atgalinis iškvietimas, iškviestas laikant tą užraktą, sukurtų aklavietę prieš save patį

Žetono šaltinis paklūsta tai pačiai drausmei vienu lygiu žemiau. CancelWithReason paima šaltinio užraktą, nusprendžia vienintelį laimintį atšaukėją, įrašo Reason, CancellationMessage ir CancelledAtTick, ir tik tada atomiškai perjungia atšaukimo vėliavėlę. Publikuoti prieš perjungiant yra tai, kas metaduomenis saugu skaityti: bet kuri gija, stebinti IsCancelled kaip True, garantuotai už jo ras pilną priežastį, o vėlesni iškvietėjai pralaimi lenktynes, grąžina False ir negali perrašyti pirmos priežasties. Registruoti atgaliniai iškvietimai fiksuojami nuotraukoje ir išvalomi užrakto viduje, tačiau iškviečiami už jo ribų, kiekvienas apvyniotas taip, kad viena nepavykusi tvarkyklė negalėtų nuslopinti likusiųjų. Jau vykdomos užduotys niekada nežudomos; jos baigiasi kooperatyviai, kai jų darbininko kūnas kitą kartą iškviečia ThrowIfCancelled, todėl Shutdown baigiasi siurbiančiu WaitForIdle prieš prijungiant gijas

Ar lygiagretūs darbininkai atlaisvina PDFium objektų nuosavybę?

Ne, ir tai riba, kurią lengviausiai neteisingai suprasti. TPdfAsyncExecutor planuoja darbą; jis nieko neteigia apie bet ko, ką liečiate to darbo viduje, gijos priklausomybę. Gyvas TPdf egzempliorius netampa lygiagrečiai pasiekiamas dėl to, kad du darbininkai atsitiktinai į jį iškviečia, o vidinis atvaizdavimo užraktas yra apsauga nuo persidengiančių atvaizdavimo iškvietimų, ne licencija dalintis dokumentu tarp gijų. Lygiagretus atvaizdavimas ar eksportas reiškia vieną TPdf vienam darbininkui, sukurtą ir sunaikintą darbo viduje

Delphi PDFium darbuotojų, kiekvieno kuriančio ir atlaisvinančio privatų TPdf egzempliorių vietoj vieno gyvo dokumento dalijimo per gijas, diagrama
Izoliacija kainuoja kiekvienam darbuotojui savąją analizę ir puslapių podėlį, bet avarija arba atšaukimas negali sugadinti bendrojo dokumento
type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // vienas dokumento egzempliorius kiekvienam darbininkui, niekada nesidalinama
  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);   // nuosavybė perkeliama į atsakymo etapą
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

Kaina reali ir verta paminėti: kiekvienas darbininkas apmoka savo pačio nagrinėjimą ir savo pačio puslapio podėlį, todėl atmintis mastelio didėja su darbininkų skaičiumi, o ne dokumentų skaičiumi. Tai yra modelio kaina, kur darbininką galima atšaukti ar sugadinti, nesugadinant nieko kito. Jei jūsų darbininkai vietoj to dalijasi peržiūros-pusės dokumentu, užrakinimo taisyklės aplink tai aprašytos atvaizdavimo užrakte ir iškvietimuose, kurie jo nepasiekia, o vieno dokumento atšaukiamas kelias yra atšaukiamame progresyviame atvaizdavime

Publikuotos sąsajos praplėtimas nesulaužant vtable

IPdfCancellationToken ir IPdfCancellationTokenSource yra COM-stiliaus sąsajos, kurias išoriniai dvejetainiai failai gali jau naudoti, todėl metodo pridėjimas prie bet kurios iš jų pastūmėtų kiekvieną vėlesnę vietą vtable ir tyliai neteisingai maršrutizuotų iškvietimus, kompiliuotus prieš seną išdėstymą. Diagnostikos, laukimo, pašalinamo atgalinio iškvietimo ir atominio-atšaukimo galimybės todėl gyvena IPdfCancellationTokenEx ir IPdfCancellationTokenSourceEx, kurios paveldi, o ne modifikuoja. New ir Run išlaiko savo pradinę semantiką esamiems iškvietėjams; naujas kodas siekia NewEx, NewTimeout ir RunEx, kai nori CancelWithReason, WaitForCancellation ar valdomą IPdfCancellationRegistration. Paveldėjimas yra vienintelis saugus būdas augint publikuotą sąsają, ir jis kainuoja vieną papildomą tipą kiekvienai kartai

NewTimeout nusipelno sąžiningos pastabos. Kiekvienas laiko limito šaltinis turi lengvą giją, laukiančią atšaukimo įvykio ar galutinio termino, kuris pirmas ateis. Saujelei ar keliems dešimtims galutinių terminų tai paprasta, žemos latencijos ir identiška Delphi, Lazarus ir C++Builder aplinkose. Tūkstančiams trumpų galutinių terminų tai neteisinga forma, ir jums reikėtų valdyti atšaukimą iš vieno programos lygio laikmačio, o ne laikyti tūkstančius laukiančių gijų

Nė viena iš šių taisyklių nėra egzotiška, kai parašyta, tačiau kiekviena iš jų yra produkcijos incidentas, kai neparašyta. Lauk teisingo etapo ir leisk kažkam siurbti synchronize eilę, skaityk QueueCapacity tik kaip laukimo eilės ribą, atšaukinėk už savo užraktų ribų ir duok kiekvienam darbininkui savo pačio dokumentą. Čia aprašytas asinchroninis sluoksnis pristatomas kaip Delphi PDFium Component dalis, kartu su atvaizdavimo, teksto ir formos API, kurį jis planuoja