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

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

Параллельный рендеринг страниц PDF из Delphi сводится к одному правилу: предоставьте каждому рабочему потоку свой собственный рендерер. Библиотека losLab PDF Library предоставляет метод RenderPagesToFilesParallel именно для этой задачи, распределяя диапазон страниц по пулу TTask с одним экземпляром TPDFlib на каждый рабочий поток, так что многоядерный компьютер превращает пакетную задачу растрирования в поток данных, масштабируемый близко к количеству ядер. Если вместо этого использовать один общий экземпляр в разных потоках, это не просто замедлит выполнение работы, а приведет к повреждению памяти и сбою приложения

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

Является ли TPDFlib потокобезопасным для параллельного рендеринга?

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

// ТАК ДЕЛАТЬ НЕ НУЖНО: один общий экземпляр, много потоков
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // каждый поток повторно входит в тот же экземпляр -> гонка данных на FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

Этот сбой не является детерминированным, именно поэтому он успешно проходит простой тест, но затем проявляется на компьютере клиента с другим числом ядер и более тяжелым документом. Вы не сможете легко исправить это с помощью блокировки вызова RenderPageToFile, так как удержание мьютекса на протяжении всего вызова рендеринга сделает выполнение последовательным и сведет на нет весь параллелизм, к которому вы стремились

Почему каждому рабочему потоку нужен собственный экземпляр TPDFlib?

Потому что экземпляр и является единицей изоляции. Когда каждый рабочий поток владеет приватным экземпляром TPDFlib, независимо загрузившим файл, каждый из них имеет свое собственное дерево страниц, собственное поле FPagePointer и собственное состояние рендеринга, так что нет разделяемых данных для возникновения состояния гонки. За эту безопасность приходится платить, и цену нужно оценить заранее: каждый рабочий поток загружает весь документ в память, поэтому пиковый объем используемой памяти примерно в N раз превышает стоимость одного экземпляра. Восемь рабочих потоков на PDF размером 300 МБ означают одновременное нахождение в памяти восьми полных результатов парсинга, и на очень больших документах именно это ограничение определяет число рабочих потоков, а не процессор. Когда документ огромен и вы ограничены по памяти, а не по процессору, метод прямого доступа, описанный в статье об обработке больших PDF без полного парсинга документа, часто оказывается лучшим решением, чем увеличение числа потоков рендеринга

API в один вызов: RenderPagesToFilesParallel

losLab PDF Library инкапсулирует весь безопасный шаблон за одним методом, так что в типовых сценариях вам не придется реализовывать его вручную. RenderPagesToFilesParallel принимает имя файла и пароль, DPI, начальную и конечную страницы (включительно), значение Options для передачи в путь растрирования страницы, формат выходного имени (где %p заменяется номером страницы) и ограничение рабочих потоков (значение 0 или меньше означает автоматический выбор). Метод возвращает количество успешно отрендеренных страниц и поддерживается только в 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 -> автоматически: min(число страниц, число ядер CPU)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

Зачем вызывать CoInitialize в каждом рабочем потоке?

Библиотека GDI+ является растризатором, лежащим в основе рендеринга страниц, и GDI+ использует модель Apartment Threading: она ожидает, что COM будет инициализирован в том потоке, который к ней обращается. В главном потоке VCL-приложения это обычно уже настроено, но в только что созданном рабочем потоке TTask — нет, и вызов рендеринга из неинициализированного потока гарантированно приведет к сбою. Поэтому каждый рабочий поток выполняет CoInitialize(nil) на входе и CoUninitialize на выходе, очерчивая весь свой жизненный цикл. Это стандартное правило работы с GDI+ или COM вне главного потока, и это вторая половина того, что обеспечивает изоляцию рабочих потоков (первой половиной является приватный экземпляр). Тот же растровый путь GDI+ управляет однопоточными движками, описанными в статье о выборе движка рендеринга для вывода PDF

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

Очевидный способ распределить 500 страниц по 8 рабочим потокам — выдать каждому фиксированный сегмент объемом около 62 страниц. Библиотека losLab PDF Library так не делает, и причина кроется в балансировке нагрузки. Вычислительная стоимость страниц сильно различается: страница простого текста рендерится за миллисекунды, тогда как страница с плотной векторной графикой или полностраничным сканированным изображением может обрабатываться в пятьдесят раз дольше. Если разделить работу на фиксированные сегменты, рабочий поток, которому достался тяжелый сегмент, будет работать долгое время после того, как остальные завершат выполнение, так что общее время выполнения будет определяться самым медленным сегментом, а не средним значением. Вместо этого каждый рабочий поток запрашивает следующую страницу из общего счетчика под короткой критической секцией, рендерит ее и возвращается за новой, что позволяет задействовать все ядра до тех пор, пока не будет исчерпан весь диапазон страниц

// Что делает каждый рабочий поток внутри пула (упрощённо)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ работает в модели Apartment Threading
    try
      LocalLib := TPDFlib.Create;   // один приватный экземпляр на каждый рабочий поток
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // атомарно захватить следующую страницу
          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 (что гарантирует нулевую стоимость при неиспользовании). Он построен на колбэках: вы настраиваете событие 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';          // необязательный общий приёмник (защищён блокировкой)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // маршалируйте в поток UI самостоятельно; OnLog срабатывает в рабочих потоках
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // по умолчанию nil; при отсутствии не несёт накладных расходов
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // Error теперь несёт текст, например 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

Какого ускорения на самом деле стоит ожидать?

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