PDFium nije thread-safe na razini modula, pa dvije instance TPdf koje rade na dvije različite datoteke u dva threada i dalje mogu jedna drugu oštetiti. PDFium Component za Delphi ovo rješava na dva načina: od v3.125.1 ValidatePdfFilesParallel serijalizira svaki nativni PDFium poziv iza jedne brave na razini procesa, dok TPdf.RenderPagesParallel svakom workeru daje vlastitu izoliranu kopiju PDFium modula. Bug koji je prisilio popravak bio je najgora vrsta povremenog. Test batch validacije prolazio je većinu vremena, zatim je jednu od dviju dobrih datoteka javio kao palu, zatim srušio sljedeći test u istom procesu s access violationom, a ponekad oborio cijeli runner s exit kodom umjesto stack tracea. Ništa nije bilo krivo s testom, i ništa nije bilo krivo ni s jednim pojedinačnim dokumentom. Pretpostavka je bila kriva: jedan TPdf po threadu nije izolacija
Zašto jedan TPdf po threadu nije dovoljno?
Jedan TPdf po threadu nije dovoljno jer PDFium svoje nesigurno stanje drži u modulu, ne u dokumentu. Svaki TPdf posjeduje vlastiti FPDF_DOCUMENT handle, ali svaki handle u procesu opslužuje isti učitani DLL, a taj DLL drži singletons na razini procesa: font cache, page modul i druge globalne strukture koje dodiruju učitavanje dokumenata, parsiranje i renderiranje. Dva threada koja učitavaju dvije nepovezane datoteke dva su threada koja u isto vrijeme pišu u isti font cache. Niko na Delphi strani ne posjeduje te podatke, pa ništa na Delphi strani ne može zaključati po dokumentu
Komponenta bravu ima, i lako je izvući krivi zaključak iz nje. TPdf vlastite render puteve omotava u unutarnju kritičnu sekciju (EnterRenderLock / LeaveRenderLock, privatne metode od TPdf). Ta je brava po instanci. Sprječava dva threada da istovremeno voze isti TPdf, što je prava opasnost, ali ne vidi drugu instancu na drugom threadu, pa cross-instance konkurencija prolazi ravno pokraj nje. Opće pravilo dovoljno je jednostavno da stane u jedan red: u jednom učitanom PDFium modulu najviše jedan thread smije biti unutar PDFiuma u bilo kojem trenutku, bez obzira koliko je dokumenata otvoreno
Kako izgleda oštećenje između dokumenata u Delphi procesu?
Oštećenje između dokumenata izgleda kao slučajna mješavina nepovezanih neuspjeha, a šteta nadživi kod koji ju je uzrokovao. Prije v3.125.1 ValidatePdfFilesParallel stvarao je jedan TPdf po worker threadu i pokretao Active := True plus izgradnju preflight izvještaja istodobno na dijeljenom modulu. Simptomi viđeni na Delphi i Free Pascal buildovima pokrili su cijeli raspon:
- Valjana datoteka ne uspijeva se učitati, ili se iz batcha vraća kao pala kad je trebala proći
- Access violation isplivava u kasnijem, nepovezanom pozivu, često u drugom testu ili drugom dokumentu
External exception C000001Dpojavljuje se u Delphiju. Taj kod jeSTATUS_ILLEGAL_INSTRUCTION, podignut odud2instrukcije koju PDFiumovi unutarnjiCHECKiIMMEDIATE_CRASHmakroi izvršavaju kad se invarijanta slomi- Proces izlazi s
0xC0000409(fail-fast, javljen kao stack buffer overrun) ili0xC0000374(oštećenje heapa), bez ijedne Delphi iznimke
Zadnje dvije točke razlog su što je bug bio tako teško uhvatiti. Paralelna validacija završila je, oštećeno globalno stanje ostalo je iza, i sljedeći fixture u istom procesu spotaknuo se o njega. U jednom Delphi Win64 regresijskom pokretanju val C000001D neuspjeha pogodio je testove koji nikad nisu dirali batch validaciju; oni su jednostavno bili prvi kod koji je koristio PDFium nakon štete. Izmjereni brojevi razmjer čine očitim. Delphi proba koja je isti uzorak provukla kroz dva workera palila je 122 od 160 dokumenata u jednom pokretanju i 138 od 160 u drugom, a jedno od tih pokretanja podiglo je External exception C000001D ravno iz prve. Stress slučaj od 8 dokumenata, 4 workera i 5 krugova palio se ili rušio u 5 od 5 pokretanja na Free Pascal Win64. Nakon popravka ista je proba palila 0 od 1.200 dokumenata
Kako ValidatePdfFilesParallel ostaje siguran od v3.125.1
ValidatePdfFilesParallel sada serijalizira nativnu polovicu svakog posla i drži upravljanu polovicu paralelnom. Svaki worker uzme jednu kritičnu sekciju na razini jedinice prije nego stvori svoj TPdf, i drži je kroz FileName, Active := True, izgradnju preflight izvještaja i Free. Stvaranje i uništavanje unutar brave namjerno su: zatvaranje dokumenta zove natrag u modul baš kao i učitavanje. Kad worker uhvati snimljen TPdfPreflightReport zapis, otpušta bravu i evaluira pravila validacije nad tim zapisom, što ne dira nikakvo PDFium stanje, pa se evaluacija pravila za jednu datoteku preklapa s PDFium poslom za sljedeću
Dvije manje promjene stigle su s popravkom. Neuspjeh učitavanja sada podiže EPdfError s LastLoadReport.ErrorMessage, pa ErrorMessage stavke imenuje stvarni pars problem umjesto sekundarne greške "nema aktivnog dokumenta". A trošak je iskreno rečen: PDFium dio batcha sada je serija, pa na batchu dominiranom parsiranjem i preflightom dodatni workeri ne kupuju mnogo. Ako ste na verziji prije v3.125.1, postavite WorkerCount na 1; to uklanja konkurenciju i oštećenje s njome
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 izričitim registryjem, odgovarajući profil odaberite sami.
// Prazan Profiles popis pokreće svako registrirano pravilo, a pravila za
// standarde koje niste preflightali izvješ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;
Prosljeđivanje nil kao registryja kraći je put: ValidatePdfFilesParallel tada sam stvara zadani registry, izvodi popis profila iz Options.Standards i oslobađa registry kad se vrati. Rezultati se uvijek vraćaju u redoslijedu unosa, kakav god bio redoslijed kojim su workeri završili. Za formate izvještaja i command-line wrapper oko istog motora pogledajte batch PDF preflight izvještaje s PDFium Component CLI-jem, a za to što same PDF/A provjere pokrivaju, PDF/A preflight validaciju u Delphiju
Kako RenderPagesParallel stvarno paralelno renderira stranice?
TPdf.RenderPagesParallel radi paralelno jer njegovi workeri nikad ne dijele PDFium modul. Metoda prvo sprema aktivni dokument u izvornu pohranu na threadu pozivatelja. Svaki worker zatim kopira učitani PDFium DLL u jedinstveno imenovanu datoteku u temp direktoriju, učita tu kopiju s LoadLibrary i inicijalizira je. Windows DLL učitan iz drugog puta tretira kao drugi modul, pa svaka kopija dobiva vlastite globalne: vlastiti font cache, vlastiti page modul, vlastito sve. Worker otvara spremljeni dokument u svom privatnom modulu, renderira svoje stranice napredno s provjerama otkazivanja između koraka, zatim uništi biblioteku, istovari kopiju i obriše datoteku
Izolacija nije besplatna, i zadane postavke to odražavaju. Svaki worker plaća DLL kopiju na disku, drugi set PDFium globala u memoriji i svježe parsiranje dokumenta. MaxWorkers = 0 znači najviše 4 workera, MaxPixelsPerPage i MaxTotalOutputBytes ograničavaju sirovi izlaz, a invertirane i night-duotone render opcije odbijaju se jer se međuspremnici vraćaju sirovi. Rezultat je TPdfParallelRenderReport čije polje Results drži jedan top-down 32-bitni međuspremnik po zatraženoj stranici, u redoslijedu zahtjeva
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 snapshot snima se na dijeljenom modulu, pa držite
// PDFium bravu cijelog procesa 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;
Obratite pozornost na bravu oko poziva. Worker moduli privatni su, ali korak snapshota na početku pokreće SaveAs na dijeljenom modulu s threada pozivatelja. Ako ništa drugo u vašem procesu istodobno ne dira TPdf, bravu možete ispustiti; ako išta dira, snapshot treba istu zaštitu kao i svaki drugi poziv dijeljenog modula
| Obrazac | Sigurno među dokumentima | PDFium posao radi paralelno | Trošak |
|---|---|---|---|
Jedan TPdf po threadu, bez dijeljene brave | Ne | Da, dok ne počne oštećivati | Povremeni padovi, oštećeno stanje procesa |
| Jedna brava na razini procesa oko svih PDFium poziva | Da | Ne | PDFium dio je serija |
ValidatePdfFilesParallel od v3.125.1 | Da | Ne; evaluacija pravila je paralelna | Parsiranje i preflight su serija |
TPdf.RenderPagesParallel | Da | Da | DLL kopija, memorija i svježe parsiranje po workeru |
Kako strukturirati vlastiti multithreaded PDFium kod?
Vaši vlastiti threadi trebaju dijeliti jednu bravu na razini procesa i držati je kroz cijeli život svakog TPdf-a kojeg koriste, ili pak koristiti komponentni API koji vam modul izolira. Brava mora biti jedan objekt za cijeli proces, ne jedna po threadu, po formi ili po dokumentu; brava koju dva threada ne dijele ne štiti ništa. Obrazac u nastavku preslikava ono što komponenta interno radi od v3.125.1: stvori, učitaj, čitaj i oslobodi unutar brave, pa sve što ne dira PDFium izvan nje
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // jedna brava za cijeli 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 jest PDFium posao
end;
finally
PdfiumLock.Release;
end;
// Ispod ovog retka nema PDFiuma, pa ovaj dio radi paralelno
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
Nekoliko pravila drži obrazac poštenim u stvarnoj aplikaciji:
- Stavite
TPdf.CreateiFreeunutar brave, ne samo očite pozive. Učitavanje, zatvaranje, čitanja svojstava poputPageCount, promjene stranica, izvlačenje teksta, renderiranje i spremanje svi dopiru u modul - Provjerite
Activenakon dodjele. Neuspjelo učitavanje ostavljaActivenaFalse, aLastLoadReport.ErrorMessagekaže zašto - Držite bravu po dokumentu, a ne po pozivu. Finije zaključavanje moguće je u principu, ali samo ako nijedan član
TPdf-a nikad ne radi izvan nje, a gruba verzija jest ona na koju se komponenta sama oslanja - Držite spor posao bez PDFiuma, poput upisa u bazu, indeksiranja i mrežnih poziva, izvan brave, ili će jedan spor potrošač serijalizirati sve
- Ne tretirajte privatnu bravu rendera po instanci kao zamjenu. Ona čuva jedan
TPdfod samog sebe i ništa više
Isti oprez vrijedi i za kod koji niste napisali kao sirove threade. Background futures dobar su način da dugi renderi ne drže UI thread, kako je opisano u background PDF renderiranju s otkazivim futures, ali future executor ne dodaje vlastitu globalnu PDFium bravu. Ako nekoliko futuresa može istovremeno voziti različite instance TPdf-a, uzmite istu bravu na razini procesa unutar svakog workera, i preglednik na glavnom threadu tretirajte kao još jednog klijenta dijeljenog modula. Upotreba između instanci kroz asinkrone API-je nije zasebno revizirana, pa konzervativna pretpostavka jest da treba istu serijalizaciju kao ručno pisani threadi. Kad trebate pravi PDFium paralelizam za nešto osim renderiranja stranica, odvojeni worker procesi daju svakom poslu vlastiti modul po konstrukciji
Brza referenca: PDFium threading pravila za Delphi
- PDFium nesigurno stanje drži na razini modula: font cache, page modul i ostali globali dijele svi dokumenti u procesu
- Jedan
TPdfpo threadu ne izolira ništa; dvije instance na dva threada i dalje se mogu međusobno oštetiti - Tipični simptomi jesu neuspjesi učitavanja, access violationi u kasnijem kodu,
External exception C000001Di izlazi s0xC0000409ili0xC0000374 - Oštećenje ustraje u procesu, pa poziv koji pada često nije onaj koji ga je uzrokovao
ValidatePdfFilesParallelsiguran je od v3.125.1; na starijim verzijama koristiteWorkerCount := 1TPdf.RenderPagesParallelstvarno je paralan jer svaki worker učita izoliranu kopiju PDFium modula- Vaši vlastiti threadi, taskovi i futures trebaju jednu bravu na razini procesa koja pokriva svaki
TPdfodCreatedoFree
PDFium Component omotava PDFium motor za Delphi s batch preflightom i validacijom, izoliranim paralelnim renderiranjem, otkazivim pozadinskim poslom i detaljnom dijagnostikom učitavanja. Detalji i izdanja na PDFium Component stranici proizvoda