Teknik Makale

PDFium thread safety: belge başına kilitler neden yetmez

PDFium modül düzeyinde thread-safe değildir; dolayısıyla iki thread'de iki farklı dosyayla çalışan iki TPdf instance'ı birbirini yine de bozabilir. Delphi için PDFium Component bunu iki yolla halleder: v3.125.1'den beri ValidatePdfFilesParallel her yerel PDFium çağrısını tek bir süreç geneli kilit ardında serileştirir; TPdf.RenderPagesParallel ise her worker'a PDFium modülünün kendi yalıtılmış kopyasını verir. Düzeltmeyi zorlayan hata, aralıklılığın en kötü türündendi. Bir batch doğrulama testi çoğu zaman geçiyor, sonra iki iyi dosyadan birini başarısız bildiriyor, sonra aynı süreçteki sonraki testi bir access violation'la çökertiyor, bazen de bütün runner'ı stack trace yerine bir exit kodla indiriyordu. Testte yanlış olan hiçbir şey yoktu ve hiçbir tek belgede de yoktu. Yanlış olan varsayımdı: thread başına bir TPdf yalıtım değildir

Thread başına bir TPdf neden yetmez?

Thread başına bir TPdf yetmez, çünkü PDFium güvensiz durumunu belgede değil modülde tutar. Her TPdf kendi FPDF_DOCUMENT handle'ına sahiptir ama süreçteki her handle aynı yüklenmiş DLL tarafından servis edilir ve o DLL süreç geneli singleton'lar tutar: font cache, page module ve belge yükleme, ayrıştırma ve render'in hepsinin dokunduğu öteki global yapılar. İki ilişkisiz dosyayı yükleyen iki thread, aynı anda aynı font cache'e yazan iki thread'dir. O veriye Delphi tarafında sahip olan yoktur; dolayısıyla Delphi tarafında onu belge başına kilitleyen hiçbir şey olamaz

