Tehnički članak

WaitForIdle Deadlock u Delphi PDFium asinkronom renderiranju

Serijsko renderiranje se zamrzne na pola puta jer izvršitelj u PDFium Component ne smatra zadatak dovršenim dok njegov odgovor nije otpremljen. Pod padSynchronize taj odgovor se pokreće na glavnoj dretvi. Ako se glavna dretva blokira bez pumpanja CheckSynchronize, radnik čeka na glavnu dretvu dok glavna dretva čeka na mirovanje

Slika u debuggeru je nepogrešiva čim je jednom vidite. Zaustavite zamrznuti proces i glavna dretva sjedi unutar čekanja na događaj mirovanja, nekoliko okvira ispod vaše vlastite serijske petlje. Prebacite se na bilo koju radnu dretvu i ona sjedi unutar TThread.Synchronize, držeći dovršen rezultat koji ne može predati. Ništa se ne vrti, nijedan CPU ne gori, proces je jednostavno parkiran. Ovaj članak govori o tome zašto to stanje uopće postoji, i o tri susjedna pravila koja odlučuju ponaša li se Delphi bazen radnika nad PDFium-om ispravno ili grize: što QueueCapacity zapravo ograničava, kojim redoslijedom gašenje mora otkazati, i što paralelizam ne kupuje kad je riječ o vlasništvu PDFium objekata

Zašto WaitForIdle zamrzne glavnu dretvu?

Zamrzava jer je mirovanje u TPdfAsyncExecutor definirano da uključuje otpremu odgovora, ne samo dovršenje radnika. Brojač koji se izvodi povećava se u DequeueTask kad radnik preuzme zadatak, a smanjuje se u TaskFinished, koju radnik poziva tek nakon što se TPdfAsyncTaskOperation.Execute vratila. Ta metoda pokreće tijelo radnika, bilježi ishod, i zatim otprema odgovor prema TPdfAsyncDispatchMode. S padSynchronize otprema je poziv TThread.Synchronize, pa se Execute ne vraća dok glavna dretva to nije pokrenula

Delphi stavlja drugu polovicu tog ugovora na vas. TThread.Synchronize dodaje metodu u globalni red čekanja i blokira pozivajuću dretvu na događaju; nešto na glavnoj dretvi mora pozvati CheckSynchronize prije nego se taj događaj ikad signalizira. VCL petlja poruka to radi umjesto vas između poruka, što je upravo razlog zašto je bug nevidljiv tijekom interaktivne upotrebe, a pojavljuje se čim napišete blokirajuću serijsku petlju. Blokirajuća glavna dretva je glavna dretva koja je napustila petlju poruka, a glavna dretva izvan petlje poruka ne prazni nikoga

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.

Dovršenje zadatka i mirovanje izvršitelja dva su različita orijentira

Namjerno su odvojeni, a znati koji od njih čekate cijeli je popravak. IPdfAsyncTask.WaitFor je zadovoljen u trenutku kad se rezultat radnika odluči: Complete upisuje konačno TPdfAsyncTaskState i postavlja događaj dovršeno prije nego se ijedan odgovor razmotri. TPdfAsyncExecutor.WaitForIdle je zadovoljen kasnije, tek kad su i broj u redu čekanja i broj koji se izvode nula, a broj koji se izvodi ne pada dok odgovor nije sletio. Dakle zadatak može biti patsSucceeded i vidljiv kroz Snapshot dok je izvršitelj još legitimno zauzet

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

Jedna posljedica vrijedna internalizacije: odgovor koji baci iznimku ne prepisuje povijest. DispatchReply hvata iznimku i pohranjuje je u ReplyErrorMessage, ostavljajući State, CancellationReason i ErrorMessage točno onakvima kakve ih je radnik odredio. UI povratni poziv koji eksplodira dok crta sličicu stoga nikad ne pretvori uspješno renderiranje u neuspjelo, a vaša telemetrija i dalje prijavljuje ono što je motor renderiranja stvarno radio. Ako želite API u obliku povratnog poziva oko jedne operacije umjesto bazena, pozadinsko renderiranje s otkazivim futures-ima pokriva taj put

Ograničava li QueueCapacity i radnike koji se izvode?

Ne. QueueCapacity u PDFium Component broji samo zadatke u redu čekanja, nikad one koji se već izvode na radniku. To je namjerno: kapacitet je namijenjen izražavanju stvarnog protutlaka na liniji čekanja, a stapanje fiksnih utora paralelizma u isti broj bi ih brojalo dvaput. S četiri radnika i kapacitetom osam možete imati dvanaest zadataka u letu, a GetStats pošteno prijavljuje podjelu kroz QueuedCount i 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;

Četiri trake TPdfAsyncPriority su stroge, ne ponderirane. DequeueTask prolazi od papCritical prema dolje do papLow i uzima prvu nepraznu traku, čuvajući FIFO redoslijed unutar svake. To daje interaktivnom zahtjevu čist način da preskoči ispred serijskog posla koji još nije počeo, ali nikad ne prekida posao koji se već izvodi, a pozivatelj koji stalno hrani papCritical može neograničeno izgladnjeti papLow. Rezervirajte gornje dvije trake za stvari na koje čovjek vidljivo čeka, i ostavite masovni izvoz na papNormal ili niže. Koristite Submit kad je pun red čekanja programska pogreška vrijedna EPdfAsyncQueueFull, a TrySubmit kad je to normalan uvjet koji namjeravate obraditi

