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