Bileşenin bir kilidi vardır ve ondan yanlış sonuç çıkarmak kolaydır. TPdf, kendi render yollarını dahili bir critical section içine sarar (EnterRenderLock / LeaveRenderLock, TPdf'in private metotları). O kilit instance başınadır. Aynı anda iki thread'in aynı TPdf'yi sürmesini engeller — gerçek bir tehlikedir bu — ama başka thread'deki ikinci bir instance'ı göremez; dolayısıyla çapraz-instance eşzamanlılık düz onun yanından geçer. Genel kural tek satırda özetlenecek kadar basittir: tek bir yüklenmiş PDFium modülünde herhangi bir anda PDFium içinde en fazla bir thread olabilir, kaç belge açık olduğuna bakılmaksızın

İki thread'in farklı belgelerde ayrı TPdf instance'ları koşturduğu PDFium Component şeması: her çağrı, font cache'i, page module'ü ve öteki süreç geneli global'leri paylaşılan tek bir yüklenmiş pdfium.dll modülünde birleşir; yükleme başarısızlıkları, access violation'lar ve fail-fast çıkışlar üretir
PDFium güvensiz durumunu belgede değil modülde tutar; dolayısıyla iki thread'deki iki TPdf instance'ı, dosyalar ne kadar ilişkisiz olursa olsun aynı font cache'e yazar

Çapraz-belge bozulması bir Delphi sürecinde nasıl görünür?

Çapraz-belge bozulması, ilişkisiz başarısızlıkların rastgele bir karışımı gibi görünür ve hasar, ona neden olan kodu aşar. v3.125.1 öncesinde ValidatePdfFilesParallel, worker thread başına bir TPdf kuruyor ve Active := True ile preflight rapor kurulumunu paylaşılan modülde eşzamanlı koşturuyordu. Hem Delphi hem Free Pascal derlemelerinde görülen belirtiler bütün yelpazeyi kaplıyordu:

  • Geçerli bir dosya yüklenemiyor ya da geçmesi gerekirken batch'ten başarısız dönüyor
  • Bir access violation, sonradan ilişkisiz bir çağrıda su yüzüne çıkıyor; çoğunlukla başka bir testte ya da başka bir belgede
  • Delphi'de External exception C000001D görünüyor. O kod STATUS_ILLEGAL_INSTRUCTION'dır; PDFium'un dahili CHECK ile IMMEDIATE_CRASH makrolarının bir değişmez bozulduğunda çalıştırdığı ud2 komutu yükseltir
  • Süreç 0xC0000409 (fail-fast; stack buffer overrun diye bildirilir) ya da 0xC0000374 (heap corruption) ile çıkıyor; hiçbir Delphi exception'ı yok

Son iki madde, hatanın neden bu kadar zor saptandığıdır. Paralel doğrulama bitiyordu, bozulmuş global durum geride kalıyordu ve aynı süreçteki sonraki fixture ona takılıyordu. Bir Delphi Win64 regresyon koşusunda bir dalga hâlindeki C000001D başarısızlıkları, batch doğrulamaya hiç dokunmamış testlere çarptı; onlar, hasardan sonra PDFium'u kullanan ilk koddan ibaretti. Ölçülen sayılar ölçeği açık eder. Aynı örneği iki worker üzerinden koşturan bir Delphi sondası bir koşuda 160 belgeden 122'sinde, başka bir koşuda 138'inde başarısız oldu ve o koşulardan biri açıkça External exception C000001D yükseltti. 8 belge, 4 worker ve 5 turdan oluşan bir stres durumu, Free Pascal Win64'te 5 koşunun 5'inde de başarısız oldu ya da çöktü. Düzeltmeden sonra aynı sonda, 1.200 belgeden 0'ında başarısız oldu

ValidatePdfFilesParallel v3.125.1'den beri nasıl güvenli kalır?

ValidatePdfFilesParallel artık her işin yerel yarısını serileştirir ve yönetilen yarısını paralel tutar. Her worker, TPdf'ini kurmadan önce bir unit düzeyi critical section alır ve onu FileName, Active := True, preflight rapor kurulumu ve Free boyunca tutar. Kurulum ve yıkım bilerek kilit içindedir: bir belgeyi kapatmak da tıpkı yüklemek gibi modüle geri çağrı yapar. Worker, yakalanmış bir TPdfPreflightReport record'una sahip olduğunda kilidi bırakır ve doğrulama kurallarını o record'a karşı değerlendirir; bu hiçbir PDFium durumuna dokunmaz, dolayısıyla bir dosyanın kural değerlendirmesi, sonrakinin PDFium işiyle örtüşür

PDFium Component ValidatePdfFilesParallel şeması: her worker, TPdf kurulumu, yükleme, preflight ve free boyunca tek bir süreç geneli critical section tutarken yakalanan raporun kural değerlendirmesi kilidin dışında paralel koşar; böylece batch'in PDFium yarısı tasarım gereği seridir
Kurulum ile yıkım kilit içinde kalır, çünkü bir belgeyi kapatmak modüle geri çağrı yapar; rapor değerlendirmesi ise hiçbir PDFium durumuna dokunmaz ve sonraki dosyayla örtüşür

Düzeltmeyle iki küçük değişiklik geldi. Bir yükleme başarısızlığı artık LastLoadReport.ErrorMessage ile EPdfError yükseltir; dolayısıyla öğenin ErrorMessage'i, ikincil bir "aktif belge yok" hatası yerine gerçek ayrıştırma sorununu adıyla söyler. Ve bedel dürüstçe söylenir: batch'in PDFium kısmı artık seridir; dolayısıyla ayrıştırma ve preflight'ın egemen olduğu bir batch'te fazladan worker'lar az şey alır. v3.125.1 öncesi bir sürümdeyseniz WorkerCount'i 1'e set edin; bu, eşzamanlılığı ve onunla birlikte bozulmayı kaldırır

uses
  System.SysUtils, PDFium, FPdfPreflightReport;

procedure ValidateBatch(const Files: array of string);
var
  Registry: TPdfValidationRuleRegistry;
  Options: TPdfBatchValidationOptions;
  Report: TPdfBatchValidationReport;
  I: Integer;
begin
  Registry := CreateDefaultPdfValidationRuleRegistry;
  try
    Options := TPdfBatchValidationOptions.Default;
    Options.WorkerCount := 4;          // 0 = işlemci sayısı, 8'le sınırlandı
    Options.Standards := [ppsPdfA];
    // Açık bir registry ile eşleşen profili kendiniz seçin.
    // Boş bir Profiles listesi kayıtlı her kuralı koşturur ve preflight
    // etmediğiniz standartların kuralları "did not pass" bildirir
    SetLength(Options.ValidationOptions.Profiles, 1);
    Options.ValidationOptions.Profiles[0] := 'PDF/A';
    Report := ValidatePdfFilesParallel(Files, Registry, Options);
  finally
    Registry.Free;
  end;

  for I := 0 to High(Report.Results) do
    case Report.Results[I].Status of
      pbvisPass:  Writeln('PASS  ', Report.Results[I].FileName);
      pbvisFail:  Writeln('FAIL  ', Report.Results[I].FileName);
      pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
                    Report.Results[I].ErrorMessage);
    else
      Writeln('SKIP  ', Report.Results[I].FileName);   // pbvisCancelled
    end;
  Writeln(Report.PassedDocumentCount, ' passed, ',
    Report.FailedDocumentCount, ' failed, ',
    Report.ErrorDocumentCount, ' errors');
