Teknik Makale

Delphi'de Paralel PDF Sayfa İşleme: İş Parçacığı Güvenliği

Delphi'de paralel olarak PDF sayfaları işlemek tek bir kurala dayanır: her işçi iş parçacığına kendi işleyicisini verin. losLab PDF Library, tam olarak bu iş için RenderPagesToFilesParallel yöntemini sunar; sayfa aralığını işçi başına bir TPDFlib örneği ile bir TTask havuzuna dağıtır, böylece çok çekirdekli bir makine toplu bir pikselleştirme (raster) işini çekirdek sayısına yakın ölçeklenen bir çıktıya dönüştürür. Bunun yerine tek bir örneği iş parçacıkları arasında paylaşırsanız, işlem yavaşlamakla kalmaz, belleği bozar ve çöker

Bu makale, gece yarısı çalışan bir görevin 500 sayfalık bir PDF'i 500 PNG'ye dönüştürmesi gerektiğinde, sistemin 16 boş çekirdeği olduğunda ve iş parçacığı kullanmaya yönelik ilk dürüst denemenizin GDI+ içinde çöktüğü durumlarda başvurduğunuz kaynaktır. Kısacası, burmadaki iş parçacığı güvenliği ayarladığınız bir bayrak değil, yapısal bir özelliktir; bu yazının geri kalanı güvenli yapının neden bu şekilde göründüğünü ve gerçek hızlanma tavanının tam olarak nerede bulunduğunu açıklamaktadır

TPDFlib paralel işleme için iş parçacığı güvenli midir?

Hayır, ve bunun etrafında bir tasarım yapmadan önce nedenini anlamakta fayda vardır. Tek bir TPDFlib örneği tek iş parçacıklı kullanım için bildirilmiştir ve buradaki keskin kenar TPDFPageTree.GetPage yöntemidir: sayfa seçmenin bir yan etkisi olarak örnek üzerindeki ortak bir FPagePointer alanına yazar. Aynı örneği çağıran iki iş parçacığı bu alan üzerinde yarışır (race condition), bu nedenle işçi A sayfa 3'ün yarısındayken işçi B sayfa ağacını sayfa 40'a yönlendirebilir. API, aşağıdaki kodu yazmanızı engellemez ve hatta hata vermeden önce birkaç sayfa için çalışabilir, bu da böyle bir hatanın davranabileceği en kötü yoldur

// DO NOT do this: one shared instance, many threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // every thread reenters the same instance -> data race on FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

Hata deterministik değildir; bu da tam olarak neden hızlı bir duman testinden geçip daha sonra farklı bir çekirdek sayısına ve daha ağır bir belgeye sahip bir müşteri makinesinde yüzeye çıktığını açıklar. Bunu ucuza çözmek için RenderPageToFile etrafına sarabileceğiniz bir kilit de yoktur, çünkü tüm işleme çağrısı boyunca bir mutex tutmak işi sıralı hale getirir ve hedeflediğiniz paralelliği çöpe atar

Her işleme işçisinin neden kendi TPDFlib örneğine ihtiyacı vardır?

Çünkü örnek, yalıtım biriminin kendisidir. Her işçi, dosyayı bağımsız olarak yükleyen özel bir TPDFlib'e sahip olduğunda, her birinin kendi sayfa ağacı, kendi FPagePointer'ı ve kendi işleme durumu olur, böylece yarışacak ortak hiçbir şey kalmaz. Bu güvenliğin önceden değerlendirmeniz gereken bir bedeli vardır: her işçi tüm belgeyi belleğe ayrıştırır, bu nedenle tepe bellek kullanımı kabaca tek örnek maliyetinin N katıdır. 300 MB'lık bir PDF üzerinde çalışan sekiz işçi, aynı anda sekiz tam ayrıştırmanın bellekte bulunması demektir ve çok büyük girdilerde bu, CPU değil, işçi sayınızı belirleyen kısıtlamadır. Belge çok büyük olduğunda ve CPU'dan ziyade bellek sınırındaysanız, tüm belgeyi ayrıştırmadan büyük PDF'leri işleme makalesindeki doğrudan erişim yolu genellikle daha fazla iş parçacığı kullanmaktan daha iyi bir kaldıraçtır

Tek çağrılık API: RenderPagesToFilesParallel

losLab PDF Library, tüm güvenli deseni tek bir yöntemin arkasında paketler, böylece yaygın durumlar için bunların hiçbirini kendiniz yazmak zorunda kalmazsınız. RenderPagesToFilesParallel dosya adını ve şifresini, bir DPI değerini, dahil edici başlangıç ve bitiş sayfasını, doğrudan sayfa başına pikselleştirme yoluna iletilen bir Options değerini, %p'nin sayfa numarasıyla değiştirildiği bir çıktı şablonunu ve sıfıra eşit veya daha küçük herhangi bir değerin otomatik anlamına geldiği bir işçi sınırını kabul eder. Başarıyla işlenen sayfa sayısını döndürür ve CoInitialize ile GDI+'a dayandığı için yalnızca Windows'a özel bir yoldur

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxWorkers = 0 -> auto: min(page count, CPU cores)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

Neden her işçi iş parçacığında CoInitialize?

