Artikel Teknis

Thread Safety PDFium: Lock Per Dokumen Gagal di Delphi

PDFium tidak thread-safe pada level module, sehingga dua instance TPdf yang mengerjakan dua file berbeda di dua thread tetap bisa saling merusak. PDFium Component for Delphi menangani ini dengan dua cara: sejak v3.125.1, ValidatePdfFilesParallel menserialisasi setiap panggilan native PDFium di balik satu lock seluruh proses, sementara TPdf.RenderPagesParallel memberi tiap worker salinan module PDFium yang terisolasi. Bug yang memaksa perbaikan itu adalah jenis intermiten yang terburuk. Test validasi batch lulus sebagian besar waktu, lalu melaporkan satu dari dua file yang baik sebagai gagal, lalu membuat test berikutnya di proses yang sama crash dengan access violation, dan kadang merobohkan seluruh runner dengan exit code alih-alih stack trace. Tak ada yang salah dengan test, dan tak ada yang salah dengan satu pun dokumen. Asumsinya yang salah: satu TPdf per thread bukan isolasi

Mengapa satu TPdf per thread tidak cukup?

Satu TPdf per thread tidak cukup karena PDFium menyimpan state tidak amannya di module, bukan di dokumen. Tiap TPdf memiliki handle FPDF_DOCUMENT-nya sendiri, tapi setiap handle di proses dilayani DLL yang termuat sama, dan DLL itu menyimpan singleton seluruh proses: font cache, page module, dan struktur global lain yang disentuh pemuatan dokumen, parsing, dan rendering. Dua thread yang memuat dua file tak berhubungan adalah dua thread yang menulis ke font cache yang sama pada saat bersamaan. Tak seorang pun memiliki data itu di sisi Delphi, sehingga tak ada apa pun di sisi Delphi yang bisa menguncinya per dokumen

Komponen memang punya lock, dan mudah menarik kesimpulan yang salah darinya. TPdf membungkus jalur render miliknya dalam critical section internal (EnterRenderLock / LeaveRenderLock, method privat TPdf). Lock itu per instance. Ia menghentikan dua thread dari menggerakkan TPdf yang sama sekaligus, bahaya yang nyata, tapi ia tak bisa melihat instance kedua di thread lain, sehingga konkurensi lintas-instance berjalan lurus melewatinya. Aturan umumnya cukup sederhana untuk dinyatakan satu baris: dalam satu module PDFium termuat, paling banyak satu thread boleh berada di dalam PDFium pada satu waktu, tak peduli berapa dokumen terbuka

Diagram PDFium Component atas dua thread yang menjalankan instance TPdf terpisah pada dokumen berbeda sementara setiap panggilan bertemu di satu module pdfium.dll termuat yang font cache, page module, dan global lainnya seluruh proses dibagi bersama, menghasilkan kegagalan muat, access violation, dan keluar fail-fast
PDFium menyimpan state tidak amannya di module, bukan di dokumen, sehingga dua instance TPdf di dua thread menulis ke font cache yang sama betapapun tak berhubungannya kedua file

Seperti apa korupsi lintas-dokumen di proses Delphi?

Korupsi lintas-dokumen tampak seperti campuran acak kegagalan tak berhubungan, dan kerusakannya hidup lebih lama dari kode yang menyebabkannya. Sebelum v3.125.1, ValidatePdfFilesParallel menciptakan satu TPdf per worker thread dan menjalankan Active := True plus pembangunan preflight report secara bersamaan di module bersama. Gejala yang terlihat di build Delphi maupun Free Pascal mencakup seluruh rentang:

  • File valid gagal dimuat, atau kembali dari batch sebagai gagal padahal seharusnya lulus
  • Access violation muncul di panggilan belakangan yang tak berhubungan, sering di test berbeda atau dokumen berbeda
  • External exception C000001D muncul di Delphi. Kode itu STATUS_ILLEGAL_INSTRUCTION, dipicu instruksi ud2 yang dieksekusi macro CHECK dan IMMEDIATE_CRASH internal PDFium ketika invariant pecah
  • Proses keluar dengan 0xC0000409 (fail-fast, dilaporkan sebagai stack buffer overrun) atau 0xC0000374 (heap corruption), tanpa exception Delphi sama sekali

Dua poin terakhir itulah kenapa bug ini begitu sulit dikejar. Validasi paralel selesai, state global yang terkorupsi tertinggal, dan fixture berikutnya di proses yang sama tersandungnya. Dalam satu run regresi Delphi Win64, gelombang kegagalan C000001D mengenai test yang tak pernah menyentuh validasi batch; mereka sekadar kode pertama yang memakai PDFium setelah kerusakan itu. Angka terukurnya membuat skalanya jelas. Probe Delphi yang menjalankan sampel yang sama lewat dua worker gagal pada 122 dari 160 dokumen di satu run dan 138 dari 160 di run lain, dan salah satu run itu memunculkan External exception C000001D langsung. Kasus stress 8 dokumen, 4 worker, dan 5 ronde gagal atau crash di 5 dari 5 run pada Free Pascal Win64. Setelah perbaikan, probe yang sama gagal 0 dari 1.200 dokumen

