Технічна стаття

Паралельний рендеринг сторінок PDF в Delphi: безпека потоків

Паралельний рендеринг сторінок PDF в Delphi зводиться до одного правила: кожному робочому потоку потрібен свій рендерер. Бібліотека losLab PDF Library пропонує метод RenderPagesToFilesParallel саме для цієї задачі, розподіляючи діапазон сторінок по пулу TTask з одним екземпляром TPDFlib на кожен потік. Це дозволяє багатоядерній машині перетворити пакетне растрування на роботу, продуктивність якої масштабується пропорційно до кількості ядер. Натомість спільне використання одного екземпляра кількома потоками призведе не просто до уповільнення, а до пошкодження пам'яті та аварійного завершення програми.

Це стаття, до якої ви звертаєтеся, коли нічне завдання має перетворити 500-сторінковий PDF на 500 файлів PNG, машина має 16 вільних ядер, а перша спроба розпаралелити процес зазнала краху всередині GDI+. Якщо коротко, то безпека потоків тут є структурною особливістю, а не прапорцем, який можна увімкнути. Далі ми розберемо, чому безпечна архітектура має саме такий вигляд і де насправді знаходиться межа прискорення роботи.

Чи є TPDFlib безпечним для багатопотокового паралельного рендерингу?

Ні, і причину цього важливо зрозуміти, перш ніж проектувати систему. Один екземпляр TPDFlib призначений для використання лише в одному потоці, а найкритичнішим місцем є TPDFPageTree.GetPage: як побічний ефект вибору сторінки цей метод записує значення у спільне поле FPagePointer екземпляра. Два потоки, що викликають один і той самий екземпляр, створюють стан перегонів (race condition) для цього поля: робочий потік А може бути на середині обробки сторінки 3, коли потік Б перенаправить дерево сторінок на сторінку 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;

Невідповідність поведінки не є детермінованою, тому помилка легко проходить швидкі тести та з'являється вже на машині клієнта з іншою кількістю ядер та більшим обсягом документа. Немає простого блокування, яке можна застосувати навколо RenderPageToFile для вирішення цієї проблеми, оскільки утримання взаємного виключення (mutex) протягом усього виклику рендерингу серіалізує роботу та нівелює переваги паралелізму, заради яких все створювалося.

Чому кожному робочому потоку потрібен власний екземпляр TPDFlib?

Тому що саме цей екземпляр є одиницею ізоляції. Коли кожен робочий потік володіє приватним екземпляром TPDFlib, який завантажив файл незалежно, він має власне дерево сторінок, власне поле FPagePointer та власний стан рендерингу, тому немає спільних даних для виникнення стану перегонів. Ця безпека має свою ціну, яку слід оцінити заздалегідь: кожен потік розбирає весь документ у пам'яті, тому пікове споживання пам'яті приблизно в N разів перевищує витрати одного екземпляра. Вісім робочих потоків для PDF розміром 300 МБ означають вісім повних розборів у пам'яті одночасно, і для дуже великих документів саме це обмеження визначатиме кількість потоків, а не процесор. Коли документ величезний і ви обмежені пам'яттю, а не процесором, прямий доступ, описаний у статті про обробку великих 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, щоб шлях без логування не мав додаткових витрат. Він побудований на зворотному виклику (callback): ви встановлюєте подію OnLog і спрямовуєте записи туди, куди потрібно хост-додатку, фільтруючи їх за рівнями llDebug / llInfo / llWarn / llError. Функція PDFlibErrorMessage перетворює числові коди помилок на зрозумілий текст, тому запис Error виглядає інформативно, а не просто як число. Додатковий запис у файл є єдиним спільним ресурсом, який захищено за допомогою TCriticalSection, завдяки чому кілька потоків можуть безпечно додавати записи в один лог-файл. Зверніть увагу на обмеження: синхронізується лише запис у файл, тому якщо ви ділитеся одним логером у створеному вручну пулі і ваш обробник OnLog взаємодіє з інтерфейсом користувача (UI), ви все одно повинні самостійно маршалізувати ці виклики назад у головний потік.

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;

Якого насправді прискорення слід очікувати?

Тверезо оцінюйте, на що саме витрачається час, оскільки паралельний рендеринг дає ефект лише тоді, коли робота дійсно обмежена процесором (CPU-bound). Виведення з високою роздільною здатністю (DPI) та складні сторінки з векторною чи градієнтною графікою потребують значних обчислень і масштабуються майже лінійно з кількістю ядер, доки процесор не буде повністю завантажений. Прості сторінки — зовсім інша справа: у цьому випадку накладні витрати на LoadFromFile для кожного потоку разом із вартістю запису вихідних файлів на диск можуть затьмарити сам рендеринг. Тоді вісім потоків, які одночасно звертаються до повільного диска, можуть завершити роботу повільніше, ніж один чистий послідовний цикл. Встановіть MaxWorkers відповідно до кількості фізичних ядер вашого процесора, стежте за використанням пам'яті при обробці великих документів, і якщо пакет виявиться обмеженим операціями введення-виведення (IO-bound), вирішенням буде швидший накопичувач або менша кількість робочих потоків, а не збільшення кількості потоків. У задачах, для яких він був розроблений, описаний тут пакетний рендеринг є частиною стандартної бібліотеки losLab PDF Library для Delphi та C++Builder. Він перетворює вільні ядра на готові сторінки без необхідності самостійно вирішувати проблеми безпеки потоків.