Artigo Técnico

Renderização Paralela de Páginas PDF no Delphi: Segurança de Threads

A renderização de páginas PDF em paralelo a partir do Delphi resume-se a uma regra: dar a cada thread de trabalho (worker thread) o seu próprio renderizador. A losLab PDF Library expõe o RenderPagesToFilesParallel exatamente para essa tarefa, distribuindo um intervalo de páginas por um pool de TTask com uma instância de TPDFlib por trabalhador (worker), para que uma máquina multi-core transforme um trabalho de rasterização em lote num rendimento que escala perto da contagem de núcleos. Partilhe uma única instância entre threads e a execução não abrandará de forma suave; em vez disso, corromperá a memória e falhará

Este é o artigo a que deve recorrer quando um trabalho noturno tem de transformar um PDF de 500 páginas em 500 PNGs, o sistema tem 16 núcleos inativos e a sua primeira tentativa honesta 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 se define, e o resto deste artigo explica a razão pela qual a estrutura segura tem o aspeto que tem e onde realmente se situa o teto de aceleração real

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

Não, e a razão merece ser compreendida antes de desenhar em torno dela. Uma única instância do TPDFlib é declarada para utilização de thread única, e a aresta afiada é o TPDFPageTree.GetPage: escreve um campo FPagePointer partilhado na instância como um efeito secundário da seleção de uma página. Duas threads que chamam a mesma instância entram em concorrência (race condition) nesse campo, pelo que o trabalhador A pode estar a meio da página 3 quando o trabalhador B aponta novamente a árvore de páginas para a página 40. Nada na API o impede de escrever o código abaixo, e este até funcionará durante algumas páginas antes de falhar, o que é a pior forma de um erro como este se comportar

// NÃO faça isto: uma instância partilhada, muitas threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // cada thread reentra na mesma instância -> concorrência de dados em FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

O falhanço não é determinístico, o que é exatamente a razão pela qual sobrevive a um teste rápido de fumo e depois surge no computador de um cliente com uma contagem de núcleos diferente e um documento mais pesado. Também não existe nenhum bloqueio que possa aplicar ao RenderPageToFile para resolver isto de forma barata, porque manter um mutex durante toda a chamada de renderização serializa o trabalho e deita fora o paralelismo que pretendia obter

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

Porque a instância é a unidade de isolamento. Assim que cada trabalhador possui um TPDFlib privado que carregou o ficheiro de forma independente, cada um tem a sua própria árvore de páginas, o seu próprio FPagePointer e o seu próprio estado de renderização, pelo que não há nada partilhado sobre o qual competir. Essa segurança tem um preço que deve avaliar antecipadamente: cada trabalhador analisa (parse) o documento completo para a memória, pelo que a pegada (footprint) de pico é cerca de N vezes o custo de uma única instância. Oito trabalhadores num PDF de 300 MB representam oito análises completas residentes em simultâneo, e em entradas muito grandes essa é a restrição que decide a sua contagem de trabalhadores, não o CPU. Quando o documento é gigante e está limitado pela memória e não pelo CPU, o caminho de acesso direto em processamento de grandes PDFs sem análise de documento completo é frequentemente uma alavanca melhor do que mais threads de renderização

A API de chamada única: RenderPagesToFilesParallel

A losLab PDF Library empacota todo o padrão seguro sob um único método, para que, no caso comum, não tenha de criar nada manualmente. O RenderPagesToFilesParallel recebe o nome do ficheiro e a palavra-passe, um DPI, uma página inicial e final inclusivas, um valor 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 auto. Devolve a contagem de páginas renderizadas com sucesso, e é um caminho exclusivo do Windows porque se apoia no CoInitialize e GDI+

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // NomeFicheiro, PalavraPasse, DPI, PaginaInicial, PaginaFinal, Opcoes, Padrao, MaxTrabalhadores
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxTrabalhadores = 0 -> auto: min(contagem de páginas, núcleos de CPU)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

Por que usar CoInitialize em cada thread de trabalho?

