Technický článek

Thread safety PDFium: proč zámky na dokument nestačí

PDFium není thread-safe na úrovni modulu, takže si dvě instance TPdf pracující na dvou různých souborech ve dvou vláknech můžou i tak vzájemně rozbit data. PDFium Component pro Delphi to řeší dvěma způsoby: od v3.125.1 serializuje ValidatePdfFilesParallel každé nativní volání PDFium za jedním zámkem napříč procesem, zatímco TPdf.RenderPagesParallel dává každému workerovi vlastní izolovanou kopii modulu PDFium. Bug, který opravu vynutil, byl to nejhorší, co umí intermittent. Test dávkové validace prošel většinou, pak ohlásil jeden ze dvou dobrých souborů jako selhaný, pak shodil přístupovým porušením další test ve stejném procesu a občas strhl celý runner dolů s exit kódem místo stack trace. S testem nebylo nic špatně a s žádným jednotlivým dokumentem taky ne. Špatně byl předpoklad: jedno TPdf na vlákno není izolace

Proč nestačí jedno TPdf na vlákno?

Jedno TPdf na vlákno nestačí, protože PDFium drží své nebezpečné stavy v modulu, ne v dokumentu. Každé TPdf vlastní vlastní handle FPDF_DOCUMENT, ale každý handle v procesu obsluhuje totéž načtené DLL a tohle DLL drží celoprocesové singletony: keš fontů, page module a další globální struktury, kterých se dotýká načítání dokumentů, parsování i vykreslování. Dvě vlákna načítající dva nesouvisející soubory jsou dvě vlákna zapisující do téže keše fontů ve stejnou chvíli. Ta data nikdo na straně Delphi nevlastní, takže je nic na straně Delphi nemůže zamykat na dokument

Komponenta zámek má a je snadné z něj vytáhnout špatný závěr. TPdf ovíjí vlastní renderovací cesty interní kritickou sekcí (EnterRenderLock / LeaveRenderLock, privátní metody TPdf). Tenhle zámek je na instanci. Zabrání dvěma vláknům řídit najednou stejné TPdf, což je reálné nebezpečí, ale nevidí druhou instanci na jiném vlákně, takže cross-instance souběh jde rovnou kolem něj. Obecné pravidlo je dost jednoduché na jednu větu: v jediném načteném modulu PDFium smí být v každém okamžiku nejvýš jedno vlákno uvnitř PDFium, ať je otevřených dokumentů kolik chce

Diagram PDFium Component dvou vláken běžících oddělené instance TPdf nad různými dokumenty, zatímco každé volání sbíhá do jednoho načteného modulu pdfium.dll, jehož keš fontů, page module a další celoprocesové globály jsou sdílené, což produkuje chyby načtení, porušení přístupu a fail-fast ukončení
PDFium drží své nebezpečné stavy v modulu, ne v dokumentu, takže dvě instance TPdf ve dvou vláknech zapisují do téže keše fontů, ať jsou soubory jakkoli nesouvisející

Jak vypadá poškození napříč dokumenty v procesu Delphi?

Poškození napříč dokumenty vypadá jako náhodná směs nesouvisejících selhání a škoda přežívá kód, který ji způsobil. Před v3.125.1 vytvářelo ValidatePdfFilesParallel jedno TPdf na pracovní vlákno a pouštělo Active := True plus stavbu preflight reportu souběžně na sdíleném modulu. Příznaky viděné na buildech Delphi i Free Pascal pokryly celé spektrum:

  • Platný soubor se nepodaří načíst, nebo se z dávky vrátí jako selhaný, i když měl projít
  • Porušení přístupu se vynoří v pozdějším, nesouvisejícím volání, často v jiném testu nebo jiném dokumentu
  • External exception C000001D se objeví v Delphi. Tenhle kód je STATUS_ILLEGAL_INSTRUCTION, vyhozený instrukcí ud2, kterou vykonávají makra CHECK a IMMEDIATE_CRASH uvnitř PDFium, když se rozbije invariant
  • Proces skončí s 0xC0000409 (fail-fast, hlášený jako stack buffer overrun) nebo 0xC0000374 (poškození heapu), bez jakékoli výjimky Delphi

Poslední dva body jsou důvod, proč se bug tak špatně dohledával. Paralelní validace doběhla, poškozený globální stav zůstal viset a další fixture ve stejném procesu o něj zakopla. V jednom regresním běhu Delphi Win64 zasáhla vlna selhání C000001D testy, které se dávkové validace nikdy nedotkly; byly prostě prvním kódem, který PDFium použil po škodě. Změřená čísla dávají rozsah jasně najevo. Sonda v Delphi, která pouštěla ten samý vzorek dvěma workery, selhala v jednom běhu u 122 ze 160 dokumentů a v jiném u 138 ze 160 a jeden z těch běhů vyhodil rovnou External exception C000001D. Stresový případ 8 dokumentů, 4 workerů a 5 kol selhal nebo spadl v 5 z 5 běhů na Free Pascal Win64. Po opravě selhala tatáž sonda u 0 ze 1 200 dokumentů

