Tehnički članak

PDFium thread safety: zašto brave po dokumentu ne pomažu

PDFium nije thread-safe na razini modula, pa dvije instance TPdf koje rade na dvije različite datoteke u dva threada i dalje mogu jedna drugu oštetiti. PDFium Component za Delphi ovo rješava na dva načina: od v3.125.1 ValidatePdfFilesParallel serijalizira svaki nativni PDFium poziv iza jedne brave na razini procesa, dok TPdf.RenderPagesParallel svakom workeru daje vlastitu izoliranu kopiju PDFium modula. Bug koji je prisilio popravak bio je najgora vrsta povremenog. Test batch validacije prolazio je većinu vremena, zatim je jednu od dviju dobrih datoteka javio kao palu, zatim srušio sljedeći test u istom procesu s access violationom, a ponekad oborio cijeli runner s exit kodom umjesto stack tracea. Ništa nije bilo krivo s testom, i ništa nije bilo krivo ni s jednim pojedinačnim dokumentom. Pretpostavka je bila kriva: jedan TPdf po threadu nije izolacija

Zašto jedan TPdf po threadu nije dovoljno?

Jedan TPdf po threadu nije dovoljno jer PDFium svoje nesigurno stanje drži u modulu, ne u dokumentu. Svaki TPdf posjeduje vlastiti FPDF_DOCUMENT handle, ali svaki handle u procesu opslužuje isti učitani DLL, a taj DLL drži singletons na razini procesa: font cache, page modul i druge globalne strukture koje dodiruju učitavanje dokumenata, parsiranje i renderiranje. Dva threada koja učitavaju dvije nepovezane datoteke dva su threada koja u isto vrijeme pišu u isti font cache. Niko na Delphi strani ne posjeduje te podatke, pa ništa na Delphi strani ne može zaključati po dokumentu

Komponenta bravu ima, i lako je izvući krivi zaključak iz nje. TPdf vlastite render puteve omotava u unutarnju kritičnu sekciju (EnterRenderLock / LeaveRenderLock, privatne metode od TPdf). Ta je brava po instanci. Sprječava dva threada da istovremeno voze isti TPdf, što je prava opasnost, ali ne vidi drugu instancu na drugom threadu, pa cross-instance konkurencija prolazi ravno pokraj nje. Opće pravilo dovoljno je jednostavno da stane u jedan red: u jednom učitanom PDFium modulu najviše jedan thread smije biti unutar PDFiuma u bilo kojem trenutku, bez obzira koliko je dokumenata otvoreno

PDFium Component dijagram dva threada koja pokreću odvojene TPdf instance nad različitim dokumentima dok se svaki poziv stječe u jednom učitanom pdfium.dll modulu čiji su font cache, page modul i ostali globali na razini procesa dijeljeni, proizvodeći neuspjehe učitavanja, access violatione i fail-fast izlaze
PDFium nesigurno stanje drži u modulu, ne u dokumentu, pa dvije TPdf instance na dva threada pišu u isti font cache ma koliko datoteke bile nepovezane

Kako izgleda oštećenje između dokumenata u Delphi procesu?

Oštećenje između dokumenata izgleda kao slučajna mješavina nepovezanih neuspjeha, a šteta nadživi kod koji ju je uzrokovao. Prije v3.125.1 ValidatePdfFilesParallel stvarao je jedan TPdf po worker threadu i pokretao Active := True plus izgradnju preflight izvještaja istodobno na dijeljenom modulu. Simptomi viđeni na Delphi i Free Pascal buildovima pokrili su cijeli raspon:

  • Valjana datoteka ne uspijeva se učitati, ili se iz batcha vraća kao pala kad je trebala proći
  • Access violation isplivava u kasnijem, nepovezanom pozivu, često u drugom testu ili drugom dokumentu
  • External exception C000001D pojavljuje se u Delphiju. Taj kod je STATUS_ILLEGAL_INSTRUCTION, podignut od ud2 instrukcije koju PDFiumovi unutarnji CHECK i IMMEDIATE_CRASH makroi izvršavaju kad se invarijanta slomi
  • Proces izlazi s 0xC0000409 (fail-fast, javljen kao stack buffer overrun) ili 0xC0000374 (oštećenje heapa), bez ijedne Delphi iznimke

Zadnje dvije točke razlog su što je bug bio tako teško uhvatiti. Paralelna validacija završila je, oštećeno globalno stanje ostalo je iza, i sljedeći fixture u istom procesu spotaknuo se o njega. U jednom Delphi Win64 regresijskom pokretanju val C000001D neuspjeha pogodio je testove koji nikad nisu dirali batch validaciju; oni su jednostavno bili prvi kod koji je koristio PDFium nakon štete. Izmjereni brojevi razmjer čine očitim. Delphi proba koja je isti uzorak provukla kroz dva workera palila je 122 od 160 dokumenata u jednom pokretanju i 138 od 160 u drugom, a jedno od tih pokretanja podiglo je External exception C000001D ravno iz prve. Stress slučaj od 8 dokumenata, 4 workera i 5 krugova palio se ili rušio u 5 od 5 pokretanja na Free Pascal Win64. Nakon popravka ista je proba palila 0 od 1.200 dokumenata

