Artigo Técnico

Renderização paralela de páginas PDF no Delphi: Segurança de Threads (Thread Safety)

A renderização de páginas PDF em paralelo no Delphi se resume a uma regra: forneça a cada thread de trabalho seu próprio renderizador. A losLab PDF Library expõe o RenderPagesToFilesParallel exatamente para esse trabalho, distribuindo um intervalo de páginas por um pool de TTask com uma instância de TPDFlib por trabalhador, para que uma máquina multi-core transforme um trabalho de rasterização em lote em um rendimento que escala perto da contagem de núcleos. Se você compartilhar uma única instância entre as threads, a execução não diminuirá de velocidade de forma suave, ela corromperá a memória e travará

Este é o artigo a que você recorre quando um trabalho noturno precisa transformar um PDF de 500 páginas em 500 imagens PNG, a máquina tem 16 núcleos ociosos e sua primeira tentativa sincera de usar threads falhou dentro do GDI+. A versão curta é que a segurança de threads aqui é uma propriedade estrutural, não uma flag que você define, e o restante deste artigo explica por que o formato seguro se parece com isso e onde o teto real de aceleração realmente está

A TPDFlib é segura para threads (thread-safe) para renderização paralela?

Não, e a razão vale a pena entender antes de projetar em torno dela. Uma única instância de TPDFlib é declarada para uso em thread única, e a ponta afiada é TPDFPageTree.GetPage: ela grava um campo compartilhado FPagePointer na instância como um efeito colateral de selecionar uma página. Duas threads chamando a mesma instância correm em uma disputa de dados (data race) nesse campo, de modo que a thread A pode estar na metade da página 3 quando a thread B aponta a árvore de páginas para a página 40. Nada na API impede você de escrever o código abaixo, e ele até rodará por algumas páginas antes de falhar, que é a pior maneira para um bug como este se comportar

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

A falha não é determinística, o que é exatamente o motivo pelo qual ela sobrevive a um teste rápido e depois surge na máquina de um cliente com uma contagem de núcleos diferente e um documento mais pesado. Também não há bloqueio que você possa envolver no RenderPageToFile para corrigir isso de forma barata, porque manter um mutex em toda a chamada de renderização serializa o trabalho e joga fora o paralelismo que você procurava

Por que cada trabalhador de renderização precisa de sua própria instância da TPDFlib?

Porque a instância is a unidade de isolamento. Uma vez que cada trabalhador possui uma TPDFlib privada que carregou o arquivo de forma independente, cada um tem sua própria árvore de páginas, seu próprio FPagePointer e seu próprio estado de renderização, portanto não há nada compartilhado para disputar. Essa segurança tem um preço que você deve mensurar de antemão: cada trabalhador analisa o documento inteiro na memória, de modo que o consumo de pico é de aproximadamente N vezes o custo de uma única instância. Oito trabalhadores em um PDF de 300 MB equivalem a oito análises completas residentes ao mesmo tempo e, em entradas muito grandes, essa é a limitação que decide a contagem de trabalhadores, não a CPU. Quando o documento é enorme e você tem limitação de memória em vez de CPU, o caminho de acesso direto abordado em processamento de grandes PDFs sem análise de documento completo geralmente é uma solução melhor do que mais threads de renderização

A API de chamada única: RenderPagesToFilesParallel

A losLab PDF Library empacota todo o padrão seguro em um único método, de modo que, para o caso comum, você não precisa implementar nada disso manualmente. O RenderPagesToFilesParallel recebe o nome do arquivo e a senha, um DPI, uma página inicial e final inclusivas, um valor de Options passado diretamente para o caminho de rasterização por página, um padrão de saída onde %p é substituído pelo número da página e um limite de trabalhadores onde qualquer valor igual ou inferior a zero significa automático. Ele retorna a contagem de páginas renderizadas com sucesso, e é um caminho exclusivo para o Windows porque depende do CoInitialize e do 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;

Por que o CoInitialize em cada thread de trabalho?