Bagaimana ValidatePdfFilesParallel tetap aman sejak v3.125.1

ValidatePdfFilesParallel kini menserialisasi setengah native tiap job dan membiarkan setengah terkelolanya paralel. Setiap worker mengambil satu critical section level unit sebelum menciptakan TPdf-nya, dan memegangnya sepanjang FileName, Active := True, pembangunan preflight report, dan Free. Penciptaan dan penghancuran sengaja di dalam lock: menutup dokumen memanggil balik ke module sama seperti memuat. Begitu worker memegang record TPdfPreflightReport yang tertangkap, ia melepas lock dan mengevaluasi aturan validasi terhadap record itu, yang tak menyentuh state PDFium, sehingga evaluasi aturan satu file tumpang tindih dengan kerja PDFium file berikutnya

Diagram ValidatePdfFilesParallel PDFium Component yang memperlihatkan tiap worker memegang satu critical section seluruh proses sepanjang create, load, preflight, dan free TPdf sementara evaluasi aturan atas report tertangkap berjalan di luar lock secara paralel, sehingga setengah PDFium batch memang serial by design
Penciptaan dan penghancuran tetap di dalam lock karena menutup dokumen memanggil balik ke module, sementara evaluasi report tak menyentuh state PDFium dan tumpang tindih dengan file berikutnya

Dua perubahan kecil menyertai perbaikan itu. Kegagalan muat kini memicu EPdfError dengan LastLoadReport.ErrorMessage, sehingga ErrorMessage milik item menyebut masalah parse yang sesungguhnya alih-alih error sekunder "tak ada dokumen aktif". Dan biayanya dinyatakan jujur: bagian PDFium batch kini serial, jadi pada batch yang didominasi parsing dan preflight, worker ekstra nyaris tak membeli apa-apa. Kalau Anda di versi sebelum v3.125.1, set WorkerCount ke 1; itu menghapus konkurensi beserta korupsinya

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 = jumlah processor, dibatasi 8
    Options.Standards := [ppsPdfA];
    // Dengan registry eksplisit, pilih sendiri profil yang cocok.
    // Daftar Profiles kosong menjalankan setiap rule terdaftar, dan rule untuk
    // standar yang tak Anda preflight melaporkan "did not pass"
    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;

Passing nil sebagai registry adalah jalur yang lebih pendek: ValidatePdfFilesParallel lalu menciptakan registry default sendiri, menurunkan daftar profil dari Options.Standards, dan mem-free registry saat kembali. Hasil selalu kembali dalam urutan input, apa pun urutan worker menyelesaikan. Untuk format report dan wrapper command-line di atas engine yang sama, lihat report preflight PDF batch dengan CLI PDFium Component, dan untuk cakupan pemeriksaan PDF/A-nya sendiri, validasi preflight PDF/A di Delphi

Bagaimana RenderPagesParallel menjalankan halaman benar-benar paralel?

TPdf.RenderPagesParallel berjalan paralel karena workernya tak pernah berbagi module PDFium. Method ini lebih dulu menyimpan dokumen aktif ke source store di calling thread. Setiap worker lalu menyalin DLL PDFium termuat ke file bernama unik di direktori temp, memuat salinan itu dengan LoadLibrary, dan menginisialisasinya. Windows memperlakukan DLL yang dimuat dari path berbeda sebagai module berbeda, sehingga tiap salinan mendapat global-nya sendiri: font cache sendiri, page module sendiri, semuanya sendiri. Worker membuka dokumen tersimpan di module privatnya, merender halamannya secara progresif dengan pemeriksaan pembatalan antar langkah, lalu menghancurkan library, meng-unload salinannya, dan menghapus filenya

Diagram RenderPagesParallel PDFium Component ketika calling thread menyimpan snapshot dokumen, lalu tiap worker menyalin DLL PDFium ke file temp unik, memuatnya sebagai module terpisah dengan global miliknya sendiri, merender halamannya dengan pemeriksaan pembatalan, dan meng-unload salinannya
Paralelisme sungguhan datang dari isolasi module: Windows memperlakukan tiap salinan DLL sebagai module berbeda, sehingga worker tak berbagi apa pun kecuali snapshot yang disimpan calling thread di bawah lock

Isolasinya tidak gratis, dan defaultnya mencerminkan itu. Tiap worker membayar salinan DLL di disk, set kedua global PDFium di memori, dan parse baru atas dokumen. MaxWorkers = 0 berarti paling banyak 4 worker, MaxPixelsPerPage dan MaxTotalOutputBytes membatasi output mentah, dan opsi render inverted serta night-duotone ditolak karena buffer dikembalikan mentah. Hasilnya adalah TPdfParallelRenderReport yang array Results-nya memuat satu buffer 32-bit top-down per halaman yang diminta, dalam urutan permintaan

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;                 // nomor halaman basis-1

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

  // Snapshot sumber diambil di module bersama, jadi pegang
  // lock PDFium seluruh proses jika thread lain juga memakai TPdf
  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;

