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
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 C000001Dsa objaví v Delphi. Ten kód jeSTATUS_ILLEGAL_INSTRUCTION, vyvolaný inštrukciouud2, ktorú vykonávajú interné makráCHECKaIMMEDIATE_CRASHPDFia, keď sa zlomí invariant- Proces skončí s
0xC0000409(fail-fast, hlásený ako stack buffer overrun) alebo0xC0000374(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ší
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
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
| Vzor | Bezpečné medzi dokumentmi | Práca PDFia beží paralelne | Cena |
|---|---|---|---|
Jedno TPdf na vlákno, žiadny zdieľaný zámok | Nie | Á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 | Áno | Nie | PDFium časť je sériová |
ValidatePdfFilesParallel od v3.125.1 | Áno | Nie; vyhodnotenie pravidiel je paralelné | Parsovanie a preflight sú sériové |
TPdf.RenderPagesParallel | Áno | Áno | Kó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.CreateaFreedovnútra zámku, nie len zjavné volania. Načítanie, zatvorenie, čítania vlastností akoPageCount, zmeny strany, extrakcia textu, vykresľovanie a ukladanie všetko zasahuje do modulu - Skontrolujte
Activepo priradení. Zlyhané načítanie necháActivenaFalseaLastLoadReport.ErrorMessagepovie 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
TPdfnebeží 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
TPdfpred 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
TPdfna 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 C000001Da ukončenia s0xC0000409alebo0xC0000374 - Poškodenie pretrváva v procese, takže zlyhavajúce volanie býva často nie to, ktoré ho spôsobilo
ValidatePdfFilesParallelje bezpečné od v3.125.1; na starších verziách použiteWorkerCount := 1TPdf.RenderPagesParallelje 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é
TPdfodCreatepoFree
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