Technischer Artikel

PDFium Thread Safety: Warum Pro-Dokument-Locks scheitern

PDFium ist nicht auf Modulebene thread-safe, zwei TPdf-Instanzen, die in zwei Threads an zwei verschiedenen Dateien arbeiten, können einander also weiterhin korrumpieren. PDFium Component for Delphi handhabt das auf zwei Arten: Seit v3.125.1 serialisiert ValidatePdfFilesParallel jeden nativen PDFium-Aufruf hinter einer prozessweiten Sperre, während TPdf.RenderPagesParallel jedem Worker seine eigene isolierte Kopie des PDFium-Moduls gibt. Der Bug, der den Fix erzwang, war die schlimmste Sorte intermittierend. Ein Batch-Validierungstest lief meistens durch, meldete dann eine von zwei guten Dateien als gescheitert, brachte dann den nächsten Test im selben Prozess mit einer Access Violation zu Fall und riss manchmal den ganzen Runner mit einem Exit-Code statt eines Stack-Traces mit in die Tiefe. Am Test war nichts falsch, und an keinem einzelnen Dokument war etwas falsch. Die Annahme war falsch: Ein TPdf pro Thread ist keine Isolation

Warum reicht ein TPdf pro Thread nicht?

Ein TPdf pro Thread reicht nicht, weil PDFium seinen unsicheren Zustand im Modul hält, nicht im Dokument. Jedes TPdf besitzt sein eigenes FPDF_DOCUMENT-Handle, aber jedes Handle im Prozess wird von derselben geladenen DLL bedient, und diese DLL hält prozessweite Singletons: den Font-Cache, das Seitenmodul und weitere globale Strukturen, die Laden, Parsen und Rendern von Dokumenten alle anfassen. Zwei Threads, die zwei nicht verwandte Dateien laden, sind zwei Threads, die zur selben Zeit in denselben Font-Cache schreiben. Auf Delphi-Seite besitzt niemand diese Daten, also kann auf Delphi-Seite auch nichts sie pro Dokument sperren

Die Komponente hat eine Sperre, und aus ihr lässt sich leicht die falsche Schlussfolgerung ziehen. TPdf wickelt seine eigenen Render-Pfade in eine interne Critical Section (EnterRenderLock / LeaveRenderLock, private Methoden von TPdf). Diese Sperre gilt pro Instanz. Sie verhindert, dass zwei Threads gleichzeitig dasselbe TPdf treiben, ein echtes Risiko, aber sie kann keine zweite Instanz auf einem anderen Thread sehen, also läuft instancesübergreifende Nebenläufigkeit geradewegs an ihr vorbei. Die allgemeine Regel lässt sich in einer Zeile nennen: In einem einzelnen geladenen PDFium-Modul darf zu jedem Zeitpunkt höchstens ein Thread innerhalb von PDFium sein, egal wie viele Dokumente offen sind

PDFium-Component-Diagramm mit zwei Threads, die getrennte TPdf-Instanzen auf verschiedenen Dokumenten fahren, während jeder Aufruf auf einem geladenen pdfium.dll-Modul konvergiert, dessen Font-Cache, Seitenmodul und weitere prozessweite Globals geteilt sind, was Ladefehler, Access Violations und Fail-fast-Exits produziert
PDFium hält seinen unsicheren Zustand im Modul, nicht im Dokument, zwei TPdf-Instanzen auf zwei Threads schreiben also in denselben Font-Cache, egal wie unverwandt die Dateien sind

Wie sieht instancesübergreifende Korruption in einem Delphi-Prozess aus?

Instancesübergreifende Korruption sieht aus wie ein zufälliger Mix unverwandter Fehler, und der Schaden überlebt den Code, der ihn verursacht hat. Vor v3.125.1 erzeugte ValidatePdfFilesParallel ein TPdf pro Worker-Thread und fuhr Active := True plus den Preflight-Report-Aufbau gleichzeitig auf dem geteilten Modul. Die Symptome, gesehen auf Delphi- wie auf Free-Pascal-Builds, spannten das ganze Spektrum auf:

  • Eine valide Datei lädt nicht, oder sie kommt aus dem Batch als gescheitert zurück, obwohl sie durchgegangen wäre
  • Eine Access Violation taucht in einem späteren, unverwandten Aufruf auf, oft in einem anderen Test oder einem anderen Dokument
  • External exception C000001D erscheint in Delphi. Dieser Code ist STATUS_ILLEGAL_INSTRUCTION, geworfen von der ud2-Instruktion, die PDFiums interne CHECK- und IMMEDIATE_CRASH-Makros ausführen, wenn eine Invariante bricht
  • Der Prozess endet mit 0xC0000409 (fail-fast, als Stack Buffer Overrun gemeldet) oder 0xC0000374 (Heap Corruption), ganz ohne Delphi-Exception