O GDI+ é o rasterizador sob a renderização de páginas, e o GDI+ é apartment-threaded: ele espera que o COM seja inicializado em qualquer thread que chame por ele. A thread principal de um aplicativo VCL geralmente já tem isso configurado, mas um trabalhador de TTask recém-criado não tem, e chamar o caminho de renderização a partir de uma thread não inicializada é uma forma garantida de travar. Portanto, cada trabalhador combina um CoInitialize(nil) na entrada com um CoUninitialize na saída, delimitando todo o seu ciclo de vida. Esta é a mesma disciplina de que qualquer trabalho com GDI+ ou COM precisa fora da thread principal, e é a segunda metade do que faz o isolamento por trabalhador realmente funcionar, sendo a primeira metade a instância privada. O mesmo caminho de rasterização GDI+ impulsiona os motores de thread única abordados em escolhendo um motor de renderização para saída de PDF

Divisão estática (sharding) versus reivindicação dinâmica de páginas

A maneira óbvia de dividir 500 páginas entre 8 trabalhadores é entregar a cada um uma fatia fixa de cerca de 62 páginas. A losLab PDF Library não faz isso, e a razão é o equilíbrio de carga. O custo de renderização de páginas varia muito: uma página de texto simples é renderizada em milissegundos, enquanto uma página com mapas vetoriais densos ou uma imagem digitalizada em página inteira pode levar cinquenta vezes mais tempo. Divida o trabalho em fatias fixas e o trabalhador que pegar a fatia mais pesada continuará rodando muito tempo depois que os outros estiverem ociosos, de modo que seu tempo total de execução será definido pela fatia mais azarada, não pela média. Em vez disso, cada trabalhador reivindica a próxima página de um contador compartilhado sob uma seção crítica curta, renderiza-a e retorna para buscar outra, o que mantém todos os núcleos ocupados até que todo o intervalo seja concluído

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

Registro em log estruturado entre threads de trabalho

Depurar um lote que morre na página 213 de 500 é péssimo sem um log, e um log comum é seu próprio bug de concorrência. A losLab PDF Library fornece a classe TPDFlibLogger, anexada por meio da propriedade TPDFlib.Logger e nula (nil) por padrão, para que o caminho sem log permaneça com custo zero. Ela é orientada a callbacks: você define OnLog e direciona os registros para onde o host desejar, filtrados por um nível llDebug / llInfo / llWarn / llError, e a função PDFlibErrorMessage transforma os códigos numéricos brutos em texto legível para que um registro de Error seja lido como mais do que um número inteiro simples. O arquivo de saída opcional é o único recurso compartilhado, e é protegido por uma TCriticalSection precisamente para que vários trabalhadores possam anexar a um único arquivo de log com segurança. Observe o limite real: apenas a gravação no arquivo é sincronizada, por isso, se você compartilhar um único logger em um pool personalizado e seu OnLog tocar na interface do usuário (UI), você ainda terá que direcionar isso de volta para a thread principal por conta própria

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;

Qual aceleração você deve realmente esperar?

Seja honesto consigo mesmo sobre para onde vai o tempo, porque a renderização paralela só vale a pena onde o trabalho é genuinamente limitado pela CPU (CPU-bound). Saídas em alta resolução (DPI) e páginas vetoriais complexas ou com sombreamento exigem muito processamento, e essas escalam de forma quase linear com a contagem de núcleos até saturar a CPU. Páginas triviais são uma história diferente: nelas, a sobrecarga de LoadFromFile por trabalhador, além do custo de gravação no disco dos arquivos de saída, pode sobrecarregar a própria renderização, e oito trabalhadores disputando um disco lento podem terminar mais devagar do que um loop serial limpo. Defina o MaxWorkers para a contagem de núcleos físicos da sua máquina em vez de algo ambicioso, monitore a memória quando o PDF de origem for grande e, se um lote for limitado por E/S (IO-bound), a solução será um armazenamento mais rápido ou menos trabalhadores, não mais threads. Utilizado nos trabalhos para os quais foi construído, o caminho de renderização em lote mostrado aqui faz parte da losLab PDF Library padrão para Delphi e C++Builder, e transforma núcleos ociosos em páginas prontas sem nenhuma das armadilhas de segurança de threads que você teria que resolver por conta própria