Paralelno renderovanje PDF stranica iz Delphi-ja svodi se na jedno pravilo: dajte svakoj radnoj niti (worker thread) sopstveni renderer. losLab PDF biblioteka izlaže metodu RenderPagesToFilesParallel upravo za taj posao, raspoređujući opseg stranica preko TTask bazena (pool) sa jednom instancom TPDFlib po radnoj niti, tako da višejezgrana mašina pretvara grupni rasterski posao u propusnu moć koja se skalira blizu broja jezgara. Deljenje jedne instance između niti, nasuprot tome, ne usporava rad elegantno, već korumpira memoriju i ruši aplikaciju
Ovo je članak koji tražite kada noćni posao mora da pretvori PDF od 500 stranica u 500 PNG datoteka, mašina ima 16 jezgara koja stoje besposlena, a vaš prvi pošteni pokušaj uvođenja niti je propao unutar GDI+ biblioteke. Ukratko, bezbednost niti (thread safety) je ovde strukturno svojstvo, a ne zastavica koju postavljate, a ostatak ovog teksta vodi kroz to zašto bezbedan oblik izgleda tako kako izgleda i gde se zapravo nalazi stvarni plafon ubrzanja
Da li je TPDFlib bezbedan za paralelno renderovanje?
Ne, i razlog za to vredi razumeti pre nego što dizajnirate oko njega. Jedna instanca TPDFlib je deklarisana za jednonitnu upotrebu, a oštra ivica je metoda TPDFPageTree.GetPage: ona upisuje deljeno polje FPagePointer na instanci kao sporedni efekat selekcije stranice. Dve niti koje pozivaju istu instancu se takmiče (race condition) za to polje, tako da radna nit A može biti na pola puta kroz stranicu 3 kada radna nit B ponovo usmeri stablo stranica na stranicu 40. Ništa u API-ju vas ne sprečava da napišete dole prikazani kod, i on će čak i raditi na nekoliko stranica pre nego što pukne, što je najgori način na koji se ovakav bag može ponašati
// 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;
Ovaj neuspeh nije deterministički, što je upravo razlog zašto preživljava brzi test pokretanja, a zatim izbija na površinu na mašini klijenta sa drugačijim brojem jezgara i težim dokumentom. Takođe, ne postoji blokada (lock) kojom možete omotati RenderPageToFile da biste to jeftino rešili, jer držanje mutex-a kroz ceo poziv renderovanja serijalizuje rad i odbacuje paralelizam koji ste želeli da postignete
Zašto je svakoj radnoj niti potrebna sopstvena instanca TPDFlib-a?
Zato što instanca jeste jedinica izolacije. Kada svaka radna nit poseduje privatni TPDFlib koji je nezavisno učitao datoteku, svaka ima sopstveno stablo stranica, sopstveni FPagePointer i sopstveno stanje renderovanja, tako da nema ničeg deljenog oko čega bi se niti takmičile. Ta bezbednost ima cenu koju treba proceniti unapred: svaka radna nit učitava i parsira ceo dokument u memoriju, tako da je vršno zauzeće memorije otprilike N puta veće od cene jedne instance. Osam radnih niti na PDF-u od 300 MB znači osam punih parsiranja u memoriji odjednom, i na veoma velikim ulazima to je ograničenje koje odlučuje o broju radnih niti, a ne CPU. Kada je dokument ogroman i kada ste ograničeni memorijom pre nego procesorom, putanja direktnog pristupa opisana u članku o obradi velikih PDF-ova bez parsiranja celog dokumenta često je bolja poluga od uvođenja više niti za renderovanje
API sa jednim pozivom: RenderPagesToFilesParallel
losLab PDF biblioteka pakuje ceo bezbedan obrazac iza jedne metode, tako da za uobičajene slučajeve ne morate ništa ručno da pišete. Metoda RenderPagesToFilesParallel uzima ime datoteke i lozinku, DPI, početnu i krajnju stranicu (inkruzivno), vrednost Options koja se prosleđuje direktno u rastersku putanju po stranici, izlazni šablon gde se %p zamenjuje brojem stranice i maksimalni broj niti gde bilo koja vrednost jednaka ili manja od nule znači automatsko određivanje. Ona vraća broj uspešno renderovanih stranica, i to je putanja rezervisana samo za Windows jer se oslanja na CoInitialize i 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;
Zašto pozivati CoInitialize na svakoj radnoj niti?
GDI+ je rasterizer koji se nalazi ispod renderovanja stranice, a GDI+ je apartmanski nitovan (apartment-threaded): on očekuje da COM bude inicijalizovan na niti koja ga poziva. Glavna nit VCL aplikacije obično to već ima podešeno, ali sveže pokrenuta radna nit klase TTask nema, i pozivanje putanje renderovanja iz neinicijalizovane niti je siguran način za rušenje aplikacije. Zato svaka radna nit uparuje poziv CoInitialize(nil) na ulazu sa CoUninitialize na izlazu, uokvirujući čitav svoj životni vek. To je ista disciplina koju zahteva bilo koji rad sa GDI+ ili COM tehnologijama van glavne niti, i to je druga polovina onoga što omogućava izolaciju po radnoj niti, dok je prva polovina privatna instanca. Isti rasterski put GDI+ pokreće jednonitne mehanizme pokrivene u članku o izboru mehanizma za renderovanje za PDF izlaz
Statičko deljenje (sharding) naspram dinamičkog preuzimanja stranica
Najočigledniji način da se 500 stranica podeli na 8 radnih niti je da se svakoj preda fiksni deo od oko 62 stranice. losLab PDF biblioteka to ne radi, a razlog je balansiranje opterećenja. Cena renderovanja stranice drastično varira: stranica običnog teksta se renderuje u milisekundama, dok stranica sa gustim vektorskim mapama ili skenirana slika bez margina (full-bleed) može trajati pedeset puta duže. Podelite posao na fiksne delove i radna nit kojoj zapadne težak deo radiće dugo nakon što druge niti pređu u stanje mirovanja, pa je vaše vreme izvršenja određeno najnesrećnijim delom, a ne prosekom. Umesto toga, svaka radna nit preuzima sledeću stranicu iz zajedničkog brojača pod kratkom kritičnom sekcijom, renderuje je i vraća se po sledeću, što drži svako jezgro zaposlenim dok se ceo opseg ne isprazni
// 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;
Strukturirano logovanje kroz radne niti
Debagovanje grupnog posla koji pukne na 213. stranici od 500 je nemoguće bez logovanja, a jednostavno logovanje je sam po sebi bag konkurentnosti. losLab PDF biblioteka isporučuje klasu TPDFlibLogger, koja se povezuje preko svojstva TPDFlib.Logger i podrazumevano je nil, tako da putanja bez logera ostaje bez ikakvih troškova. Ona radi po principu povratnog poziva (callback-first): podešavate događaj OnLog i usmeravate zapise gde god host želi, filtrirane nivoima llDebug / llInfo / llWarn / llError, a funkcija PDFlibErrorMessage pretvara sirove numeričke kodove u čitljiv tekst, tako da zapis o grešci Error predstavlja više od samog broja. Opcioni upis u datoteku (file sink) je jedini deljeni resurs i zaštićen je kritičnom sekcijom TCriticalSection upravo da bi više radnih niti moglo bezbedno da piše u istu datoteku. Obratite pažnju na jasnu granicu: samo je taj upis u datoteku sinhronizovan, pa ako delite jedan logger kroz sopstveni bazen niti i vaš OnLog dodiruje korisnički interfejs (UI), i dalje morate sami da maršalujete te podatke nazad na glavnu nit
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;
Koliko ubrzanje zapravo možete očekivati?
Budite iskreni prema sebi o tome gde odlazi vreme, jer se paralelno renderovanje isplati samo tamo gde je posao zaista ograničen procesorom (CPU-bound). Izlaz sa visokim DPI-jem i složene vektorske ili osenčene stranice su računarski zahtevni, i oni se skaliraju skoro linearno sa brojem jezgara dok ne zasitite procesor. Jednostavne stranice su druga priča: tu opterećenje poziva LoadFromFile po radnoj niti, plus cena pisanja izlaznih datoteka na disk, mogu nadmašiti samo renderovanje, a osam radnih niti koje opterećuju jedan spor disk mogu završiti sporije od čiste serijske petlje. Postavite MaxWorkers na stvarni broj fizičkih jezgara umesto na neku željenu vrednost, pratite memoriju kada je izvorni PDF veliki, a ako se ispostavi da je grupni posao ograničen ulazom/izlazom (IO-bound), rešenje je brži skladišni prostor ili manje radnih niti, a ne više niti. Kada se koristi za poslove za koje je napravljen, grupni put renderovanja prikazan ovde deo je standardne losLab PDF biblioteke za Delphi i C++Builder, i pretvara slobodna jezgra u završene stranice bez ijedne zamke bezbednosti niti koje biste inače morali sami da rešavate