GDI+, sayfa işlemenin altındaki pikselleştiricidir ve GDI+ daire iş parçacıklıdır (apartment-threaded): kendisini çağıran her iş parçacığında COM'un başlatılmasını bekler. Bir VCL uygulamasının ana iş parçacığı genellikle bunu zaten ayarlamıştır, ancak yeni oluşturulan bir TTask işçisi bunu yapmaz og işleme yolunu başlatılmamış bir iş parçacığından çağırmak çökmek için güvenilir bir yoldur. Dolayısıyla her işçi, girişteki bir CoInitialize(nil) ile çıkıştaki bir CoUninitialize'ı eşleştirerek tüm ömrünü parantez içine alır. Bu, ana iş parçacığının dışındaki her GDI+ veya COM çalışmasının ihtiyaç duyduğu disiplindir ve özel örnekle birlikte işçi bazında yalıtımı fiilen koruyan şeyin ikinci yarısıdır. Aynı GDI+ pikselleştirme yolu, PDF çıktısı için işleme motoru seçme makalesinde ele alınan tek iş parçacıklı motorları yönlendirir

Statik bölme (sharding) dinamik sayfa talebine karşı

500 sayfayı 8 işçiye bölmenin bariz yolu, her birine yaklaşık 62 sayfalık sabit bir dilim vermektir. losLab PDF Library bunu yapmaz, nedeni ise yük dengesidir. Sayfa maliyeti büyük ölçüde değişir: gövde metninden oluşan bir sayfa milisaniyeler içinde işlenirken, yoğun vektör haritaları veya tam sayfayı kaplayan taranmış bir resim içeren bir sayfa elli kat daha uzun sürebilir. İşi sabit dilimlere bölün; ağır dilimi çizen işçi diğerleri boşta kaldıktan çok sonra da çalışmaya devam eder, bu nedenle gerçek süreniz ortalamaya göre değil, en şanssız dilime göre belirlenir. Bunun yerine, her işçi kısa bir kritik bölüm altında ortak bir sayaçtan bir sonraki sayfayı talep eder, işler ve yenisi için geri gelir; bu da tüm aralık tükenene kadar her çekirdeği meşgul tutar

// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ is apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // one private instance per worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // claim the next page atomically
          try
            PageNum := NextPage;
            Inc(NextPage);
          finally
            IdxLock.Leave;
          end;
          if PageNum > EndPage then Break;
          LocalLib.RenderPageToFile(DPI, PageNum, 0,
            Format('page_%d.png', [PageNum]));
        end;
      finally
        LocalLib.Free;
      end;
    finally
      CoUninitialize;
    end;
  end;

İşçi iş parçacıkları arasında yapılandırılmış günlük kaydı

500 sayfanın 213. sayfasında ölen bir toplu işlemi günlük olmadan hata ayıklamak sefil bir iştir ve sıradan bir günlük de kendi başına bir eşzamanlılık hatasıdır. losLab PDF Library, TPDFlib.Logger özelliği üzerinden bağlanan ve günlük kullanılmayan yolda sıfır maliyetli kalması için varsayılan olarak nil olan TPDFlibLogger sınıfını sunar. Bu, öncelikli geri çağrıdır: OnLog'u ayarlar ve kayıtları ana bilgisayarın istediği yere yönlendirirsiniz; bunlar llDebug / llInfo / llWarn / llError seviyelerine göre filtrelenir ve PDFlibErrorMessage ham sayısal kodları insan tarafından okunabilir metne dönüştürür, böylece bir Error kaydı sadece çıplak bir tamsayıdan daha fazlasını ifade eder. İsteğe bağlı dosya havuzu tek paylaşılan kaynaktır ve birkaç işçinin tek bir günlük dosyasına güvenli bir şekilde ekleme yapabilmesi için tam olarak bir TCriticalSection ile korunur. Dürüst sınırı unutmayın: yalnızca o dosya havuzu senkronize edilir, dolayısıyla kendiniz oluşturduğunuz bir havuzda tek bir günlüğü paylaşırsanız ve OnLog metodunuz kullanıcı arayüzüne dokunursa, bunu ana iş parçacığına yine kendiniz yönlendirmeniz gerekir

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // optional shared sink (lock-guarded)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // marshal to the UI thread yourself; OnLog fires on worker threads
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil by default; zero-cost when unset
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

Gerçekten ne kadar hızlanma beklemelisiniz?

Zamanın nereye gittiği konusunda kendinize karşı dürüst olun, çünkü paralel işleme yalnızca işin gerçekten CPU sınırında olduğu durumlarda kazanç sağlar. Yüksek DPI çıktısı ve karmaşık vektör veya gölgeli sayfalar yoğun hesaplama gerektirir ve bunlar CPU'yu doyurana kadar çekirdek sayısıyla neredeyse doğrusal olarak ölçeklenir. Basit sayfalar ise farklı bir hikayedir: orada işçi başına LoadFromFile ek yükü, artı çıktı dosyalarını yazmanın disk maliyeti işlemenin kendisini gölgede bırakabilir ve yavaş bir diski zorlayan sekiz işçi, temiz bir seri döngüden daha yavaş tamamlanabilir. MaxWorkers değerini hayali bir sayı yerine fiziksel çekirdek sayınıza ayarlayın, kaynak PDF büyük olduğunda belleği izleyin ve toplu bir işin GDI sınırında olduğu ortaya çıkarsa çözüm daha fazla iş parçacığı değil, daha hızlı depolama veya daha az işçidir. Tasarlandığı işlerde kullanıldığında, burada gösterilen toplu işleme yolu Delphi ve C++Builder için standart losLab PDF Library'nin bir parçasıdır ve aksi takdirde kendiniz çözmek zorunda kalacağınız iş parçacığı güvenliği tuzakları olmadan boşta kalan çekirdekleri tamamlanmış sayfalara dönüştürür