PDFium nu e thread-safe la nivel de modul, deci două instanțe TPdf care lucrează pe două fișiere diferite în două thread-uri se pot totuși corupe una pe alta. PDFium Component pentru Delphi gestionează asta în două feluri: din v3.125.1, ValidatePdfFilesParallel serializează fiecare apel nativ PDFium în spatele unui singur lacăt la nivel de proces, în timp ce TPdf.RenderPagesParallel dă fiecărui worker propria copie izolată a modulului PDFium. Bug-ul care a forțat repararea a fost de cel mai rău fel, intermitent. Un test de validare de batch trecea de cele mai multe ori, apoi raporta unul dintre două fișiere bune ca picat, apoi prăbușea testul următor din același proces cu un access violation, și câteodată trăgea jos întregul runner cu un cod de ieșire în loc de stack trace. Nu era nimic în neregulă cu testul, și nici cu vreun document în parte. Presupunerea era greșită: un TPdf per thread nu e izolare
De ce nu ajunge un TPdf per thread?
Un TPdf per thread nu ajunge pentru că PDFium își ține starea nesigură în modul, nu în document. Fiecare TPdf deține propriul lui handle FPDF_DOCUMENT, dar fiecare handle din proces e deservit de același DLL încărcat, iar DLL-ul acela deține singletons la nivel de proces: cache-ul de fonturi, page module-ul și alte structuri globale pe care încărcarea documentelor, parsarea și randarea le ating pe toate. Două thread-uri care încarcă două fișiere fără legătură sunt două thread-uri scriind în același cache de fonturi în același timp. Nimeni nu deține datele acelea pe partea Delphi, deci nimic pe partea Delphi nu le poate încuia per document
Componenta are un lacăt, și e ușor să tragi concluzia greșită din el. TPdf își învelește căile proprii de randare într-o secțiune critică internă (EnterRenderLock / LeaveRenderLock, metode private ale lui TPdf). Lacătul acela e per instanță. Împiedică două thread-uri să conducă același TPdf în același timp, ceea ce e un pericol real, dar nu vede o a doua instanță pe alt thread, deci concurența între instanțe trece drept pe lângă el. Regula generală e destul de simplă de spus într-o linie: într-un singur modul PDFium încărcat, cel mult un thread poate fi în interiorul PDFium în orice moment, indiferent câte documente sunt deschise
Cum arată corupția între documente într-un proces Delphi?
Corupția între documente arată ca un amestec aleator de eșecuri fără legătură, iar paguba supraviețuiește codului care a cauzat-o. Înainte de v3.125.1, ValidatePdfFilesParallel crea un TPdf per thread de worker și rula Active := True plus construirea raportului de preflight concurent pe modulul partajat. Simptomele văzute pe build-urile atât Delphi, cât și Free Pascal, au acoperit toată gama:
- Un fișier valid pice la încărcare, sau se întoarce din batch drept picat când ar fi trebuit să treacă
- Un access violation iese la suprafață într-un apel ulterior, fără legătură, adesea într-un alt test sau alt document
External exception C000001Dapare în Delphi. Codul acela eSTATUS_ILLEGAL_INSTRUCTION, ridicat de instrucțiuneaud2pe care o execută macro-urileCHECKșiIMMEDIATE_CRASHinterne ale PDFium când o invariantă se rupe- Procesul iese cu
0xC0000409(fail-fast, raportat drept stack buffer overrun) sau0xC0000374(heap corruption), fără nicio excepție Delphi
Ultimele două puncte sunt motivul pentru care bug-ul a fost greu de prins. Validarea paralelă se termina, starea globală coruptă rămânea în urmă, iar următorul fixture din același proces se împiedica de ea. Într-o rulare de regresie Delphi Win64, un val de eșecuri C000001D a lovit teste care nu atingeau deloc validarea de batch; erau pur și simplu primele coduri care foloseau PDFium după pagubă. Numerele măsurate spun limpede mărimea. O sondă Delphi care rula același eșantion prin doi worker-i a picat 122 din 160 de documente într-o rulare și 138 din 160 în alta, iar una dintre rulări a ridicat direct External exception C000001D. Un caz de stres cu 8 documente, 4 worker-i și 5 runde a picat sau s-a prăbușit în 5 din 5 rulări pe Free Pascal Win64. După reparare, aceeași sondă a picat 0 din 1.200 de documente
Cum rămâne ValidatePdfFilesParallel în siguranță din v3.125.1
ValidatePdfFilesParallel serializează acum partea nativă a fiecărui job și ține partea managed paralelă. Fiecare worker ia o secțiune critică la nivel de unit înainte să-și creeze TPdf-ul și o ține de-a lungul lui FileName, Active := True, construirii raportului de preflight și Free. Crearea și distrugerea sunt în interiorul lacătului în mod deliberat: închiderea unui document apelează înapoi în modul exact cum o face încărcarea. Odată ce worker-ul are o înregistrare TPdfPreflightReport capturată, eliberează lacătul și evaluează regulile de validare contra acelei înregistrări, ceea ce nu atinge nicio stare PDFium, deci evaluarea regulilor pentru un fișier se suprapune cu lucrul PDFium pentru următorul
Două schimbări mai mici au venit odată cu repararea. Un eșec de încărcare ridică acum EPdfError cu LastLoadReport.ErrorMessage, deci ErrorMessage-ul articolului numește problema reală de parsare în loc de o eroare secundară „no active document”. Iar costul e spus onest: partea PDFium a batch-ului e acum serială, deci pe un batch dominat de parsare și preflight, worker-i în plus cumpără puțin. Dacă sunteți pe o versiune de dinainte de v3.125.1, setați WorkerCount pe 1; asta elimină concurența și odată cu ea corupția
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 = număr de procesoare, plafonat la 8
Options.Standards := [ppsPdfA];
// Cu un registry explicit, alegeți voi profilul potrivit.
// O listă Profiles goală rulează fiecare regulă înregistrată, iar regulile
// pentru standarde nepreflight-uite raportează „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;
Pasarea lui nil drept registry e ruta mai scurtă: ValidatePdfFilesParallel creează apoi singur registry-ul implicit, derivă lista de profiluri din Options.Standards și eliberează registry-ul când se întoarce. Rezultatele se întorc întotdeauna în ordinea de intrare, orice ordine au terminat worker-ii. Pentru formatele de raport și wrapper-ul de linie de comandă din jurul aceluiași motor, vezi rapoarte de preflight PDF în batch cu CLI-ul PDFium Component, iar pentru ce acoperă verificările PDF/A în sine, validarea de preflight PDF/A în Delphi
Cum rulează RenderPagesParallel paginile cu adevărat în paralel?
TPdf.RenderPagesParallel rulează în paralel pentru că worker-ii lui nu împart niciodată un modul PDFium. Metoda salvează întâi documentul activ într-un magazin sursă pe thread-ul apelant. Fiecare worker copiază apoi DLL-ul PDFium încărcat într-un fișier cu nume unic în directorul temp, încarcă copia aceea cu LoadLibrary și o inițializează. Windows tratează un DLL încărcat de la o altă cale drept un alt modul, deci fiecare copie își capătă globalele proprii: propriul cache de fonturi, propriul page module, propriul ei tot. Worker-ul deschide documentul salvat în modulul lui privat, rendează paginile lui progresiv cu verificări de anulare între pași, apoi distruge biblioteca, descarcă copia și șterge fișierul
Izolarea nu e gratis, iar valorile implicite reflectă asta. Fiecare worker plătește o copie de DLL pe disc, un al doilea set de globale PDFium în memorie și o parsare proaspătă a documentului. MaxWorkers = 0 înseamnă cel mult 4 worker-i, MaxPixelsPerPage și MaxTotalOutputBytes plafonează output-ul brut, iar opțiunile de randare inverted și night-duotone sunt respinse fiindcă bufferele se întorc brute. Rezultatul e un TPdfParallelRenderReport al cărui array Results deține un buffer top-down de 32 de biți per pagină cerută, în ordinea cererii
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; // numerele de pagini au baza unu
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// Snapshot-ul sursă se face pe modulul partajat, deci țineți lacătul
// PDFium la nivel de proces dacă alte thread-uri folosesc și ele 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;
Observați lacătul din jurul apelului. Modulele worker sunt private, dar pasul de snapshot de la început rulează SaveAs pe modulul partajat din thread-ul apelant. Dacă nimic altceva din procesul vostru atinge TPdf concurent, puteți renunța la lacăt; dacă ceva o face, snapshot-ul are nevoie de aceeași protecție ca orice alt apel pe modul partajat
| Tipar | Sigur între documente | Lucrul PDFium rulează în paralel | Cost |
|---|---|---|---|
Un TPdf per thread, fără lacăt partajat | Nu | Da, până corupe | Crash-uri intermitente, stare de proces avariată |
| Un lacăt la nivel de proces în jurul tuturor apelurilor PDFium | Da | Nu | Partea PDFium e serială |
ValidatePdfFilesParallel din v3.125.1 | Da | Nu; evaluarea regulilor e paralelă | Parsarea și preflight-ul sunt seriale |
TPdf.RenderPagesParallel | Da | Da | Copie de DLL, memorie și o parsare proaspătă per worker |
Cum ar trebui să structurați propriul cod PDFium multithread?
Thread-urile voastre proprii ar trebui să împartă un singur lacăt la nivel de proces și să-l țină pe toată durata de viață a fiecărui TPdf pe care îl folosesc, sau să folosească un API de componentă care izolează modulul pentru voi. Lacătul trebuie să fie un singur obiect pentru tot procesul, nu unul per thread, per formă sau per document; un lacăt pe care două thread-uri nu-l împart nu protejează nimic. Tiparul de mai jos oglindește ce face componenta intern din v3.125.1: creați, încărcați, citiți și eliberați în interiorul lacătului, apoi faceți tot ce nu atinge PDFium în afara lui
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // un singur lacăt pentru tot procesul
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 închiderea documentului e lucru PDFium
end;
finally
PdfiumLock.Release;
end;
// Nicio apelare PDFium sub linia asta, deci partea asta rulează în paralel
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
Câteva reguli țin tiparul onest într-o aplicație reală:
- Puneți
TPdf.CreateșiFreeîn interiorul lacătului, nu doar apelurile evidente. Încărcarea, închiderea, citirile de proprietăți precumPageCount, schimbările de pagină, extragerea de text, randarea și salvarea ajung toate în modul - Verificați
Activedupă atribuire. O încărcare eșuată lasăActivepeFalse, iarLastLoadReport.ErrorMessagespune de ce - Țineți lacătul per document, nu per apel. O încuiere mai fină e posibilă în principiu, dar doar dacă niciun membru
TPdfnu rulează vreodată în afara ei, iar versiunea grosieră e cea de care se bazează însăși componenta - Țineți lucrul lent non-PDFium, precum scrieri în baza de date, indexarea și apelurile de rețea, în afara lacătului, altfel un singur consumator lent serializează totul
- Nu tratați lacătul intern de randare per instanță drept un substitut. El păzește un
TPdfde el însuși și nimic mai mult
Aceeași prudență se aplică codului pe care nu l-ați scris drept thread-uri brute. Future-urile de fundal sunt o cale bună de a ține randările lungi departe de thread-ul UI, cum e descris în randarea PDF în fundal cu future-uri anulabile, dar executorul de future-uri nu adaugă un lacăt PDFium global propriu. Dacă mai multe future-uri pot conduce instanțe TPdf diferite în același timp, luați același lacăt la nivel de proces în interiorul fiecărui worker, și tratați un viewer pe thread-ul principal ca încă un client al modulului partajat. Utilizarea între instanțe prin API-urile asincrone nu a fost auditată separat, deci presupunerea conservatoare e că are nevoie de aceeași serializare ca thread-urile scrise de mână. Când aveți nevoie de paralelism PDFium real pentru altceva decât randare de pagini, procesele worker separate dau fiecărui job propriul modul prin construcție
Referință rapidă: regulile de threading PDFium pentru Delphi
- Starea nesigură a PDFium-ului e la nivel de modul: cache-ul de fonturi, page module-ul și alte globale sunt partajate de fiecare document din proces
- Un
TPdfper thread nu izolează nimic; două instanțe pe două thread-uri se pot totuși corupe una pe alta - Simptomele tipice sunt eșecuri de încărcare, access violations în cod ulterior,
External exception C000001D, și ieșiri cu0xC0000409sau0xC0000374 - Corupția persistă în proces, deci apelul care pice nu e adesea cel care a cauzat-o
ValidatePdfFilesParallele sigur din v3.125.1; pe versiunile mai vechi folosițiWorkerCount := 1TPdf.RenderPagesParallele cu adevărat paralel pentru că fiecare worker încarcă o copie izolată a modulului PDFium- Thread-urile, task-urile și future-urile voastre au nevoie de un lacăt la nivel de proces care acoperă fiecare
TPdfde laCreatelaFree
PDFium Component învelește motorul PDFium pentru Delphi cu preflight și validare de batch, randare paralelă izolată, lucru în fundal anubil și diagnostice detaliate de încărcare. Detalii și ediții sunt pe pagina de produs PDFium Component