A PDFium modul szinten nem szálbiztos, így két TPdf példány is egymás adatait rongálhatja, ha két különböző fájlon dolgozik két szálban. A Delphihez készült PDFium Component ezt kétféleképpen kezeli: v3.125.1 óta a ValidatePdfFilesParallel minden natív PDFium hívást egy, az egész folyamatra érvényes lock mögé szerializál, a TPdf.RenderPagesParallel pedig minden workernek a PDFium modul saját, izolált másolatát adja. A hiba, ami kikényszerítette a javítást, a legrosszabb fajta intermittens volt. Egy kötegvalidálási teszt a futások többségében rendben ment, aztán a két jó fájl egyikét hibásnak jelentette, aztán access violationnel lelőtte ugyanabban a folyamatban a következő tesztet, és néha az egész futtatót is elvitte egy exit kóddal, stack trace helyett. A teszttel nem volt baj, és egyetlen dokumentummal sem. A feltevés volt rossz: egy TPdf szálonként nem jelent izolációt
Miért nem elég egy TPdf szálonként?
Egy TPdf szálonként azért nem elég, mert a PDFium a nem szálbiztos állapotát a modulban tartja, nem a dokumentumban. Minden TPdf a saját FPDF_DOCUMENT handlejét birtokolja, de a folyamatban minden handlet ugyanaz a betöltött DLL szolgál ki, és az olyan folyamat szintű singletonokat tart: a font cacheet, a page modulet és egyéb globális struktúrákat, amibe a dokumentumbetöltés, a parse és a renderelés is beletalál. Két szál, ami két egymással kapcsolatban nem álló fájlt tölt, két szál, ami ugyanabba a font cachebe ír egyszerre. Delphi oldalon ez az adat nem senkié, ezért Delphi oldalon semmi nem tudja dokumentumonként lockolni
A komponensben van lock, és abból könnyű rossz következtetést levonni. A TPdf a saját render útjait egy belső critical sectionbe csomagolja (EnterRenderLock / LeaveRenderLock, a TPdf privát metódusai). Az a lock példányonkénti. Megakadályozza, hogy két szál egyszerre ugyanazt a TPdf-et hajtsa – ez valódi veszély –, de egy másik szálon futó második példányt nem lát, így a példányok közti konkurencia egyenesen átsétál rajta. Az általános szabály egy sorban is elmondható: egyetlen betöltött PDFium modulban bármely pillanatban legfeljebb egy szál lehet a PDFiumon belül, akárhány dokumentum van nyitva
Hogyan néz ki a dokumentumok közti rongálás egy Delphi folyamatban?
A dokumentumok közti rongálás egymással össze nem függő hibák véletlenszerű keverékének tűnik, és a kár túléli az őt okozó kódot. v3.125.1 előtt a ValidatePdfFilesParallel worker szálonként egy TPdf-et készített, és az Active := True-t meg a preflight riport felépítését egyszerre futtatta a megosztott modulon. A Delphi és a Free Pascal buildeken is látott tünetek a teljes skálát lefedték:
- Egy valid fájl nem tölt be, vagy a kötegből hibásként jön vissza, amikor át kellett volna mennie
- Egy access violation egy későbbi, egymással össze nem függő hívásban tör fel, gyakran másik tesztben vagy másik dokumentumban
External exception C000001Djelenik meg Delphiben. Ez a kód aSTATUS_ILLEGAL_INSTRUCTION: akkor tör fel, amikor a PDFium belsőCHECKésIMMEDIATE_CRASHmakrói lefuttatják aud2utasítást egy sérült invariánsra- A folyamat
0xC0000409-cel lép ki (fail-fast, stack buffer overrunként jelentve) vagy0xC0000374-cel (heap corruption), Delphi kivétel nélkül
Az utolsó két pont miatt volt olyan nehéz rajtakapni a hibát. A párhuzamos validálás befejeződött, a megrongált globális állapot ott maradt, és ugyanabban a folyamatban a következő fixture belebotlott. Egy Delphi Win64 regressziós futásban C000001D hibák hulláma sújtotta azokat a teszteket, amik a kötegvalidáláshoz soha nem nyúltak; egyszerűen ők voltak az első kód, ami a kár után PDFiumot használt. A mért számok kézzelfoghatóvá teszik a nagyságrendet. Egy Delphi próbaprogram, ami ugyanazt a mintát két workeren futtatta, az egyik futásban 160 dokumentumból 122-nél hibázott, a másikban 160-ból 138-nál, és az egyik futás External exception C000001D-t dobott ki nyíltan. Egy 8 dokumentumos, 4 workeres, 5 körös stresszeset a Free Pascal Win64-en 5 futásból 5-ben hibázott vagy crashtelt. A javítás után ugyanez a próbaprogram 1200 dokumentumból 0-nál hibázott
Hogyan marad biztonságos a ValidatePdfFilesParallel v3.125.1 óta
A ValidatePdfFilesParallel mostantól minden munka natív felét szerializálja, a menedzselt felét párhuzamosan tartja. Minden worker egység szintű critical sectiont foglal, mielőtt létrehozza a TPdf-jét, és az egész FileName, Active := True, preflight riport felépítés és Free alatt tartja. A létrehozás és a megsemmisítés szándékosan a lockon belül van: egy dokumentum bezárása ugyanúgy visszahív a modulba, mint a betöltés. Amint a workernek megvan a rögzített TPdfPreflightReport rekord, elengedi a lockot, és ahhoz a rekordhoz értékeli a validálási szabályokat, ami PDFium állapotot nem érint, így az egyik fájl szabályértékelése fedésben van a következő PDFium munkájával
A javítással két kisebb változás is jött. Egy betöltési hiba mostantól EPdfError-t dob LastLoadReport.ErrorMessage-mal, így a tétel ErrorMessage-e a tényleges parse problémát nevezi meg egy másodlagos „nincs aktív dokumentum” hiba helyett. És az ár őszintén le van írva: a köteg PDFium fele mostantól szerialis, így parse-ban és preflightban gazdag kötegen a plusz workerek keveset érnek. Ha v3.125.1 előtti verzión vagy, állítsd a WorkerCount-ot 1-re; ezzel a konkurencia is eltűnik, és a rongálás vele
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 = processzorszám, maximum 8
Options.Standards := [ppsPdfA];
// Explicit registry esetén a megfelelő profilt magad válaszd ki.
// Üres Profiles lista minden regisztrált szabályt futtat, és azok a
// standardok, amiket nem preflightoltál, "did not pass" eredményt adnak
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;
nil-t átadni registryként a rövidebb út: a ValidatePdfFilesParallel ekkor maga készíti el a default registryt, az Options.Standards-ból származtatja a profillistát, és visszatéréskor felszabadítja. Az eredmények mindig bemeneti sorrendben jönnek vissza, akármilyen sorrendben fejezték be a workerek. A riport formátumokról és ugyanennek a motornak a parancssori burkáról a kötegelt PDF preflight riportok a PDFium Component CLI-vel szól, arról pedig, mit fednek a PDF/A ellenőrzések maguk, a PDF/A preflight validáció Delphiben
Hogyan futtat valóban párhuzamosan oldalakat a RenderPagesParallel?
A TPdf.RenderPagesParallel azért fut párhuzamosan, mert a workerei soha nem osztoznak PDFium modulon. A metódus előbb a hívó szálon elmenti az aktív dokumentumot egy forrástárba. Aztán minden worker a betöltött PDFium DLL-t egy egyedi nevű fájlba másolja a temp könyvtárban, azt a másolatot LoadLibrary-vel tölti, és inicializálja. A Windows egy másik útvonalról betöltött DLL-t másik modulként kezel, így minden másolat megkapja a saját globálisait: a saját font cacheét, a saját page modulejét, a saját mindenét. A worker a saját privát moduljában nyitja meg az elmentett dokumentumot, fokozatosan rendereli az oldalait lépések közti megszakítás-ellenőrzésekkel, aztán megsemmisíti a libraryt, unloadolja a másolatot, és törli a fájlt
Az izoláció nem ingyen van, és a defaultok ezt tükrözik. Minden worker fizet egy DLL másolatért a lemezen, egy második PDFium globális szettért a memóriában, és a dokumentum friss parse-jáért. A MaxWorkers = 0 azt jelenti, hogy legfeljebb 4 worker, a MaxPixelsPerPage és a MaxTotalOutputBytes a nyers kimenetet korlátozza, az invertált és éjszakai duotónia render opciók pedig elutasításra kerülnek, mert a bufferek nyersen jönnek vissza. Az eredmény egy TPdfParallelRenderReport, aminek a Results tömbje kért oldalonként egy fentről lefelé olvasott 32 bites buffert tartalmaz, kérés sorrendben
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; // az oldalszámok 1-alapúak
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// A forrás snapshot a megosztott modulon készül, ezért fogd
// a folyamat szintű PDFium lockot, ha más szálak is használnak TPdf-et
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;
Vedd észre a lockot a hívás körül. A worker modulok privátak, de az elején lévő snapshot lépés a hívó szálról SaveAs-t futtat a megosztott modulon. Ha a folyamatodban semmi más nem érint konkurensen TPdf-et, elhagyhatod a lockot; ha bármi érinti, a snapshotnak ugyanaz a védelem jár, mint minden más megosztott modulos hívásnak
| Minta | Dokumentumok közt biztonságos | PDFium munka párhuzamosan fut | Ár |
|---|---|---|---|
TPdf szálonként, megosztott lock nélkül | Nem | Igen, amíg meg nem rongál | Intermittens crashek, megrongált folyamatállapot |
| Egy folyamat szintű lock minden PDFium hívás körül | Igen | Nem | A PDFium rész szerialis |
ValidatePdfFilesParallel v3.125.1 óta | Igen | Nem; a szabályértékelés párhuzamos | A parse és a preflight szerialis |
TPdf.RenderPagesParallel | Igen | Igen | DLL másolat, memória és friss parse workerenként |
Hogyan építsd fel a saját többszálú PDFium kódodat?
A saját szálaid egyetlen folyamat szintű lockon osszák meg, és tartsák azt minden általuk használt TPdf teljes élete alatt, vagy használjanak olyan komponens API-t, ami helyetted izolálja a modult. A locknak egyetlen objektumnak kell lennie az egész folyamatra, nem szálonkénti, formonkénti vagy dokumentumonkéntinek; egy lock, amit két szál nem oszt meg, semmit nem véd. Az alábbi minta azt tükrözi, amit a komponens v3.125.1 óta belül csinál: create, load, olvasás és free a lockon belül, minden, ami PDFiumot nem érint, azon kívül
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // egy lock az egész folyamatra
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; // a dokumentum bezárása is PDFium munka
end;
finally
PdfiumLock.Release;
end;
// E sor alatt nincs több PDFium, ezért ez a rész párhuzamosan fut
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
Néhány szabály tartja őszintén a mintát egy valódi alkalmazásban:
- A
TPdf.Create-et és aFree-t tedd a lockba, ne csak a kézenfekvő hívásokat. Betöltés, bezárás, olyan property olvasások, mint aPageCount, oldalváltás, szövegkinyerés, renderelés és mentés mind beleér a modulba - A hozzárendelés után nézd meg az
Active-ot. Egy elbukott betöltésFalse-on hagyja azActive-ot, és aLastLoadReport.ErrorMessagemegmondja, miért - A lockot dokumentumonként tartsd, nem hívásonként. Finomabb lockolás elvileg lehetséges, de csak akkor, ha semelyik
TPdftag soha nem fut azon kívül, és a durva változat az, amire maga a komponens is támaszkodik - A lassú, nem PDFium munkát, például adatbázisírásokat, indexelést és hálózati hívásokat tartsd a lockon kívül, különben egy lassú fogyasztó mindent szerializál
- A privát, példányonkénti render lockot ne kezeld helyettesítőnek. Az egy
TPdf-et védi önmagától, és semmi mást
Ugyanez az óvatosság érvényes arra a kódra is, amit nem írtál nyers szálakként. A háttér futurek jó módszerek arra, hogy a hosszú renderelések ne az UI szálon fussanak, ahogy a PDF renderelés a háttérben megszakítható futurekkel írja, de a future executor nem ad hozzá saját globális PDFium lockot. Ha több future is hajthat egyszerre különböző TPdf példányokat, fogd ugyanazt a folyamat szintű lockot minden workeren belül, és a fő szálon futó viewert kezeld a megosztott modul még egy klienseként. A példányok közti használat az aszinkron API-kon át nincs külön auditálva, ezért a konzervatív feltevés az, hogy ugyanazt a szerializációt igényli, mint a kézzel írt szálak. Ha valami más miatt kell valódi PDFium párhuzamosítás, mint az oldalrenderelés, a külön worker folyamatok építésből adják minden munkának a saját modulját
Gyorsreferencia: PDFium szálkezelési szabályok Delphihez
- A PDFium nem szálbiztos állapota modul szintű: a font cache, a page module és egyéb globálisokat a folyamat minden dokumentuma közösen használja
- Egy
TPdfszálonként semmit nem izolál; két példány két szálon még mindig rongálhatja egymást - Tipikus tünetek: betöltési hibák, access violationök későbbi kódban,
External exception C000001D, és kilépések0xC0000409-cel vagy0xC0000374-cel - A rongálás a folyamatban megmarad, így a hibázó hívás gyakran nem az, ami okozta
- A
ValidatePdfFilesParallelv3.125.1 óta biztonságos; régebbi verziókonWorkerCount := 1-t használj - A
TPdf.RenderPagesParallelvalóban párhuzamos, mert minden worker a PDFium modul egy izolált másolatát tölti - A saját szálaid, taszkjaid és futureid egyetlen folyamat szintű lockot igényelnek, ami minden
TPdf-et fed aCreate-től aFree-ig
A PDFium Component a PDFium engineet csomagolja Delphihez kötegelt preflighttal és validációval, izolált párhuzamos rendereléssel, megszakítható háttérmunkával és részletes betöltési diagnosztikával. Részletek és kiadások a PDFium Component terméklapon