Die letzten zwei Punkte sind der Grund, warum der Bug so schwer festzunageln war. Die parallele Validierung lief zu Ende, der korrumpierte globale Zustand blieb zurück, und die nächste Fixture im selben Prozess stolperte über ihn. In einem Delphi-Win64-Regressionlauf trafen C000001D-Fehler in Serie Tests, die Batch-Validierung nie angefasst hatten; sie waren schlicht der erste Code, der nach dem Schaden PDFium benutzte. Die ausgemessenen Zahlen machen das Ausmaß handgreiflich. Eine Delphi-Sonde, die dieselbe Stichprobe durch zwei Worker jagte, scheiterte in einem Lauf an 122 von 160 Dokumenten und in einem anderen an 138 von 160, und einer dieser Läufe warf External exception C000001D direkt. Ein Stressfall mit 8 Dokumenten, 4 Workern und 5 Runden scheiterte oder stürzte in 5 von 5 Läufen auf Free Pascal Win64 ab. Nach dem Fix scheiterte dieselbe Sonde an 0 von 1.200 Dokumenten

Wie ValidatePdfFilesParallel seit v3.125.1 sicher bleibt

ValidatePdfFilesParallel serialisiert jetzt die native Hälfte jedes Jobs und hält die verwaltete Hälfte parallel. Jeder Worker nimmt eine Critical Section auf Unit-Ebene, bevor er sein TPdf erzeugt, und hält sie durch FileName, Active := True, den Preflight-Report-Aufbau und Free. Erzeugung und Zerstörung sitzen mit Absicht in der Sperre: Das Schließen eines Dokuments ruft ebenso ins Modul zurück wie das Laden. Hat der Worker einen erfassten TPdfPreflightReport-Record, gibt er die Sperre frei und wertet die Validierungsregeln gegen diesen Record aus, was keinen PDFium-Zustand anfasst, die Regelauswertung für eine Datei überlappt sich also mit der PDFium-Arbeit für die nächste

PDFium-Component-ValidatePdfFilesParallel-Diagramm, das zeigt, wie jeder Worker eine prozessweite Critical Section über TPdf-Create, Laden, Preflight und Free hält, während die Regelauswertung des erfassten Reports außerhalb der Sperre parallel läuft, die PDFium-Hälfte des Batches also von Design her seriell ist
Erzeugung und Zerstörung bleiben in der Sperre, weil das Schließen eines Dokuments ins Modul zurückruft, während die Report-Auswertung keinen PDFium-Zustand anfasst und sich mit der nächsten Datei überlappt

Zwei kleinere Änderungen kamen mit dem Fix. Ein Ladefehler wirft jetzt EPdfError mit LastLoadReport.ErrorMessage, das ErrorMessage des Elements benennt also das echte Parse-Problem statt eines sekundären „no active document“-Fehlers. Und die Kosten werden ehrlich benannt: Der PDFium-Teil des Batches ist jetzt seriell, bei einem Batch, der von Parsing und Preflight dominiert wird, kaufen zusätzliche Worker also wenig. Sind Sie auf einer Version vor v3.125.1, setzen Sie WorkerCount auf 1; das nimmt die Nebenläufigkeit und mit ihr die Korruption

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 = Prozessorenzahl, gedeckelt bei 8
    Options.Standards := [ppsPdfA];
    // Mit explizitem Registry das passende Profil selbst wählen.
    // Eine leere Profiles-Liste fährt jede registrierte Regel, und Regeln für
    // Standards, die Sie nicht gepreflightet haben, melden „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;

