Równoległe renderowanie stron PDF w środowisku Delphi sprowadza się do jednej zasady: każdy wątek roboczy musi otrzymać własny obiekt renderujący. Biblioteka losLab PDF Library udostępnia do tego zadania metodę RenderPagesToFilesParallel, która rozdziela zakres stron na pulę zadań TTask z jedną instancją TPDFlib na każdy wątek roboczy. Dzięki temu wielordzeniowy procesor zamienia zadanie wsadowego rastrowania w przepustowość skalującą się niemal liniowo z liczbą rdzeni. Udostępnienie jednej instancji wielu wątkom jednocześnie nie powoduje jedynie spowolnienia działania, lecz prowadzi do uszkodzenia pamięci i awarii aplikacji
To jest artykuł, po który sięgniesz, gdy nocne zadanie wsadowe musi przekształcić 500-stronicowy plik PDF w 500 plików PNG, serwer posiada 16 bezczynnych rdzeni, a Twoja pierwsza próba wielowątkowości zakończyła się błędem wewnątrz GDI+. W skrócie: bezpieczeństwo wątkowe jest tutaj cechą strukturalną kodu, a nie przełącznikiem, który można włączyć. W dalszej części wyjaśnimy, dlaczego bezpieczna architektura wygląda w ten sposób i gdzie w rzeczywistości znajduje się limit przyspieszenia
Czy komponent TPDFlib jest bezpieczny wątkowo przy renderowaniu równoległym?
Nie, a przyczyna tego stanu rzeczy jest warta zrozumienia przed zaprojektowaniem architektury. Pojedyncza instancja TPDFlib jest przeznaczona wyłącznie do użytku jednowątkowego, a newralgicznym punktem jest metoda TPDFPageTree.GetPage: jako efekt uboczny wyboru strony zapisuje ona współdzielone pole FPagePointer w instancji. Dwa wątki odwołujące się do tej samej instancji wywołują wyścig (data race) o to pole, przez co wątek roboczy A może być w połowie przetwarzania strony 3, gdy wątek roboczy B przestawi drzewo stron na stronę 40. Interfejs API nie uniemożliwia napisania poniższego kodu, a aplikacja może nawet poprawnie przetworzyć kilka stron przed wystąpieniem błędu, co jest najgorszym możliwym objawem błędu tego typu
// 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;
Błąd nie występuje w sposób deterministyczny, co sprawia, że łatwo przechodzi proste testy dymne i ujawnia się dopiero na maszynie klienta z inną liczbą rdzeni lub przy przetwarzaniu bardziej skomplikowanego dokumentu. Nie ma też prostego sposobu na rozwiązanie tego problemu poprzez zablokowanie wywołania RenderPageToFile za pomocą sekcji krytycznej lub blokady lock, ponieważ blokowanie wątków na czas renderowania całkowicie zniweczyłoby korzyści płynące z wielowątkowości
Dlaczego każdy wątek roboczy potrzebuje własnej instancji TPDFlib?
Ponieważ to instancja stanowi jednostkę izolacji. Gdy każdy wątek roboczy posiada prywatny obiekt TPDFlib, który niezależnie załadował plik, każdy z nich dysponuje własnym drzewem stron, własnym wskaźnikiem FPagePointer oraz własnym stanem renderowania, eliminując współdzielone elementy mogące powodować wyścigi. To bezpieczeństwo ma jednak swoją cenę, którą należy ocenić na wstępie: każdy wątek roboczy analizuje (parsuje) cały dokument do pamięci, co oznacza, że szczytowe zużycie pamięci jest w przybliżeniu N-krotnością kosztu pojedynczej instancji. Osiem wątków roboczych pracujących nad plikiem PDF o rozmiarze 300 MB oznacza osiem pełnych struktur dokumentu w pamięci jednocześnie. Przy bardzo dużych plikach to właśnie pamięć, a nie procesor, określa limit liczby wątków. Gdy dokument jest ogromny i ogranicza Cię pamięć, a nie wydajność procesora, lepszym rozwiązaniem niż dodawanie kolejnych wątków renderujących jest często ścieżka bezpośredniego dostępu opisana w artykule o przetwarzaniu dużych plików PDF bez pełnego parsowania dokumentu
Jednolinijkowe API: RenderPagesToFilesParallel
Biblioteka losLab PDF Library opakowuje cały ten bezpieczny wzorzec w jedną metodę, dzięki czemu w typowych sytuacjach nie musisz pisać tego kodu ręcznie. Metoda RenderPagesToFilesParallel przyjmuje nazwę pliku i hasło, rozdzielczość DPI, początkowy i końcowy numer strony (włącznie), wartość Options przekazywaną bezpośrednio do ścieżki rastrowania pojedynczej strony, szablon nazwy pliku wyjściowego (gdzie %p jest zastępowane numerem strony) oraz limit wątków roboczych (gdzie wartość równa lub mniejsza od zera oznacza automatyczny dobór). Metoda zwraca liczbę pomyślnie wyrenderowanych stron i jest dostępna wyłącznie w systemie Windows, ponieważ opiera się na CoInitialize oraz 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;
Dlaczego CoInitialize na każdym wątku roboczym?
GDI+ to silnik rastrujący używany pod maską renderowania stron, który działa w modelu apartment-threaded: oczekuje inicjalizacji technologii COM na każdym wątku, który go wywołuje. Główny wątek aplikacji VCL zazwyczaj ma to już skonfigurowane, lecz nowo utworzony wątek roboczy TTask nie, a wywołanie ścieżki renderowania z niezinicjalizowanego wątku to sprawdzony sposób na awarię programu. Dlatego każdy wątek roboczy paruje wywołanie CoInitialize(nil) na wejściu z CoUninitialize na wyjściu, obejmując tym samym cały cykl swojego życia. Jest to ta sama dyscyplina, jakiej wymaga każda praca z GDI+ lub COM poza wątkiem głównym i stanowi drugą połowę sukcesu izolacji wątków (pierwszą jest prywatna instancja). Ta sama ścieżka rastrowania GDI+ napędza jednowątkowe silniki opisane w notatce o wyborze silnika renderującego dla wyjścia PDF
Statyczny podział na fragmenty kontra dynamiczne przydzielanie stron
Oczywistym sposobem na podział 500 stron między 8 wątków jest przydzielenie każdemu stałego fragmentu po około 62 strony. Biblioteka losLab PDF Library tego nie robi, a powodem jest zrównoważenie obciążenia (load balancing). Koszt wyrenderowania strony różni się diametralnie: strona ze zwykłym tekstem renderuje się w milisekundy, podczas gdy strona z gęstą grafiką wektorową lub pełnoekranowym skanem może zająć pięćdziesiąt razy więcej czasu. Podział pracy na sztywne części sprawiłby, że wątek renderujący cięższy fragment pracowałby długo po tym, jak inne wątki przeszłyby w stan bezczynności, więc całkowity czas wykonania zależałby od najwolniejszego fragmentu, a nie od średniej. Zamiast tego każdy wątek roboczy pobiera kolejną stronę ze wspólnego licznika w ramach krótkiej sekcji krytycznej, renderuje ją i wraca po następną, co pozwala na pełne obciążenie każdego rdzenia aż do zakończenia całego zadania
// 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;
Strukturyzowane logowanie w wątkach roboczych
Diagnozowanie zadania wsadowego, które ulega awarii na stronie 213 z 500, jest niezwykle trudne bez pliku dziennika (loga), z kolei prosty log może sam w sobie stać się źródłem błędów współbieżności. Biblioteka losLab PDF Library zawiera komponent TPDFlibLogger, podłączany poprzez właściwość TPDFlib.Logger, który domyślnie ma wartość nil, dzięki czemu ścieżka bez logowania nie generuje żadnych kosztów wydajnościowych. Opiera się on na mechanizmie zwrotnym (callback): konfigurujesz zdarzenie OnLog i kierujesz wpisy tam, gdzie wymaga tego aplikacja, filtrując je według poziomów llDebug / llInfo / llWarn / llError, a funkcja PDFlibErrorMessage tłumaczy surowe kody liczbowe na tekst czytelny dla człowieka. Opcjonalny plik dziennika jest jedynym współdzielonym zasobem i jest chroniony przez sekcję krytyczną TCriticalSection, co pozwala wielu wątkom na bezpieczne jednoczesne zapisywanie do jednego pliku. Warto jednak pamiętać o wyraźnej granicy: synchronizowany jest wyłącznie zapis do pliku, więc jeśli współdzielisz jeden obiekt logera w ręcznie utworzonej puli, a Twoje zdarzenie OnLog odwołuje się do interfejsu użytkownika (UI), nadal musisz samodzielnie przesłać te operacje do głównego wątku aplikacji
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;
Jakiego przyspieszenia można się rzeczywiście spodziewać?
Należy uczciwie ocenić, na co przeznaczany jest czas procesora, ponieważ renderowanie równoległe przynosi korzyści tylko wtedy, gdy zadanie jest ograniczone wydajnością procesora (CPU-bound). Generowanie obrazów o wysokiej rozdzielczości DPI oraz przetwarzanie skomplikowanych stron z grafiką wektorową lub cieniowaniem wymagają dużej mocy obliczeniowej i skalują się niemal liniowo z liczbą rdzeni. Proste strony to zupełnie inna historia: w ich przypadku narzut związany z wywołaniem LoadFromFile dla każdego wątku oraz operacje dyskowe zapisu plików wyjściowych mogą zdominować samo renderowanie. W rezultacie osiem wątków obciążających jeden wolny dysk twardy może zakończyć pracę wolniej niż czysta pętla jednowątkowa. Ustaw MaxWorkers na wartość odpowiadającą liczbie fizycznych rdzeni procesora, monitoruj zużycie pamięci przy dużych plikach PDF, a jeśli zadanie okaże się ograniczone operacjami wejścia/wyjścia (IO-bound), rozwiązaniem będzie szybszy dysk lub mniejsza liczba wątków, a nie dodawanie kolejnych wątków. Używana do zadań, do których została stworzona, wsadowa ścieżka renderowania opisana w tym artykule wchodzi w skład standardowej biblioteki losLab PDF Library dla Delphi i C++Builder, zamieniając bezczynne rdzenie procesora w gotowe strony bez żadnych pułapek związanych z bezpieczeństwem wątków, które w innym przypadku musiałbyś rozwiązywać samodzielnie