Artigo Técnico

Thread safety do PDFium: por que locks por documento falham

O PDFium não é thread-safe a nível de módulo, então duas instâncias de TPdf trabalhando em dois arquivos diferentes em duas threads ainda podem corromper uma à outra. O PDFium Component para Delphi lida com isso de duas maneiras: desde a v3.125.1, o ValidatePdfFilesParallel serializa toda chamada nativa do PDFium atrás de um lock de processo inteiro, enquanto o TPdf.RenderPagesParallel dá a cada worker a própria cópia isolada do módulo PDFium. O bug que forçou o conserto era do pior tipo de intermitente. Um teste de validação em lote passava na maior parte do tempo, depois reportava um de dois arquivos bons como falho, depois crashava o próximo teste no mesmo processo com um access violation, e às vezes derrubava o runner inteiro com um exit code em vez de um stack trace. Nada estava errado com o teste, e nada estava errado com documento nenhum. A suposição estava errada: um TPdf por thread não é isolamento

Por que um TPdf por thread não basta?

Um TPdf por thread não basta porque o PDFium mantém o estado inseguro dele no módulo, não no documento. Cada TPdf possui o próprio handle FPDF_DOCUMENT, mas todo handle no processo é servido pela mesma DLL carregada, e essa DLL guarda singletons de processo: o font cache, o page module, e outras estruturas globais que carregamento, parsing e renderização de documentos todos tocam. Duas threads carregando dois arquivos sem relação são duas threads escrevendo no mesmo font cache ao mesmo tempo. Ninguém é dono desses dados do lado Delphi, então nada do lado Delphi pode travá-los por documento

O componente tem um lock, e é fácil tirar a conclusão errada dele. O TPdf embrulha os próprios caminhos de render dele numa critical section interna (EnterRenderLock / LeaveRenderLock, métodos privados do TPdf). Esse lock é por instância. Ele impede que duas threads dirijam o mesmo TPdf ao mesmo tempo, o que é um perigo real, mas ele não vê uma segunda instância noutra thread, então concorrência entre instâncias passa direto por ele. A regra geral é simples o bastante para caber numa linha: num único módulo PDFium carregado, no máximo uma thread pode estar dentro do PDFium a qualquer momento, independente de quantos documentos estejam abertos

Diagrama do PDFium Component de duas threads rodando instâncias TPdf separadas sobre documentos diferentes enquanto toda chamada converge para um único módulo pdfium.dll carregado, cujo font cache, page module e outros globals de processo são compartilhados, produzindo falhas de carregamento, access violations e saídas fail-fast
O PDFium mantém o estado inseguro dele no módulo, não no documento, então duas instâncias de TPdf em duas threads escrevem no mesmo font cache não importa quão sem relação sejam os arquivos

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 de worker e rodava Active := True mais a construção do relatório preflight concorrentemente no módulo compartilhado. Os sintomas vistos em builds Delphi e Free Pascal cobriram o espectro inteiro:

  • Um arquivo válido falha ao carregar, ou volta do lote como falho quando deveria passar
  • Um access violation aparece numa chamada posterior sem relação, muitas vezes num teste diferente ou num documento diferente
  • External exception C000001D aparece no Delphi. Esse código é STATUS_ILLEGAL_INSTRUCTION, levantado pela instrução ud2 que os macros CHECK e IMMEDIATE_CRASH internos do PDFium executam quando um invariante quebra
  • O processo sai com 0xC0000409 (fail-fast, reportado como stack buffer overrun) ou 0xC0000374 (heap corruption), sem exceção Delphi nenhuma

Os dois últimos pontos são o motivo de o bug ser tão difícil de prender. A validação paralela terminava, o estado global corrompido ficava para trás, e o próximo fixture no mesmo processo tropeçava nele. Numa rodada de regressão Delphi Win64, uma onda de falhas C000001D atingiu testes que nunca tocaram em validação em lote; eles eram simplesmente o primeiro código a usar o PDFium depois do dano. Os números medidos tornam a escala clara. Uma probe Delphi que rodava a mesma amostra por dois workers falhou em 122 de 160 documentos numa rodada e 138 de 160 noutra, e uma dessas rodadas levantou External exception C000001D na lata. Um caso de estresse de 8 documentos, 4 workers e 5 rodadas falhou ou crashou em 5 de 5 rodadas no Free Pascal Win64. Depois do conserto, a mesma probe falhou em 0 de 1.200 documentos

Como o ValidatePdfFilesParallel permanece seguro desde a v3.125.1

