Teknik Makale

Delphi PDFium Async Rendering'de WaitForIdle Deadlock'u

PDFium Component'teki executor, yanıtı gönderilene kadar bir görevi bitmiş saymadığı için bir toplu render işi yolun ortasında donar. padSynchronize altında bu yanıt ana iş parçacığında çalışır. Ana iş parçacığı CheckSynchronize'ı pompalamadan bloke olursa, işçi ana iş parçacığını beklerken ana iş parçacığı boşta olmayı bekler

Bir kez görüldükten sonra hata ayıklayıcı görüntüsü karıştırılamaz. Donmuş süreci duraklatın, ana iş parçacığı, kendi toplu döngünüzün birkaç çerçeve altında, boşta olay bekleme içinde oturur. Herhangi bir işçi iş parçacığına geçin, TThread.Synchronize içinde oturur, devredemediği bitmiş bir sonucu tutar. Hiçbir şey dönmez, hiçbir CPU yanmaz, süreç basitçe park edilmiştir. Bu yazı, bu durumun neden var olduğu ve PDFium üzerindeki bir Delphi işçi havuzunun düzgün davranıp davranmadığını ya da ısırıp ısırmadığını belirleyen üç komşu kural hakkındadır: QueueCapacity gerçekte neyi sınırlar, kapatma hangi sırayla iptal etmek zorundadır ve paralellik PDFium nesne sahipliği söz konusu olduğunda size ne satın almaz

WaitForIdle neden ana iş parçacığını asar?

Asar çünkü TPdfAsyncExecutor'da boşta olmak, yalnızca işçi tamamlanmasını değil yanıt gönderimini de içerecek şekilde tanımlanmıştır. Çalışan sayısı, bir işçi bir görevi aldığında DequeueTask'ta artırılır ve yalnızca TPdfAsyncTaskOperation.Execute döndükten sonra işçinin çağırdığı TaskFinished'ta azaltılır. O metot, işçi gövdesini çalıştırır, sonucu kaydeder ve ardından TPdfAsyncDispatchMode'a göre yanıtı gönderir. padSynchronize ile gönderim bir TThread.Synchronize çağrısıdır, bu yüzden ana iş parçacığı bunu çalıştırana kadar Execute dönmez

Delphi, o sözleşmenin ikinci yarısını size bırakır. TThread.Synchronize, metodu genel bir kuyruğa ekler ve çağıran iş parçacığını bir olayda bloke eder; ana iş parçacığında bir şeyin, o olay hiç sinyallenmeden önce CheckSynchronize'ı çağırması gerekir. VCL mesaj döngüsü bunu mesajlar arasında sizin için yapar; bu tam olarak hatanın etkileşimli kullanım sırasında görünmez olmasının ve bloke edici bir toplu döngü yazdığınız anda ortaya çıkmasının nedenidir. Bloke eden bir ana iş parçacığı, mesaj döngüsünden ayrılmış bir ana iş parçacığıdır ve mesaj döngüsünün dışındaki bir ana iş parçacığı kimseyi boşaltmıyordur

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.

Görev tamamlanması ve executor boşta olma iki farklı kilometre taşıdır

Kasıtlı olarak ayrılmışlardır ve hangisini beklediğinizi bilmek düzeltmenin tamamıdır. IPdfAsyncTask.WaitFor, işçi sonucu karara bağlandığı anda tatmin olur: Complete, herhangi bir yanıt değerlendirilmeden önce nihai TPdfAsyncTaskState'i yazar ve done olayını ayarlar. TPdfAsyncExecutor.WaitForIdle daha sonra tatmin olur, kuyruğa alınan ve çalışan sayıların ikisi de sıfır olduğunda, ve çalışan sayısı yanıt varana kadar düşmez. Bu yüzden bir görev patsSucceeded olabilir ve Snapshot aracılığıyla gözlemlenebilirken, executor hâlâ meşrü olarak meşgul olabilir

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

