PDFium nije thread-safe na nivou modula, pa dve TPdf instance koje rade na dva različita fajla u dva threada i dalje mogu jedna drugu da pokvare. PDFium Component za Delphi ovo rešava na dva načina: od v3.125.1 ValidatePdfFilesParallel serializuje svaki nativni PDFium poziv iza jedne brave za ceo proces, dok TPdf.RenderPagesParallel daje svakom radniku sopstvenu izolovanu kopiju PDFium modula. Bag koji je prisilio popravku bio je najgora vrsta povremenog. Batch test validacije prolazio je većinom, pa javljao jedan od dva dobra fajla kao pao, pa rušio sledeći test u istom procesu s access violation, a ponekad gurao čitav runner sa exit kodom umesto stack trace-a. Ništa nije bilo pogrešno s testom, i ništa nije bilo pogrešno s bilo kojim pojedinačnim dokumentom. Pretpostavka je bila pogrešna: jedan TPdf po threadu nije izolacija
Zašto jedan TPdf po threadu nije dovoljan?
Jedan TPdf po threadu nije dovoljan jer PDFium drži svoje nebezbedno stanje u modulu, ne u dokumentu. Svaki TPdf poseduje sopstveni FPDF_DOCUMENT handle, ali svaki handle u procesu opslužuje isti učitani DLL, i taj DLL drži singleton-ove za ceo proces: keš fontova, page modul i druge globalne strukture koje učitavanje dokumenta, parsiranje i renderovanje sve diraju. Dva threada koja učitavaju dva nepovezana fajla su dva threada koja istovremeno pišu u isti keš fontova. Nitko na Delphi strani ne poseduje te podatke, pa ništa na Delphi strani ne može da ih zaključa po dokumentu
Komponenta ima bravu, i iz nje je lako izvući pogrešan zaključak. TPdf omotava sopstvene render puteve internom critical sekcijom (EnterRenderLock / LeaveRenderLock, privatne metode TPdf-a). Ta brava je po instanci. Sprečava dva threada da istovremeno voze isti TPdf, što je prava opasnost, ali ne vidi drugu instancu na drugom threadu, pa međuinstancna konkurencija prolazi pravo pored nje. Opšte pravilo je dovoljno prosto za jedan red: u jednom učitanom PDFium modulu najviše jedan thread sme biti unutra u PDFium-u u bilo kom trenutku, bez obzira koliko je dokumenata otvoreno
Kako izgleda korupcija između dokumenata u Delphi procesu?
Korupcija između dokumenata izgleda kao slučajna mešavina nepovezanih neuspeha, i šteta nadživi kod koji ju je izazvao. Pre v3.125.1 ValidatePdfFilesParallel je pravio jedan TPdf po radniku i vodio Active := True plus gradnju preflight izveštaja istovremeno nad deljenim modulom. Simptomi viđeni na i Delphi i Free Pascal buildovima pokrili su ceo raspon:
- Valjan fajl ne uspeva da se učita, ili se vraća iz batcha kao pao kad je trebalo da prođe
- Access violation isplivava u kasnijem, nepovezanom pozivu, često u drugom testu ili drugom dokumentu
External exception C000001Dse pojavljuje u Delphiju. Taj kod jeSTATUS_ILLEGAL_INSTRUCTION, podignutud2instrukcijom koju PDFium-ovi interniCHECKiIMMEDIATE_CRASHmakroi izvršavaju kad se invarijanta slomi- Proces izlazi s
0xC0000409(fail-fast, prijavljen kao stack buffer overrun) ili0xC0000374(heap korupcija), bez ijednog Delphi izuzetka
Poslednja dva boda su razlog što je bag bio tako teško uhvatiti. Paralelna validacija se završila, pokvareno globalno stanje ostalo je iza, i sledeća probna površina u istom procesu saplela se o njega. U jednom Delphi Win64 regresionom pokretanju talas C000001D neuspeha pogodio je testove koji nikad nisu dirali batch validaciju; oni su jednostavno bili prvi kod koji je koristio PDFium posle štete. Izmereni brojevi čine obim jasnim. Delphi proba koja je provlačila isti uzorak kroz dva radnika pala je na 122 od 160 dokumenata u jednom pokretanju i 138 od 160 u drugom, i jedno od tih pokretanja podiglo je External exception C000001D ravno. Stres slučaj od 8 dokumenata, 4 radnika i 5 krugova padao je ili rušio u 5 od 5 pokretanja na Free Pascal Win64. Posle popravke, ista proba pala je na 0 od 1.200 dokumenata
Kako ValidatePdfFilesParallel ostaje bezbedan od v3.125.1
ValidatePdfFilesParallel sada serializuje nativnu polovinu svakog posla i drži upravljanu polovinu paralelnom. Svaki radnik uzima jednu critical sekciju na nivou jedinice pre nego što napravi svoj TPdf, i drži je kroz FileName, Active := True, gradnju preflight izveštaja i Free. Stvaranje i uništavanje su unutar brave namerno: zatvaranje dokumenta vraća se u modulu baš kao i učitavanje. Kad radnik uhvati TPdfPreflightReport zapis, pušta bravu i vrednuje validaciona pravila nad tim zapisom, što ne dira PDFium stanje, pa vrednovanje pravila za jedan fajl preklapa PDFium posao za sledeći
Dve manje promene došle su s popravkom. Neuspeh učitavanja sada podiže EPdfError s LastLoadReport.ErrorMessage-om, pa ErrorMessage stavke imenuje stvarni problem parsiranja umesto sekundarne greške „nema aktivnog dokumenta“. I cena je rečena iskreno: PDFium deo batcha je sada serialan, pa na batchu dominiranom parsiranjem i preflight-om dodatni radnici kupuju malo. Ako ste na verziji pre v3.125.1, postavite WorkerCount na 1; to uklanja konkurenciju i korupciju s njom
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 eksplicitnim registrom, sami izaberite poklapajući profil.
// Prazna Profiles lista vodi svako registrovano pravilo, i pravila za
// standarde koje niste preflight-ovali izveš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;
Prosleđivanje nil-a kao registra je kraći put: ValidatePdfFilesParallel tada sama pravi podrazumevani registar, izvodi listu profila iz Options.Standards-a i oslobađa registar kad se vrati. Rezultati uvek dolaze u redosledu unosa, kakav god bio redosled kojim su radnici završili. Za formate izveštaja i komandno-linijski omotač oko istog engine-a pogledajte batch PDF preflight izveštaje s PDFium Component CLI-jem, a o tome šta same PDF/A provere pokrivaju, PDF/A preflight validaciju u Delphiju
Kako RenderPagesParallel vodi stranice zaista paralelno?
TPdf.RenderPagesParallel radi paralelno jer njegovi radnici nikad ne dele PDFium modul. Metoda prvo čuva aktivni dokument u izvornu ostavu na threadu pozivaoca. Svaki radnik zatim kopira učitani PDFium DLL u jedinstveno imenovan fajl u temp direktorijumu, učita tu kopiju s LoadLibrary-jem i inicijalizuje je. Windows tretira DLL učitan s drugačije putanje kao drugi modul, pa svaka kopija dobija svoje globalne podatke: svoj keš fontova, svoj page modul, sve svoje. Radnik otvara sačuvani dokument u svom privatnom modulu, renderuje svoje stranice progresivno s proverama otkazivanja između koraka, pa uništi biblioteku, istovara kopiju i briše fajl
Izolacija nije besplatna, i podrazumevane vrednosti to odražavaju. Svaki radnik plaća DLL kopiju na disku, drugi skup PDFium globala u memoriji i svežu analizu dokumenta. MaxWorkers = 0 znači najviše 4 radnika, MaxPixelsPerPage i MaxTotalOutputBytes ograničavaju sirovi izlaz, a opcije renderovanja invertovanog i noćnog duotona se odbijaju jer se baferi vraćaju sirovi. Rezultat je TPdfParallelRenderReport čiji Results niz drži po jedan odozgo-naniže 32-bitni bafer po traženoj stranici, u redosledu zahteva
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 snimak se pravi na deljenom modulu, pa držite
// PDFium bravu za ceo proces 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;
Primetite bravu oko poziva. Radnički moduli su privatni, ali korak snimka na početku vodi SaveAs na deljenom modulu iz threada pozivaoca. Ako ništa drugo u vašem procesu ne dira TPdf istovremeno, možete baciti bravu; ako bilo šta dira, snimku treba ista zaštita kao svakom drugom pozivu na deljeni modul
| Obrazac | Bezbedno između dokumenata | PDFium posao radi paralelno | Cena |
|---|---|---|---|
Jedan TPdf po threadu, bez deljene brave | Ne | Da, dok ne pokvari | Povremeni crash-evi, oštećeno stanje procesa |
| Jedna brava za ceo proces oko svih PDFium poziva | Da | Ne | PDFium deo je serialan |
ValidatePdfFilesParallel od v3.125.1 | Da | Ne; vrednovanje pravila je paralelno | Parsiranje i preflight su serialni |
TPdf.RenderPagesParallel | Da | Da | DLL kopija, memorija i sveža analiza po radniku |
Kako treba da uredite sopstveni višethreadni PDFium kod?
Vaši sopstveni threadovi treba da dele jednu bravu za ceo proces i drže je kroz ceo život svakog TPdf-a kojeg koriste, ili da koriste API komponente koji vam izoluje modul. Brava mora biti jedan objekat za ceo proces, ne jedna po threadu, po formi ili po dokumentu; brava koju dva threada ne dele ne štiti ništa. Obrazac ispod ogleda ono što komponenta radi interno od v3.125.1: stvaranje, učitavanje, čitanje i oslobađanje unutar brave, pa sve što ne dira PDFium van nje
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // jedna brava za ceo 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 je PDFium posao
end;
finally
PdfiumLock.Release;
end;
// Ispod ove linije nema PDFium-a, pa ovaj deo radi paralelno
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
Par pravila drži obrazac poštenim u pravoj aplikaciji:
- Stavite
TPdf.CreateiFreeunutar brave, ne samo očigledne pozive. Učitavanje, zatvaranje, čitanja svojstava poputPageCount-a, promene stranice, izvlačenje teksta, renderovanje i čuvanje svi dopiru do modula - Proverite
Activeposle dodele. Pao učitavanje ostavljaActivenaFalse, aLastLoadReport.ErrorMessagekaže zašto - Držite bravu po dokumentu, a ne po pozivu. Finija zaključavanja su moguća u principu, ali samo ako nijedan član
TPdf-a nikad ne radi van nje, i gruba verzija je ona na kojoj se sama komponenta oslanja - Držite spori ne-PDFium posao, poput upisa u bazu, indeksiranja i mrežnih poziva, van brave, ili će jedan spor potrošač serializovati sve
- Ne tretirajte privatnu bravu renderovanja po instanci kao zamenu. Ona čuva jedan
TPdfod samog sebe i ništa više
Ista oprez važi i za kod koji niste napisali kao sirove threadove. Pozadinski futures su dobar način da duga renderovanja držite van UI threada, kako je opisano u tekstu o pozadinskom PDF renderovanju s otkazivim futures-ima, ali executor future-a ne dodaje globalnu PDFium bravu svoga. Ako više future-a može istovremeno voziti različite TPdf instance, uzimajte istu bravu za ceo proces unutar svakog radnika, i tretirajte pregledač na glavnom threadu kao još jednog klijenta deljenog modula. Međuinstancna upotreba kroz asinhrone API-je nije posebno auditirana, pa je konzervativna pretpostavka da treba istu serializaciju kao ručno pisani threadovi. Kad treba pravi PDFium paralelizam za nešto drugo osim renderovanja stranica, odvojeni radni procesi daju svakom poslu sopstveni modul po konstrukciji
Brzi podsetnik: PDFium pravila thredovanja za Delphi
- PDFium nebezbedno stanje je na nivou modula: keš fontova, page modul i ostale globale dele svi dokumenti u procesu
- Jedan
TPdfpo threadu ne izoluje ništa; dve instance na dva threada i dalje mogu jedna drugu pokvariti - Tipični simptomi su neuspesi učitavanja, access violation-i u kasnijem kodu,
External exception C000001D, i izlasci s0xC0000409ili0xC0000374 - Korupcija ostaje u procesu, pa padajući poziv često nije onaj koji ju je izazvao
ValidatePdfFilesParallelje bezbedan od v3.125.1; na starijim verzijama koristiteWorkerCount := 1TPdf.RenderPagesParallelje zaista paralelan jer svaki radnik učita izolovanu kopiju PDFium modula- Vaši sopstveni threadovi, taskovi i futures trebaju jednu bravu za ceo proces koja pokriva svaki
TPdfodCreatedoFree
PDFium Component omotava PDFium engine za Delphi s batch preflight-om i validacijom, izolovanim paralelnim renderovanjem, otkazivim pozadinskim poslom i detaljnom dijagnostikom učitavanja. Detalji i izdanja su na stranici proizvoda PDFium Component