Teknik Makale

Delphi'de İptal Edilebilir Futurelarla Arka Plan PDF Render

PDFium'da bir sayfayı render etmek senkrondur. Kütüphaneye çağrı yaparsınız, o verdiğiniz bitmap'e rasterleştirir ve kontrol, pikseller yazıldığında geri gelir. Tek bir ekran boyutunda sayfa, tek bir yakınlaştırma düzeyinde birkaç milisaniye sürer ve kimse fark etmez. 200 sayfalık bir belgenin 300 dpi dışa aktarımı ya da her sayfayı aynı anda rasterleştirmek zorunda olan bir küçük resim şeridi için aynı çağrı saniyelerine mal olur. Bu çağrıyı ana iş parçacığından yaparsanız mesaj döngüsü durur, pencere yeniden çizmeyi bırakır ve Windows başlık çubuğunuzun üzerine o korkunç "Not Responding" yazısını boyar. İş doğru; yanlış olan, onu çalıştırdığınız yerdir

Çözüm, uzun render işini bir arka plan iş parçacığına taşımak ve sonucu bitmap'in bir kontrole verilebildiği ana iş parçacığına geri getirmektir. PDFium bunu yapmanıza engel değildir ama devir teslimi güvenli kılması bağlayıcı katmanın işidir; çünkü "işçide çalıştır, arayüzde yanıtla" etrafındaki hata yüzeyi geniştir ve arızalar aralıklı gelir. PDFiumPas'taki FPdfAsync birimi, bu örüntüye tek bir doğru uygulama sunmak için vardır; üstelik uzun bir renderin gerçekte nasıl davrandığına uyan bir iptal modeliyle birlikte

İşin şekli

Üç işlem, bir renderin bir kareden uzun sürdüğü durumları domine eder. Toplu render bir sayfa aralığını gezer ve her sayfayı — genellikle diske — rasterleştirir. Çok sayfalı dışa aktarım aynısını yapar ama çıktıyı tek bir dosyada birleştirir. Arka plan sayfa renderi, kullanıcı henüz önbellekte olmayan bir sayfaya atladığında bir görüntüleyicinin yaptığı işidir; bitmap iş parçacığı dışında üretilir ve hazır olduğunda gösterilir. Üçü de aynı kısıtları paylaşır. UI iş parçacığının barındıramayacağı kadar uzun çalışırlar, UI iş parçacığının er geç ihtiyaç duyduğu bir sonuç üretirler ve kullanıcı bunlardan vazgeçebilir. Belgeyi kapatmak, sayfayı geçmek veya İptal'e basmak, kullanıcıyı artık istemediği bir çıktıyı bekletmek yerine işi durdurmalıdır

Tasarımı biçimlendiren kısıt en sonuncusudur. İptal edilemeyen bir render, belgeyi açık tutan ve cevap önemini yitirdikten sonra da CPU yakan bir renderdir. Bu yüzden birim, birbiriyle kurgulanan iki ilkelin etrafında kurulmuştur: sonucu geriye taşıyan bir future ve iptal talebini ileriye taşıyan bir belirteç

Fire-and-forget bir future

TPdfFuture<T>.Run bir işçi, bir yanıt ve isteğe bağlı bir iptal belirteci alır. İşçiyi bir arka plan iş parçacığında başlatır ve işçi bittiğinde yanıtı ana iş parçacığında iletir. T genel parametresi, render'in ürettiği şeydir; çoğunlukla bir bitmap tanıtıcısı ya da bir durum kaydı. İşçi iş parçacığı dışında çalışır; yanıt, VCL'ye dokunmanın güvenli olduğu yerde çalışır

Delphi'de iptal edilebilir arka plan PDF işlemesinin şeması: ana iş parçacığı bir çalışıcı ve bir yanıtla bir future başlatır; arka plan iş parçacığı sayfalar arasında bir iptal belirteci yoklar ve yanıt, ana iş parçacığında başarı, iptal edildi ve başarısız sonuçlarıyla çalışır
Future, işçiye salt okunur bir jeton verir ve yanıt ana iş parçacığına düşer; üç ulaşılabilir durum üzerinde bir kez dallanır
class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