end;

Registry olarak nil geçirmek kısa yoldur: ValidatePdfFilesParallel o zaman varsayılan registry'nin kendisini kurar, profil listesini Options.Standards'tan türetir ve dönerken registry'yi serbest bırakır. Sonuçlar, worker'lar hangi sırada bitmiş olursa olsun daima girdi sırasıyla döner. Rapor biçimleri ve aynı motorun etrafındaki komut satırı sarmalayıcısı için PDFium Component CLI ile batch PDF preflight raporları'na, PDF/A kontrollerinin kendilerinin neleri kapsadığı için Delphi'de PDF/A preflight doğrulaması yazısına bakın

RenderPagesParallel sayfaları gerçekten paralel nasıl koşturur?

TPdf.RenderPagesParallel paralel koşar, çünkü worker'ları hiçbir zaman bir PDFium modülü paylaşmaz. Metot önce aktif belgeyi, çağıran thread üzerinde bir kaynak deposuna kaydeder. Sonra her worker, yüklenmiş PDFium DLL'sini geçici dizinde benzersiz adlı bir dosyaya kopyalar, o kopyayı LoadLibrary ile yükler ve başlatır. Windows, başka bir yoldan yüklenen bir DLL'yi farklı bir modül sayar; dolayısıyla her kopya kendi global'lerini alır: kendi font cache'i, kendi page module'ü, kendi her şeyi. Worker, kaydedilmiş belgeyi kendi özel modülünde açar, sayfalarını adımlar arası iptal kontrolleriyle aşamalı olarak render eder, sonra kütüphaneyi yok eder, kopyayı boşaltır ve dosyayı siler

PDFium Component RenderPagesParallel şeması: çağıran thread bir belge anlık görüntüsü kaydeder; sonra her worker PDFium DLL'sini benzersiz bir geçici dosyaya kopyalar, onu kendi global'leri olan ayrı bir modül olarak yükler, sayfalarını iptal kontrolleriyle render eder ve kopyayı boşaltır
Gerçek paralellik modül yalıtımından gelir: Windows her DLL kopyasını farklı bir modül sayar; dolayısıyla worker'lar, çağıran thread'in kilit altında kaydettiği anlık görüntü dışında hiçbir şey paylaşmaz

Yalıtım bedava değildir ve varsayılanlar bunu yansıtır. Her worker, diskte bir DLL kopyasının, bellekte ikinci bir PDFium global kümesinin ve belgenin taze bir ayrıştırmasının bedelini öder. MaxWorkers = 0 en çok 4 worker demektir; MaxPixelsPerPage ile MaxTotalOutputBytes ham çıktıyı tavanlar ve tamponlar ham döndürüldüğü için inverted ile gece-duotone render seçenekleri reddedilir. Sonuç, Results dizisi istenen her sayfa için istek sırasıyla bir üstten-aşağı 32 bitlik tampon tutan bir TPdfParallelRenderReport'tur