Jak je ValidatePdfFilesParallel bezpečné od v3.125.1

ValidatePdfFilesParallel teď serializuje nativní polovinu každé práce a nechává spravovanou polovinu paralelní. Každý worker si vezme jednu kritickou sekci na úrovni unity, dřív než vytvoří své TPdf, a drží ji skrz FileName, Active := True, stavbu preflight reportu a Free. Tvorba i ničení jsou ve zámku záměrně: zavření dokumentu se vrací do modulu úplně stejně jako načtení. Jakmile má worker zachycený záznam TPdfPreflightReport, zámek pustí a vyhodnocuje validační pravidla proti tomu záznamu, což se PDFium stavu nedotýká, takže vyhodnocení pravidel pro jeden soubor se překrývá s prací PDFium pro další

Diagram ValidatePdfFilesParallel v PDFium Component ukazující každého workera držícího jednu kritickou sekci napříč procesem skrz vytvoření TPdf, načtení, preflight a uvolnění, zatímco vyhodnocení pravidel zachyceného reportu běží mimo zámek paralelně, takže PDFium polovina dávky je serialní po návrhu
Tvorba i ničení zůstávají uvnitř zámku, protože zavření dokumentu se vrací do modulu, zatímco vyhodnocení reportu se PDFium stavu nedotýká a překrývá se s dalším souborem

S opravou přišly dvě menší změny. Selhání načtení teď vyhodí EPdfError s LastLoadReport.ErrorMessage, takže ErrorMessage položky jmenuje skutečný problém parsování místo vedlejší chyby „no active document“. A náklady jsou řečené nahlas: PDFium část dávky je teď serialní, takže na dávce, kterou ovládá parsování a preflight, kupují navíc workery málo. Jste-li na verzi před v3.125.1, nastavte WorkerCount na 1; tím zmizí souběh i s ním poškození

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 procesorů, strop 8
    Options.Standards := [ppsPdfA];
    // S explicitním registrem si vyberte odpovídající profil sami.
    // Prázdný seznam Profiles pouští každé registrované pravidlo a pravidla pro
    // standardy, které nepreflightnete, hlásí "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;

Podat nil jako registr je kratší cesta: ValidatePdfFilesParallel si pak vytvoří výchozí registr sama, odvodí seznam profilů z Options.Standards a registr při návratu uvolní. Výsledky se vždycky vracejí ve vstupním pořadí, ať pracovníci skončili v jakémkoli. Formáty reportu a command-line wrapper nad tímtéž jádrem popisuje dávkové preflight reporty PDF s PDFium Component CLI a co kontroly PDF/A samotné pokrývají, validace PDF/A preflight v Delphi

Jak pouští RenderPagesParallel stránky doopravdy paralelně?

TPdf.RenderPagesParallel běží paralelně, protože jeho workery nikdy nesdílejí modul PDFium. Metoda nejdřív uloží aktivní dokument do zdrojového úložiště na volajícím vlákně. Každý worker pak zkopíruje načtené DLL PDFium do jednoznačně pojmenovaného souboru v dočasném adresáři, načte tu kopii přes LoadLibrary a inicializuje ji. Windows bere DLL načtené z jiné cesty jako jiný modul, takže každá kopie dostane vlastní globály: vlastní keš fontů, vlastní page module, vlastní všechno. Worker otevře uložený dokument ve svém soukromém modulu, renderuje své stránky postupně s kontrolami zrušení mezi kroky, pak knihovnu zničí, kopii unloadne a soubor smaže

Diagram RenderPagesParallel v PDFium Component, kde volající vlákno uloží snapshot dokumentu, pak každý worker zkopíruje DLL PDFium do unikátního dočasného souboru, načte ho jako oddělený modul s vlastními globály, renderuje své stránky s kontrolami zrušení a kopii unloadne
Skutečný paralelismus přichází z izolace modulů: Windows bere každou kopii DLL jako jiný modul, takže workery nesdílejí nic kromě snapshotu, který volající vlákno uložilo pod zámkem

Izolace není zadarmo a defaulty to odrážejí. Každý worker platí za kopii DLL na disku, druhou sadu globálů PDFium v paměti a čerstvý parse dokumentu. MaxWorkers = 0 znamená nejvýš 4 workery, MaxPixelsPerPage a MaxTotalOutputBytes omezují syrový výstup a invertované a noční duotone volby renderu se odmítají, protože buffery se vracejí syrové. Výsledkem je TPdfParallelRenderReport, jehož pole Results drží jeden top-down 32bitový buffer na vyžádanou stránku, v pořadí požadavků

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ánek jsou od jedničky

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

  // Zdrojový snapshot se bere na sdíleném modulu, takže držte
  // PDFium zámek napříč procesem, když TPdf používají i jiná 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šimněte si zámku kolem volání. Workerní moduly jsou soukromé, ale snapshot krok na začátku pouští SaveAs na sdíleném modulu z volajícího vlákna. Nedotýká-li se TPdf v procesu nic jiného souběžně, zámek můžete zahodit; dotýká-li se, snapshot potřebuje tutéž ochranu jako každé jiné volání sdíleného modulu