Kasıtlı olarak eksik bırakılan şey, herhangi bir türde Wait yaklaşımıdır. Future tamamlanana kadar çağıranı bloklayan bir yöntem yoktur ve bu bir gözden kaçırma değildir. Ana iş parçacığından çağrılan bir Wait, bir arayüzü kilitlemenin klasik yoludur: işçi, yanıtını Synchronize ile ana iş parçacığa geçirmek için ana iş parçacığına ihtiyaç duyar; ana iş parçacığı Wait içinde park etmiştir ve iki taraf da ilerleyemez. Bu ilkelin sunulmaması, bu işi kendisi yazmayı deneyenlerin en çok devirdiği örüntüyü elemine eder. Gerçekten bloklaması gereken kod, sıradan bir TThread kullanıp sonuçlarına katlanmalıdır. Future, fire-and-forget durumu içindir; arka plan render de zaten tam olarak budur

Sonuç, TPdfFutureResult<T> içine sarılır; bu kayıt, yanına üç şeyden hangisinin gerçekleştiğini söyler. IsSuccess, işçinin normal döndüğünü ve Value'un render'i taşıdığını anlamına gelir. IsCancelled, belirtecin tetiklendiğini ve işçinin bir iptal noktasında çekildiğini anlamına gelir. IsFailure, işçinin istisna fırlattığını anlamına gelir ve ErrorMessage metni taşır. Yanıt, durumları bir kez inceler ve dallanır; bir sentinel değere bakarak dönen bitmap'in gerçek olup olmadığını tahmin etmez

Yanıt neden Queue ile değil Synchronize ile gider?

Bu birimdeki en öğretici kısım tek bir satırdır: yanıtın ana iş parçacığına nasıl döndüğü. Bariz tercih TThread.Queue'dur. Queue, yanıtı ana iş parçacığının kuyruğuna ekler ve anında döner; fire-and-forget bir future'un istediği şeye tam olarak benzer. Burada yanlıştır ve nedeni açıklamaya değer; çünkü bu, yazmayı düşünebileceğiniz bütün testleri geçen türden bir hatadır

İşçi iş parçacığı FreeOnTerminate := True ile oluşturulur. Bu, Execute döner dönmez iş parçacığının kendini söktüğü ve TThread.Destroy'un temizliğin bir parçası olarak RemoveQueuedEvents(Self)'i çağırdığı anlamına gelir. RemoveQueuedEvents, hedefi ölmekte olan iş parçacığı olan kuyruğa alınmış tüm metotları temizler. Dizi şöyle işler: işçi biter, yanıtı kendisine karşı kuyruğa ekler, Execute döner, iş parçacığı kendini yok eder ve RemoveQueuedEvents, ana iş parçacığın henüz çalıştırmadığı yanıtı siler. Sonuç tamamen kaybolur. Daha kötüsü, ana iş parçacık kuyruktaki yanıtı alıp çalıştırmaya başladığı an ile iş parçacığının serbest bırakıldığı an örtüştüğünde, yanıt yarısı yok edilmiş bir nesnenin alanlarına dokunur; bu bir use-after-free'dir

FPdfAsync bu yüzden yanıtı Queue ile değil Synchronize ile iletir. Synchronize, ana iş parçacık yanıtı sonuna kadar çalıştırana dek işçi iş parçacığını bloklar. Yanıt çalışırken işçi hâlâ yaşıyor; altından bir şey serbest bırakılacak yoktur ve iş parçacığı, yanıt iletilemeden Execute'tan dömez (dolayısıyla kendini yok etmeye başlamaz). Teslimat garantidir ve use-after-free penceresi yoktur

TThread.Queue'nun FreeOnTerminate Delphi çalışıcı iş parçacığını yok ederken bir future yanıtını neden sessizce düşürebildiğinin ve Synchronize'a geçmenin arka plan PDFium işlemesinde teslimatı nasıl garanti ettiğinin yan yana zaman çizelgesi
Queue, FreeOnTerminate'li bir iş parçacığının teslim edilmemiş kendi yanıtını temizlemesine izin verirdi; Synchronize, ana iş parçacığı yanıtı bitirene dek işçiyi canlı tutar
procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // zaten iptal edildi mi? çalışanı atla
    FResult.Value := FWorker(FToken);
  except
    on E: EPdfOperationCancelled do
    begin
      FResult.Status := pfsCancelled;
      FResult.ErrorMessage := E.Message;
    end;
    on E: Exception do
    begin
      FResult.Status := pfsFailure;
      FResult.ErrorMessage := E.Message;
    end;
  end;

  if Assigned(FReply) then
    // Synchronize değil Queue: bu iş parçacığı FreeOnTerminate, bu yüzden kuyruğa alınmış bir yanıt
    // ana iş parçacığı çalıştırmadan önce RemoveQueuedEvents tarafından düşürülebilir.
    Synchronize(DispatchReply);
end;

