Tehnički članak

PDFium thread safety: zašto brave po dokumentu padaju

PDFium nije thread-safe na nivou modula, pa dve TPdf instance koje rade na dva različita fajla u dva threada i dalje mogu jedna drugu da pokvare. PDFium Component za Delphi ovo rešava na dva načina: od v3.125.1 ValidatePdfFilesParallel serializuje svaki nativni PDFium poziv iza jedne brave za ceo proces, dok TPdf.RenderPagesParallel daje svakom radniku sopstvenu izolovanu kopiju PDFium modula. Bag koji je prisilio popravku bio je najgora vrsta povremenog. Batch test validacije prolazio je većinom, pa javljao jedan od dva dobra fajla kao pao, pa rušio sledeći test u istom procesu s access violation, a ponekad gurao čitav runner sa exit kodom umesto stack trace-a. Ništa nije bilo pogrešno s testom, i ništa nije bilo pogrešno s bilo kojim pojedinačnim dokumentom. Pretpostavka je bila pogrešna: jedan TPdf po threadu nije izolacija

Zašto jedan TPdf po threadu nije dovoljan?

Jedan TPdf po threadu nije dovoljan jer PDFium drži svoje nebezbedno stanje u modulu, ne u dokumentu. Svaki TPdf poseduje sopstveni FPDF_DOCUMENT handle, ali svaki handle u procesu opslužuje isti učitani DLL, i taj DLL drži singleton-ove za ceo proces: keš fontova, page modul i druge globalne strukture koje učitavanje dokumenta, parsiranje i renderovanje sve diraju. Dva threada koja učitavaju dva nepovezana fajla su dva threada koja istovremeno pišu u isti keš fontova. Nitko na Delphi strani ne poseduje te podatke, pa ništa na Delphi strani ne može da ih zaključa po dokumentu

Komponenta ima bravu, i iz nje je lako izvući pogrešan zaključak. TPdf omotava sopstvene render puteve internom critical sekcijom (EnterRenderLock / LeaveRenderLock, privatne metode TPdf-a). Ta brava je po instanci. Sprečava dva threada da istovremeno voze isti TPdf, što je prava opasnost, ali ne vidi drugu instancu na drugom threadu, pa međuinstancna konkurencija prolazi pravo pored nje. Opšte pravilo je dovoljno prosto za jedan red: u jednom učitanom PDFium modulu najviše jedan thread sme biti unutra u PDFium-u u bilo kom trenutku, bez obzira koliko je dokumenata otvoreno

PDFium Component dijagram dva threada koja vode odvojene TPdf instance nad različitim dokumentima dok se svaki poziv stiče u jednom učitanom pdfium.dll modulu čiji se keš fontova, page modul i ostali globali za ceo proces dele, dajući neuspehe učitavanja, access violation-e i fail-fast izlaske
PDFium drži svoje nebezbedno stanje u modulu, ne u dokumentu, pa dve TPdf instance na dva threada pišu u isti keš fontova ma koliko fajlovi bili nepovezani

Kako izgleda korupcija između dokumenata u Delphi procesu?

Korupcija između dokumenata izgleda kao slučajna mešavina nepovezanih neuspeha, i šteta nadživi kod koji ju je izazvao. Pre v3.125.1 ValidatePdfFilesParallel je pravio jedan TPdf po radniku i vodio Active := True plus gradnju preflight izveštaja istovremeno nad deljenim modulom. Simptomi viđeni na i Delphi i Free Pascal buildovima pokrili su ceo raspon:

  • Valjan fajl ne uspeva da se učita, ili se vraća iz batcha kao pao kad je trebalo da prođe
  • Access violation isplivava u kasnijem, nepovezanom pozivu, često u drugom testu ili drugom dokumentu
  • External exception C000001D se pojavljuje u Delphiju. Taj kod je STATUS_ILLEGAL_INSTRUCTION, podignut ud2 instrukcijom koju PDFium-ovi interni CHECK i IMMEDIATE_CRASH makroi izvršavaju kad se invarijanta slomi
  • Proces izlazi s 0xC0000409 (fail-fast, prijavljen kao stack buffer overrun) ili 0xC0000374 (heap korupcija), bez ijednog Delphi izuzetka

Poslednja dva boda su razlog što je bag bio tako teško uhvatiti. Paralelna validacija se završila, pokvareno globalno stanje ostalo je iza, i sledeća probna površina u istom procesu saplela se o njega. U jednom Delphi Win64 regresionom pokretanju talas C000001D neuspeha pogodio je testove koji nikad nisu dirali batch validaciju; oni su jednostavno bili prvi kod koji je koristio PDFium posle štete. Izmereni brojevi čine obim jasnim. Delphi proba koja je provlačila isti uzorak kroz dva radnika pala je na 122 od 160 dokumenata u jednom pokretanju i 138 od 160 u drugom, i jedno od tih pokretanja podiglo je External exception C000001D ravno. Stres slučaj od 8 dokumenata, 4 radnika i 5 krugova padao je ili rušio u 5 od 5 pokretanja na Free Pascal Win64. Posle popravke, ista proba pala je na 0 od 1.200 dokumenata