procedure RenderAllPages(Pdf: TPdf);
var
  Options: TPdfParallelRenderOptions;
  Report: TPdfParallelRenderReport;
  Pages: array of Integer;
  I: Integer;
begin
  SetLength(Pages, Pdf.PageCount);
  for I := 0 to High(Pages) do
    Pages[I] := I + 1;                 // sayfa numaraları 1 tabanlıdır

  Options := TPdfParallelRenderOptions.Default;
  Options.Dpi := 150;
  Options.MaxWorkers := 4;

  // Kaynak anlık görüntüsü paylaşılan modülde alınır; öteki thread'ler de
  // TPdf kullanıyorsa süreç geneli PDFium kilidini tutun
  PdfiumLock.Acquire;
  try
    Report := Pdf.RenderPagesParallel(Pages, Options);
  finally
    PdfiumLock.Release;
  end;

  for I := 0 to High(Report.Results) do
    if Report.Results[I].Status = pprsSucceeded then
      SavePageBuffer(Report.Results[I])   // Width, Height, Stride, PixelFormat, Pixels
    else
      Writeln('Page ', Report.Results[I].PageNumber, ': ',
        Report.Results[I].ErrorMessage);
end;

Çağrının çevresindeki kilide dikkat. Worker modülleri özeldir ama baştaki anlık görüntü adımı, çağıran thread üzerinden paylaşılan modülde SaveAs koşturur. Süreçte başka hiçbir şey eşzamanlı olarak TPdf'ye dokunmuyorsa kilidi atlayabilirsiniz; bir şey dokunuyorsa anlık görüntü, öteki her paylaşılan-modül çağrısıyla aynı korumayı ister

ÖrüntüBelgeler arası güvenliPDFium işi paralel koşarBedel
Thread başına bir TPdf, paylaşılan kilit yokHayırEvet, bozulana dekAralıklı çökmeler, hasarlı süreç durumu
Bütün PDFium çağrılarının çevresinde tek süreç geneli kilitEvetHayırPDFium kısmı seridir
ValidatePdfFilesParallel, v3.125.1'den beriEvetHayır; kural değerlendirmesi paralelAyrıştırma ile preflight seridir
TPdf.RenderPagesParallelEvetEvetWorker başına DLL kopyası, bellek ve taze ayrıştırma

Kendi çok thread'li PDFium kodunuzu nasıl yapılandırmalısınız?

Kendi thread'leriniz tek bir süreç genesi kilidi paylaşmalı ve kullandıkları her TPdf'in bütün ömrü boyunca tutmalı ya da sizin için modülü yalıtan bir bileşen API'si kullanmalıdır. Kilit, bütün süreç için tek bir object olmak zorundadır; thread başına, form başına ya da belge başına değil. İki thread'in paylaşmadığı bir kilit hiçbir şeyi korumaz. Aşağıdaki örüntü, bileşenin v3.125.1'den beri içeride yaptığını aynalar: kurulumu, yüklemeyi, okumayı ve serbest bırakmayı kilit içinde; PDFium'a dokunmayan her şeyi dışında yapın

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // bütün süreç için tek kilit

type
  TTextExtractThread = class(TThread)
  private
    FFileName: string;
    FText: string;
  protected
    procedure Execute; override;
  public
    constructor Create(const AFileName: string);
    property ExtractedText: string read FText;
  end;

constructor TTextExtractThread.Create(const AFileName: string);
begin
  inherited Create(True);
  FFileName := AFileName;
end;

procedure TTextExtractThread.Execute;
var
  Pdf: TPdf;
  Page: Integer;
  Raw: TStringBuilder;