Perhatikan lock di sekeliling panggilan itu. Module worker bersifat privat, tapi langkah snapshot di awal menjalankan SaveAs di module bersama dari calling thread. Kalau tak ada yang lain di proses Anda menyentuh TPdf secara bersamaan, lock bisa dilepas; kalau ada, snapshot butuh perlindungan yang sama dengan setiap panggilan shared-module lainnya

PolaAman lintas dokumenKerja PDFium berjalan paralelBiaya
Satu TPdf per thread, tanpa lock bersamaTidakYa, sampai ia korupsiCrash intermiten, state proses rusak
Satu lock seluruh proses mengelilingi semua panggilan PDFiumYaTidakBagian PDFium serial
ValidatePdfFilesParallel sejak v3.125.1YaTidak; evaluasi aturan paralelParsing dan preflight serial
TPdf.RenderPagesParallelYaYaSalinan DLL, memori, dan parse baru per worker

Bagaimana seharusnya Anda menyusun kode PDFium multithread sendiri?

Thread Anda sendiri sebaiknya berbagi satu lock seluruh proses dan memegangnya sepanjang umur setiap TPdf yang dipakai, atau pakai API komponen yang mengisolasi module untuk Anda. Lock itu harus satu objek untuk seluruh proses, bukan satu per thread, per form, atau per dokumen; lock yang tak dibagi dua thread tak melindungi apa pun. Pola di bawah mencerminkan apa yang dilakukan komponen secara internal sejak v3.125.1: create, load, read, dan free di dalam lock, lalu lakukan semua yang tak menyentuh PDFium di luarnya

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

var
  PdfiumLock: TCriticalSection;        // satu lock untuk seluruh proses

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;                      // menutup dokumen juga kerja PDFium
      end;
    finally
      PdfiumLock.Release;
    end;
    // Tak ada PDFium di bawah baris ini, jadi bagian ini berjalan paralel
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Beberapa aturan menjaga pola itu tetap jujur di aplikasi nyata:

  • Taruh TPdf.Create dan Free di dalam lock, bukan hanya panggilan yang jelas. Memuat, menutup, pembacaan property seperti PageCount, pergantian halaman, ekstraksi teks, rendering, dan penyimpanan semuanya menjalar ke module
  • Cek Active setelah meng-assign-nya. Muat yang gagal membiarkan Active di False, dan LastLoadReport.ErrorMessage mengatakan kenapanya
  • Pegang lock per dokumen alih-alih per panggilan. Penguncian lebih halus mungkin pada prinsipnya, tapi hanya jika tak ada satu pun member TPdf yang berjalan di luarnya, dan versi kasarnya itulah yang diandalkan komponen sendiri
  • Jaga kerja non-PDFium yang lambat, seperti tulis database, indexing, dan panggilan jaringan, di luar lock, atau satu consumer lambat akan menserialisasi semuanya
  • Jangan memperlakukan render lock privat per-instance sebagai pengganti. Ia menjaga satu TPdf dari dirinya sendiri dan tak lebih dari itu

Kehati-hatian yang sama berlaku untuk kode yang tak Anda tulis sebagai thread mentah. Background futures adalah cara yang bagus menjauhkan render panjang dari UI thread, seperti dijelaskan di rendering PDF latar belakang dengan cancellable futures, tapi future executor tak menambahkan lock PDFium global miliknya sendiri. Kalau beberapa futures bisa menggerakkan instance TPdf berbeda pada saat bersamaan, ambil lock seluruh proses yang sama di dalam tiap worker, dan perlakukan viewer di main thread sebagai satu klien lagi dari module bersama. Pemakaian lintas-instance lewat API asinkron belum diaudit terpisah, jadi asumsi konservatifnya adalah ia butuh serialisasi yang sama dengan thread tulisan tangan. Ketika Anda butuh paralelisme PDFium sungguhan untuk hal selain rendering halaman, worker process terpisah memberi tiap job module-nya sendiri secara konstruksi

Referensi cepat: aturan threading PDFium untuk Delphi

  • State tidak aman PDFium bersifat seluruh module: font cache, page module, dan global lain dibagi oleh setiap dokumen di proses
  • Satu TPdf per thread tak mengisolasi apa pun; dua instance di dua thread tetap bisa saling merusak
  • Gejala tipikalnya adalah kegagalan muat, access violation di kode belakangan, External exception C000001D, dan keluar dengan 0xC0000409 atau 0xC0000374
  • Korupsi bertahan di proses, sehingga panggilan yang gagal sering bukan yang menyebabkannya
  • ValidatePdfFilesParallel aman sejak v3.125.1; di versi lebih lama pakai WorkerCount := 1
  • TPdf.RenderPagesParallel benar-benar paralel karena tiap worker memuat salinan terisolasi dari module PDFium
  • Thread, task, dan future Anda sendiri butuh satu lock seluruh proses yang meliputi tiap TPdf dari Create sampai Free

PDFium Component membungkus engine PDFium untuk Delphi dengan preflight dan validasi batch, rendering paralel terisolasi, kerja latar belakang yang bisa dibatalkan, dan diagnostik muat yang rinci. Detail dan edisi ada di halaman produk PDFium Component