Article technique

Threads PDFium : le verrou par document échoue en Delphi

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

Schéma PDFium Component de deux threads exécutant des instances TPdf séparées sur des documents différents tandis que chaque appel converge vers un module pdfium.dll chargé unique dont le cache de polices, le module de pages et les autres globaux à l’échelle du processus sont partagés, produisant échecs de chargement, violations d’accès et sorties fail-fast
PDFium garde son état non sûr dans le module, pas dans le document, donc deux instances TPdf sur deux threads écrivent dans le même cache de polices, si sans rapport que soient les fichiers

À 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 C000001D apparaît en Delphi. Ce code est STATUS_ILLEGAL_INSTRUCTION, levé par l’instruction ud2 qu’exécutent les macros internes CHECK et IMMEDIATE_CRASH de PDFium quand un invariant casse
  • Le processus se termine avec 0xC0000409 (fail-fast, rapporté comme un dépassement de tampon de pile) ou 0xC0000374 (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

Schéma ValidatePdfFilesParallel de PDFium Component montrant chaque worker tenant une section critique à l’échelle du processus à travers création, chargement, prévol et libération du TPdf tandis que l’évaluation des règles du rapport capturé tourne hors du verrou en parallèle, si bien que la moitié PDFium du lot est série de par la conception
La création et la destruction restent dans le verrou parce que fermer un document fait un rappel dans le module, tandis que l’évaluation du rapport ne touche aucun état PDFium et chevauche le fichier 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

Schéma RenderPagesParallel de PDFium Component où le thread appelant sauvegarde un instantané du document, puis chaque worker copie la DLL PDFium vers un fichier temporaire unique, la charge comme un module séparé avec ses propres globaux, rend ses pages avec des vérifications d’annulation et décharge la copie
Le vrai parallélisme vient de l’isolation de module : Windows traite chaque copie de DLL comme un module différent, donc les workers ne partagent rien sinon l’instantané que le thread appelant a sauvegardé sous le verrou

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émaSûr entre documentsLe travail PDFium tourne en parallèleCoût
Un TPdf par thread, sans verrou partagéNonOui, jusqu’à la corruptionPlantages intermittents, état de processus abîmé
Un verrou à l’échelle du processus autour de tous les appels PDFiumOuiNonLa partie PDFium est série
ValidatePdfFilesParallel depuis v3.125.1OuiNon ; l’évaluation des règles est parallèleAnalyse et prévol sont séries
TPdf.RenderPagesParallelOuiOuiCopie 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.Create et Free dans le verrou, pas seulement les appels évidents. Chargement, fermeture, lectures de propriétés telles que PageCount, changements de page, extraction de texte, rendu et sauvegarde atteignent tous le module
  • Vérifiez Active après l’avoir affecté. Un chargement échoué laisse Active à False, et LastLoadReport.ErrorMessage dit 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 TPdf ne 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 TPdf contre 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 TPdf par 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 avec 0xC0000409 ou 0xC0000374
  • La corruption persiste dans le processus, donc l’appel qui échoue n’est souvent pas celui qui l’a causée
  • ValidatePdfFilesParallel est sûr depuis v3.125.1 ; sur les versions plus anciennes, utilisez WorkerCount := 1
  • TPdf.RenderPagesParallel est 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 TPdf de Create à 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