O ValidatePdfFilesParallel agora serializa a metade nativa de cada trabalho e mantém a metade gerenciada paralela. Todo worker toma uma critical section a nível de unit antes de criar o seu TPdf, e a segura através de FileName, Active := True, a construção do relatório preflight, e Free. Criação e destruição ficam dentro do lock de propósito: fechar um documento chama de volta para o módulo tanto quanto carregar. Uma vez que o worker tem um record TPdfPreflightReport capturado, ele libera o lock e avalia as regras de validação contra esse record, o que não toca em estado PDFium nenhum, então a avaliação de regras de um arquivo se sobrepõe ao trabalho PDFium do próximo

Diagrama do ValidatePdfFilesParallel do PDFium Component mostrando cada worker segurando uma critical section de processo inteiro através de create, load, preflight e free do TPdf, enquanto a avaliação de regras do relatório capturado roda fora do lock em paralelo, então a metade PDFium do lote é serial por design
Criação e destruição ficam dentro do lock porque fechar um documento chama de volta para o módulo, enquanto a avaliação do relatório não toca em estado PDFium e se sobrepõe ao próximo arquivo

Duas mudanças menores vieram com o conserto. Uma falha de carregamento agora lança EPdfError com LastLoadReport.ErrorMessage, então o ErrorMessage do item nomeia o problema real de parse em vez de um erro secundário "sem documento ativo". E o custo é dito com honestidade: a parte PDFium do lote agora é serial, então num lote dominado por parsing e preflight, workers extras compram pouco. Se você está numa versão anterior à v3.125.1, defina WorkerCount como 1; isso remove a concorrência e a corrupção junto

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, limitado a 8
    Options.Standards := [ppsPdfA];
    // Com um registry explícito, selecione você mesmo o perfil correspondente.
    // Uma lista Profiles vazia roda toda regra registrada, e regras de
    // standards que você não pré-checou 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 registry é o caminho mais curto: o ValidatePdfFilesParallel então cria o registry padrão ele mesmo, deriva a lista de perfis do Options.Standards, e libera o registry quando retorna. Os resultados sempre voltam na ordem de entrada, seja qual for a ordem em que os workers terminaram. Para os formatos de relatório e o wrapper de linha de comando em volta do mesmo motor, veja relatórios de preflight PDF em lote com a CLI do PDFium Component, e para o que as checagens PDF/A em si cobrem, validação de preflight PDF/A em Delphi

Como o RenderPagesParallel roda páginas de verdade em paralelo?

O TPdf.RenderPagesParallel roda em paralelo porque os workers dele nunca compartilham um módulo PDFium. O método primeiro salva o documento ativo numa store de origem na thread chamadora. Cada worker então copia a DLL PDFium carregada para um arquivo de nome único no diretório temp, carrega essa cópia com LoadLibrary, e a inicializa. O Windows trata uma DLL carregada de um caminho diferente como um módulo diferente, então cada cópia ganha os próprios globals: o próprio font cache, o próprio page module, o próprio tudo. O worker abre o documento salvo no módulo privado dele, renderiza as páginas dele progressivamente com checagens de cancelamento entre passos, depois destrói a biblioteca, descarrega a cópia e apaga o arquivo

Diagrama do RenderPagesParallel do PDFium Component em que a thread chamadora salva um snapshot do documento, depois cada worker copia a DLL PDFium para um arquivo temp único, carrega-a como um módulo separado com globals próprios, renderiza as páginas dele com checagens de cancelamento e descarrega a cópia
Paralelismo de verdade vem do isolamento de módulo: o Windows trata cada cópia da DLL como um módulo diferente, então os workers não compartilham nada exceto o snapshot que a thread chamadora salvou sob o lock

O isolamento não é de graça, e os padrões refletem isso. Cada worker paga por uma cópia da DLL no disco, um segundo conjunto de globals do PDFium na memória, e um parse novo do documento. MaxWorkers = 0 significa no máximo 4 workers, MaxPixelsPerPage e MaxTotalOutputBytes limitam a saída crua, e as opções de render invertido e duotone noturno são rejeitadas porque os buffers voltam crus. O resultado é um TPdfParallelRenderReport cujo array Results guarda um buffer top-down de 32 bits por página pedida, na 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;                 // números de página são base um

  Options := TPdfParallelRenderOptions.Default;
  Options.Dpi := 150;
  Options.MaxWorkers := 4;

  // O snapshot de origem é tirado no módulo compartilhado, então segure o
  // lock PDFium de todo o processo se outras threads também usam 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 lock em volta da chamada. Os módulos dos workers são privados, mas o passo de snapshot no começo roda o SaveAs no módulo compartilhado a partir da thread chamadora. Se nada mais no seu processo toca em TPdf concorrentemente você pode dispensar o lock; se algo toca, o snapshot precisa da mesma proteção que qualquer outra chamada de módulo compartilhado