Kako ValidatePdfFilesParallel ostaje bezbedan od v3.125.1

ValidatePdfFilesParallel sada serializuje nativnu polovinu svakog posla i drži upravljanu polovinu paralelnom. Svaki radnik uzima jednu critical sekciju na nivou jedinice pre nego što napravi svoj TPdf, i drži je kroz FileName, Active := True, gradnju preflight izveštaja i Free. Stvaranje i uništavanje su unutar brave namerno: zatvaranje dokumenta vraća se u modulu baš kao i učitavanje. Kad radnik uhvati TPdfPreflightReport zapis, pušta bravu i vrednuje validaciona pravila nad tim zapisom, što ne dira PDFium stanje, pa vrednovanje pravila za jedan fajl preklapa PDFium posao za sledeći

PDFium Component ValidatePdfFilesParallel dijagram pokazuje svakog radnika koji drži jednu critical sekciju za ceo proces kroz TPdf stvaranje, učitavanje, preflight i oslobađanje dok se vrednovanje pravila uhvaćenog izveštaja vodi van brave paralelno, pa je PDFium polovina batcha serialna po dizajnu
Stvaranje i uništavanje ostaju unutar brave jer zatvaranje dokumenta vraća se u modul, dok vrednovanje izveštaja ne dira PDFium stanje i preklapa sledeći fajl

Dve manje promene došle su s popravkom. Neuspeh učitavanja sada podiže EPdfError s LastLoadReport.ErrorMessage-om, pa ErrorMessage stavke imenuje stvarni problem parsiranja umesto sekundarne greške „nema aktivnog dokumenta“. I cena je rečena iskreno: PDFium deo batcha je sada serialan, pa na batchu dominiranom parsiranjem i preflight-om dodatni radnici kupuju malo. Ako ste na verziji pre v3.125.1, postavite WorkerCount na 1; to uklanja konkurenciju i korupciju s njom

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 eksplicitnim registrom, sami izaberite poklapajući profil.
    // Prazna Profiles lista vodi svako registrovano pravilo, i pravila za
    // standarde koje niste preflight-ovali izveš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;

Prosleđivanje nil-a kao registra je kraći put: ValidatePdfFilesParallel tada sama pravi podrazumevani registar, izvodi listu profila iz Options.Standards-a i oslobađa registar kad se vrati. Rezultati uvek dolaze u redosledu unosa, kakav god bio redosled kojim su radnici završili. Za formate izveštaja i komandno-linijski omotač oko istog engine-a pogledajte batch PDF preflight izveštaje s PDFium Component CLI-jem, a o tome šta same PDF/A provere pokrivaju, PDF/A preflight validaciju u Delphiju

Kako RenderPagesParallel vodi stranice zaista paralelno?

TPdf.RenderPagesParallel radi paralelno jer njegovi radnici nikad ne dele PDFium modul. Metoda prvo čuva aktivni dokument u izvornu ostavu na threadu pozivaoca. Svaki radnik zatim kopira učitani PDFium DLL u jedinstveno imenovan fajl u temp direktorijumu, učita tu kopiju s LoadLibrary-jem i inicijalizuje je. Windows tretira DLL učitan s drugačije putanje kao drugi modul, pa svaka kopija dobija svoje globalne podatke: svoj keš fontova, svoj page modul, sve svoje. Radnik otvara sačuvani dokument u svom privatnom modulu, renderuje svoje stranice progresivno s proverama otkazivanja između koraka, pa uništi biblioteku, istovara kopiju i briše fajl

PDFium Component RenderPagesParallel dijagram gde thread pozivaoca čuva snimak dokumenta, pa svaki radnik kopira PDFium DLL u jedinstveni temp fajl, učita ga kao odvojeni modul sa sopstvenim globalima, renderuje svoje stranice s proverama otkazivanja i istovara kopiju
Pravi paralelizam dolazi iz izolacije modula: Windows tretira svaku DLL kopiju kao drugi modul, pa radnici ne dele ništa osim snimka koji je thread pozivaoca sačuvao pod bravom

Izolacija nije besplatna, i podrazumevane vrednosti to odražavaju. Svaki radnik plaća DLL kopiju na disku, drugi skup PDFium globala u memoriji i svežu analizu dokumenta. MaxWorkers = 0 znači najviše 4 radnika, MaxPixelsPerPage i MaxTotalOutputBytes ograničavaju sirovi izlaz, a opcije renderovanja invertovanog i noćnog duotona se odbijaju jer se baferi vraćaju sirovi. Rezultat je TPdfParallelRenderReport čiji Results niz drži po jedan odozgo-naniže 32-bitni bafer po traženoj stranici, u redosledu zahteva

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 snimak se pravi na deljenom modulu, pa držite
  // PDFium bravu za ceo proces 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;

