PDFium non è thread-safe a livello di modulo, quindi due istanze di TPdf che lavorano su due file diversi in due thread possono comunque corrompersi a vicenda. PDFium Component for Delphi lo gestisce in due modi: dalla v3.125.1, ValidatePdfFilesParallel serializza ogni chiamata PDFium nativa dietro a un unico lucchetto a livello di processo, mentre TPdf.RenderPagesParallel dà a ogni worker la sua copia isolata del modulo PDFium. Il bug che ha imposto la correzione era del peggior tipo, quello intermittente. Un test di validazione batch passava quasi sempre, poi riportava uno di due file buoni come fallito, poi faceva crashare il test successivo nello stesso processo con un access violation, e a volte portava giù l'intero runner con un exit code anziché uno stack trace. Il test non aveva niente di sbagliato, e nessun singolo documento aveva niente di sbagliato. L'assunzione era sbagliata: un TPdf per thread non è isolamento
Perché un TPdf per thread non basta?
Un TPdf per thread non basta perché PDFium tiene il suo stato insicuro nel modulo, non nel documento. Ogni TPdf possiede il proprio handle FPDF_DOCUMENT, ma ogni handle del processo è servito dalla stessa DLL caricata, e quella DLL contiene singleton a livello di processo: la cache dei font, il page module, e altre strutture globali che caricamento, parsing e rendering dei documenti toccano tutti. Due thread che caricano due file non imparentati sono due thread che scrivono nella stessa cache dei font nello stesso momento. Nessuno possiede quei dati sul lato Delphi, quindi niente sul lato Delphi può metterci un lucchetto per documento
Il componente ha un lucchetto, ed è facile tirarne la conclusione sbagliata. TPdf avvolge i propri percorsi di render in una critical section interna (EnterRenderLock / LeaveRenderLock, metodi private di TPdf). Quel lucchetto è per istanza. Impedisce a due thread di pilotare lo stesso TPdf in contemporanea, che è un pericolo reale, ma non vede una seconda istanza su un altro thread, quindi la concorrenza cross-istanza gli passa dritta accanto. La regola generale è semplice da dire in una riga: in un singolo modulo PDFium caricato, al massimo un thread può trovarsi dentro PDFium in un dato momento, a prescindere da quanti documenti siano aperti
Che aspetto ha la corruzione cross-document in un processo Delphi?
La corruzione cross-document ha l'aspetto di un mix casuale di fallimenti non imparentati, e il danno sopravvive al codice che l'ha causata. Prima della v3.125.1, ValidatePdfFilesParallel creava un TPdf per thread worker e faceva girare Active := True più la costruzione del report preflight in concorrenza sul modulo condiviso. I sintomi visti sia sulle build Delphi sia su quelle Free Pascal coprivano tutta la gamma:
- Un file valido non si carica, o torna dal batch come fallito quando avrebbe dovuto passare
- Un access violation emerge in una chiamata successiva e non imparentata, spesso in un test diverso o su un documento diverso
- In Delphi appare
External exception C000001D. Quel codice èSTATUS_ILLEGAL_INSTRUCTION, sollevato dall'istruzioneud2che le macro interneCHECKeIMMEDIATE_CRASHdi PDFium eseguono quando un invariante si rompe - Il processo esce con
0xC0000409(fail-fast, riportato come stack buffer overrun) o0xC0000374(heap corruption), senza alcuna eccezione Delphi
Gli ultimi due punti sono il motivo per cui il bug era così difficile da inchiodare. La validazione parallela finiva, lo stato globale corrotto restava lì, e il prossimo fixture nello stesso processo ci inciampava. In un'esecuzione di regressione Delphi Win64, un'ondata di fallimenti C000001D colpiva test che non toccavano mai la validazione batch; erano semplicemente il primo codice a usare PDFium dopo il danno. I numeri misurati rendono la scala evidente. Una sonda Delphi che faceva girare lo stesso campione attraverso due worker falliva su 122 di 160 documenti in un'esecuzione e 138 di 160 in un'altra, e una di quelle esecuzioni sollevava direttamente External exception C000001D. Un caso di stress con 8 documenti, 4 worker e 5 round falliva o crashava in 5 esecuzioni su 5 su Free Pascal Win64. Dopo la correzione, la stessa sonda falliva su 0 di 1.200 documenti
Come resta sicuro ValidatePdfFilesParallel dalla v3.125.1
ValidatePdfFilesParallel ora serializza la metà nativa di ogni job e tiene la metà gestita in parallelo. Ogni worker prende una critical section a livello di unità prima di creare il suo TPdf, e la tiene attraverso FileName, Active := True, la costruzione del report preflight, e Free. Creazione e distruzione stanno dentro il lucchetto di proposito: chiudere un documento richiama nel modulo proprio come il caricamento. Una volta che il worker ha un record TPdfPreflightReport catturato, rilascia il lucchetto e valuta le regole di validazione contro quel record, che non tocca alcuno stato PDFium, così la valutazione delle regole di un file si sovrappone al lavoro PDFium del successivo
Due cambiamenti minori sono arrivati con la correzione. Un fallimento di caricamento ora solleva EPdfError con LastLoadReport.ErrorMessage, così l'ErrorMessage della voce nomina il vero problema di parse anziché un errore secondario "no active document". E il costo viene dichiarato onestamente: la parte PDFium del batch ora è seriale, quindi su un batch dominato da parsing e preflight, worker extra comprano poco. Se sei su una versione precedente alla v3.125.1, imposta WorkerCount a 1; questo toglie la concorrenza e con essa la corruzione
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 = numero dei processori, limitato a 8
Options.Standards := [ppsPdfA];
// Con un registry esplicito, seleziona tu il profilo corrispondente.
// Una lista Profiles vuota fa girare ogni regola registrata, e le regole per
// standard di cui non hai fatto preflight riportano "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;
Passare nil come registry è la via più breve: ValidatePdfFilesParallel allora crea da solo il registry predefinito, ricava la lista dei profili da Options.Standards, e libera il registry quando ritorna. I risultati tornano sempre nell'ordine di input, qualsiasi sia l'ordine in cui i worker hanno finito. Per i formati di report e il wrapper a riga di comando attorno allo stesso motore, vedi report preflight PDF in batch con la CLI di PDFium Component, e per ciò che i controlli PDF/A coprono davvero, validazione preflight PDF/A in Delphi
Come fa RenderPagesParallel a far girare le pagine davvero in parallelo?
TPdf.RenderPagesParallel gira in parallelo perché i suoi worker non condividono mai un modulo PDFium. Il metodo prima salva il documento attivo in uno store sorgente sul thread chiamante. Ogni worker poi copia la DLL PDFium caricata in un file con nome univoco nella directory temporanea, carica quella copia con LoadLibrary, e la inizializza. Windows tratta una DLL caricata da un percorso diverso come un modulo diverso, quindi ogni copia prende le sue globali: la sua cache dei font, il suo page module, il suo tutto. Il worker apre il documento salvato nel suo modulo privato, renderizza le sue pagine progressivamente con controlli di cancellazione tra i passi, poi distrugge la libreria, scarica la copia e cancella il file
L'isolamento non è gratis, e i default lo riflettono. Ogni worker paga una copia della DLL su disco, un secondo insieme di globali PDFium in memoria, e un parse fresco del documento. MaxWorkers = 0 significa al massimo 4 worker, MaxPixelsPerPage e MaxTotalOutputBytes limitano l'output grezzo, e le opzioni di render invertite e in duotone notturno vengono rifiutate perché i buffer vengono restituiti grezzi. Il risultato è un TPdfParallelRenderReport il cui array Results contiene un buffer top-down a 32 bit per pagina richiesta, nell'ordine di richiesta
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; // i numeri di pagina sono a base uno
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// Lo snapshot sorgente è preso sul modulo condiviso, quindi tieni il
// lucchetto PDFium a livello di processo se anche altri thread usano 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;
Nota il lucchetto attorno alla chiamata. I moduli dei worker sono privati, ma il passo dello snapshot all'inizio esegue SaveAs sul modulo condiviso dal thread chiamante. Se nient'altro nel tuo processo tocca TPdf in concorrenza puoi togliere il lucchetto; se qualcosa lo fa, lo snapshot ha bisogno della stessa protezione di ogni altra chiamata al modulo condiviso
| Pattern | Sicuro tra documenti | Lavoro PDFium in parallelo | Costo |
|---|---|---|---|
Un TPdf per thread, nessun lucchetto condiviso | No | Sì, finché non corrompe | Crash intermittenti, stato del processo danneggiato |
| Un lucchetto a livello di processo attorno a tutte le chiamate PDFium | Sì | No | La parte PDFium è seriale |
ValidatePdfFilesParallel dalla v3.125.1 | Sì | No; la valutazione delle regole è parallela | Parsing e preflight sono seriali |
TPdf.RenderPagesParallel | Sì | Sì | Copia DLL, memoria e un parse fresco per worker |
Come strutturare il tuo codice PDFium multithread?
I tuoi thread dovrebbero condividere un unico lucchetto a livello di processo e tenerlo per l'intera vita di ogni TPdf che usano, oppure usare un'API del componente che isola il modulo per te. Il lucchetto deve essere un oggetto unico per l'intero processo, non uno per thread, per form o per documento; un lucchetto che due thread non condividono non protegge niente. Il pattern qui sotto ricalca ciò che il componente fa internamente dalla v3.125.1: create, load, read e free dentro il lucchetto, poi tutto ciò che non tocca PDFium fuori da esso
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // un lucchetto per l'intero processo
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; // chiudere il documento è lavoro PDFium anche quello
end;
finally
PdfiumLock.Release;
end;
// Niente PDFium sotto questa riga, quindi questa parte gira in parallelo
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
Poche regole tengono il pattern onesto in un'applicazione reale:
- Metti
TPdf.CreateeFreedentro il lucchetto, non solo le chiamate ovvie. Caricare, chiudere, le letture di proprietà comePageCount, i cambi di pagina, l'estrazione testo, il rendering e il salvataggio arrivano tutti fino al modulo - Controlla
Activedopo averlo assegnato. Un caricamento fallito lasciaActiveaFalse, eLastLoadReport.ErrorMessagedice perché - Tieni il lucchetto per documento anziché per chiamata. Lucchettature più fini sono possibili in linea di principio, ma solo se nessun membro di
TPdfgira mai fuori da esso, e la versione grossolana è quella su cui il componente stesso conta - Tieni il lavoro lento non-PDFium, come scritture su database, indicizzazione e chiamate di rete, fuori dal lucchetto, altrimenti un solo consumatore lento serializza tutto
- Non trattare il lucchetto di render privato per istanza come un sostituto. Protegge un
TPdfda sé stesso e niente di più
La stessa cautela vale per il codice che non hai scritto come thread grezzi. I future in background sono un buon modo di tenere i render lunghi fuori dal thread UI, come descritto in rendering PDF in background con future cancellabili, ma l'executor dei future non aggiunge di suo un lucchetto PDFium globale. Se più future possono pilotare istanze TPdf diverse allo stesso tempo, prendi lo stesso lucchetto a livello di processo dentro ogni worker, e tratta un viewer sul thread principale come un altro cliente del modulo condiviso. L'uso cross-istanza attraverso le API asincrone non è stato auditato separatamente, quindi l'assunzione conservativa è che richieda la stessa serializzazione dei thread scritti a mano. Quando ti serve vero parallelismo PDFium per qualcosa di diverso dal rendering di pagine, processi worker separati danno a ogni job il suo modulo per costruzione
Riferimento rapido: regole di threading PDFium per Delphi
- Lo stato insicuro di PDFium è a livello di modulo: cache dei font, page module e altre globali sono condivise da ogni documento del processo
- Un
TPdfper thread non isola niente; due istanze su due thread possono comunque corrompersi a vicenda - I sintomi tipici sono fallimenti di caricamento, access violation in codice successivo,
External exception C000001D, e uscite con0xC0000409o0xC0000374 - La corruzione persiste nel processo, quindi la chiamata che fallisce spesso non è quella che l'ha causata
ValidatePdfFilesParallelè sicuro dalla v3.125.1; sulle versioni più vecchie usaWorkerCount := 1TPdf.RenderPagesParallelè davvero parallelo perché ogni worker carica una copia isolata del modulo PDFium- I tuoi thread, task e future hanno bisogno di un lucchetto a livello di processo che copra ogni
TPdfdaCreateaFree
PDFium Component incapsula il motore PDFium per Delphi con preflight e validazione batch, rendering parallelo isolato, lavoro in background cancellabile e diagnostica di caricamento dettagliata. Dettagli ed edizioni sono sulla pagina prodotto di PDFium Component