Paralelné vykresľovanie PDF stránok v Delphi sa riadi jedným pravidlom: poskytnite každému pracovnému vláknu jeho vlastný vykresľovač. Knižnica losLab PDF Library vystavuje metódu RenderPagesToFilesParallel presne na túto úlohu, pričom distribuuje rozsah stránok naprieč fondom (poolom) TTask s jednou inštanciou TPDFlib na jedného pracovníka (worker), takže viacjadrový stroj premení dávkovú úlohu rastrovacieho procesu na priepustnosť, ktorá škáluje blízko k počtu jadier. Ak namiesto toho zdieľate jednu inštanciu medzi vláknami, beh programu sa nespomalí hladko, ale dôjde k poškodeniu pamäte a pádu
Toto je článok, po ktorom siahnete, keď nočná úloha musí previesť 500-stránkové PDF na 500 PNG súborov, server má 16 nečinných jadier a váš prvý seriózny pokus o vytvorenie vlákien zlyhal vo vnútri GDI+. Skrátená verzia je, že bezpečnosť vlákien je tu štrukturálna vlastnosť, nie príznak, ktorý nastavíte, a zvyšok tohto článku prechádza dôvodmi, prečo bezpečný model vyzerá tak, ako vyzerá, a kde sa v skutočnosti nachádza skutočný strop zrýchlenia
Je TPDFlib bezpečný pre vlákna (thread-safe) pri paralelnom vykresľovaní?
Nie a dôvod stojí za pochopenie predtým, než o ňom navrhnete riešenie. Jedna inštancia TPDFlib je deklarovaná pre jednovláknové použitie a kritickým bodom je TPDFPageTree.GetPage: ako vedľajší efekt výberu stránky zapisuje do zdieľaného poľa FPagePointer v inštancii. Dve vlákna pristupujúce k tej istej inštancii zápasia o toto pole, takže pracovník A môže byť v polovici vykresľovania stránky 3, keď pracovník B znova nasmeruje strom stránok na stránku 40. Nič v API vám nebráni napísať kód uvedený nižšie, pričom ten dokonca pobeží na niekoľkých stránkach, kým zlyhá, čo je ten najhorší spôsob správania pre takúto chybu
// 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;
Zlyhanie nie je deterministické, čo je presne dôvod, prečo prežije rýchly základný test (smoke test) a potom sa objaví na počítači zákazníka s iným počtom jadier a zložitejším dokumentom. Neexistuje ani zámok, ktorým by ste mohli obaliť RenderPageToFile na lacné vyriešenie tohto problému, pretože držanie mutexu naprieč celým vykresľovacím volaním by serializovalo prácu a zahodilo paralelizmus, pre ktorý ste prišli
Prečo potrebuje každý vykresľovací pracovník svoju vlastnú inštanciu TPDFlib?
Pretože inštancia je jednotkou izolácie. Hneď ako každý pracovník vlastní súkromnú inštanciu TPDFlib, ktorá načítala súbor nezávisle, každý z nich má vlastný strom stránok, svoje vlastné pole FPagePointer a svoj vlastný stav vykresľovania, takže neexistuje nič zdieľané, o čo by dochádzalo k súbehu. Táto bezpečnosť má však svoju cenu, ktorú by ste si mali vopred prepočítať: každý pracovník spracováva (parsuje) celý dokument do pamäte, takže špičková stopa v pamäti je približne N-násobok nákladov na jednu inštanciu. Osem pracovníkov na 300 MB PDF znamená osem plných spracovaní dokumentov prítomných v pamäti naraz, a pri veľmi veľkých vstupoch je to obmedzenie, ktoré rozhoduje o počte vašich pracovníkov, nie CPU. Keď je dokument obrovský a ste obmedzení pamäťou a nie procesorom, cesta priameho prístupu opísaná v časti spracovanie veľkých PDF súborov bez plného parsovania dokumentu je často lepšou pákou než viac vykresľovacích vlákien
Rozhranie na jedno volanie: RenderPagesToFilesParallel
Knižnica losLab PDF Library bali celý tento bezpečný vzor do jedinej metódy, takže v bežnom prípade nemusíte nič z toho písať ručne. Metóda RenderPagesToFilesParallel prijíma názov súboru a heslo, DPI, inkluzívnu počiatočnú a koncovú stránku, hodnotu Options odovzdanú priamo ceste rastrovacieho procesu na stránku, výstupný vzor, kde je %p is nahradené číslom stránky, a limit pracovníkov, pričom akákoľvek hodnota rovná alebo menšia ako nula znamená auto. Vracia počet úspešne vykreslených stránok a ide o cestu určenú iba pre Windows, pretože sa spolieha na 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(page count, CPU cores)
WriteLn(Format('%d pages rendered', [Rendered]));
finally
Pdf.Free;
end;
end;
Prečo CoInitialize na každom pracovnom vlákne?
GDI+ je rastrovací nástroj pod kapotou vykresľovania stránok a GDI+ je typovaný ako apartment-threaded: očakáva, že COM bude inicializované na akomkoľvek vlákne, ktoré ho volá. Hlavné vlákno aplikácie VCL to zvyčajne už má nastavené, ale čerstvo vytvorený pracovník TTask nie, a volanie vykresľovacej cesty z neinicializovaného vlákna je spoľahlivý spôsob, ako spôsobiť pád. Každý pracovník preto spája CoInitialize(nil) pri vstupe s CoUninitialize pri výstupe, čím ohraničuje celú svoju životnosť. Toto je rovnaká disciplína, akú vyžaduje akákoľvek práca s GDI+ alebo COM mimo hlavného vlákna, a je to druhá polovica toho, čo robí izoláciu pracovníkov skutočne funkčnou (prvou polovicou je súkromná inštancia). Rovnaká rastrovacia cesta GDI+ poháňa jednovláknové enginy opísané v časti výber vykresľovacieho enginu pre výstup PDF
Statické delenie (sharding) verzus dynamické preberanie stránok
Zrejmým spôsobom, ako rozdeliť 500 stránok medzi 8 pracovníkov, je odovzdať každému pevný diel s približne 62 stránkami. Knižnica losLab PDF Library to nerobí a dôvodom je vyváženie zaťaženia (load balance). Náročnosť stránok sa dramaticky líši: stránka so samotným textom tela sa vykreslí za milisekundy, kým stránka s hustými vektorovými mapami alebo naskenovaný obrázok cez celú stranu môže trvať päťdesiatkrát dlhšie. Ak rozdelíte prácu na pevné diely, pracovník, na ktorého vyjde náročný diel, pobeží ešte dlho po tom, čo ostatní už skončili, takže váš celkový čas na dokončenie bude určený tým najpomalším dielom, nie priemerom. Namiesto toho si každý pracovník vyžiada ďalšiu stránku zo zdieľaného počítadla pod krátkou kritickou sekciou, vykreslí ju a vráti sa pre ďalšiu, čo udržuje každé jadro vyťažené, kým sa nevyčerpá celý rozsah
// 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;
Štruktúrované logovanie naprieč pracovnými vláknami
Ladenie dávky, ktorá zomrie na stránke 213 z 500, je bez logu zúfalé a naivný log je sám o sebe chybou súbežnosti. Knižnica losLab PDF Library dodáva TPDFlibLogger, pripojený cez vlastnosť TPDFlib.Logger a predvolene nastavený na nil, takže cesta bez logera zostáva bez dodatočných nákladov. Je založený na callbacku: nastavíte OnLog a smerujete záznamy kamkoľvek hostiteľ vyžaduje, filtrované podľa úrovní llDebug / llInfo / llWarn / llError, a funkcia PDFlibErrorMessage prekladá nespracované číselné kódy na text zrozumiteľný pre ľudí, takže záznam Error čítate ako viac než len holé celé číslo. Voliteľný výstupný súbor (file sink) je jediným zdieľaným prostriedkom a je chránený pomocou TCriticalSection presne preto, aby mohlo niekoľko pracovníkov bezpečne zapisovať do jedného súboru logu. Všimnite si čestné hranice: synchronizovaný je iba tento súborový výstup, takže ak zdieľate jeden loger naprieč ručne zostaveným fondom a vaša udalosť OnLog zasahuje do používateľského rozhrania (UI), musíte si toto prepojenie do hlavného vlákna zabezpečiť sami
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;
Aké zrýchlenie by ste mali v skutočnosti očakávať?
Buďte k sebe úprimní v tom, kam plynie čas, pretože paralelné vykresľovanie sa vyplatí iba vtedy, keď je práca skutočne obmedzená procesorom (CPU-bound). Výstup s vysokým DPI a zložité vektorové alebo tieňované stránky sú náročné na výpočet a tie sa škálujú takmer lineárne s počtom jadier, až kým nenasýtite CPU. Jednoduché stránky sú iný príbeh: tam môže režijný čas LoadFromFile na pracovníka spolu s diskovými nákladmi na zápis výstupných súborov zaplaviť samotné vykresľovanie a osem pracovníkov bojujúcich s jedným pomalým diskom môže skončiť pomalšie ako čistá sériová slučka. Nastavte MaxWorkers na váš fyzický počet jadier a nie na niečo fiktívne, sledujte pamäť, keď je zdrojové PDF veľké, a ak sa ukáže, že dávka je obmedzená vstupno-výstupnými operáciami (IO-bound), riešením je rýchlejšie úložisko alebo menej pracovníkov, nie viac vlákien. Dávková vykresľovacia cesta zobrazená tu, použitá na úlohy, na ktoré bola postavená, je súčasťou štandardnej knižnice losLab PDF Library pre Delphi a C++Builder a premieňa nečinné jadrá na hotové stránky bez akýchkoľvek pascí bezpečnosti vlákien, ktoré by ste inak museli riešiť sami