Kako ValidatePdfFilesParallel ostaje siguran od v3.125.1

ValidatePdfFilesParallel sada serijalizira nativnu polovicu svakog posla i drži upravljanu polovicu paralelnom. Svaki worker uzme jednu kritičnu sekciju na razini jedinice prije nego stvori svoj TPdf, i drži je kroz FileName, Active := True, izgradnju preflight izvještaja i Free. Stvaranje i uništavanje unutar brave namjerno su: zatvaranje dokumenta zove natrag u modul baš kao i učitavanje. Kad worker uhvati snimljen TPdfPreflightReport zapis, otpušta bravu i evaluira pravila validacije nad tim zapisom, što ne dira nikakvo PDFium stanje, pa se evaluacija pravila za jednu datoteku preklapa s PDFium poslom za sljedeću

PDFium Component ValidatePdfFilesParallel dijagram pokazuje svaki worker koji drži jednu kritičnu sekciju na razini procesa kroz TPdf stvaranje, učitavanje, preflight i oslobađanje, dok se evaluacija pravila uhvaćenog izvještaja odvija izvan brave paralelno, pa je PDFium polovica batcha serija po dizajnu
Stvaranje i uništavanje ostaju unutar brave jer zatvaranje dokumenta zove natrag u modul, dok evaluacija izvještaja ne dira nikakvo PDFium stanje i preklapa sljedeću datoteku

Dvije manje promjene stigle su s popravkom. Neuspjeh učitavanja sada podiže EPdfError s LastLoadReport.ErrorMessage, pa ErrorMessage stavke imenuje stvarni pars problem umjesto sekundarne greške "nema aktivnog dokumenta". A trošak je iskreno rečen: PDFium dio batcha sada je serija, pa na batchu dominiranom parsiranjem i preflightom dodatni workeri ne kupuju mnogo. Ako ste na verziji prije v3.125.1, postavite WorkerCount na 1; to uklanja konkurenciju i oštećenje s njome

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 = broj procesora, ograničeno na 8
    Options.Standards := [ppsPdfA];
    // S izričitim registryjem, odgovarajući profil odaberite sami.
    // Prazan Profiles popis pokreće svako registrirano pravilo, a pravila za
    // standarde koje niste preflightali izvještavaju "nije prošlo"
    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;

Prosljeđivanje nil kao registryja kraći je put: ValidatePdfFilesParallel tada sam stvara zadani registry, izvodi popis profila iz Options.Standards i oslobađa registry kad se vrati. Rezultati se uvijek vraćaju u redoslijedu unosa, kakav god bio redoslijed kojim su workeri završili. Za formate izvještaja i command-line wrapper oko istog motora pogledajte batch PDF preflight izvještaje s PDFium Component CLI-jem, a za to što same PDF/A provjere pokrivaju, PDF/A preflight validaciju u Delphiju

Kako RenderPagesParallel stvarno paralelno renderira stranice?

TPdf.RenderPagesParallel radi paralelno jer njegovi workeri nikad ne dijele PDFium modul. Metoda prvo sprema aktivni dokument u izvornu pohranu na threadu pozivatelja. Svaki worker zatim kopira učitani PDFium DLL u jedinstveno imenovanu datoteku u temp direktoriju, učita tu kopiju s LoadLibrary i inicijalizira je. Windows DLL učitan iz drugog puta tretira kao drugi modul, pa svaka kopija dobiva vlastite globalne: vlastiti font cache, vlastiti page modul, vlastito sve. Worker otvara spremljeni dokument u svom privatnom modulu, renderira svoje stranice napredno s provjerama otkazivanja između koraka, zatim uništi biblioteku, istovari kopiju i obriše datoteku

PDFium Component RenderPagesParallel dijagram u kojem thread pozivatelja sprema snapshot dokumenta, zatim svaki worker kopira PDFium DLL u jedinstvenu temp datoteku, učita je kao zasebni modul s vlastitim globalnima, renderira svoje stranice s provjerama otkazivanja i istovara kopiju
Pravi paralelizam dolazi iz izolacije modula: Windows svaku DLL kopiju tretira kao drugi modul, pa workeri ne dijele ništa osim snapshota koji je thread pozivatelja spremio pod bravom

Izolacija nije besplatna, i zadane postavke to odražavaju. Svaki worker plaća DLL kopiju na disku, drugi set PDFium globala u memoriji i svježe parsiranje dokumenta. MaxWorkers = 0 znači najviše 4 workera, MaxPixelsPerPage i MaxTotalOutputBytes ograničavaju sirovi izlaz, a invertirane i night-duotone render opcije odbijaju se jer se međuspremnici vraćaju sirovi. Rezultat je TPdfParallelRenderReport čije polje Results drži jedan top-down 32-bitni međuspremnik po zatraženoj stranici, u redoslijedu zahtjeva

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;                 // brojevi stranica kreću od 1

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

  // Izvorni snapshot snima se na dijeljenom modulu, pa držite
  // PDFium bravu cijelog procesa ako i drugi threadovi koriste 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;