İçselleştirilmeye değer bir sonuç: hata fırlatan bir yanıt geçmişi yeniden yazmaz. DispatchReply, exception'ı yakalar ve ReplyErrorMessage'da saklar; State, CancellationReason ve ErrorMessage'ı işçinin belirlediği şekilde tam olarak bırakır. Bir küçük resim boyarken patlayan bir UI callback'i bu yüzden başarılı bir render'ı asla başarısız bir render'a dönüştürmez ve telemetriniz render motorunun gerçekte ne yaptığını bildirmeye devam eder. Bir havuz yerine tek bir işlem etrafında callback-şekilli API isterseniz, iptal edilebilir future'larla arka plan rendering o yolu kapsar

QueueCapacity çalışan işçileri de sınırlar mı?

Hayır. PDFium Component'te QueueCapacity, yalnızca kuyruğa alınmış görevleri sayar, bir işçi üzerinde zaten çalışmakta olanları asla saymaz. Bu kasıtlıdır: kapasite, bekleme hattı üzerindeki gerçek geri basıncı ifade etmek içindir ve sabit eşzamanlılık yuvalarını aynı sayıya katlamak onları iki kez saymak olurdu. Dört işçi ve sekizlik bir kapasiteyle, uçuşta on iki göreve sahip olabilirsiniz ve GetStats, ayrımı QueuedCount ve RunningCount aracılığıyla dürüstçe raporlar

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'nin dört şeridi, ağırlıklı değil, katıdır. DequeueTask, papCritical'dan papLow'a doğru gezer ve boş olmayan ilk şeridi alır, her şerit içinde FIFO sırasını koruyarak. Bu, etkileşimli bir isteğe henüz başlamamış bir toplu işin önüne geçmek için temiz bir yol verir, ama zaten çalışan işi hiçbir zaman kesmez ve papCritical'ı sürekli besleyen bir çağıran, papLow'u süresiz olarak aç bırakabilir. Üst iki şeridi bir insanın görünür biçimde beklediği şeyler için ayırın ve toplu dışa aktarmayı papNormal ya da altında bırakın. Dolu bir kuyruğun bir EPdfAsyncQueueFull'a değer bir programlama hatası olduğu durumda Submit'i, ele almayı düşündüğünüz normal bir koşul olduğunda TrySubmit'i kullanın

Shutdown neden executor kilidinin dışında iptal eder?

Çünkü içeride iptal etmek, kilit sırasını tersine çevirir ve gerçekleştirmeye çalıştığınız kapatmayı asar. Shutdown(True), executor kilidini alır, kapatma bayrağını çevirir ve AppendSnapshot aracılığıyla bekleyen her görevi yerel bir anlık görüntü dizisine ekler. Ardından kilidi serbest bırakır ve yalnızca sonrasında anlık görüntüyü gezip her girdide Cancel'ı çağırır. Bir görevi iptal etmek, token kaynağında kayıtlı kullanıcı callback'lerini tetikler ve bu callback'ler sıradan uygulama kodudur: GetStats'i sorgulayabilir, telafi edici iş gönderebilir ya da boşta olmayı bekleyebilirler. Bunların her biri executor kilidine yeniden girer ve o kilit tutulurken çağrılan bir callback kendisine karşı deadlock olurdu

Token kaynağı bir seviye aşağıda aynı disipline uyar. CancelWithReason, kaynak kilidini alır, tek kazanan iptalciye karar verir, Reason, CancellationMessage ve CancelledAtTick'i yazar ve yalnızca sonra iptal bayrağını atomik olarak çevirir. Çevirmeden önce yayınlamak, meta veriyi okumak için güvenli kılan şeydir: IsCancelled'ı True olarak gözlemleyen herhangi bir iş parçacığı, arkasında eksiksiz bir neden bulacağını garanti eder ve daha sonraki çağıranlar yarışı kaybeder, False döndürür ve ilk nedenin üzerine yazamaz. Kayıtlı callback'ler kilidin içinde anlık görüntüsü alınır ve temizlenir ama dışında çağrılır; her biri, başarısız olan bir işleyicinin geri kalanını bastıramaması için sarmalanır. Zaten çalışmakta olan görevler asla öldürülmez; işçi gövdeleri bir sonraki ThrowIfCancelled çağrısında işbirlikçi biçimde biter; bu yüzden Shutdown, iş parçacıklarına katılmadan önce pompalayan bir WaitForIdle ile biter

