Odborný článok

Thread safety PDFia: prečo per-document zámky zlyhajú

PDFium nie je thread-safe na úrovni modulu, takže dve inštancie TPdf pracujúce na dvoch rôznych súboroch v dvoch vláknach si môžu aj tak navzájom pokaziť dáta. PDFium Component pre Delphi to rieši dvomi spôsobmi: od v3.125.1 serializuje ValidatePdfFilesParallel každé natívne volanie PDFia za jedným zámkom na celý proces, zatiaľ čo TPdf.RenderPagesParallel dáva každému workerovi vlastnú izolovanú kópiu modulu PDFium. Bug, ktorý vynútil opravu, bol najhorším druhom prerušovaného. Test dávkovej validácie prešiel väčšinu času, potom ohlásil jeden z dvoch dobrých súborov ako zlyhaný, potom zrazil ďalší test v tom istom procese access violation a niekedy povalil celý runner s exit kódom namiesto stack trace. Test nemal nič a žiadny jednotlivý dokument nemal nič. Predpoklad bol zlý: jedno TPdf na vlákno nie je izolácia

Prečo nie je jedno TPdf na vlákno dosť?

Jedno TPdf na vlákno nestačí, pretože PDFium drží svoj nezabezpečený stav v module, nie v dokumente. Každé TPdf vlastní vlastný handle FPDF_DOCUMENT, ale každý handle v procese obsluhuje to isté načítané DLL a toto DLL drží singletony na úrovni procesu: cache písiem, page module a ďalšie globálne štruktúry, ktorých sa dotýka načítanie, parsovanie aj vykresľovanie dokumentov. Dve vlákna načítavajúce dva nesúvisiace súbory sú dve vlákna zapisujúce do tej istej cache písiem v ten istý čas. Nikto na strane Delphi tie dáta nevlastní, takže nič na strane Delphi ich nedokáže zamknúť na dokument

Komponent zámok má a je ľahké z neho urobiť zlý záver. TPdf balí vlastné render cesty do internej kritickej sekcie (EnterRenderLock / LeaveRenderLock, súkromné metódy TPdf). Tento zámok je na inštanciu. Zabráni dvom vláknam riadiť to isté TPdf naraz, čo je skutočné nebezpečenstvo, ale nevidí druhú inštanciu na inom vlákne, takže konkurencia medzi inštanciami prejde priamo okolo neho. Všeobecné pravidlo je dosť jednoduché na jeden riadok: v jedinom načítanom module PDFium môže byť v každom okamihu vo vnútri PDFia nanajvýš jedno vlákno, bez ohľadu na to, koľko dokumentov je otvorených

Diagram PDFium Component dvoch vlákien bežiacich na samostatných inštanciách TPdf nad rôznymi dokumentmi, zatiaľ čo každé volanie sa zbieha na jedinom načítanom module pdfium.dll, ktorej cache písiem, page module a ďalšie globálne premenné procesu sú zdieľané, čo dáva zlyhania načítania, access violation a fail-fast ukončenia
PDFium drží svoj nezabezpečený stav v module, nie v dokumente, takže dve inštancie TPdf na dvoch vláknach zapisujú do tej istej cache písiem bez ohľadu na to, aké nesúvisiace súbory sú

Ako vyzerá poškodenie medzi dokumentmi v Delphi procese?

Poškodenie medzi dokumentmi vyzerá ako náhodná zmes nesúvisiacich zlyhaní a škoda prežije kód, ktorý ju spôsobil. Pred v3.125.1 vytváralo ValidatePdfFilesParallel jedno TPdf na worker vlákno a púšťalo Active := True plus stavbu preflight reportu súbežne nad zdieľaným modulom. Príznaky pozorované na zostaveniach Delphi aj Free Pascal pokryli celé spektrum:

  • Platný súbor sa nepodarí načítať alebo príde z dávky ako zlyhaný, keď mal prejsť
  • Access violation vychádza na povrch v neskoršom, nesúvisiacom volaní, často v inom teste alebo inom dokumente
  • External exception C000001D sa objaví v Delphi. Ten kód je STATUS_ILLEGAL_INSTRUCTION, vyvolaný inštrukciou ud2, ktorú vykonávajú interné makrá CHECK a IMMEDIATE_CRASH PDFia, keď sa zlomí invariant
  • Proces skončí s 0xC0000409 (fail-fast, hlásený ako stack buffer overrun) alebo 0xC0000374 (poškodenie heapu), bez akejkoľvek Delphi výnimky