Genel ders, tek bir birimin çok ötesine uzanır. Fire-and-forget eşzamansız geri çağrılar, ince biçimde yanlış yapması en kolay eşzamanlılık örüntüsüdür; çünkü mutlu yol ilk denemede çalışır ve hata, iş parçacığı söküm sırası ile kuyruk arasındaki etkileşimde yaşar. İstek üzerine yeniden üretilemez. Ana iş parçacığın kuyruğu boşaltmış olmasına ve işçinin kendini yok etmiş olmasına bağlıdır; zamanlayıcı bunu her koşuda farklı karar verir. Bağlayıcı katmanda bir kez doğru yapılmış bir ilkel, arka plan render gerektiren her uygulamada aynı kodu yeniden türetmekten çok daha değerlidir

Geri çağrılar neden metot işaretçileri?

İşçi ve yanıt anonim metot değildir. procedure of object türleridir — TPdfFutureWorker<T> ve TPdfFutureReply<T> — ve bu tercih derleyici matrisi tarafından dayatılmıştır. PDFiumPas, Delphi XE5 ve üzerinde ve Delphi modundaki Free Pascal 3.2 üzerinde derlenir; o modda FPC 3.2 anonim metotları desteklemez. Yerel değişkenleri yakalayan bir reference-to-procedure geri çağrı Delphi'de derlenir, FPC'de derlenmez; bu yüzden birim, iki derleyicinin de kabul ettiği en düşük ortak paydayı kullanır

Pratik sonuç, durumun nerede yaşadığıdır. Bir anonim metot yerelleri kapatır; bir metot işaretçisi kapatmaz. Bu yüzden işçinin ihtiyaç duyduğu her durum — sayfa indeksi, yakınlaştırma, çıktı yolu — ve yanıtın güncellemesi gereken her durum — hedef görüntü kontrolü veya ilerleme etiketi — metodu geçirilen nesneye asılmalıdır. Bir görüntüleyicide bu nesne genellikle form ya da formun sahiplendiği bir render denetleyicisidir. Bu, istemeye istemeye kabullenilen bir geçici çözüm değildir; durumun sahipliğini, kapanışın içine gizlemek yerine, alan nesne üzerinde açık ve görünür kılar

Sert sonlandırma değil, işbirlikçi iptal

Buradaki iptal işbirlikçidir. İşçi iş parçacığının içine girip onu sonlandıran bir API yoktur; çünkü render ortasında bir iş parçacığını sonlandırmak, PDFium'u kilitlerin ve yarısı yazılmış bitmaplerin başında bırakır ve zorla öldürmeden sonraki süreç durumu hakkında akıl yürütülebilir bir şey kalmaz. Onun yerine işçiye salt okunur bir belirteç verilir ve onu denetlemesi beklenir; render döngüsü de durmanın temiz olduğu yerlerde — sayfalar arasında ya da döşemeler arasında — denetlenecek şekilde yazılır

Belirteç, iptali gözlemenin üç yolunu sunar. IsCancelled, kendisi test edip karar vermek isteyen bir döngü için ucuz bir boolean yoklamasıdır. ThrowIfCancelled yaygın durumdur: doğal bir iptal noktasında çağırın, iptal istenmişse EPdfOperationCancelled fırlatır ve istisna işçiyi doğrudan future'a geri sarar. RegisterCallback, kaynak iptal edildiğinde bir kez tetiklenen tek atımlık bir bildirim ekler; işçinin sıkı bir döngüde değil, kesintiye uğratabileceği bir şeyin içinde bloke olduğu durumlarda kullanışlıdır

İş parçacığı sınırının önem taşıdığı yer istisnadır. İşçi EPdfOperationCancelled fırlattığında future bunu yakalar ve iptal edilmiş durumuna çevirir; yani yanıt bir başarısızlık değil IsCancelled görür. İstisna nesnesinin kendisi asla ana iş parçacığa taşınmaz. İşçi iş parçacığında doğar ve orada ölür; yalnızca mesaj dizisi ErrorMessage'a kopyalanır. Yaşayan bir istisna nesnesini iş parçacıkları arasında taşımak, işi bitmekte olan bir iş parçacığının sahiplendiği belleğe el atmak anlamına gelir; Synchronize ile teslimatın var olma nedeni de tam olarak bu sınıf hatayı önlemektir. Sınırı bir durum kodu ve bir dizi temiz geçer; bir nesne geçmez

Delphi için PDFium Component'te işbirlikçi iptal modelinin şeması: form yazılabilir iptal belirteci kaynağına sahip olur; arka plan çalışıcısı yalnızca temiz noktalarda yoklanan salt-okunur bir belirteç alır ve sonuçlar, iş parçacıkları sonlandırılmadan başarı, iptal veya başarısızlığa dönüşür
İptal işbirlikçidir: form yazılabilir kaynağı elinde tutar, işçi yalnızca yoklama üyelerini görür ve istisna asla iş parçacığı sınırını geçmez

