PDFium n’est pas thread-safe au niveau du module, donc deux instances TPdf travaillant sur deux fichiers différents dans deux threads peuvent quand même se corrompre mutuellement. PDFium Component pour Delphi gère cela de deux façons : depuis v3.125.1, ValidatePdfFilesParallel sérialise chaque appel PDFium natif derrière un unique verrou à l’échelle du processus, tandis que TPdf.RenderPagesParallel donne à chaque worker sa copie isolée du module PDFium. Le bug qui a forcé le correctif était de la pire espèce d’intermittent. Un test de validation par lots passait la plupart du temps, puis déclarait un des deux bons fichiers échoué, puis faisait planter le test suivant dans le même processus avec une violation d’accès, et parfois emportait tout le runner avec un code de sortie au lieu d’une pile d’appels. Le test n’avait aucun défaut, et aucun document individuel non plus. C’est l’hypothèse qui était fausse : un TPdf par thread, ce n’est pas de l’isolation
Pourquoi un TPdf par thread ne suffit-il pas ?
Un TPdf par thread ne suffit pas parce que PDFium garde son état non sûr dans le module, pas dans le document. Chaque TPdf possède son propre handle FPDF_DOCUMENT, mais chaque handle du processus est servi par la même DLL chargée, et cette DLL détient des singletons à l’échelle du processus : le cache de polices, le module de pages, et d’autres structures globales que le chargement de documents, l’analyse et le rendu touchent tous. Deux threads qui chargent deux fichiers sans rapport sont deux threads qui écrivent dans le même cache de polices au même moment. Personne ne possède ces données côté Delphi, donc rien côté Delphi ne peut les verrouiller par document
Le composant a bien un verrou, et il est facile d’en tirer la mauvaise conclusion. TPdf enveloppe ses propres chemins de rendu dans une section critique interne (EnterRenderLock / LeaveRenderLock, méthodes privées de TPdf). Ce verrou est par instance. Il empêche deux threads de piloter le même TPdf en même temps, ce qui est un vrai danger, mais il ne voit pas une seconde instance sur un autre thread, donc la concurrence entre instances passe droit au travers. La règle générale est assez simple pour tenir en une ligne : dans un seul module PDFium chargé, au plus un thread peut être à l’intérieur de PDFium à tout instant, quel que soit le nombre de documents ouverts
À quoi ressemble la corruption inter-documents dans un processus Delphi ?
La corruption inter-documents ressemble à un mélange aléatoire de défaillances sans rapport, et les dégâts survivent au code qui les a causés. Avant v3.125.1, ValidatePdfFilesParallel créait un TPdf par thread worker et exécutait Active := True plus la construction du rapport de prévol en concurrence sur le module partagé. Les symptômes observés sur les builds Delphi et Free Pascal couvraient toute la gamme :
- Un fichier valide échoue au chargement, ou revient du lot comme échoué alors qu’il aurait dû passer
- Une violation d’accès fait surface dans un appel ultérieur sans rapport, souvent dans un autre test ou un autre document
External exception C000001Dapparaît en Delphi. Ce code estSTATUS_ILLEGAL_INSTRUCTION, levé par l’instructionud2qu’exécutent les macros internesCHECKetIMMEDIATE_CRASHde PDFium quand un invariant casse- Le processus se termine avec
0xC0000409(fail-fast, rapporté comme un dépassement de tampon de pile) ou0xC0000374(corruption du tas), sans aucune exception Delphi
Les deux derniers points expliquent pourquoi le bug fut si difficile à cerner. La validation parallèle se terminait, l’état global corrompu restait sur place, et le fixture suivant dans le même processus butait dessus. Dans une campagne de régression Delphi Win64, une vague d’échecs C000001D a frappé des tests qui ne touchaient jamais la validation par lots ; ils étaient simplement les premiers codes à utiliser PDFium après les dégâts. Les chiffres mesurés rendent l’ampleur manifeste. Une sonde Delphi qui faisait passer le même échantillon par deux workers a échoué 122 documents sur 160 dans une course et 138 sur 160 dans une autre, et une de ces courses a levé External exception C000001D carrément. Un cas de stress de 8 documents, 4 workers et 5 tours a échoué ou planté dans 5 courses sur 5 sur Free Pascal Win64. Après le correctif, la même sonde a échoué 0 document sur 1 200
Comment ValidatePdfFilesParallel reste sûr depuis v3.125.1
ValidatePdfFilesParallel sérialise désormais la moitié native de chaque tâche et garde la moitié managée parallèle. Chaque worker prend une section critique au niveau de l’unité avant de créer son TPdf, et la garde à travers FileName, Active := True, la construction du rapport de prévol, et Free. La création et la destruction sont dans le verrou à dessein : fermer un document fait un rappel dans le module tout comme le chargement. Une fois que le worker a capturé un enregistrement TPdfPreflightReport, il relâche le verrou et évalue les règles de validation contre cet enregistrement, ce qui ne touche aucun état PDFium, si bien que l’évaluation des règles d’un fichier chevauche le travail PDFium du suivant
Deux changements plus petits sont venus avec le correctif. Un échec de chargement lève désormais EPdfError avec LastLoadReport.ErrorMessage, donc le ErrorMessage de l’élément nomme le vrai problème d’analyse au lieu d’une erreur secondaire « pas de document actif ». Et le coût est énoncé honnêtement : la partie PDFium du lot est désormais série, donc sur un lot dominé par l’analyse et le prévol, des workers supplémentaires rapportent peu. Si vous êtes sur une version antérieure à v3.125.1, réglez WorkerCount à 1 ; cela supprime la concurrence et la corruption avec
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 = nombre de processeurs, plafonné à 8
Options.Standards := [ppsPdfA];
// Avec un registre explicite, sélectionnez vous-même le profil correspondant.
// Une liste Profiles vide exécute chaque règle enregistrée, et les règles des
// standards que vous n’avez pas prévolés rapportent "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;
Passer nil comme registre est la voie courte : ValidatePdfFilesParallel crée alors lui-même le registre par défaut, déduit la liste de profils de Options.Standards, et libère le registre en retournant. Les résultats reviennent toujours dans l’ordre des entrées, quel que soit l’ordre d’achèvement des workers. Pour les formats de rapport et le wrapper en ligne de commande autour du même moteur, voir les rapports de prévol PDF par lots avec le CLI de PDFium Component, et pour ce que couvrent les vérifications PDF/A elles-mêmes, la validation de prévol PDF/A en Delphi
Comment RenderPagesParallel exécute-t-il les pages vraiment en parallèle ?
TPdf.RenderPagesParallel tourne en parallèle parce que ses workers ne partagent jamais un module PDFium. La méthode sauvegarde d’abord le document actif dans un magasin source sur le thread appelant. Chaque worker copie ensuite la DLL PDFium chargée vers un fichier au nom unique dans le répertoire temporaire, charge cette copie avec LoadLibrary, et l’initialise. Windows traite une DLL chargée depuis un chemin différent comme un module différent, donc chaque copie obtient ses propres globaux : son propre cache de polices, son propre module de pages, son propre tout. Le worker ouvre le document sauvegardé dans son module privé, rend ses pages progressivement avec des vérifications d’annulation entre les étapes, puis détruit la bibliothèque, décharge la copie et supprime le fichier
L’isolation n’est pas gratuite, et les défauts le reflètent. Chaque worker paie une copie de DLL sur disque, un second jeu de globaux PDFium en mémoire, et une analyse fraîche du document. MaxWorkers = 0 signifie au plus 4 workers, MaxPixelsPerPage et MaxTotalOutputBytes plafonnent la sortie brute, et les options de rendu inversé et duotone de nuit sont refusées parce que les tampons sont renvoyés bruts. Le résultat est un TPdfParallelRenderReport dont le tableau Results tient un tampon 32 bits top-down par page demandée, dans l’ordre des demandes
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; // les numéros de page sont à base 1
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// L’instantané source est pris sur le module partagé, donc tenez le
// verrou PDFium à l’échelle du processus si d’autres threads utilisent aussi 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;
Notez le verrou autour de l’appel. Les modules des workers sont privés, mais l’étape d’instantané au début exécute SaveAs sur le module partagé depuis le thread appelant. Si rien d’autre dans votre processus ne touche TPdf en concurrence, vous pouvez lâcher le verrou ; si quelque chose le fait, l’instantané a besoin de la même protection que tout autre appel au module partagé
| Schéma | Sûr entre documents | Le travail PDFium tourne en parallèle | Coût |
|---|---|---|---|
Un TPdf par thread, sans verrou partagé | Non | Oui, jusqu’à la corruption | Plantages intermittents, état de processus abîmé |
| Un verrou à l’échelle du processus autour de tous les appels PDFium | Oui | Non | La partie PDFium est série |
ValidatePdfFilesParallel depuis v3.125.1 | Oui | Non ; l’évaluation des règles est parallèle | Analyse et prévol sont séries |
TPdf.RenderPagesParallel | Oui | Oui | Copie de DLL, mémoire et analyse fraîche par worker |
Comment structurer votre propre code PDFium multithread ?
Vos propres threads devraient partager un unique verrou à l’échelle du processus et le tenir pendant toute la vie de chaque TPdf qu’ils utilisent, ou sinon utiliser une API du composant qui isole le module pour vous. Le verrou doit être un objet unique pour tout le processus, pas un par thread, par fiche ou par document ; un verrou que deux threads ne partagent pas ne protège rien. Le schéma ci-dessous reflète ce que le composant fait en interne depuis v3.125.1 : créer, charger, lire et libérer dans le verrou, puis faire tout ce qui ne touche pas PDFium à l’extérieur
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // un verrou pour tout le processus
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; // fermer le document est du travail PDFium aussi
end;
finally
PdfiumLock.Release;
end;
// Aucun PDFium sous cette ligne, donc cette partie tourne en parallèle
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
Quelques règles gardent le schéma honnête dans une vraie application :
- Mettez
TPdf.CreateetFreedans le verrou, pas seulement les appels évidents. Chargement, fermeture, lectures de propriétés telles quePageCount, changements de page, extraction de texte, rendu et sauvegarde atteignent tous le module - Vérifiez
Activeaprès l’avoir affecté. Un chargement échoué laisseActiveàFalse, etLastLoadReport.ErrorMessagedit pourquoi - Tenez le verrou par document plutôt que par appel. Un verrouillage plus fin est possible en principe, mais seulement si aucun membre de
TPdfne s’exécute jamais en dehors, et la version grossière est celle dont le composant lui-même dépend - Gardez le travail non PDFium lent, telles que les écritures en base, l’indexation et les appels réseau, hors du verrou, sinon un consommateur lent sérialisera tout
- Ne traitez pas le verrou de rendu privé par instance comme un substitut. Il protège un
TPdfcontre lui-même et rien de plus
La même prudence vaut pour le code que vous n’avez pas écrit en threads bruts. Les futures en arrière-plan sont un bon moyen de tenir les rendus longs hors du thread UI, comme décrit dans le rendu PDF en arrière-plan avec des futures annulables, mais l’exécuteur de futures n’ajoute pas de verrou PDFium global en propre. Si plusieurs futures peuvent piloter des instances TPdf différentes en même temps, prenez le même verrou à l’échelle du processus dans chaque worker, et traitez un visualiseur sur le thread principal comme un client de plus du module partagé. L’usage entre instances via les API asynchrones n’a pas été audité séparément, donc l’hypothèse conservatrice est qu’il exige la même sérialisation que des threads écrits à la main. Quand vous avez besoin d’un vrai parallélisme PDFium pour autre chose que le rendu de pages, des processus workers séparés donnent à chaque tâche son propre module de par la construction
Aide-mémoire : les règles de threading PDFium pour Delphi
- L’état non sûr de PDFium est à l’échelle du module : cache de polices, module de pages et autres globaux sont partagés par chaque document du processus
- Un
TPdfpar thread n’isole rien ; deux instances sur deux threads peuvent encore se corrompre mutuellement - Les symptômes typiques sont des échecs de chargement, des violations d’accès dans du code ultérieur,
External exception C000001D, et des sorties avec0xC0000409ou0xC0000374 - La corruption persiste dans le processus, donc l’appel qui échoue n’est souvent pas celui qui l’a causée
ValidatePdfFilesParallelest sûr depuis v3.125.1 ; sur les versions plus anciennes, utilisezWorkerCount := 1TPdf.RenderPagesParallelest vraiment parallèle parce que chaque worker charge une copie isolée du module PDFium- Vos propres threads, tâches et futures ont besoin d’un verrou à l’échelle du processus couvrant chaque
TPdfdeCreateàFree
PDFium Component enveloppe le moteur PDFium pour Delphi avec prévol et validation par lots, rendu parallèle isolé, travail en arrière-plan annulable et diagnostics de chargement détaillés. Détails et éditions sont sur la page produit PDFium Component