nil als Registry zu übergeben ist der kürzere Weg: ValidatePdfFilesParallel erzeugt dann selbst das Default-Registry, leitet die Profil-Liste aus Options.Standards ab und gibt das Registry bei der Rückkehr frei. Ergebnisse kommen stets in Eingabereihenfolge zurück, in welcher Reihenfolge die Worker auch fertig wurden. Für die Report-Formate und den Kommandozeilen-Wrapper um dieselbe Engine siehe Batch-PDF-Preflight-Reports mit der PDFium Component CLI, und was die PDF/A-Prüfungen selbst abdecken, siehe PDF/A-Preflight-Validierung in Delphi

Wie läuft RenderPagesParallel die Seiten wirklich parallel?

TPdf.RenderPagesParallel läuft parallel, weil seine Worker nie ein PDFium-Modul teilen. Die Methode sichert zuerst das aktive Dokument in einen Quell-Speicher auf dem aufrufenden Thread. Dann kopiert jeder Worker die geladene PDFium-DLL in eine eindeutig benannte Datei im Temp-Verzeichnis, lädt diese Kopie mit LoadLibrary und initialisiert sie. Windows behandelt eine DLL aus einem anderen Pfad als ein anderes Modul, jede Kopie bekommt also eigene Globals: eigenen Font-Cache, eigenes Seitenmodul, alles Eigene. Der Worker öffnet das gesicherte Dokument in seinem privaten Modul, rendert seine Seiten progressiv mit Abbruch-Prüfungen zwischen den Schritten, zerstört dann die Bibliothek, entlädt die Kopie und löscht die Datei

PDFium-Component-RenderPagesParallel-Diagramm, in dem der aufrufende Thread einen Dokument-Snapshot sichert, dann jeder Worker die PDFium-DLL in eine eindeutige Temp-Datei kopiert, sie als separates Modul mit eigenen Globals lädt, seine Seiten mit Abbruch-Prüfungen rendert und die Kopie entlädt
Echte Parallelität kommt aus der Modul-Isolation: Windows behandelt jede DLL-Kopie als anderes Modul, die Worker teilen also nichts außer dem Snapshot, den der aufrufende Thread unter der Sperre gesichert hat

Die Isolation ist nicht gratis, und die Defaults spiegeln das. Jeder Worker zahlt für eine DLL-Kopie auf der Disk, einen zweiten Satz PDFium-Globals im Speicher und ein frisches Parsing des Dokuments. MaxWorkers = 0 heißt höchstens 4 Worker, MaxPixelsPerPage und MaxTotalOutputBytes deckeln die Rohausgabe, und die Invert- und Night-Duotone-Render-Optionen werden abgewiesen, weil die Buffer roh zurückkommen. Das Ergebnis ist ein TPdfParallelRenderReport, dessen Results-Array je angefragter Seite einen Top-down-32-Bit-Buffer hält, in Anfragereihenfolge

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;                 // Seitenzahlen sind 1-basiert

  Options := TPdfParallelRenderOptions.Default;
  Options.Dpi := 150;
  Options.MaxWorkers := 4;

  // Der Quell-Snapshot läuft auf dem geteilten Modul, also die
  // prozessweite PDFium-Sperre halten, wenn andere Threads auch TPdf nutzen
  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;

Beachten Sie die Sperre um den Aufruf. Die Worker-Module sind privat, aber der Snapshot-Schritt am Anfang läuft mit SaveAs auf dem geteilten Modul vom aufrufenden Thread aus. Fassen Sie im Prozess sonst nichts TPdf gleichzeitig an, können Sie die Sperre weglassen; tut es irgendetwas, braucht der Snapshot denselben Schutz wie jeder andere Aufruf auf dem geteilten Modul

MusterSicher über Dokumente hinwegPDFium-Arbeit läuft parallelKosten
Ein TPdf pro Thread, keine geteilte SperreNeinJa, bis es korrumpiertIntermittierende Abstürze, beschädigter Prozesszustand
Eine prozessweite Sperre um alle PDFium-AufrufeJaNeinDer PDFium-Teil ist seriell
ValidatePdfFilesParallel seit v3.125.1JaNein; die Regelauswertung ist parallelParsing und Preflight sind seriell
TPdf.RenderPagesParallelJaJaDLL-Kopie, Speicher und ein frisches Parsing pro Worker

Wie sollten Sie Ihren eigenen multithreaded PDFium-Code strukturieren?