Zašto Shutdown otkazuje izvan brave izvršitelja?

Zato što otkazivanje unutar nje obrne redoslijed brave i zamrzne gašenje koje pokušavate izvesti. Shutdown(True) uzima bravu izvršitelja, prevrne zastavicu gašenja, i dodaje svaki zadatak na čekanju u lokalno polje snimke kroz AppendSnapshot. Zatim otpušta bravu i tek nakon toga prolazi snimku pozivajući Cancel na svakom unosu. Otkazivanje zadatka pokreće korisničke povratne pozive registrirane na njegovom izvoru tokena, a ti povratni pozivi su obični aplikacijski kôd: mogu upitati GetStats, poslati kompenzacijski posao, ili čekati na mirovanje. Svaki od njih ponovno ulazi u bravu izvršitelja, a povratni poziv pozvan dok je ta brava držana bi se zaledio sam protiv sebe

Izvor tokena poštuje istu disciplinu jednu razinu niže. CancelWithReason uzima bravu izvora, odlučuje jedinog pobjedničkog otkazivača, upisuje Reason, CancellationMessage i CancelledAtTick, i tek tada atomarno okreće zastavicu otkazano. Objavi-prije-okreni je ono što čini metapodatke sigurnima za čitanje: bilo koja dretva koja opazi IsCancelled kao True zajamčeno pronalazi potpun razlog iza toga, a kasniji pozivatelji gube utrku, vraćaju False, i ne mogu prepisati prvi razlog. Registrirani povratni pozivi se snimaju i brišu unutar brave, ali pozivaju izvan nje, svaki omotan tako da jedan neuspjeli rukovatelj ne može suzbiti ostale. Zadaci koji se već izvode nikad se ne ubijaju; završavaju kooperativno kad njihovo tijelo radnika sljedeći put pozove ThrowIfCancelled, zato Shutdown završava pumpajućim WaitForIdle prije pridruživanja dretvama

Opuštaju li paralelni radnici vlasništvo PDFium objekata?

Ne opuštaju, i ovo je granica koja se najlakše pogrešno pročita. TPdfAsyncExecutor raspoređuje posao; ne daje nikakvu tvrdnju o pripadnosti dretvi bilo čega što dotaknete unutar tog posla. Živa instanca TPdf ne postaje istovremeno dostupna zato što je dva radnika slučajno pozovu; interna brava renderiranja je zaštita protiv preklapajućih poziva renderiranja, ne dozvola za dijeljenje dokumenta preko dretvi. Paralelno renderiranje ili izvoz znači po jedan TPdf po radniku, stvoren i uništen unutar posla

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;

Cijena je stvarna i vrijedi je navesti: svaki radnik plaća svoje parsiranje i svoju predmemoriju stranica, pa memorija skalira s brojem radnika, a ne s brojem dokumenata. To je cijena modela u kojem se radnik može otkazati ili srušiti bez da pokvari nekog drugog. Ako vaši radnici umjesto toga dijele dokument sa strane prikaza, pravila zaključavanja oko toga pokrivena su u bravi renderiranja i pozivima koji je promašuju, a otkazivi put jednog dokumenta je u otkazivom progresivnom renderiranju

Proširivanje objavljenog sučelja bez lomljenja vtable-a

IPdfCancellationToken i IPdfCancellationTokenSource su sučelja u COM stilu koja vanjski binarni fajlovi možda već koriste, pa bi dodavanje metode bilo kojem od njih pomaklo svaki kasniji slot u vtable-u i tiho pogrešno usmjerilo pozive kompajlirane protiv starog rasporeda. Dijagnostičke, čekajuće, uklonjive-povratni-poziv i atomske-otkazivanje sposobnosti stoga žive u IPdfCancellationTokenEx i IPdfCancellationTokenSourceEx, koji nasljeđuju umjesto da mijenjaju. New i Run zadržavaju svoju izvornu semantiku za postojeće pozivatelje; novi kôd poseže za NewEx, NewTimeout i RunEx kad želi CancelWithReason, WaitForCancellation ili upravljanu IPdfCancellationRegistration. Nasljeđivanje je jedini siguran način rasta objavljenog sučelja, i košta jedan dodatni tip po generaciji

NewTimeout zaslužuje iskrenu napomenu. Svaki izvor isteka posjeduje laganu dretvu koja čeka na događaj otkazivanja ili rok, ovisno što prije stigne. Za šačicu ili nekoliko desetaka rokova to je jednostavno, niske latencije i identično kroz Delphi, Lazarus i C++Builder. Za tisuće kratkih rokova to je pogrešan oblik, i trebali biste umjesto toga pokretati otkazivanje iz jednog timera na razini aplikacije, a ne držati tisuće čekajućih dretvi

Nijedno od ovih pravila nije egzotično jednom kad se zapiše, ali svako od njih je proizvodni incident kad nije. Čekajte na pravi orijentir i pustite nešto da pumpa red sinkronizacije, čitajte QueueCapacity kao granicu samo na liniji čekanja, otkazujte izvan svojih brava, i dajte svakom radniku vlastiti dokument. Asinkroni sloj opisan ovdje isporučuje se kao dio Delphi PDFium Component, uz API-je renderiranja, teksta i obrazaca koje raspoređuje