Randarea paginilor PDF în paralel în Delphi se rezumă la o singură regulă: oferiți fiecărui fir de execuție worker propriul său generator de randare. losLab PDF Library expune funcția RenderPagesToFilesParallel exact pentru această sarcină, distribuind un interval de pagini într-un pool TTask cu o singură instanță TPDFlib pentru fiecare worker, astfel încât o mașină multi-core transformă o lucrare de rasterizare în masă într-un debit care se scalează aproape de numărul de nuclee (cores). Partajarea unei singure instanțe între firele de execuție, în schimb, nu reduce performanța treptat, ci corupe memoria și provoacă blocarea aplicației
Acesta este articolul pe care îl citiți când o sarcină programată nocturnă trebuie să transforme un PDF de 500 de pagini în 500 de imagini PNG, serverul are 16 nuclee neutilizate, iar prima dvs. încercare reală de execuție paralelă s-a blocat în GDI+. Varianta scurtă este că siguranța firelor de execuție (thread safety) în acest caz este o proprietate structurală, nu o setare pe care o activați, iar restul acestui articol explică de ce structura sigură are această formă și unde se află de fapt limita reală de accelerare a vitezei
Este TPDFlib sigur pentru firele de execuție în randarea paralelă?
Nu, iar motivul merită înțeles înainte de a proiecta în jurul acestuia. O singură instanță TPDFlib este declarată pentru utilizare pe un singur fir de execuție, iar punctul critic este TPDFPageTree.GetPage: aceasta scrie un câmp partajat FPagePointer pe instanță ca efect secundar al selectării unei pagini. Două fire de execuție care apelează aceeași instanță concurează pentru acel câmp (data race), astfel încât workerul A poate fi la jumătatea paginii 3 când workerul B redirecționează arborele de pagini către pagina 40. Nimic din API nu vă împiedică să scrieți codul de mai jos, iar acesta va rula chiar pentru câteva pagini înainte de a genera o eroare, ceea ce este cel mai periculos mod de manifestare pentru un astfel de bug
// 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 ce are nevoie fiecare worker de randare de propria instanță TPDFlib?
Deoarece instanța este unitatea de izolare. Odată ce fiecare worker deține o instanță privată TPDFlib care a încărcat fișierul independent, fiecare are propriul său arbore de pagini, propriul său FPagePointer și propria stare de randare, astfel încât nu există elemente partajate care să genereze conflicte. Această siguranță are un preț pe care ar trebui să-l evaluați de la început: fiecare worker analizează întregul document în memorie, deci amprenta de vârf a memoriei este de aproximativ N ori costul unei singure instanțe. Opt workeri pe un PDF de 300 MB înseamnă opt analize complete rezidente simultan în memorie, iar pe date de intrare mari aceasta este constrângerea care decide numărul de workeri, nu procesorul. Când documentul este uriaș și sunteți limitat de memorie mai degrabă decât de CPU, calea de acces direct descrisă în procesarea PDF-urilor mari fără analizarea completă a documentului reprezintă adesea o opțiune mai bună decât creșterea numărului de fire de randare
API-ul cu un singur apel: RenderPagesToFilesParallel
losLab PDF Library împachetează întregul model sigur în spatele unei singure metode, astfel încât, în cazurile comune, nu trebuie să scrieți cod manual pentru acestea. RenderPagesToFilesParallel primește numele fișierului și parola, un DPI, paginile de început și de sfârșit, o valoare Options transmisă direct către calea de rasterizare per pagină, un șablon de ieșire în care %p este înlocuit cu numărul paginii și o limită de workeri unde orice valoare mai mică sau egală cu zero înseamnă auto. Aceasta returnează numărul de pagini randate cu succes și este o cale disponibilă doar pe Windows deoarece se bazează pe 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;
De ce este necesar CoInitialize pe fiecare fir de execuție worker?
GDI+ este motorul de rasterizare de sub randarea paginilor, iar GDI+ funcționează în mod apartment-threaded: se așteaptă ca COM să fie inițializat pe orice fir care îl apelează. Firul principal al unei aplicații VCL are de obicei acest lucru configurat deja, dar un worker TTask nou creat nu îl are, iar apelarea căii de randare de pe un fir neinițializat este o modalitate sigură de a bloca aplicația. Astfel, fiecare worker asociază un apel CoInitialize(nil) la intrare cu un CoUninitialize la ieșire, încadrându-și întreaga durată de viață. Aceasta este aceeași regulă pe care o necesită orice activitate GDI+ sau COM în afara firului principal și reprezintă a doua componentă ce asigură izolarea per worker, prima fiind instanța privată. Aceeași cale de rasterizare GDI+ controlează motoarele cu un singur fir descrise în alegerea unui motor de randare pentru ieșirea PDF
Partajarea statică (sharding) versus alocarea dinamică a paginilor
Modul evident de a împărți 500 de pagini la 8 workeri este să oferiți fiecăruia o porțiune fixă de aproximativ 62 de pagini. losLab PDF Library nu face acest lucru, iar motivul este echilibrarea sarcinii (load balancing). Timpul de randare al unei pagini variază enorm: o pagină de text simplu se randează în câteva milisecunde, în timp ce o pagină cu hărți vectoriale dense sau o imagine scanată completă poate dura de cincizeci de ori mai mult. Împărțiți munca în porțiuni fixe și workerul care randează porțiunea cea mai grea va rula mult timp după ce ceilalți au terminat, astfel încât timpul total va fi stabilit de cea mai lentă porțiune, nu de medie. În schimb, fiecare worker revendică următoarea pagină dintr-un contor partajat protejat de o secțiune critică scurtă, o randează și revine pentru alta, ceea ce menține fiecare nucleu ocupat până când întregul interval este finalizat
// 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;
Logarea structurată pe firele de execuție worker
Depanarea unui lot care se oprește la pagina 213 din 500 este extrem de dificilă fără un jurnal (log), iar un jurnal simplist poate genera propriul său bug de concurență. losLab PDF Library include TPDFlibLogger, atașat prin proprietatea TPDFlib.Logger și având valoarea implicită nil, astfel încât utilizarea fără jurnal să nu aibă costuri suplimentare. Acesta utilizează callback-uri: configurați OnLog și direcționați înregistrările unde dorește gazda, filtrate după nivelurile llDebug / llInfo / llWarn / llError, iar PDFlibErrorMessage transformă codurile numerice brute în text inteligibil, astfel încât o înregistrare de tip Error să însemne mai mult decât un simplu număr. Fișierul de destinație opțional este singura resursă partajată și este protejat de un TCriticalSection tocmai pentru ca mai mulți workeri să poată scrie în siguranță în același fișier de jurnal. Rețineți limita reală: doar scrierea în fișier este sincronizată, deci dacă partajați un singur logger într-un pool personalizat iar funcția dvs. OnLog interacționează cu interfața grafică, va trebui totuși să redirecționați acele apeluri către firul principal (main thread) pe cont propriu
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;
La ce accelerare de viteză ar trebui să vă așteptați de fapt?
Fiți realiști cu privire la alocarea timpului, deoarece randarea paralelă merită efortul doar atunci când activitatea este într-adevăr limitată de procesor (CPU-bound). Randarea la DPI ridicat și paginile vectoriale complexe sau cu umbre consumă multă putere de calcul, iar acestea se scalează aproape liniar cu numărul de nuclee până când saturați procesorul. Paginile simple reprezintă o altă poveste: în acest caz, costul general al funcției LoadFromFile per worker, plus costul de scriere a fișierelor de ieșire pe disc, pot depăși randarea în sine, iar opt workeri care suprasolicită un singur disc lent pot termina mai greu decât o buclă serială simplă. Setați MaxWorkers la numărul fizic de nuclee ale procesorului mai degrabă decât la o valoare dorită, monitorizați memoria când PDF-ul sursă este mare, iar dacă un lot se dovedește a fi limitat de I/O, soluția este o stocare mai rapidă sau mai puțini workeri, nu mai multe fire. Folosită pentru sarcinile pentru care a fost creată, calea de randare în masă prezentată aici face parte din varianta standard a losLab PDF Library pentru Delphi și C++Builder, transformând nucleele libere în pagini finalizate, fără capcanele legate de thread safety pe care altfel ar fi trebuit să le rezolvați singuri