Het parallel renderen van PDF-pagina's vanuit Delphi komt neer op één regel: geef elke worker-thread zijn eigen renderer. losLab PDF Library biedt RenderPagesToFilesParallel voor precies die taak, waarbij een paginabereik wordt verdeeld over een TTask-pool met één TPDFlib-instantie per worker, zodat een multi-core machine een batch-rastertaak omzet in een doorvoer die dicht bij het aantal cores schaalt. Als u in plaats daarvan één enkele instantie over threads deelt, vertraagt de uitvoering niet geleidelijk, maar raakt het geheugen corrupt en crasht de applicatie
Dit is het artikel waar u naar grijpt wanneer een nachtelijke taak een PDF van 500 pagina's moet omzetten in 500 PNGs, de server 16 ongebruikte cores heeft en uw eerste eerlijke poging om dit te threaden vastliep binnen GDI+. De korte versie is dat draadveiligheid hier een structurele eigenschap is, niet een vlag die u instelt, en de rest van dit artikel legt uit waarom de veilige vorm er zo uitziet en waar het werkelijke plafond voor snelheidsverhoging ligt
Is TPDFlib draadveilig voor parallelle rendering?
Nee, en de reden is de moeite waard om te begrijpen voordat u eromheen ontwerpt. Een enkele TPDFlib-instantie is gedeclareerd voor single-threaded gebruik, en de scherpe rand is TPDFPageTree.GetPage: deze schrijft een gedeeld FPagePointer-veld op de instantie als een bijwerking van het selecteren van een pagina. Twee threads die dezelfde instantie aanroepen racen om dat veld, zodat worker A halverwege pagina 3 kan zijn wanneer worker B de paginaboom opnieuw verwijst naar pagina 40. Niets aan de API verhindert u om de onderstaande code te schrijven, en deze zal zelfs een paar pagina's draaien voordat hij faalt, wat de slechtste manier is voor een dergelijke bug om zich te manifesteren
// 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;
De failure is niet deterministisch, wat precies is waarom het een snelle rooktest overleeft en vervolgens opduikt op een machine van een klant met een ander aantal cores en een zwaarder document. Er is ook geen lock die u om RenderPageToFile heen kunt wikkelen om dit goedkoop op te lossen, omdat het vasthouden van een mutex over de hele render-aanroep het werk serialiseert en de parallelliteit weggooit waarvoor u kwam
Waarom heeft elke render-worker zijn eigen TPDFlib-instantie nodig?
Omdat de instantie de eenheid van isolatie is. Zodra elke worker een eigen TPDFlib bezit die het bestand onafhankelijk heeft geladen, heeft elke worker zijn eigen paginaboom, zijn eigen FPagePointer en zijn eigen renderstatus, zodat er niets gedeeld is om om te racen. Die veiligheid heeft een prijs die u vooraf moet inschatten: elke worker parseert het hele document in het geheugen, dus de piekvoetafdruk is ongeveer N keer de kosten van een enkele instantie. Acht workers op een PDF van 300 MB betekent acht volledige parses die tegelijkertijd in het geheugen aanwezig zijn, en bij zeer grote invoer is dat de beperking die uw aantal workers bepaalt, niet de CPU. Wanneer het document enorm is en u eerder geheugengebonden bent dan CPU-gebonden, is de directe-toegangsweg in grote PDF's verwerken zonder parsing van het volledige document vaak een betere hefboom dan meer render-threads
De API voor één aanroep: RenderPagesToFilesParallel
losLab PDF Library verpakt het hele veilige patroon achter een enkele methode, zodat u er voor het algemene geval niets zelf van hoeft te schrijven. RenderPagesToFilesParallel neemt de bestandsnaam en het wachtwoord, een DPI, een inclusieve begin- en eindpagina, een Options-waarde die rechtstreeks wordt doorgegeven aan het rasterpad per pagina, een uitvoerpatroon waarbij %p wordt vervangen door het paginanummer, en een worker-limiet waarbij elke waarde op of onder nul auto betekent. Het retourneert het aantal succesvol gerenderde pagina's, en het is een Windows-only pad omdat het leunt op CoInitialize en GDI+
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;
Waarom CoInitialize op elke worker-thread?
GDI+ is de rasterizer onder paginarendering, en GDI+ is apartment-threaded: het verwacht dat COM is geïnitialiseerd op de thread die het aanroept. De hoofdthread van een VCL-app heeft dat meestal al ingesteld, maar een nieuw aangemaakte TTask-worker heeft dat niet, en het aanroepen van het renderpad vanuit een niet-geïnitialiseerde thread is een betrouwbare manier om te crashen. Dus elke worker koppelt een CoInitialize(nil) bij binnenkomst aan een CoUninitialize bij het verlaten, wat de hele levensduur omkadert. Dit is dezelfde discipline die elk GDI+- of COM-werk buiten de hoofdthread nodig heeft, en it is de tweede helft van wat de isolatie per worker daadwerkelijk in stand houdt, waarbij de eerste helft de privé-instantie is. Hetzelfde GDI+-rasterpad stuurt de single-threaded engines aan die worden behandeld in een rendering-engine kiezen voor PDF-uitvoer
Statische sharding versus dynamische paginaclaim
De voor de hand liggende manier om 500 pagina's over 8 workers te verdelen, is om elk een vast deel van ongeveer 62 pagina's te geven. losLab PDF Library doet dat niet, en the reden is de lastverdeling. De kosten per pagina variëren enorm: een pagina met platte tekst rendert in milliseconden, een pagina met dichte vectorkaarten of een volledig gescande afbeelding kan vijftig keer langer duren. Verdeel het werk in vaste delen en de worker die toevallig het zware deel trekt, draait nog lang nadat de anderen inactief zijn geworden, zodat uw totale doorlooptijd wordt bepaald door het meest ongelukkige deel, niet door het gemiddelde. In plaats daarvan claimt elke worker de volgende pagina van een gedeelde teller onder een korte kritieke sectie, rendert deze en komt terug voor een volgende, waardoor elke core bezig blijft totdat het hele bereik is leeggemaakt
// 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;
Gestructureerd loggen over worker-threads
Het debuggen van een batch die sterft op pagina 213 van de 500 is ellendig zonder een logboek, en a naïef logboek is zijn eigen gelijktijdigheidsbug. losLab PDF Library levert TPDFlibLogger, gekoppeld via de eigenschap TPDFlib.Logger en standaard nil, zodat het pad zonder logboek kosteloos blijft. Het is callback-first: u stelt OnLog in en stuurt records naar waar de host dat wil, gefilterd op een llDebug- / llInfo- / llWarn- / llError-niveau, en PDFlibErrorMessage zet de ruwe numerieke codes om in menselijke tekst, zodat een Error-record als meer dan een kaal getal leest. De optionele bestandsafvoer is de enige gedeelde bron, en deze wordt bewaakt door een TCriticalSection juist zodat verschillende workers veilig aan één logbestand kunnen toevoegen. Let op de eerlijke grens: alleen die bestandsafvoer is gesynchroniseerd, dus als u één logger deelt over een zelfgemaakte pool en uw OnLog de UI raakt, moet u dat nog steeds zelf terugsturen naar de hoofdthread
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;
Hoeveel versnelling mag u eigenlijk verwachten?
Wees eerlijk tegen uzelf over waar de tijd naartoe gaat, want parallel renderen loont alleen waar het werk echt CPU-gebonden is. Uitvoer met een hoge DPI en complexe vector- of schaduwpagina's zijn rekenintensief, en deze schalen bijna lineair met het aantal cores totdat u de CPU verzadigt. Eenvoudige pagina's zijn een ander verhaal: daar kan de overhead van LoadFromFile per worker, plus de schrijf-kosten op de schijf voor het schrijven van de uitvoerbestanden, de rendering zelf overschaduwen, en acht workers die op één trage schijf tekeergaan, kunnen langzamer eindigen dan een schone seriële lus. Stel MaxWorkers in op uw fysieke aantal cores in plaats van iets ambitieus, let op het geheugen wanneer de bron-PDF groot is, en als een batch IO-gebonden blijkt te zijn, is de oplossing snellere opslag of minder workers, niet meer threads. Gebruikt voor de taken waarvoor het is gebouwd, maakt het hier getoonde batch-renderpad deel uit van de standaard losLab PDF Library voor Delphi en C++Builder, en zet het ongebruikte cores om in voltooide pagina's zonder de valkuilen van draadveiligheid die u anders zelf zou moeten oplossen