O GDI+ é o rasterizador por baixo da renderização de páginas, e o GDI+ usa o modelo apartment-threaded: espera que o COM seja inicializado em qualquer thread que o chame. A thread principal de uma aplicação VCL normalmente já tem isso configurado, mas um trabalhador 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 falhar. Assim, cada trabalhador emparelha um CoInitialize(nil) à entrada com um CoUninitialize à saída, delimitando todo o seu tempo de vida. Esta é a mesma disciplina de que qualquer trabalho GDI+ ou COM precisa fora da thread principal, e é a segunda metade do que faz com que o isolamento por trabalhador realmente se sustente, sendo a primeira metade a instância privada. O mesmo caminho de rasterização GDI+ impulsiona os motores de thread única abordados em escolher um motor de renderização para saída PDF

Fragmentação estática versus reivindicação dinâmica de páginas

A forma óbvia de dividir 500 páginas por 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 (load balance). O custo da página varia enormemente: uma página de texto de corpo é renderizada em milissegundos, enquanto uma página de mapas vetoriais densos ou uma imagem digitalizada em sangria total pode demorar cinquenta vezes mais. Divida o trabalho em fragmentos fixos e o trabalhador que por acaso obtiver a fatia pesada executará muito depois de os outros terem ficado inativos, pelo que o seu tempo real de relógio é definido pelo fragmento mais azarado, e não pela média. Em vez disso, cada trabalhador reivindica a página seguinte de um contador partilhado sob uma secção crítica curta, renderiza-a e regressa para obter outra, o que mantém cada núcleo ocupado até que todo o intervalo esteja esgotado

// O que cada trabalhador faz dentro do pool (simplificado)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ é apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // uma instância privada por trabalhador
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // reivindica a página seguinte atomicamente
          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;

Registo estruturado (logging) entre threads de trabalho

Depurar um lote que falha na página 213 de 500 é horrível sem um log, e um log ingénuo é o seu próprio bug de concorrência. A losLab PDF Library fornece o TPDFlibLogger, anexado através da propriedade TPDFlib.Logger e nil por padrão para que o caminho sem logger permaneça com custo zero. É prioritariamente focado em callbacks: define o OnLog e encaminha os registos para onde o host desejar, filtrados por um nível llDebug / llInfo / llWarn / llError, e o PDFlibErrorMessage transforma os códigos numéricos em bruto em texto legível por humanos para que um registo de Error pareça mais do que um mero número inteiro. O recetor de ficheiro opcional é o recurso partilhado e é protegido por uma TCriticalSection precisamente para que vários trabalhadores possam acrescentar ao mesmo ficheiro de log com segurança. Note o limite real: apenas esse recetor de ficheiro está sincronizado, pelo que se partilhar um logger num pool criado manualmente e o seu OnLog tocar na interface de utilizador (UI), ainda terá de gerir esse retorno para a thread principal por si mesmo

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // recetor partilhado opcional (protegido por lock)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // gira a transferência para a thread de UI por si mesmo; OnLog é acionado nas threads de trabalho
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil por padrão; custo zero quando não configurado
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // um Error carrega agora texto, por exemplo, 401 -> "Palavra-passe errada ou permissão negada"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

Que aceleração real deve realmente esperar?

Seja honesto consigo mesmo sobre onde o tempo é gasto, porque a renderização paralela só compensa onde o trabalho é genuinamente limitado pelo CPU. Saídas de alto DPI e páginas com vetores complexos ou sombreados exigem muito poder de processamento, e estas escalam de forma quase linear com a contagem de núcleos até saturar o CPU. Páginas banais são uma história diferente: aí, o custo extra do LoadFromFile por trabalhador, somado ao custo de disco para gravar os ficheiros de saída, pode sobrecarregar a própria renderização, e oito trabalhadores a disputar um único disco lento podem terminar mais devagar do que um loop sequencial limpo. Defina o MaxWorkers para a sua contagem física de núcleos em vez de algo aspiracional, monitorize a memória quando o PDF de origem for grande e, se um lote se revelar limitado por E/S (IO-bound), a solução é um armazenamento mais rápido ou menos trabalhadores, não mais threads. Utilizado nos trabalhos para os quais foi concebido, o caminho de renderização em lote apresentado aqui faz parte da biblioteca padrão losLab PDF Library para Delphi e C++Builder, e transforma núcleos inativos em páginas concluídas sem nenhuma das armadilhas de segurança de threads que de outra forma teria de resolver sozinho