PadrãoSeguro entre documentosTrabalho do PDFium roda em paraleloCusto
Um TPdf por thread, sem lock compartilhadoNãoSim, até corromperCrashes intermitentes, estado do processo danificado
Um lock de processo inteiro em volta de todas as chamadas PDFiumSimNãoA parte do PDFium é serial
ValidatePdfFilesParallel desde a v3.125.1SimNão; a avaliação de regras é paralelaParsing e preflight são seriais
TPdf.RenderPagesParallelSimSimCópia da DLL, memória e um parse novo por worker

Como estruturar o seu próprio código PDFium multithread?

As suas próprias threads devem compartilhar um lock de processo inteiro e segurá-lo por toda a vida de cada TPdf que usarem, ou então usar uma API do componente que isole o módulo para você. O lock tem de ser um único objeto para o processo todo, não um por thread, por form ou por documento; um lock que duas threads não compartilham não protege nada. O padrão abaixo espelha o que o componente faz internamente desde a v3.125.1: criar, carregar, ler e liberar dentro do lock, então fazer tudo que não toca no PDFium fora dele

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // um lock para o processo inteiro

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 do PDFium
      end;
    finally
      PdfiumLock.Release;
    end;
    // Nada de PDFium abaixo desta linha, então esta parte roda em paralelo
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

initialization
  PdfiumLock := TCriticalSection.Create;
finalization
  PdfiumLock.Free;

Algumas regras mantêm o padrão honesto numa aplicação real:

  • Ponha o TPdf.Create e o Free dentro do lock, não só as chamadas óbvias. Carregar, fechar, leituras de propriedades como PageCount, mudanças de página, extração de texto, renderização e salvar todos alcançam o módulo
  • Confira o Active depois de atribuí-lo. Um carregamento que falhou deixa o Active em False, e o LastLoadReport.ErrorMessage diz por quê
  • Segure o lock por documento em vez de por chamada. Locking mais fino é possível em princípio, mas só se nenhum membro de TPdf rodar jamais fora dele, e a versão grossa é a que o próprio componente usa
  • Mantenha trabalho lento não PDFium, como gravações em banco de dados, indexação e chamadas de rede, fora do lock, ou um consumidor lento serializa tudo
  • Não trate o lock de render privado por instância como substituto. Ele guarda um TPdf contra ele mesmo e nada mais

A mesma cautela vale para código que você não escreveu como threads cruas. Background futures são um bom jeito de manter renders longos fora da thread de UI, como descrito em renderização PDF em background com futures canceláveis, mas o executor de futures não adiciona um lock PDFium global próprio. Se vários futures podem dirigir instâncias de TPdf diferentes ao mesmo tempo, tome o mesmo lock de processo inteiro dentro de cada worker, e trate um viewer na thread principal como mais um cliente do módulo compartilhado. Uso entre instâncias pelas APIs assíncronas não foi auditado separadamente, então a suposição conservadora é de que ele precisa da mesma serialização que threads escritas à mão. Quando você precisa de paralelismo PDFium de verdade para algo além da renderização de páginas, processos worker separados dão a cada trabalho o próprio módulo dele por construção

Referência rápida: regras de threading do PDFium para Delphi

  • O estado inseguro do PDFium vale para o módulo inteiro: font cache, page module e outros globals são compartilhados por todo documento do processo
  • Um TPdf por thread não isola nada; duas instâncias em duas threads ainda podem corromper uma à outra
  • Os sintomas típicos são falhas de carregamento, access violations em código posterior, External exception C000001D, e saídas com 0xC0000409 ou 0xC0000374
  • A corrupção persiste no processo, então a chamada que falha muitas vezes não é a que a causou
  • O ValidatePdfFilesParallel é seguro desde a v3.125.1; em versões anteriores use WorkerCount := 1
  • O TPdf.RenderPagesParallel é paralelo de verdade porque cada worker carrega uma cópia isolada do módulo PDFium
  • As suas próprias threads, tasks e futures precisam de um lock de processo inteiro cobrindo cada TPdf do Create ao Free

O PDFium Component embrulha o motor PDFium para Delphi com preflight e validação em lote, renderização paralela isolada, trabalho em background cancelável e diagnósticos de carregamento detalhados. Detalhes e edições estão na página de produto do PDFium Component