Posledné dva body sú dôvod, prečo bolo tak ťažké bug prichytiť. Paralelná validácia skončila, poškodený globálny stav ostal ležať a ďalšia fixtúra v tom istom procese oň zakopla. V jednom regresnom behu Delphi Win64 zasiahla vlna zlyhaní C000001D testy, ktoré sa dávkovej validácie nikdy nedotkli; boli jednoducho prvým kódom, ktorý použil PDFium po škode. Namerané čísla dávajú rozsah jasne najavo. Delphi sonda, ktorá prehnala ten istý vzor cez dvoch workerov, zlyhala v jednom behu na 122 zo 160 dokumentov a v inom na 138 zo 160 a jeden z týchto behov vyhodil rovno External exception C000001D. Stresový prípad 8 dokumentov, 4 workerov a 5 kôl zlyhal alebo spadol v 5 z 5 behov na Free Pascal Win64. Po oprave zlyhala tá istá sonda na 0 z 1 200 dokumentov

Ako zostáva ValidatePdfFilesParallel bezpečné od v3.125.1

ValidatePdfFilesParallel teraz serializuje natívnu polovicu každej úlohy a spravovanú polovicu necháva paralelnú. Každý worker vezme jednu kritickú sekciu na úrovni jednotky, skôr než vytvorí svoje TPdf, a drží ju cez FileName, Active := True, stavbu preflight reportu a Free. Vytvorenie a zničenie sú vo vnútri zámku zámerne: zatvorenie dokumentu volá späť do modulu rovnako ako načítanie. Keď má worker zachytený záznam TPdfPreflightReport, uvoľní zámok a vyhodnotí validačné pravidlá proti tomu záznamu, čo sa nedotýka žiadneho stavu PDFia, takže vyhodnocovanie pravidiel jedného súboru sa prekrýva s PDFium prácou pre ďalší

Diagram ValidatePdfFilesParallel v PDFium Component ukazujúci každého workera držiaceho jednu kritickú sekciu na celý proces cez TPdf vytvorenie, načítanie, preflight a uvoľnenie, zatiaľ čo vyhodnotenie pravidiel nad zachyteným reportom beží mimo zámku paralelne, takže PDFium polovica dávky je podľa návrhu sériová
Vytvorenie a zničenie ostávajú vo vnútri zámku, lebo zatvorenie dokumentu volá späť do modulu, kým vyhodnotenie reportu sa nedotýka žiadneho stavu PDFia a prekrýva sa s ďalším súborom

S opravou prišli dve menšie zmeny. Zlyhanie načítania teraz vyvolá EPdfError s LastLoadReport.ErrorMessage, takže ErrorMessage položky menuje skutočný problém parsovania namiesto vedľajšej chyby „žiadny aktívny dokument“. A cena je povedaná úprimne: PDFium časť dávky je teraz sériová, takže na dávke dominovanej parsovaním a preflightom prinášajú ďalší workeri málo. Ak ste na verzii pred v3.125.1, nastavte WorkerCount na 1; to odstráni konkurenciu aj s ňou poškodenie

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 = počet procesorov, stropnuté na 8
    Options.Standards := [ppsPdfA];
    // S explicitným registrom si zvoľte zodpovedajúci profil sami.
    // Prázdny zoznam Profiles pustí každé registrované pravidlo a pravidlá
    // pre štandardy, ktoré ste nepreflightovali, hlásia "nepreš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;

