O PDFium não é thread-safe ao nível do módulo, por isso duas instâncias TPdf a trabalhar dois ficheiros diferentes em duas threads ainda se podem corromper uma à outra. O PDFium Component para Delphi trata disto de duas maneiras: desde a v3.125.1, o ValidatePdfFilesParallel serializa toda a chamada PDFium nativa atrás de um cadeado de todo o processo, enquanto o TPdf.RenderPagesParallel dá a cada worker a sua própria cópia isolada do módulo PDFium. O bug que forçou a correção era o pior tipo de intermitente. Um teste de validação em lote passava na maioria das vezes, depois reportava um de dois ficheiros bons como falhado, depois crashava o teste seguinte no mesmo processo com um access violation, e às vezes derrubava o runner inteiro com um código de saída em vez de um stack trace. Nada estava errado com o teste, e nada estava errado com qualquer documento isolado. O pressuposto estava errado: um TPdf por thread não é isolamento
Porque é que um TPdf por thread não chega?
Um TPdf por thread não chega porque o PDFium guarda o seu estado inseguro no módulo, não no documento. Cada TPdf tem o seu próprio handle FPDF_DOCUMENT, mas todos os handles no processo são servidos pela mesma DLL carregada, e essa DLL guarda singletons de todo o processo: a cache de fontes, o módulo de páginas, e outras estruturas globais que o carregamento, a análise e a renderização de documentos todas tocam. Duas threads a carregar dois ficheiros sem relação são duas threads a escrever na mesma cache de fontes ao mesmo tempo. Ninguém é dono desses dados do lado Delphi, por isso nada do lado Delphi os pode trancar por documento
O componente tem um cadeado, e é fácil tirar daí a conclusão errada. O TPdf embrulha os seus próprios caminhos de renderização numa secção crítica interna (EnterRenderLock / LeaveRenderLock, métodos privados de TPdf). Esse cadeado é por instância. Impede duas threads de comandar o mesmo TPdf ao mesmo tempo, o que é um perigo real, mas não vê uma segunda instância noutra thread, por isso a concorrência entre instâncias passa direto por ele. A regra geral é simples de enunciar numa linha: num único módulo PDFium carregado, no máximo uma thread pode estar dentro do PDFium em qualquer momento, independentemente de quantos documentos estão abertos
Como é a corrupção entre documentos num processo Delphi?
A corrupção entre documentos parece uma mistura aleatória de falhas sem relação, e o dano sobrevive ao código que o causou. Antes da v3.125.1, o ValidatePdfFilesParallel criava um TPdf por thread worker e corria Active := True mais a construção do relatório preflight em concorrência no módulo partilhado. Os sintomas vistos tanto em compilações Delphi como Free Pascal cobriam todo o espectro:
- Um ficheiro válido falha a carregar, ou volta do lote como falhado quando devia passar
- Um access violation aparece numa chamada posterior sem relação, muitas vezes num teste diferente ou num documento diferente
- O
External exception C000001Daparece em Delphi. Esse código éSTATUS_ILLEGAL_INSTRUCTION, lançado pela instruçãoud2que os macrosCHECKeIMMEDIATE_CRASHinternos do PDFium executam quando um invariante se parte - O processo sai com
0xC0000409(fail-fast, reportado como stack buffer overrun) ou0xC0000374(corrupção de heap), sem exceção Delphi nenhuma
Os dois últimos pontos são a razão de o bug ser tão difícil de fixar. A validação paralela terminava, o estado global corrompido ficava para trás, e o fixture seguinte no mesmo processo tropeçava nele. Numa corrida de regressão Delphi Win64, uma vaga de falhas C000001D atingiu testes que nunca tocaram na validação em lote; eram simplesmente o primeiro código a usar o PDFium depois do dano. Os números medidos tornam a escala clara. Uma sonda Delphi que corria a mesma amostra através de dois workers falhou 122 de 160 documentos numa corrida e 138 de 160 noutra, e uma dessas corridas lançou logo External exception C000001D. Um caso de stress de 8 documentos, 4 workers e 5 rondas falhou ou crashou em 5 de 5 corridas em Free Pascal Win64. Depois da correção, a mesma sonda falhou 0 de 1.200 documentos
Como o ValidatePdfFilesParallel se mantém seguro desde a v3.125.1
O ValidatePdfFilesParallel agora serializa a metade nativa de cada trabalho e mantém a metade gerida paralela. Cada worker toma uma secção crítica ao nível da unidade antes de criar o seu TPdf, e segura-a através de FileName, Active := True, a construção do relatório preflight, e Free. Criação e destruição estão dentro do cadeado de propósito: fechar um documento chama de volta para o módulo tal como carregar. Assim que o worker tem um registo TPdfPreflightReport capturado, larga o cadeado e avalia as regras de validação contra esse registo, o que não toca em estado PDFium nenhum, por isso a avaliação de regras de um ficheiro sobrepõe-se ao trabalho PDFium do seguinte
Duas mudanças menores vieram com a correção. Uma falha de carga agora lança EPdfError com LastLoadReport.ErrorMessage, por isso o ErrorMessage do item nomeia o problema real de parse em vez de um erro secundário "no active document". E o custo é dito honestamente: a parte PDFium do lote agora é serial, por isso num lote dominado por parsing e preflight, workers extra compram pouco. Se está numa versão anterior à v3.125.1, ponha o WorkerCount a 1; isso remove a concorrência e a corrupção com ela
uses
System.SysUtils, PDFium, FPdfPreflightReport;
procedure ValidateBatch(const Files: array of string);
var
Registry: TPdfValidationRuleRegistry;
Options: TPdfBatchValidationOptions;
Report: TPdfBatchValidationReport;
I: Integer;
begin
Registry := CreateDefaultPdfValidationRuleRegistry;
try
Options := TPdfBatchValidationOptions.Default;
Options.WorkerCount := 4; // 0 = contagem de processadores, limitada a 8
Options.Standards := [ppsPdfA];
// Com um registo explícito, selecione você o perfil correspondente.
// Uma lista Profiles vazia corre todas as regras registadas, e regras de
// padrões sem preflight reportam "did not pass"
SetLength(Options.ValidationOptions.Profiles, 1);
Options.ValidationOptions.Profiles[0] := 'PDF/A';
Report := ValidatePdfFilesParallel(Files, Registry, Options);
finally
Registry.Free;
end;
for I := 0 to High(Report.Results) do
case Report.Results[I].Status of
pbvisPass: Writeln('PASS ', Report.Results[I].FileName);
pbvisFail: Writeln('FAIL ', Report.Results[I].FileName);
pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
Report.Results[I].ErrorMessage);
else
Writeln('SKIP ', Report.Results[I].FileName); // pbvisCancelled
end;
Writeln(Report.PassedDocumentCount, ' passed, ',
Report.FailedDocumentCount, ' failed, ',
Report.ErrorDocumentCount, ' errors');
end;
Passar nil como registo é o caminho mais curto: o ValidatePdfFilesParallel então cria o registo por omissão ele próprio, deriva a lista de perfis de Options.Standards, e liberta o registo quando regressa. Os resultados voltam sempre pela ordem do input, seja qual for a ordem em que os workers terminaram. Para os formatos de relatório e o wrapper de linha de comandos em torno do mesmo motor, veja relatórios preflight PDF em lote com a CLI do PDFium Component, e para o que as verificações PDF/A em si cobrem, validação preflight PDF/A em Delphi
Como corre o RenderPagesParallel páginas verdadeiramente em paralelo?
O TPdf.RenderPagesParallel corre em paralelo porque os seus workers nunca partilham um módulo PDFium. O método primeiro grava o documento ativo numa loja de origem na thread chamadora. Cada worker depois copia a DLL PDFium carregada para um ficheiro de nome único na pasta temporária, carrega essa cópia com LoadLibrary, e inicializa-a. O Windows trata uma DLL carregada de um caminho diferente como um módulo diferente, por isso cada cópia recebe os seus próprios globais: a sua própria cache de fontes, o seu próprio módulo de páginas, o seu tudo. O worker abre o documento gravado no seu módulo privado, renderiza as suas páginas progressivamente com verificações de cancelamento entre passos, depois destrói a biblioteca, descarrega a cópia e apaga o ficheiro
O isolamento não é grátis, e as predefinições refletem-no. Cada worker paga uma cópia da DLL em disco, um segundo conjunto de globais PDFium em memória, e um parse fresco do documento. MaxWorkers = 0 significa no máximo 4 workers, MaxPixelsPerPage e MaxTotalOutputBytes limitam o output cru, e as opções de renderização invertida e duotone noturna são recusadas porque os buffers voltam crus. O resultado é um TPdfParallelRenderReport cujo array Results guarda um buffer de 32 bits top-down por página pedida, pela ordem do pedido
procedure RenderAllPages(Pdf: TPdf);
var
Options: TPdfParallelRenderOptions;
Report: TPdfParallelRenderReport;
Pages: array of Integer;
I: Integer;
begin
SetLength(Pages, Pdf.PageCount);
for I := 0 to High(Pages) do
Pages[I] := I + 1; // os números de página são de base um
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// O snapshot da origem é tomado no módulo partilhado, por isso segure o
// cadeado PDFium de todo o processo se outras threads também usarem TPdf
PdfiumLock.Acquire;
try
Report := Pdf.RenderPagesParallel(Pages, Options);
finally
PdfiumLock.Release;
end;
for I := 0 to High(Report.Results) do
if Report.Results[I].Status = pprsSucceeded then
SavePageBuffer(Report.Results[I]) // Width, Height, Stride, PixelFormat, Pixels
else
Writeln('Page ', Report.Results[I].PageNumber, ': ',
Report.Results[I].ErrorMessage);
end;
Note o cadeado à volta da chamada. Os módulos dos workers são privados, mas o passo de snapshot no início corre SaveAs no módulo partilhado a partir da thread chamadora. Se nada mais no seu processo tocar no TPdf em concorrência pode largar o cadeado; se algo tocar, o snapshot precisa da mesma proteção que todas as outras chamadas ao módulo partilhado
| Padrão | Seguro entre documentos | Trabalho PDFium corre em paralelo | Custo |
|---|---|---|---|
Um TPdf por thread, sem cadeado partilhado | Não | Sim, até corromper | Crashes intermitentes, estado do processo danificado |
| Um cadeado de todo o processo à volta de todas as chamadas PDFium | Sim | Não | A parte PDFium corre em série |
ValidatePdfFilesParallel desde a v3.125.1 | Sim | Não; a avaliação de regras é paralela | Parsing e preflight correm em série |
TPdf.RenderPagesParallel | Sim | Sim | Cópia da DLL, memória e um parse fresco por worker |
Como deve estruturar o seu próprio código PDFium multithread?
As suas próprias threads devem partilhar um cadeado de todo o processo e segurá-lo durante toda a vida de cada TPdf que usarem, ou então usar uma API do componente que isole o módulo por você. O cadeado tem de ser um único objeto para todo o processo, não um por thread, por formulário ou por documento; um cadeado que duas threads não partilham não protege nada. O padrão abaixo espelha o que o componente faz internamente desde a v3.125.1: criar, carregar, ler e libertar dentro do cadeado, depois fazer tudo o que não toca no PDFium fora dele
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // um cadeado para todo o processo
type
TTextExtractThread = class(TThread)
private
FFileName: string;
FText: string;
protected
procedure Execute; override;
public
constructor Create(const AFileName: string);
property ExtractedText: string read FText;
end;
constructor TTextExtractThread.Create(const AFileName: string);
begin
inherited Create(True);
FFileName := AFileName;
end;
procedure TTextExtractThread.Execute;
var
Pdf: TPdf;
Page: Integer;
Raw: TStringBuilder;
begin
Raw := TStringBuilder.Create;
try
PdfiumLock.Acquire;
try
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FFileName;
Pdf.Active := True;
if not Pdf.Active then
raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
for Page := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := Page;
Raw.AppendLine(Pdf.Text);
end;
finally
Pdf.Free; // fechar o documento também é trabalho PDFium
end;
finally
PdfiumLock.Release;
end;
// Nenhum PDFium abaixo desta linha, por isso esta parte corre em paralelo
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
Umas quantas regras mantêm o padrão honesto numa aplicação real:
- Ponha o
TPdf.Createe oFreedentro do cadeado, não só as chamadas óbvias. Carregar, fechar, leituras de propriedades comoPageCount, mudanças de página, extração de texto, renderização e gravação todas chegam ao módulo - Verifique o
Activedepois de o atribuir. Uma carga falhada deixa oActiveemFalse, e oLastLoadReport.ErrorMessagediz porquê - Segure o cadeado por documento em vez de por chamada. Um cadeado mais fino é possível em princípio, mas só se nenhum membro de
TPdfcorrer nunca fora dele, e a versão grosseira é a que o próprio componente usa - Mantenha o trabalho lento não PDFium, como escritas em base de dados, indexação e chamadas de rede, fora do cadeado, ou um consumidor lento serializa tudo
- Não trate o cadeado de renderização privado por instância como um substituto. Ele guarda um
TPdfcontra si mesmo e nada mais
A mesma cautela aplica-se a código que não escreveu como threads cruas. Futures em segundo plano são uma boa maneira de tirar renderizações compridas da thread de UI, como descrito em renderização PDF em segundo plano com futures canceláveis, mas o executor de futures não acrescenta um cadeado PDFium global próprio. Se vários futures podem comandar instâncias TPdf diferentes ao mesmo tempo, tome o mesmo cadeado de todo o processo dentro de cada worker, e trate um viewer na thread principal como mais um cliente do módulo partilhado. O uso entre instâncias através das APIs assíncronas não foi auditado separadamente, por isso o pressuposto conservador é que precisa da mesma serialização que threads escritas à mão. Quando precisar de paralelismo PDFium real para outra coisa além da renderização de páginas, processos worker separados dão a cada trabalho o seu próprio módulo por construção
Referência rápida: regras de threads PDFium para Delphi
- O estado inseguro do PDFium é de todo o módulo: a cache de fontes, o módulo de páginas e outros globais são partilhados por todos os documentos no processo
- Um
TPdfpor thread não isola nada; duas instâncias em duas threads ainda se podem corromper uma à outra - Os sintomas típicos são falhas de carga, access violations em código posterior,
External exception C000001D, e saídas com0xC0000409ou0xC0000374 - A corrupção persiste no processo, por isso a chamada que falha muitas vezes não é a que a causou
- O
ValidatePdfFilesParallelé seguro desde a v3.125.1; em versões anteriores useWorkerCount := 1 - O
TPdf.RenderPagesParallelé genuinamente paralelo porque cada worker carrega uma cópia isolada do módulo PDFium - As suas próprias threads, tasks e futures precisam de um cadeado de todo o processo cobrindo cada
TPdfdoCreateaoFree
O PDFium Component embrulha o motor PDFium para Delphi com preflight e validação em lote, renderização paralela isolada, trabalho em segundo plano cancelável e diagnósticos de carga detalhados. Detalhes e edições estão na página do produto PDFium Component