Primetite bravu oko poziva. Radnički moduli su privatni, ali korak snimka na početku vodi SaveAs na deljenom modulu iz threada pozivaoca. Ako ništa drugo u vašem procesu ne dira TPdf istovremeno, možete baciti bravu; ako bilo šta dira, snimku treba ista zaštita kao svakom drugom pozivu na deljeni modul

ObrazacBezbedno između dokumenataPDFium posao radi paralelnoCena
Jedan TPdf po threadu, bez deljene braveNeDa, dok ne pokvariPovremeni crash-evi, oštećeno stanje procesa
Jedna brava za ceo proces oko svih PDFium pozivaDaNePDFium deo je serialan
ValidatePdfFilesParallel od v3.125.1DaNe; vrednovanje pravila je paralelnoParsiranje i preflight su serialni
TPdf.RenderPagesParallelDaDaDLL kopija, memorija i sveža analiza po radniku

Kako treba da uredite sopstveni višethreadni PDFium kod?

Vaši sopstveni threadovi treba da dele jednu bravu za ceo proces i drže je kroz ceo život svakog TPdf-a kojeg koriste, ili da koriste API komponente koji vam izoluje modul. Brava mora biti jedan objekat za ceo proces, ne jedna po threadu, po formi ili po dokumentu; brava koju dva threada ne dele ne štiti ništa. Obrazac ispod ogleda ono što komponenta radi interno od v3.125.1: stvaranje, učitavanje, čitanje i oslobađanje unutar brave, pa sve što ne dira PDFium van nje

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

var
  PdfiumLock: TCriticalSection;        // jedna brava za ceo 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 je PDFium posao
      end;
    finally
      PdfiumLock.Release;
    end;
    // Ispod ove linije nema PDFium-a, pa ovaj deo radi paralelno
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Par pravila drži obrazac poštenim u pravoj aplikaciji:

  • Stavite TPdf.Create i Free unutar brave, ne samo očigledne pozive. Učitavanje, zatvaranje, čitanja svojstava poput PageCount-a, promene stranice, izvlačenje teksta, renderovanje i čuvanje svi dopiru do modula
  • Proverite Active posle dodele. Pao učitavanje ostavlja Active na False, a LastLoadReport.ErrorMessage kaže zašto
  • Držite bravu po dokumentu, a ne po pozivu. Finija zaključavanja su moguća u principu, ali samo ako nijedan član TPdf-a nikad ne radi van nje, i gruba verzija je ona na kojoj se sama komponenta oslanja
  • Držite spori ne-PDFium posao, poput upisa u bazu, indeksiranja i mrežnih poziva, van brave, ili će jedan spor potrošač serializovati sve
  • Ne tretirajte privatnu bravu renderovanja po instanci kao zamenu. Ona čuva jedan TPdf od samog sebe i ništa više

Ista oprez važi i za kod koji niste napisali kao sirove threadove. Pozadinski futures su dobar način da duga renderovanja držite van UI threada, kako je opisano u tekstu o pozadinskom PDF renderovanju s otkazivim futures-ima, ali executor future-a ne dodaje globalnu PDFium bravu svoga. Ako više future-a može istovremeno voziti različite TPdf instance, uzimajte istu bravu za ceo proces unutar svakog radnika, i tretirajte pregledač na glavnom threadu kao još jednog klijenta deljenog modula. Međuinstancna upotreba kroz asinhrone API-je nije posebno auditirana, pa je konzervativna pretpostavka da treba istu serializaciju kao ručno pisani threadovi. Kad treba pravi PDFium paralelizam za nešto drugo osim renderovanja stranica, odvojeni radni procesi daju svakom poslu sopstveni modul po konstrukciji

Brzi podsetnik: PDFium pravila thredovanja za Delphi

  • PDFium nebezbedno stanje je na nivou modula: keš fontova, page modul i ostale globale dele svi dokumenti u procesu
  • Jedan TPdf po threadu ne izoluje ništa; dve instance na dva threada i dalje mogu jedna drugu pokvariti
  • Tipični simptomi su neuspesi učitavanja, access violation-i u kasnijem kodu, External exception C000001D, i izlasci s 0xC0000409 ili 0xC0000374
  • Korupcija ostaje u procesu, pa padajući poziv često nije onaj koji ju je izazvao
  • ValidatePdfFilesParallel je bezbedan od v3.125.1; na starijim verzijama koristite WorkerCount := 1
  • TPdf.RenderPagesParallel je zaista paralelan jer svaki radnik učita izolovanu kopiju PDFium modula
  • Vaši sopstveni threadovi, taskovi i futures trebaju jednu bravu za ceo proces koja pokriva svaki TPdf od Create do Free

PDFium Component omotava PDFium engine za Delphi s batch preflight-om i validacijom, izolovanim paralelnim renderovanjem, otkazivim pozadinskim poslom i detaljnom dijagnostikom učitavanja. Detalji i izdanja su na stranici proizvoda PDFium Component