begin
  Raw := TStringBuilder.Create;
  try
    PdfiumLock.Acquire;
    try
      Pdf := TPdf.Create(nil);
      try
        Pdf.FileName := FFileName;
        Pdf.Active := True;
        if not Pdf.Active then
          raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
        for Page := 1 to Pdf.PageCount do
        begin
          Pdf.PageNumber := Page;
          Raw.AppendLine(Pdf.Text);
        end;
      finally
        Pdf.Free;                      // belgeyi kapatmak da PDFium işidir
      end;
    finally
      PdfiumLock.Release;
    end;
    // Bu satırın altında PDFium yok; dolayısıyla bu kısım paralel koşar
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

initialization
  PdfiumLock := TCriticalSection.Create;
finalization
  PdfiumLock.Free;

Birkaç kural, örüntüyü gerçek bir uygulamada dürüst tutar:

  • TPdf.Create ile Free'yi kilit içine koyun; yalnızca bariz çağrıları değil. Yükleme, kapatma, PageCount gibi property okumaları, sayfa değişimleri, metin çıkarma, render ve kaydetmenin hepsi modüle uzanır
  • Active'i atadıktan sonra kontrol edin. Başarısız bir yükleme Active'i False'ta bırakır ve LastLoadReport.ErrorMessage nedenini söyler
  • Kilidi çağrı başına değil belge başına tutun. Daha ince kilitleme ilke olarak mümkündür ama yalnızca hiçbir TPdf üyesinin dışında koşmadığı durumlarda; bileşenin kendisinin bel bağladığı da kaba sürümdür
  • Veritabanı yazımları, indeksleme ve ağ çağrıları gibi yavaş PDFium-dışı işleri kilit dışında tutun; yoksa yavaş bir tüketici her şeyi serileştirir
  • Özel instance başına render kilidini bir ikame olarak görmeyin. O, tek bir TPdf'yi kendisine karşı korur, daha fazlasını değil

Aynı dikkat, ham thread olarak yazmadığınız kod için de geçerlidir. Arka plan future'ları, uzun render'ları UI thread'inin dışında tutmanın iyi bir yoludur; iptal edilebilir future'larla arka plan PDF render yazısında anlatıldığı gibi. Ama future executor'ı kendine ait bir global PDFium kilidi eklemez. Birkaç future aynı anda farklı TPdf instance'larını sürebiliyorsa her worker içinde aynı süreç geneli kilidi alın ve ana thread'deki bir görüntüleyiciyi paylaşılan modülün bir müşterisi daha sayın. Asenkron API'ler üzerinden çapraz-instance kullanım ayrıca denetlenmemiştir; dolayısıyla tutucu varsayım, elle yazılmış thread'lerle aynı serileştirmeye ihtiyaç duyduğudur. Sayfa render dışında gerçek PDFium paralelliğine ihtiyaç duyduğunuzda ayrı worker süreçleri, her işe yapım gereği kendi modülünü verir

Hızlı başvuru: Delphi için PDFium threading kuralları

  • PDFium'un güvensiz durumu modül genişidir: font cache, page module ve öteki global'ler süreçteki her belge tarafından paylaşılır
  • Thread başına bir TPdf hiçbir şeyi yalıtmaz; iki thread'deki iki instance birbirini yine de bozabilir
  • Tipik belirtiler: yükleme başarısızlıkları, sonraki kodda access violation'lar, External exception C000001D ve 0xC0000409 ya da 0xC0000374 ile çıkışlar
  • Bozulma süreçte kalıcıdır; dolayısıyla başarısız olan çağrı çoğunlukla ona neden olan değildir
  • ValidatePdfFilesParallel v3.125.1'den beri güvenlidir; eski sürümlerde WorkerCount := 1 kullanın
  • TPdf.RenderPagesParallel gerçekten paraleldir; çünkü her worker, PDFium modülünün yalıtılmış bir kopyasını yükler
  • Kendi thread'leriniz, task'leriniz ve future'larınız, her TPdf'i Create'ten Free'ye kapsayan tek bir süreç geneli kilit ister

PDFium Component, PDFium motorunu Delphi için batch preflight ve doğrulama, yalıtılmış paralel render, iptal edilebilir arka plan işi ve ayrıntılı yükleme teşhisiyle sarar. Ayrıntılar ve sürümler PDFium Component ürün sayfasında