Artigo Técnico

Thread safety no PDFium: cadeados por documento falham

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

Diagrama PDFium Component de duas threads a correr instâncias TPdf separadas sobre documentos diferentes enquanto toda a chamada converge num único módulo pdfium.dll carregado cuja cache de fontes, módulo de páginas e outros globais de todo o processo são partilhados, produzindo falhas de carga, access violations e saídas fail-fast
o PDFium guarda o seu estado inseguro no módulo, não no documento, por isso duas instâncias TPdf em duas threads escrevem na mesma cache de fontes não importa quão sem relação sejam os ficheiros

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 C000001D aparece em Delphi. Esse código é STATUS_ILLEGAL_INSTRUCTION, lançado pela instrução ud2 que os macros CHECK e IMMEDIATE_CRASH internos do PDFium executam quando um invariante se parte
  • O processo sai com 0xC0000409 (fail-fast, reportado como stack buffer overrun) ou 0xC0000374 (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

Diagrama ValidatePdfFilesParallel do PDFium Component a mostrar cada worker a segurar uma secção crítica de todo o processo através de create, load, preflight e free do TPdf enquanto a avaliação de regras do relatório capturado corre fora do cadeado em paralelo, por isso a metade PDFium do lote é serial por desenho
criação e destruição ficam dentro do cadeado 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 sobrepõe-se ao ficheiro 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

Diagrama RenderPagesParallel do PDFium Component em que a thread chamadora grava um snapshot do documento, depois cada worker copia a DLL PDFium para um ficheiro temporário único, carrega-a como um módulo separado com os seus próprios globais, renderiza as suas páginas com verificações de cancelamento e descarrega a cópia
o paralelismo real vem do isolamento de módulos: o Windows trata cada cópia da DLL como um módulo diferente, por isso os workers não partilham nada senão o snapshot que a thread chamadora gravou sob o cadeado

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ãoSeguro entre documentosTrabalho PDFium corre em paraleloCusto
Um TPdf por thread, sem cadeado partilhadoNãoSim, até corromperCrashes intermitentes, estado do processo danificado
Um cadeado de todo o processo à volta de todas as chamadas PDFiumSimNãoA parte PDFium corre em série
ValidatePdfFilesParallel desde a v3.125.1SimNão; a avaliação de regras é paralelaParsing e preflight correm em série
TPdf.RenderPagesParallelSimSimCó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.Create e o Free dentro do cadeado, 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 gravação todas chegam ao módulo
  • Verifique o Active depois de o atribuir. Uma carga falhada deixa o Active em False, e o LastLoadReport.ErrorMessage diz 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 TPdf correr 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 TPdf contra 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 TPdf por 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 com 0xC0000409 ou 0xC0000374
  • 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 use WorkerCount := 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 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 segundo plano cancelável e diagnósticos de carga detalhados. Detalhes e edições estão na página do produto PDFium Component