Obratite pozornost na bravu oko poziva. Worker moduli privatni su, ali korak snapshota na početku pokreće SaveAs na dijeljenom modulu s threada pozivatelja. Ako ništa drugo u vašem procesu istodobno ne dira TPdf, bravu možete ispustiti; ako išta dira, snapshot treba istu zaštitu kao i svaki drugi poziv dijeljenog modula

ObrazacSigurno među dokumentimaPDFium posao radi paralelnoTrošak
Jedan TPdf po threadu, bez dijeljene braveNeDa, dok ne počne oštećivatiPovremeni padovi, oštećeno stanje procesa
Jedna brava na razini procesa oko svih PDFium pozivaDaNePDFium dio je serija
ValidatePdfFilesParallel od v3.125.1DaNe; evaluacija pravila je paralelnaParsiranje i preflight su serija
TPdf.RenderPagesParallelDaDaDLL kopija, memorija i svježe parsiranje po workeru

Kako strukturirati vlastiti multithreaded PDFium kod?

Vaši vlastiti threadi trebaju dijeliti jednu bravu na razini procesa i držati je kroz cijeli život svakog TPdf-a kojeg koriste, ili pak koristiti komponentni API koji vam modul izolira. Brava mora biti jedan objekt za cijeli proces, ne jedna po threadu, po formi ili po dokumentu; brava koju dva threada ne dijele ne štiti ništa. Obrazac u nastavku preslikava ono što komponenta interno radi od v3.125.1: stvori, učitaj, čitaj i oslobodi unutar brave, pa sve što ne dira PDFium izvan nje

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

var
  PdfiumLock: TCriticalSection;        // jedna brava za cijeli proces

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;                      // i zatvaranje dokumenta jest PDFium posao
      end;
    finally
      PdfiumLock.Release;
    end;
    // Ispod ovog retka nema PDFiuma, pa ovaj dio radi paralelno
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Nekoliko pravila drži obrazac poštenim u stvarnoj aplikaciji:

  • Stavite TPdf.Create i Free unutar brave, ne samo očite pozive. Učitavanje, zatvaranje, čitanja svojstava poput PageCount, promjene stranica, izvlačenje teksta, renderiranje i spremanje svi dopiru u modul
  • Provjerite Active nakon dodjele. Neuspjelo učitavanje ostavlja Active na False, a LastLoadReport.ErrorMessage kaže zašto
  • Držite bravu po dokumentu, a ne po pozivu. Finije zaključavanje moguće je u principu, ali samo ako nijedan član TPdf-a nikad ne radi izvan nje, a gruba verzija jest ona na koju se komponenta sama oslanja
  • Držite spor posao bez PDFiuma, poput upisa u bazu, indeksiranja i mrežnih poziva, izvan brave, ili će jedan spor potrošač serijalizirati sve
  • Ne tretirajte privatnu bravu rendera po instanci kao zamjenu. Ona čuva jedan TPdf od samog sebe i ništa više

Isti oprez vrijedi i za kod koji niste napisali kao sirove threade. Background futures dobar su način da dugi renderi ne drže UI thread, kako je opisano u background PDF renderiranju s otkazivim futures, ali future executor ne dodaje vlastitu globalnu PDFium bravu. Ako nekoliko futuresa može istovremeno voziti različite instance TPdf-a, uzmite istu bravu na razini procesa unutar svakog workera, i preglednik na glavnom threadu tretirajte kao još jednog klijenta dijeljenog modula. Upotreba između instanci kroz asinkrone API-je nije zasebno revizirana, pa konzervativna pretpostavka jest da treba istu serijalizaciju kao ručno pisani threadi. Kad trebate pravi PDFium paralelizam za nešto osim renderiranja stranica, odvojeni worker procesi daju svakom poslu vlastiti modul po konstrukciji

Brza referenca: PDFium threading pravila za Delphi

  • PDFium nesigurno stanje drži na razini modula: font cache, page modul i ostali globali dijele svi dokumenti u procesu
  • Jedan TPdf po threadu ne izolira ništa; dvije instance na dva threada i dalje se mogu međusobno oštetiti
  • Tipični simptomi jesu neuspjesi učitavanja, access violationi u kasnijem kodu, External exception C000001D i izlazi s 0xC0000409 ili 0xC0000374
  • Oštećenje ustraje u procesu, pa poziv koji pada često nije onaj koji ga je uzrokovao
  • ValidatePdfFilesParallel siguran je od v3.125.1; na starijim verzijama koristite WorkerCount := 1
  • TPdf.RenderPagesParallel stvarno je paralan jer svaki worker učita izoliranu kopiju PDFium modula
  • Vaši vlastiti threadi, taskovi i futures trebaju jednu bravu na razini procesa koja pokriva svaki TPdf od Create do Free

PDFium Component omotava PDFium motor za Delphi s batch preflightom i validacijom, izoliranim paralelnim renderiranjem, otkazivim pozadinskim poslom i detaljnom dijagnostikom učitavanja. Detalji i izdanja na PDFium Component stranici proizvoda