Ihre eigenen Threads sollten sich eine prozessweite Sperre teilen und sie über den gesamten Lebenszyklus jedes TPdf, den sie benutzen, halten — oder eine Komponenten-API nehmen, die Ihnen das Modul isoliert. Die Sperre muss ein einziges Objekt für den ganzen Prozess sein, nicht eine pro Thread, pro Formular oder pro Dokument; eine Sperre, die zwei Threads nicht teilen, schützt nichts. Das folgende Muster spiegelt, was die Komponente intern seit v3.125.1 tut: erzeugen, laden, lesen und freigeben in der Sperre, alles, was PDFium nicht anfasst, außerhalb

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // eine Sperre für den ganzen Prozess

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;                      // Das Schließen des Dokuments ist auch PDFium-Arbeit
      end;
    finally
      PdfiumLock.Release;
    end;
    // Unterhalb dieser Zeile kein PDFium, dieser Teil läuft also parallel
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

initialization
  PdfiumLock := TCriticalSection.Create;
finalization
  PdfiumLock.Free;

Ein paar Regeln halten das Muster in einer echten Anwendung ehrlich:

  • TPdf.Create und Free in die Sperre legen, nicht nur die offensichtlichen Aufrufe. Laden, Schließen, Property-Lesezugriffe wie PageCount, Seitenwechsel, Textextraktion, Rendern und Sichern reichen alle ins Modul
  • Active nach der Zuweisung prüfen. Ein gescheitertes Laden lässt Active auf False, und LastLoadReport.ErrorMessage sagt warum
  • Die Sperre pro Dokument halten statt pro Aufruf. Feineres Locking ist im Prinzip möglich, aber nur, wenn kein TPdf-Member jemals außerhalb läuft, und die grobe Variante ist die, auf die sich die Komponente selbst verlässt
  • Langsame Nicht-PDFium-Arbeit, etwa Datenbank-Schreibvorgänge, Indexierung und Netzwerk-Aufrufe, außerhalb der Sperre halten, sonst serialisiert ein langsamer Consumer alles
  • Die private Instanz-Rendersperre nicht als Ersatz behandeln. Sie bewacht ein TPdf vor sich selbst und nichts weiter

Dieselbe Vorsicht gilt für Code, den Sie nicht als rohe Threads geschrieben haben. Background-Futures sind ein guter Weg, lange Rendervorgänge vom UI-Thread fernzuhalten, wie in Background-PDF-Rendering mit abbrechbaren Futures beschrieben, aber der Future-Executor fügt keine eigene globale PDFium-Sperre hinzu. Können mehrere Futures gleichzeitig verschiedene TPdf-Instanzen treiben, nehmen Sie dieselbe prozessweite Sperre in jedem Worker, und behandeln Sie einen Viewer auf dem Hauptthread als einen weiteren Kunden des geteilten Moduls. Instancesübergreifende Nutzung über die asynchronen APIs wurde nicht separat auditiert, die konservative Annahme ist also, dass sie dieselbe Serialisierung braucht wie handgeschriebene Threads. Wollen Sie echte PDFium-Parallelität für etwas anderes als Seiten-Rendering, geben getrennte Worker-Prozesse jedem Job sein eigenes Modul per Konstruktion

Kurzreferenz: PDFium-Threading-Regeln für Delphi

  • PDFiums unsicherer Zustand ist modulweit: Font-Cache, Seitenmodul und weitere Globals werden von jedem Dokument im Prozess geteilt
  • Ein TPdf pro Thread isoliert nichts; zwei Instanzen auf zwei Threads können einander weiterhin korrumpieren
  • Typische Symptome sind Ladefehler, Access Violations in späterem Code, External exception C000001D, und Exits mit 0xC0000409 oder 0xC0000374
  • Korruption persistiert im Prozess, der fehlschlagende Aufruf ist also oft nicht der, der sie verursacht hat
  • ValidatePdfFilesParallel ist seit v3.125.1 sicher; auf älteren Versionen WorkerCount := 1 verwenden
  • TPdf.RenderPagesParallel ist wirklich parallel, denn jeder Worker lädt eine isolierte Kopie des PDFium-Moduls
  • Ihre eigenen Threads, Tasks und Futures brauchen eine prozessweite Sperre, die jedes TPdf von Create bis Free abdeckt

PDFium Component umhüllt die PDFium-Engine für Delphi mit Batch-Preflight und -Validierung, isoliertem parallelem Rendering, abbrechbarer Background-Arbeit und detaillierter Ladediagnostik. Details und Editionen finden Sie auf der PDFium Component product page