Paralel işçiler PDFium nesne sahipliğini gevşetir mi?

Gevşetmezler ve bu, yanlış okunması en olası sınırdır. TPdfAsyncExecutor işi zamanlar; o iş içinde dokunduğunuz herhangi bir şeyin iş parçacığı ilişkisi hakkında hiçbir iddiada bulunmaz. Canlı bir TPdf örneği, iki işçinin tesadüfen onu çağırması nedeniyle eşzamanlı olarak erişilebilir hale gelmez ve dahili render kilidi, çakışan render çağrılarına karşı bir koruyucudur, bir dokümanı iş parçacıkları arasında paylaşmaya izin değil. Paralel rendering ya da dışa aktarma, iş içinde oluşturulan ve yok edilen işçi başına bir TPdf demektir

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;

Maliyet gerçektir ve adlandırılmaya değer: her işçi kendi ayrıştırmasını ve kendi sayfa önbelleğini öder, bu yüzden bellek, doküman sayısıyla değil işçi sayısıyla ölçeklenir. Bu, bir işçinin başkasını bozmadan iptal edilebileceği ya da çökebileceği bir modelin bedelidir. İşçileriniz bunun yerine görüntüleyici-tarafı bir doküman örneğini paylaşıyorsa, bunun etrafındaki kilitleme kuralları render kilidi ve onu kaçıran çağrılarda ele alınmıştır ve tek-doküman iptal edilebilir yol iptal edilebilir aşamalı renderingdedir

vtable'ı bozmadan yayınlanmış bir arayüzü genişletmek

IPdfCancellationToken ve IPdfCancellationTokenSource, harici ikili dosyaların zaten tüketiyor olabileceği COM-tarzı arayüzlerdir, bu yüzden herhangi birine bir metot eklemek, vtable'daki her sonraki yuvayı kaydırır ve eski düzene karşı derlenmiş çağrıları sessizce yanlış yönlendirir. Tanılama, bekleme, kaldırılabilir-callback ve atomik-iptal yetenekleri bu yüzden değiştirmek yerine miras alan IPdfCancellationTokenEx ve IPdfCancellationTokenSourceEx'te yaşar. New ve Run, mevcut çağıranlar için orijinal anlambilimlerini korur; CancelWithReason, WaitForCancellation ya da yönetilen bir IPdfCancellationRegistration istediğinde yeni kod NewEx, NewTimeout ve RunEx'e uzanır. Miras, yayınlanmış bir arayüzü büyütmenin tek güvenli yoludur ve nesil başına bir ek tür maliyeti getirir

NewTimeout dürüst bir not hak eder. Her zaman aşımı kaynağı, iptal olayını ya da son teslim tarihini, hangisi önce gelirse onu bekleyen hafif bir iş parçacığına sahiptir. Bir avuç ya da birkaç düzine son tarih için bu basit, düşük gecikmeli ve Delphi, Lazarus ve C++Builder boyunca aynıdır. Binlerce kısa son tarih için bu yanlış şekildir ve binlerce bekleyen iş parçacığı tutmak yerine iptali tek bir uygulama seviyesi zamanlayıcıdan yönlendirmelisiniz

Bu kuralların hiçbiri yazıldıktan sonra egzotik değildir, ama her biri yazılmadığında bir üretim olayıdır. Doğru kilometre taşını bekleyin ve bir şeyin synchronize kuyruğunu pompalamasına izin verin, QueueCapacity'yi yalnızca bekleme hattı üzerinde bir sınır olarak okuyun, kilitlerinizin dışında iptal edin ve her işçiye kendi dokümanını verin. Burada anlatılan async katmanı, zamanladığı rendering, metin ve form API'lerinin yanı sıra Delphi PDFium Component'in bir parçası olarak sunulur