Podanie nil ako registra je kratšia cesta: ValidatePdfFilesParallel potom vytvorí predvolený register samo, odvodí zoznam profilov z Options.Standards a register uvoľní, keď sa vráti. Výsledky sa vždy vracajú vo vstupnom poradí, v akomkoľvek poradí, v akom workery skončili. Formáty reportov a príkazový obal okolo toho istého engine popisuje dávkové PDF preflight reporty s PDFium Component CLI a to, čo samy pokrývajú kontroly PDF/A, validácia preflight PDF/A v Delphi

Ako bežia strany v RenderPagesParallel naozaj paralelne?

TPdf.RenderPagesParallel beží paralelne, pretože jeho workery nikdy nezdieľajú modul PDFium. Metóda najprv uloží aktívny dokument do zdrojového úložiska na volajúcom vlákne. Každý worker potom skopíruje načítané PDFium DLL do jednoznačne pomenovaného súboru v temp adresári, načíta tú kópiu cez LoadLibrary a inicializuje ju. Windows berie DLL načítané z inej cesty ako iný modul, takže každá kópia dostane vlastné globálne premenné: vlastnú cache písiem, vlastný page module, vlastné všetko. Worker otvorí uložený dokument vo svojom súkromnom module, vykreslí jeho strany postupne s kontrolami zrušenia medzi krokmi, potom zničí knižnicu, vyloží kópiu a zmaže súbor

Diagram RenderPagesParallel v PDFium Component, kde volajúce vlákno uloží snímku dokumentu, potom každý worker skopíruje PDFium DLL do jedinečného temp súboru, načíta ju ako samostatný modul s vlastnými globálnymi premennými, vykreslí jej strany s kontrolami zrušenia a vyloží kópiu
Skutočný paralelizmus prichádza z izolácie modulov: Windows berie každú kópiu DLL ako iný modul, takže workery nezdieľajú nič okrem snímky, ktorú uložilo volajúce vlákno pod zámkom

Izolácia nie je zadarmo a predvolené nastavenia to odrážajú. Každý worker platí za kópiu DLL na disku, druhú sadu globálnych premenných PDFia v pamäti a čerstvý parse dokumentu. MaxWorkers = 0 znamená nanajvýš 4 workerov, MaxPixelsPerPage a MaxTotalOutputBytes stropujú surový výstup a invertované a nočné duotónové render voľby sa odmietajú, lebo buffre sa vracajú surové. Výsledkom je TPdfParallelRenderReport, ktorého pole Results drží jeden top-down 32-bit buffer na požadovanú stranu, v poradí požiadaviek

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;                 // čísla strán sú od jednej

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

  // Zdrojová snímka sa berie na zdieľanom module, takže držte zámok
  // PDFia na celý proces, ak TPdf používajú aj iné vlákna
  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;

Všimnite si zámok okolo volania. Moduly workerov sú súkromné, ale krok snímky na začiatku púšťa SaveAs na zdieľanom module z volajúceho vlákna. Ak sa inak nič vo vašom procese nedotýka TPdf súbežne, zámok môžete vynechať; ak sa čokoľvek dotýka, snímka potrebuje tú istú ochranu ako každé iné volanie zdieľaného modulu

VzorBezpečné medzi dokumentmiPráca PDFia beží paralelneCena
Jedno TPdf na vlákno, žiadny zdieľaný zámokNieÁno, kým sa to nepokazíPrerušované pády, poškodený stav procesu
Jeden zámok na celý proces okolo všetkých volaní PDFiaÁnoNiePDFium časť je sériová
ValidatePdfFilesParallel od v3.125.1ÁnoNie; vyhodnotenie pravidiel je paralelnéParsovanie a preflight sú sériové
TPdf.RenderPagesParallelÁnoÁnoKópia DLL, pamäť a čerstvý parse na workera

Ako štruktúrovať vlastný viacvláknový PDFium kód?

