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
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 C000001Derscheint in Delphi. Dieser Code istSTATUS_ILLEGAL_INSTRUCTION, geworfen von derud2-Instruktion, die PDFiums interneCHECK- undIMMEDIATE_CRASH-Makros ausführen, wenn eine Invariante bricht- Der Prozess endet mit
0xC0000409(fail-fast, als Stack Buffer Overrun gemeldet) oder0xC0000374(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
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
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
| Muster | Sicher über Dokumente hinweg | PDFium-Arbeit läuft parallel | Kosten |
|---|---|---|---|
Ein TPdf pro Thread, keine geteilte Sperre | Nein | Ja, bis es korrumpiert | Intermittierende Abstürze, beschädigter Prozesszustand |
| Eine prozessweite Sperre um alle PDFium-Aufrufe | Ja | Nein | Der PDFium-Teil ist seriell |
ValidatePdfFilesParallel seit v3.125.1 | Ja | Nein; die Regelauswertung ist parallel | Parsing und Preflight sind seriell |
TPdf.RenderPagesParallel | Ja | Ja | DLL-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.CreateundFreein die Sperre legen, nicht nur die offensichtlichen Aufrufe. Laden, Schließen, Property-Lesezugriffe wiePageCount, Seitenwechsel, Textextraktion, Rendern und Sichern reichen alle ins ModulActivenach der Zuweisung prüfen. Ein gescheitertes Laden lässtActiveaufFalse, undLastLoadReport.ErrorMessagesagt 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
TPdfvor 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
TPdfpro 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 mit0xC0000409oder0xC0000374 - Korruption persistiert im Prozess, der fehlschlagende Aufruf ist also oft nicht der, der sie verursacht hat
ValidatePdfFilesParallelist seit v3.125.1 sicher; auf älteren VersionenWorkerCount := 1verwendenTPdf.RenderPagesParallelist 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
TPdfvonCreatebisFreeabdeckt
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