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
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 C000001Dse objeví v Delphi. Tenhle kód jeSTATUS_ILLEGAL_INSTRUCTION, vyhozený instrukcíud2, kterou vykonávají makraCHECKaIMMEDIATE_CRASHuvnitř PDFium, když se rozbije invariant- Proces skončí s
0xC0000409(fail-fast, hlášený jako stack buffer overrun) nebo0xC0000374(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ší
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
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
| Vzor | Bezpečné napříč dokumenty | Práce PDFium běží paralelně | Cena |
|---|---|---|---|
Jedno TPdf na vlákno, bez sdíleného zámku | Ne | Ano, dokud to nerozbije data | Občasné pády, poškozený stav procesu |
| Jeden zámek napříč procesem kolem všech volání PDFium | Ano | Ne | Část PDFium je serialní |
ValidatePdfFilesParallel od v3.125.1 | Ano | Ne; vyhodnocení pravidel je paralelní | Parsování a preflight jsou serialní |
TPdf.RenderPagesParallel | Ano | Ano | Kopie 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.CreateiFreedo zámku, ne jen zjevná volání. Načítání, zavírání, čtení vlastností jakoPageCount, změny stránek, extrakce textu, renderování i ukládání všichni sahají do modulu - Zkontrolujte
Activepo přiřazení. Selhané načtení necháActivenaFalseaLastLoadReport.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
TPdfnikdy 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
TPdfpř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
TPdfna 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 C000001Da ukončení s0xC0000409nebo0xC0000374 - Poškození v procesu přetrvává, takže selhávající volání často není to, které ho způsobilo
ValidatePdfFilesParallelje bezpečné od v3.125.1; na starších verzích použijteWorkerCount := 1TPdf.RenderPagesParallelje 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é
TPdfodCreatepoFree
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