Das parallele Rendern von PDF-Seiten aus Delphi läuft auf eine einzige Regel hinaus: Geben Sie jedem Worker-Thread seinen eigenen Renderer. Die losLab PDF Library stellt für genau diese Aufgabe die Methode RenderPagesToFilesParallel bereit, die einen Seitenbereich über einen TTask-Pool mit einer TPDFlib-Instanz pro Worker verteilt. So verwandelt eine Multi-Core-Maschine einen Batch-Raster-Job in einen Durchsatz, der fast linear mit der Anzahl der CPU-Kerne skaliert. Teilt man sich hingegen eine einzige Instanz über mehrere Threads hinweg, verlangsamt sich der Ablauf nicht etwa sanft, sondern führt zu Speicherfehlern und Abstürzen
Dies ist der Artikel, den Sie zu Rate ziehen, wenn ein nächtlicher Job ein 500-seitiges PDF in 500 PNG-Dateien umwandeln muss, der Server 16 ungenutzte Kerne hat und Ihr erster ehrlicher Versuch, dies mit Threads umzusetzen, innerhalb von GDI+ scheiterte. Die Kurzfassung lautet: Threadsicherheit ist hier eine strukturelle Eigenschaft und kein Flag, das Sie setzen. Der Rest dieses Beitrags führt Sie durch die Gründe, warum das sichere Design genau so aussehen muss und wo die tatsächliche Grenze für den Geschwindigkeitszuwachs liegt
Ist TPDFlib threadsicher für paralleles Rendern?
Nein, und der Grund ist wichtig zu verstehen, bevor Sie darum herum entwickeln. Eine einzelne TPDFlib-Instanz ist für die Verwendung in einem einzelnen Thread deklariert. Die kritische Stelle ist TPDFPageTree.GetPage: Als Nebeneffekt der Auswahl einer Seite schreibt sie in ein gemeinsam genutztes FPagePointer-Feld der Instanz. Zwei Threads, die dieselbe Instanz aufrufen, erzeugen ein Wettlaufszenario (Race Condition) auf diesem Feld, sodass Worker A gerade auf Seite 3 arbeiten kann, während Worker B den Seitenbaum bereits auf Seite 40 umstellt. Nichts an der API hindert Sie daran, den folgenden Code zu schreiben, und er wird sogar für einige Seiten laufen, bevor er abstürzt. Dies ist die schlimmste Art, wie sich ein solcher Fehler verhalten kann
// DO NOT do this: one shared instance, many threads
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
Pdf.LoadFromFile('report.pdf', '');
TParallel.For(1, Pdf.PageCount,
procedure(Page: Integer)
begin
// every thread reenters the same instance -> data race on FPagePointer
Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
end);
Pdf.Free;
end;
Der Fehler tritt nicht deterministisch auf, was genau der Grund ist, warum er einen schnellen Rauchtest übersteht und dann auf einem Kundensystem mit einer anderen Kernanzahl und einem schwereren Dokument zutage tritt. Es gibt auch keine Sperre, die Sie um RenderPageToFile legen können, um das Problem einfach zu beheben. Denn das Halten eines Mutex über den gesamten Renderaufruf hinweg würde die Arbeit serialisieren und die Parallelität zunichte machen, wegen der Sie überhaupt gekommen sind
Warum benötigt jeder Render-Worker seine eigene TPDFlib-Instanz?
Weil die Instanz die Einheit der Isolation darstellt. Sobald jeder Worker eine private TPDFlib-Instanz besitzt, die die Datei unabhängig geladen hat, verfügt jeder über einen eigenen Seitenbaum, einen eigenen FPagePointer und einen eigenen Renderzustand. Somit gibt es keine gemeinsamen Daten, die zu Wettläufen führen könnten. Diese Sicherheit hat einen Preis, den Sie im Voraus kalkulieren sollten: Jeder Worker parst das gesamte Dokument im Arbeitsspeicher, sodass die maximale Speichernutzung etwa das N-Fache der Kosten einer einzelnen Instanz beträgt. Acht Worker auf einer 300 MB großen PDF-Datei bedeuten acht vollständige, gleichzeitig im Speicher befindliche Analysen. Bei sehr großen Eingabedaten ist dies die Einschränkung, die über Ihre Worker-Anzahl entscheidet, nicht die CPU. Wenn das Dokument riesig ist und Sie an Speichergrenzen stoßen, ist der direkte Zugriffspfad, wie im Artikel Verarbeitung großer PDF-Dateien ohne vollständige Dokumentanalyse beschrieben, oft das bessere Mittel als mehr Render-Threads
Die All-in-One-API: RenderPagesToFilesParallel
Die losLab PDF Library kapselt das gesamte sichere Muster hinter einer einzigen Methode, sodass Sie für den Regelfall nichts davon manuell implementieren müssen. RenderPagesToFilesParallel benötigt den Dateinamen und das Passwort, einen DPI-Wert, eine inklusive Start- und Endseite, einen Options-Wert, der direkt an den Rasterpfad pro Seite übergeben wird, ein Ausgabemuster, bei dem %p durch die Seitennummer ersetzt wird, und eine Worker-Obergrenze (wobei jeder Wert kleiner oder gleich null als automatische Erkennung gewertet wird). Sie gibt die Anzahl der erfolgreich gerenderten Seiten zurück und ist ein reiner Windows-Pfad, da sie auf CoInitialize und GDI+ aufbaut
var
Pdf: TPDFlib;
Rendered: Integer;
begin
Pdf := TPDFlib.Create;
try
// FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
Rendered := Pdf.RenderPagesToFilesParallel(
'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
// MaxWorkers = 0 -> auto: min(page count, CPU cores)
WriteLn(Format('%d pages rendered', [Rendered]));
finally
Pdf.Free;
end;
end;
Warum CoInitialize für jeden Worker-Thread?
GDI+ ist der zugrunde liegende Rasterizer für das Seitenrendering, und GDI+ arbeitet nach dem Apartment-Thread-Modell: Es erwartet, dass COM in dem Thread initialisiert ist, der es aufruft. Der Hauptthread einer VCL-Anwendung hat dies normalerweise bereits eingerichtet, aber ein frisch erzeugter TTask-Worker tut dies nicht. Der Aufruf des Renderpfads von einem nicht initialisierten Thread aus führt zuverlässig zu Abstürzen. Daher paart jeder Worker ein CoInitialize(nil) beim Start mit einem CoUninitialize beim Beenden, um seine gesamte Lebensdauer zu umklammern. Dies ist dieselbe Disziplin, die jede GDI+- oder COM-Arbeit außerhalb des Hauptthreads erfordert. Es ist die zweite Hälfte dessen, was die Isolation pro Worker stabil macht — die erste Hälfte ist die private Instanz. Derselbe GDI+-Rasterpfad steuert die Single-Thread-Engines, die unter Auswahl einer Rendering-Engine für PDF-Ausgaben behandelt werden
Statische Partitionierung versus dynamische Seitenanforderung
Der offensichtliche Weg, 500 Seiten auf 8 Worker aufzuteilen, besteht darin, jedem eine feste Portion von etwa 62 Seiten zuzuweisen. Die losLab PDF Library tut das nicht, und der Grund dafür ist die Lastverteilung. Die Kosten pro Seite variieren drastisch: Eine Seite mit reinem Text wird in Millisekunden gerendert, während eine Seite mit dichten Vektorgrafiken oder einem vollflächig gescannten Bild fünfzigmal länger dauern kann. Teilen Sie die Arbeit in feste Slices auf, läuft der Worker mit der schwersten Portion noch lange nach den anderen, sodass Ihre Gesamtzeit vom langsamsten Slice bestimmt wird und nicht vom Durchschnitt. Stattdessen fordert jeder Worker die nächste Seite von einem gemeinsamen Zähler unter einem kurzen kritischen Abschnitt an, rendert sie und holt sich die nächste. So bleibt jeder Kern beschäftigt, bis der gesamte Bereich abgearbeitet ist
// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
procedure
var
LocalLib: TPDFlib;
PageNum: Integer;
begin
CoInitialize(nil); // GDI+ is apartment-threaded
try
LocalLib := TPDFlib.Create; // one private instance per worker
try
LocalLib.LoadFromFile(FileName, '');
while True do
begin
IdxLock.Enter; // claim the next page atomically
try
PageNum := NextPage;
Inc(NextPage);
finally
IdxLock.Leave;
end;
if PageNum > EndPage then Break;
LocalLib.RenderPageToFile(DPI, PageNum, 0,
Format('page_%d.png', [PageNum]));
end;
finally
LocalLib.Free;
end;
finally
CoUninitialize;
end;
end;
Strukturiertes Logging über Worker-Threads hinweg
Das Debuggen eines Batch-Laufs, der auf Seite 213 von 500 fehlschlägt, ist ohne Protokoll extrem mühsam, und ein unvorsichtiges Logging kann selbst zu einem Nebenläufigkeitsproblem werden. Die losLab PDF Library liefert TPDFlibLogger mit, der über die Eigenschaft TPDFlib.Logger eingebunden wird und standardmäßig nil ist, damit der Betrieb ohne Logger keine Kosten verursacht. Die Klasse arbeitet nach dem Callback-Prinzip: Sie richten OnLog ein und leiten Datensätze dorthin weiter, wo die Host-Anwendung sie benötigt, gefiltert nach den Stufen llDebug / llInfo / llWarn / llError. PDFlibErrorMessage übersetzt die rohen numerischen Codes in lesbaren Text, sodass ein Error-Datensatz als mehr als nur eine bloße Ganzzahl ausgegeben wird. Der optionale Dateischreiber ist die einzige gemeinsam genutzte Ressource und wird präzise durch eine TCriticalSection geschützt, damit mehrere Worker sicher in eine einzige Logdatei schreiben können. Beachten Sie die ehrliche Grenze: Nur der Dateischreiber ist synchronisiert. Wenn Sie also einen gemeinsamen Logger über einen selbstgebauten Pool nutzen und Ihr OnLog die Benutzeroberfläche berührt, müssen Sie diesen Aufruf weiterhin selbst in den Hauptthread leiten
var
Pdf: TPDFlib;
Log: TPDFlibLogger;
begin
Log := TPDFlibLogger.Create;
Log.Level := llInfo; // llDebug, llInfo, llWarn, llError
Log.FileName := 'render.log'; // optional shared sink (lock-guarded)
Log.OnLog :=
procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
begin
if Level = llError then
// marshal to the UI thread yourself; OnLog fires on worker threads
WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
end;
Pdf := TPDFlib.Create;
Pdf.Logger := Log; // nil by default; zero-cost when unset
try
Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
'out\page_%p.png', 0);
// an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
finally
Pdf.Free;
Log.Free;
end;
end;
Mit wie viel Geschwindigkeitszuwachs sollten Sie tatsächlich rechnen?
Seien Sie ehrlich zu sich selbst, wo die meiste Zeit verbracht wird, denn paralleles Rendern zahlt sich nur dort aus, wo die Arbeit wirklich CPU-gebunden ist. Ausgaben mit hoher Auflösung (DPI) und komplexe Vektor- oder schattierte Seiten sind rechenintensiv und skalieren nahezu linear mit der Kernanzahl, bis die CPU ausgelastet ist. Bei einfachen Seiten sieht es anders aus: Hier können der Overhead für das Laden der Datei pro Worker (LoadFromFile) und die Festplattenlaufzeit zum Schreiben der Ausgabedateien das eigentliche Rendern überschatten. Acht Worker, die auf einer einzigen langsamen Festplatte konkurrieren, können langsamer abschließen als eine saubere serielle Schleife. Setzen Sie MaxWorkers auf Ihre Anzahl an physischen Kernen und nicht auf einen utopischen Wert, behalten Sie bei großen PDF-Dateien den Arbeitsspeicher im Auge, und falls sich ein Batch-Lauf als I/O-gebunden herausstellt, liegt die Lösung in schnellerem Speicher oder weniger Workern, nicht in mehr Threads. Richtig eingesetzt für die Aufgaben, für die er gebaut wurde, ist der hier gezeigte Batch-Render-Pfad Teil der standardmäßigen losLab PDF Library für Delphi und C++Builder. Er verwandelt ungenutzte CPU-Kerne in fertige Seiten — ganz ohne die Threadsicherheitsfallen, die Sie sonst selbst lösen müssten