Technical Article

Параллельный рендеринг 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 не мешает вам написать приведенный ниже код, и он даже выполнится для нескольких страниц перед сбоем, что является наихудшим поведением для подобной ошибки

// 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, так как удержание мьютекса на протяжении всего вызова рендеринга сделает выполнение последовательным и сведет на нет весь параллелизм, к которому вы стремились

Почему каждому рабочему потоку нужен собственный экземпляр 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 -> auto: min(page count, CPU cores)
    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 так не делает, и причина кроется в балансировке нагрузки. Вычислительная стоимость страниц сильно различается: страница простого текста рендерится за миллисекунды, тогда как страница с плотной векторной графикой или полностраничным сканированным изображением может обрабатываться в пятьдесят раз дольше. Если разделить работу на фиксированные сегменты, рабочий поток, которому достался тяжелый сегмент, будет работать долгое время после того, как остальные завершат выполнение, так что общее время выполнения будет определяться самым медленным сегментом, а не средним значением. Вместо этого каждый рабочий поток запрашивает следующую страницу из общего счетчика под короткой критической секцией, рендерит ее и возвращается за новой, что позволяет задействовать все ядра до тех пор, пока не будет исчерпан весь диапазон страниц

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