Техническа статия

Паралелно изобразяване на PDF страници в Delphi: Нишкова безопасност

Изобразяването на PDF страници паралелно от Delphi се свежда до едно правило: осигурете на всяка работна нишка собствен рендериращ модул. losLab PDF Library предоставя RenderPagesToFilesParallel точно за тази задача, разпределяйки диапазон от страници в пул от TTask задачи с една инстанция на TPDFlib за всеки работник, така че многоядрена машина превръща груповата задача за растеризиране в пропускателна способност, която се мащабира близко до броя на ядрата. Ако вместо това споделите една инстанция между нишките, изпълнението няма просто да се забави плавно, а ще повреди паметта и ще се срине

Това е статията, към която се насочвате, когато нощна задача трябва да превърне PDF от 500 страници в 500 PNG изображения, компютърът има 16 празни ядра и първият ви сериозен опит за работа с нишки е пропаднал вътре в GDI+. Накратко, нишковата безопасност тук е структурно свойство, а не флаг, който просто задавате, и осталата част от материала обяснява защо безопасната архитектура изглежда по този начин и къде всъщност се намира реалният таван на ускорението

Безопасен ли е TPDFlib за паралелно изобразяване с нишки?

Не, и причината си струва да бъде разбрана, преди да проектирате архитектура около нея. Единична инстанция на TPDFlib е декларирана за еднонишкова употреба, а критичната точка е TPDFPageTree.GetPage: тя записва споделено поле FPagePointer в инстанцията като страничен ефект от избора на страница. Две нишки, обръщащи се към една и съща инстанция, се състезават за това поле, така че работник A може да е по средата на изобразяването на страница 3, когато работник B пренасочи дървото на страниците към страница 40. Нищо в API-то не ви пречи да напишете кода по-долу и той дори ще работи за няколко страници, преди да се срине, което е най-неприятният начин на поведение за такъв тип бъг

// 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;

Проблемът не е детерминистичен, което е причината той да премине бърз тест за работоспособност и да се появи едва на клиентска машина с различен брой ядра и по-тежък документ. Също така няма заключване (lock), което да поставите около RenderPageToFile за лесно решение, тъй като задържането на мютекс (mutex) през цялото извикване на рендерирането ще сериализира работата и ще провали паралелизма, към който се стремите

Защо всеки изобразяващ работник се нуждае от собствена TPDFlib инстанция?

Защото инстанцията е единицата за изолация. След като всеки работник притежава частен TPDFlib, заредил файла независимо, всеки има собствено дърво на страниците, собствено поле FPagePointer и собствено състояние за изобразяване, така че няма нищо споделено, за което да възникне състезание. Тази безопасност има цена, която трябва да предвидите предварително: всеки работник анализира целия документ в паметта, така че пиковият отпечатък е приблизително N пъти цената на единична инстанция. Осем работника върху 300 MB PDF означава осем пълни анализа на документи в паметта едновременно, и при много големи входни файлове това е ограничението, което определя броя на работниците ви, а не процесорът. Когато документът е огромен и сте ограничени от паметта, а не от процесора, пътят за директен достъп в обработка на големи PDF файлове без пълен анализ на документа често е по-добро решение от повече нишки за изобразяване

API за еднократно извикване: RenderPagesToFilesParallel

losLab PDF Library пакетира целия безопасен модел зад един единствен метод, така че за общия случай не трябва да програмирате нищо от това ръчно. RenderPagesToFilesParallel приема името на файла и паролата, DPI, включителен начален и краен номер на страница, стойност Options, която се предава директно към растерния път за всяка страница, изходен шаблон, където %p се заменя от номера на страницата, и ограничение на работниците, където всяка стойност на или под нулата означава автоматично определяне. Той връща броя на успешно изобразените страници и е наличен само за Windows, тъй като се основава на CoInitialize и 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;

Защо CoInitialize на всяка работна нишка?