VzorBezpečné napříč dokumentyPráce PDFium běží paralelněCena
Jedno TPdf na vlákno, bez sdíleného zámkuNeAno, dokud to nerozbije dataObčasné pády, poškozený stav procesu
Jeden zámek napříč procesem kolem všech volání PDFiumAnoNeČást PDFium je serialní
ValidatePdfFilesParallel od v3.125.1AnoNe; vyhodnocení pravidel je paralelníParsování a preflight jsou serialní
TPdf.RenderPagesParallelAnoAnoKopie DLL, paměť a čerstvý parse na workera

Jak strukturovat vlastní vícevláknový kód nad PDFium?

Vlastní vlákna by měla sdílet jeden zámek napříč procesem a držet ho po celou dobu života každého TPdf, které používají, nebo použít API komponenty, které modul izoluje za vás. Zámek musí být jeden objekt pro celý proces, ne jeden na vlákno, na formulář nebo na dokument; zámek, který dvě vlákna nesdílí, nechrání nic. Vzor níže zrcadlí to, co komponenta dělá interně od v3.125.1: vytvoř, načti, čti a uvolni uvnitř zámku, pak dělej všechno, co se PDFium nedotýká, mimo něj

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

var
  PdfiumLock: TCriticalSection;        // jeden zámek pro 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;                      // zavření dokumentu je taky práce PDFium
      end;
    finally
      PdfiumLock.Release;
    end;
    // Pod touhle řádkou žádné PDFium, takže tahle část běží paralelně
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Pár pravidel udrží vzor čestný v reálné aplikaci:

  • Dejte TPdf.Create i Free do zámku, ne jen zjevná volání. Načítání, zavírání, čtení vlastností jako PageCount, změny stránek, extrakce textu, renderování i ukládání všichni sahají do modulu
  • Zkontrolujte Active po přiřazení. Selhané načtení nechá Active na False a LastLoadReport.ErrorMessage řekne proč
  • Držte zámek na dokument, ne na volání. Jemnější zamykání je v principu možné, ale jen když žádný člen TPdf nikdy neuběhne mimo něj a hrubá verze je ta, na které sama komponenta stojí
  • Pomalu běžící práci mimo PDFium, jako zápisy do databáze, indexování a síťová volání, držte mimo zámek, jinak jeden pomalý konzument serializuje všechno
  • Neberte soukromý zámek renderu na instanci jako náhradu. Chrání jedno TPdf před sebou samým a nic víc

Táž obezřetnost platí pro kód, který jste nenapsali jako holá vlákna. Background futures jsou dobrý způsob, jak držet dlouhé rendery mimo UI vlákno, jak popisuje vykreslování PDF na pozadí se zrušitelnými futures, ale exekutor future nepřidává vlastní globální zámek PDFium. Mohou-li různé futures řídit různé instance TPdf ve stejnou chvíli, vezměte v každém workerovi týž zámek napříč procesem a berte prohlížeč na hlavním vlákně jako jednoho dalšího klienta sdíleného modulu. Cross-instance užívání přes asynchronní API nebylo samostatně auditováno, takže konzervativní předpoklad je, že potřebuje tutéž serializaci jako ručně psaná vlákna. Když potřebujete skutečný paralelismus PDFium k něčemu jinému než renderování stránek, dávají oddělené worker procesy každé práci vlastní modul už konstrukcí

Rychlý přehled: pravidla vláken PDFium pro Delphi

  • Nebezpečný stav PDFium je celomodulový: keš fontů, page module a další globály sdílí každý dokument v procesu
  • Jedno TPdf na vlákno neizoluje nic; dvě instance ve dvou vláknech si můžou pořád vzájemně rozbít data
  • Typické příznaky jsou chyby načtení, porušení přístupu v pozdějším kódu, External exception C000001D a ukončení s 0xC0000409 nebo 0xC0000374
  • Poškození v procesu přetrvává, takže selhávající volání často není to, které ho způsobilo
  • ValidatePdfFilesParallel je bezpečné od v3.125.1; na starších verzích použijte WorkerCount := 1
  • TPdf.RenderPagesParallel je doopravdy paralelní, protože každý worker načítá izolovanou kopii modulu PDFium
  • Vlastní vlákna, tasky a futures potřebují jeden zámek napříč procesem pokrývající každé TPdf od Create po Free

PDFium Component obaluje jádro PDFium pro Delphi s dávkovým preflightem a validací, izolovaným paralelním renderováním, zrušitelnou prací na pozadí a detailní diagnostikou načítání. Detaily a edice najdete na stránce produktu PDFium Component