İki arayüz: işçi kendini iptal edemesin

İptal, bilinçli olarak iki arayüze bölünmüştür. IPdfCancellationTokenSource yazma tarafıdır: Cancel içerir ve onu oluşturan sahibi — genellikle form — elinde tutar ve kullanıcı düğmeye bastığında ya da form kapandığında Cancel çağırır. IPdfCancellationToken okuma tarafıdır: IsCancelled, ThrowIfCancelled ve RegisterCallback içerir ve işçinin aldığı şey tam olarak budur. Tek bir somut nesne ikisini de uygular ama işçiye yalnızca belirteç verilir; yani çalıştırdığı işlemi iptal etmesinin bir yolu yoktur. Bu bölünme API düzeyinde bir koruma bariyeridir. Belirteci üzerinden Cancel'a ulaşabilen bir işçi, kafa karışmış bir kod parçasını kendini iptal etmeye davet ederdi; tip sistemi bu olasılığı ortadan kaldırır

Çağıranın bir render istediği ama asla iptal etmeyi amaçlamadığı durum için eşleşen bir ayrıntı vardır. Birim, her çağrı için taze bir kaynak zorlamak yerine kalıcı olarak iptal-edilmemiş durumda olan tekil bir belirteç olan PdfNoCancellationToken'ı açığa çıkarır. Run, belirteç argümanı nil bırakıldığında onu yerine koyar. Bu tekil, ilk kullanımda tembel olarak değil, birim başlatması sırasında baştan inşa edilir; nedeni yine eşzamanlılıktır. Farklı işçi iş parçacıklarındaki birkaç Run çağrısı aynı anda tembel oluşturulan bir tekile uzansaydı, inşasında yarışabilir, bir kopya sızdırabilir ya da yarısı başlatılmış bir örnek görebilirdi. Onu herhangi bir işçi çalışmadan önce inşa etmek, yarışı tümüyle ortadan kaldırır

İptal edilebilir bir render çalıştırmak

Pratikte bir kaynak oluşturur, onu formda tutar, Token'ını bir işçi metodu ve bir yanıt metoduyla birlikte Run'a geçersiniz ve İptal düğmesini kaynağa bağlarsınız. İşçi render ederken belirteci denetler; yanıt, sonuç döndüğünde arayüzü bir kez günceller. Geri çağrılar metot işaretçileri olduğundan, işçi ve yanıt formun alanlarından ihtiyaç duydukları her şeyi okur

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // alan, form üzerinde yaşar
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // çalışan bunu bir sonraki iptal noktasında gözlemler
end;

// Arka plan iş parçacığında çalışır. FPageRange / FOutputDir değerlerini formdan okur.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // sayfalar arasında temiz duruş
    RenderOnePage(PageIndex);       // senkron PDFium rasterleştirmesi
  end;
  Result := True;
end;

// Ana iş parçacığında çalışır. Burada VCL'ye dokunmak güvenlidir.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
  if AResult.IsSuccess then
    StatusLabel.Caption := 'Render complete'
  else if AResult.IsCancelled then
    StatusLabel.Caption := 'Cancelled'
  else
    StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;

Yanıt üç sonucu da ele alır; çünkü üçü de ulaşılabilirdir. Biten bir render başarı bildirir, İptal'e basan kullanıcı iptal edildi dalını görür ve yazılamayan bir dosya ya da ayrıştırılamayan bir sayfa, mesajlı bir başarısızlık olarak gelir. Bu dalların hiçbiri bloklamaz, hiçbiri işçi iş parçacığına dokunmaz ve işçinin ürettiği bitmap ya da durum, future tarafından UI'ye sahip iş parçacığında teslim edildikten sonra okunur

Aynı iş parçacığı disiplini bir görüntüleyicinin başka yerlerinde de karşılığını verir. Render edilen bitmaplerin yakınlaştırma değişimlerinde saklanması ve yeniden kullanılması render önbelleği ve yakınlaştırma performansı notumuzda, PDFium sınırını Delphi altında güvenli tutmanın daha geniş sorusu ise PDFium Component ABI'sini bellek güvenliği için sağlamlaştırma yazısında ele alınır. Burada anlatılan async altyapısı, Delphi ve C++Builder için PDFium Component'in parçası olarak, bu blogda başka yerde ele alınan rendering, metin ve form API'leriyle birlikte gelir