GDI+ е растеризаторът зад изобразяването на страници и GDI+ работи с модел на апартаментни нишки (apartment-threaded): той очаква COM да бъде инициализиран на всяка нишка, която се обръща към него. Основната нишка на VCL приложение обикновено вече има тази настройка, но прясно създаден TTask работник няма, и извикването на пътя за рендериране от неинициализирана нишка е сигурен начин за срив. Затова всеки работник съчетава CoInitialize(nil) при влизане с CoUninitialize при излизане, обхващайки целия си жизнен цикъл. Това е същата практика, която всяка GDI+ или COM работа изисква извън основната нишка, и е втората половина от това, което прави изолацията на ниво работник действително стабилна, като първата половина е частната инстанция. Същият растерна схема на GDI+ задвижва еднонишковите енджини, разгледани в избор на рендериращ енджин за PDF изход

Статично разделяне спрямо динамично изискване на страници

Очевидният начин за разделяне на 500 страници между 8 работника е да се даде на всеки фиксиран отрязък от около 62 страници. losLab PDF Library не прави това и причината е балансирането на натоварването. Цената за обработка на страница варира изключително много: страница с основен текст се изобразява за милисекунди, докато страница с гъсти векторни карти или сканирано изображение на цял екран може да отнеме петдесет пъти по-вече време. Ако разделите работата на фиксирани части, работникът, който изтегли тежкия отрязък, ще работи дълго след като останалите са приключили, така че времето за завършване ще се определи от най-бавната част, а не от средната. Вместо това всеки работник изисква следващата страница от споделен брояч под кратка критична секция, изобразява я и се връща за следващата, което поддържа всяко ядро заето, докато целият диапазон се изчерпи

// 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;

Структурирано логване между работните нишки

Дебъгването на партида, която спира на страница 213 от 500, е кошмар без лог, а прост лог е сам по себе си грешка в конкурентността. losLab PDF Library доставя TPDFlibLogger, прикачен чрез свойството TPDFlib.Logger и nil по подразбиране, така че пътят без логър остава без допълнителни разходи. Той работи с обратни извиквания (callbacks): настройвате OnLog и насочвате записите накъдето решите, филтрирани по ниво llDebug / llInfo / llWarn / llError, а PDFlibErrorMessage превръща необработените числови кодове в четим текст, така че записът за Error е нещо повече от просто число. Незадължителният изходен файл е единственият споделен ресурс и той е защитен с TCriticalSection именно за да могат няколко работника да пишат в един лог файл безопасно. Забележете реалната граница: само изходът към файла е синхронизиран, така че ако споделяте един логър в ръчно написан пул и вашият OnLog докосва потребителския интерфейс, все още трябва сами да маршализирате това обратно към основната нишка

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;

Какво ускорение всъщност трябва да очаквате?

Бъдете честни със себе си относно това къде отива времето, тъй като паралелното изобразяване носи полза само когато работата е реално ограничена от процесора. Продукцията с висок DPI и сложните векторни или градиентно запълнени страници са изчислително тежки и се мащабират почти линейно с броя на ядрата, докато натоварите процесора напълно. Семплите страници са друга история: там допълнителните разходи за LoadFromFile на всеки работник, плюс дисковата цена за записване на изходните файлове, могат да засенчат самото рендериране, и осем работника, претоварващи един бавен диск, могат да приключат по-бавно от обикновен последователен цикъл. Настройте MaxWorkers на реалния брой физически ядра, а не на желана стойност, следете паметта при голям изходен PDF файл, и ако се окаже, че партидата е ограничена от дисковите операции (I/O-bound), решението е по-бързо съхранение или по-малко работници, а не повече нишки. Използван за задачите, за които е създаден, пътят за групово рендериране, показан тук, е част от стандартната losLab PDF Library за Delphi и C++Builder, като той превръща неизползваните ядра в готови страници без капаните за нишкова безопасност, които иначе би трябвало да решавате сами