Vykreslování stránek PDF paralelně v Delphi se opírá o jediné pravidlo: každému pracovnímu vláknu dejte jeho vlastní vykreslovač. losLab PDF Library vystavuje `RenderPagesToFilesParallel` přesně pro tuto úlohu, kdy rozprostře rozsah stránek mezi fond `TTask` s jednou instancí `TPDFlib` na jednoho pracovníka (worker), takže vícejádrový stroj přemění hromadnou rastrovací úlohu na propustnost škálující se blízko k počtu jader. Sdílejte namísto toho jedinou instanci napříč vlákny a běh se nezpomalí elegantně, ale poškodí paměť a selže
Toto je článek, po kterém sáhnete, když noční úloha musí převést 500stránkové PDF na 500 souborů PNG, stroj má 16 nečinných jader a váš první upřímný pokus o použití vláken selhal uvnitř GDI+. Stručná verze je taková, že vláknová bezpečnost je zde strukturální vlastností, nikoli příznakem, který nastavíte, a zbytek článku vysvětluje, proč bezpečný tvar vypadá tak, jak vypadá, a kde se skutečně nachází strop pro reálné zrychlení
Je TPDFlib bezpečný pro paralelní vykreslování z hlediska vláken?
Ne, a důvod stojí za pochopení předtím, než kolem něj navrhnete architekturu. Jedna instance `TPDFlib` je deklarována pro jednovláknové použití a jejím ostrým rysem je `TPDFPageTree.GetPage`: jako vedlejší účinek výběru stránky zapisuje do sdíleného pole `FPagePointer` v instanci. Dvě vlákna volající stejnou instanci závodí o toto pole, takže pracovník A může být v polovině stránky 3, když pracovník B přesměruje strom stránek na stránku 40. Nic v API vám nebrání v napsání níže uvedeného kódu a ten dokonce i na několik stránek poběží, než selže, což je ten nejhorší způsob, jakým se taková chyba může chovat
// Toto NEDĚLEJTE: jedna sdílená instance, mnoho vláken
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
Pdf.LoadFromFile('report.pdf', '');
TParallel.For(1, Pdf.PageCount,
procedure(Page: Integer)
begin
// každé vlákno znovu vstupuje do stejné instance -> datový závod o FPagePointer
Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
end);
Pdf.Free;
end;
Selhání není deterministické, což je přesně ten důvod, proč přežije rychlý kouřový test a poté se projeví na zákaznickém stroji s jiným počtem jader a těžším dokumentem. Neexistuje ani žádný zámek, kterým byste mohli `RenderPageToFile` levně obalit, protože držení mutexu po celou dobu volání vykreslování práci serializuje a zahodí paralelismus, pro který jste si přišli
Proč každý pracovník vykreslování potřebuje vlastní instanci TPDFlib?
Protože instance je jednotkou izolace. Jakmile každý pracovník vlastní soukromou instanci `TPDFlib`, která načetla soubor nezávisle, má svůj vlastní strom stránek, své vlastní pole `FPagePointer` a svůj vlastní stav vykreslování, takže zde není nic sdíleného, o co by se dalo závodit. Tato bezpečnost má svou cenu, kterou byste si měli předem spočítat: každý pracovník analýzuje celý dokument do paměti, takže špičková paměťová stopa je zhruba N-násobkem nákladů na jednu instanci. Osm pracovníků nad 300MB PDF znamená osm kompletních analýz přítomných v paměti naráz, a u velmi velkých vstupů je to právě toto omezení, které rozhoduje o počtu pracovníků, nikoli CPU. Pokud je dokument obrovský a jste omezeni spíše pamětí než procesorem, je přímá cesta přístupu popsaná v zpracování velkých PDF bez analýzy celého dokumentu často lepším nástrojem než více vláken vykreslování
API na jedno volání: RenderPagesToFilesParallel
losLab PDF Library balí celý bezpečný vzor za jedinou metodu, takže pro běžný případ nemusíte nic z toho psát ručně. `RenderPagesToFilesParallel` přijímá název souboru a heslo, DPI, inkluzivní počáteční a koncovou stránku, hodnotu `Options` předávanou přímo do cesty rastrování jednotlivých stránek, výstupní masku, kde je `%p` nahrazeno číslem stránky, a limit pracovníků, kde jakákoli hodnota rovná nebo menší než nula znamená automatické určení. Vrací počet úspěšně vykreslených stránek a jedná se o cestu určenou výhradně pro Windows, protože se opírá o `CoInitialize` a 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(počet stránek, jádra CPU)
WriteLn(Format('%d pages rendered', [Rendered]));
finally
Pdf.Free;
end;
end;
Proč provádět CoInitialize na každém pracovním vlákně?
GDI+ je rastrovacím modulem pod vykreslováním stránek a GDI+ je apartmánově vláknový (apartment-threaded): očekává, že COM bude inicializován v jakémkoli vlákně, které do něj volá. Hlavní vlákno aplikace VCL to obvykle již nastaveno má, ale nově vytvořené pracovní vlákno `TTask` nikoli, a volání cesty vykreslování z neinicializovaného vlákna je spolehlivým způsobem, jak způsobit pád. Každý pracovník tedy na vstupu spáruje `CoInitialize(nil)` s `CoUninitialize` při ukončení, čímž ohraničí celou svou životnost. Jedná se o stejnou disciplínu, jakou vyžaduje jakákoli práce s GDI+ nebo COM mimo hlavní vlákno, a je to druhá polovina toho, co činí izolaci pro jednotlivé pracovníky skutečně platnou, přičemž první polovinou je soukromá instance. Stejná cesta rastrování GDI+ pohání jednovláknové stroje popsané v výběru vykreslovacího stroje pro výstup PDF
Statické dělení (sharding) versus dynamické nárokování stránek
Zřejmým způsobem, jak rozdělit 500 stránek mezi 8 pracovníků, je předat každému pevný řez o délce přibližně 62 stránek. losLab PDF Library to nedělá a důvodem je vyvážení zátěže. Náklady na stránku se dramaticky liší: stránka s běžným textem se vykreslí v milisekundách, stránka s hustými vektorovými mapami nebo naskenovaný obrázek přes celou plochu může trvat padesátkrát déle. Rozdělte práci na pevné bloky a pracovník, který dostane na starosti těžkou část, poběží dlouho poté, co ostatní již nebudou nic dělat, takže váš reálný čas se bude odvíjet od nejméně šťastného bloku, nikoli od průměru. Místo toho si každý pracovník vyžádá další stránku ze sdíleného počítadla pod krátkou kritickou sekcí, vykreslí ji a vrátí se pro další, což udržuje každé jádro vytížené, dokud se nevyčerpá celý rozsah
// Co každý pracovník dělá uvnitř fondu (zjednodušeno)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
procedure
var
LocalLib: TPDFlib;
PageNum: Integer;
begin
CoInitialize(nil); // GDI+ je apartment-threaded
try
LocalLib := TPDFlib.Create; // jedna soukromá instance na jednoho pracovníka
try
LocalLib.LoadFromFile(FileName, '');
while True do
begin
IdxLock.Enter; // atomicky nárokovat další stránku
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;
Strukturované logování napříč pracovními vlákny
Ladění dávky, která zemře na stránce 213 z 500, je bez logu ubohé a naivní log je sám o sobě chybou souběžnosti. losLab PDF Library dodává `TPDFlibLogger`, připojený přes vlastnost `TPDFlib.Logger` a ve výchozím nastavení nastavený na nil, takže cesta bez logu má nulové náklady. Funguje na principu zpětných volání (callback-first): nastavíte `OnLog` a směrujete záznamy kamkoli hostitel potřebuje, filtrované podle úrovně `llDebug` / `llInfo` / `llWarn` / `llError`, a `PDFlibErrorMessage` převádí surové číselné kódy na lidský text, takže záznam `Error` nese více než jen holé číslo. Volitelný souborový cíl je jediným sdíleným prostředkem a je střežen pomocí `TCriticalSection` právě proto, aby několik pracovníků mohlo bezpečně zapisovat do jednoho logovacího souboru. Vezměte v potaz upřímnou hranici: synchronizován je pouze tento souborový cíl, takže pokud sdílíte jeden logger napříč ručně vytvořeným fondem a vaše `OnLog` se dotýká uživatelského rozhraní, musíte si toto přesměrování zpět do hlavního vlákna zařídit sami
var
Pdf: TPDFlib;
Log: TPDFlibLogger;
begin
Log := TPDFlibLogger.Create;
Log.Level := llInfo; // llDebug, llInfo, llWarn, llError
Log.FileName := 'render.log'; // volitelný sdílený cíl (střežený zámkem)
Log.OnLog :=
procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
begin
if Level = llError then
// marshalujte do vlákna UI sami; OnLog se spouští na pracovních vláknech
WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
end;
Pdf := TPDFlib.Create;
Pdf.Logger := Log; // ve výchozím nastavení nil; nulové náklady, pokud není nastaven
try
Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
'out\page_%p.png', 0);
// chyba nyní nese text, např. 401 -> "Wrong password or permission denied"
finally
Pdf.Free;
Log.Free;
end;
end;
Jak velké zrychlení byste měli ve skutečnosti očekávat?
Buďte k sobě upřímní v tom, kam se čas ztrácí, protože paralelní vykreslování se vyplatí pouze tam, kde je práce skutečně omezena výkonem procesoru (CPU-bound). Výstup ve vysokém rozlišení DPI a složité vektorové nebo stínované stránky jsou výpočetně náročné a škálují se téměř lineárně s počtem jader, dokud nenasytíte procesor. Stránky s triviálním obsahem jsou jiný příběh: tam mohou režijní náklady na `LoadFromFile` pro každého pracovníka plus diskové náklady na zápis výstupních souborů pohltit samotné vykreslování a osm pracovníků zahlcujících jeden pomalý disk může skončit pomaleji než čistá sériová smyčka. Nastavte `MaxWorkers` na počet fyzických jader spíše než na něco ambiciózního, sledujte paměť při velkém zdrojovém PDF, a pokud se ukáže, že dávka je omezena vstupem a výstupem (IO-bound), řešením je rychlejší úložiště nebo méně pracovníků, nikoli více vláken. Při použití na úlohy, pro které byl vytvořen, je zde ukázaná cesta hromadného vykreslování součástí standardní knihovny losLab PDF Library pro Delphi a C++Builder