Vlastné vlákna majú zdieľať jeden zámok na celý proces a držať ho po celý život každého TPdf, ktoré používajú, alebo použiť API komponentu, ktoré vám modul izoluje. Zámok musí byť jediný objekt pre celý proces, nie jeden na vlákno, formulár alebo dokument; zámok, ktorý dve vlákna nezdieľajú, nechráni nič. Vzor nižšie zrkadlí to, čo komponent robí interne od v3.125.1: vytvor, načítaj, čítaj a uvoľni vo vnútri zámku a všetko, čo sa nedotýka PDFia, rob mimo neho

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

var
  PdfiumLock: TCriticalSection;        // jeden zámok pre celý 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;                      // zatvorenie dokumentu je tiež práca PDFia
      end;
    finally
      PdfiumLock.Release;
    end;
    // Pod týmto riadkom žiadne PDFium, takže táto časť beží paralelne
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Pár pravidiel drží vzor poctivý v reálnej aplikácii:

  • Dajte TPdf.Create a Free dovnútra zámku, nie len zjavné volania. Načítanie, zatvorenie, čítania vlastností ako PageCount, zmeny strany, extrakcia textu, vykresľovanie a ukladanie všetko zasahuje do modulu
  • Skontrolujte Active po priradení. Zlyhané načítanie nechá Active na False a LastLoadReport.ErrorMessage povie prečo
  • Držte zámok na dokument skôr než na volanie. Jemnejšie zamykanie je v princípe možné, ale len ak nikdy žiadny člen TPdf nebeží mimo neho a hrubá verzia je tá, na ktorú sa komponent sám spolieha
  • Držte pomalú prácu mimo PDFia, ako zápisy do databázy, indexovanie a sieťové volania, mimo zámku, inak jeden pomalý konzument zserializuje všetko
  • Neberajte súkromný zámok renderu na inštanciu ako náhradu. Chráni jedno TPdf pred samým sebou a nič viac

Rovnaká opatrnosť platí pre kód, ktorý ste nenapísali ako surové vlákna. Background futures sú dobrý spôsob, ako držať dlhé rendery mimo UI vlákna, ako popisuje vykresľovanie PDF na pozadí so zrušiteľnými futures, ale future executor nepridáva vlastný globálny zámok PDFia. Ak viaceré futures môžu súčasne riadiť rôzne inštancie TPdf, vezmite ten istý zámok na celý proces vo vnútri každého workera a berte prehliadač na hlavnom vlákne ako ďalšieho klienta zdieľaného modulu. Používanie medzi inštanciami cez asynchrónne API nebolo osobitne auditované, takže konzervatívny predpoklad je, že potrebuje rovnakú serializáciu ako ručne písané vlákna. Keď potrebujete skutočný paralelizmus PDFia na niečo iné než vykresľovanie strán, oddelené worker procesy dajú každému jobu vlastný modul konštrukciou

Rýchly prehľad: pravidlá vlákien PDFia pre Delphi

  • Nezabezpečený stav PDFia je na celej úrovni modulu: cache písiem, page module a ďalšie globálne premenné zdieľa každý dokument v procese
  • Jedno TPdf na vlákno neizoluje nič; dve inštancie na dvoch vláknach si môžu stále navzájom pokaziť dáta
  • Typické príznaky sú zlyhania načítania, access violation v neskoršom kóde, External exception C000001D a ukončenia s 0xC0000409 alebo 0xC0000374
  • Poškodenie pretrváva v procese, takže zlyhavajúce volanie býva často nie to, ktoré ho spôsobilo
  • ValidatePdfFilesParallel je bezpečné od v3.125.1; na starších verziách použite WorkerCount := 1
  • TPdf.RenderPagesParallel je naozaj paralelné, lebo každý worker načíta izolovanú kópiu modulu PDFium
  • Vlastné vlákna, úlohy a futures potrebujú jeden zámok na celý proces pokrývajúci každé TPdf od Create po Free

PDFium Component obaluje engine PDFium pre Delphi s dávkovým preflightom a validáciou, izolovaným paralelným vykresľovaním, zrušiteľnou prácou na pozadí a podrobnou diagnostikou načítania. Detaily a edície sú na stránke produktu PDFium Component