Artículo técnico

Thread safety de PDFium: los candados por documento fallan

PDFium no es thread-safe a nivel de módulo, así que dos instancias de TPdf trabajando sobre dos archivos distintos en dos hilos pueden corromperse entre sí igual. PDFium Component para Delphi lo maneja de dos maneras: desde v3.125.1, ValidatePdfFilesParallel serializa cada llamada nativa a PDFium detrás de un candado de todo el proceso, mientras que TPdf.RenderPagesParallel le da a cada worker su propia copia aislada del módulo PDFium. El bug que forzó la corrección era del peor tipo de intermitente. Una prueba de validación por lotes pasaba la mayoría de las veces, luego reportaba como fallido uno de dos archivos buenos, luego crasheaba la siguiente prueba en el mismo proceso con un access violation, y a veces tiraba abajo todo el runner con un código de salida en vez de un stack trace. Nada estaba mal en la prueba, y nada estaba mal en ningún documento suelto. El supuesto estaba mal: un TPdf por hilo no es aislamiento

¿Por qué no basta un TPdf por hilo?

Un TPdf por hilo no basta porque PDFium guarda su estado inseguro en el módulo, no en el documento. Cada TPdf es dueño de su propio handle FPDF_DOCUMENT, pero cada handle del proceso lo atiende la misma DLL cargada, y esa DLL carga singletons de proceso: la caché de fuentes, el page module, y otras estructuras globales que la carga de documentos, el parseo y el renderizado tocan todos. Dos hilos cargando dos archivos sin relación son dos hilos escribiendo en la misma caché de fuentes al mismo tiempo. Nadie es dueño de esos datos del lado Delphi, así que nada del lado Delphi puede bloquearlos por documento

El componente sí tiene un candado, y es fácil sacar la conclusión equivocada de él. TPdf envuelve sus propias rutas de render en una sección crítica interna (EnterRenderLock / LeaveRenderLock, métodos privados de TPdf). Ese candado es por instancia. Evita que dos hilos manejen el mismo TPdf a la vez, que es un peligro real, pero no puede ver una segunda instancia en otro hilo, así que la concurrencia entre instancias pasa de largo directamente. La regla general es simple de enunciar en una línea: en un solo módulo PDFium cargado, a lo sumo un hilo puede estar dentro de PDFium en cualquier momento, sin importar cuántos documentos estén abiertos

Diagrama de PDFium Component de dos hilos corriendo instancias TPdf separadas sobre documentos distintos mientras cada llamada converge en un módulo pdfium.dll cargado cuya caché de fuentes, page module y demás globales de proceso son compartidos, produciendo fallos de carga, access violations y salidas fail-fast
PDFium guarda su estado inseguro en el módulo, no en el documento, así que dos instancias TPdf en dos hilos escriben en la misma caché de fuentes sin importar lo poco relacionados que estén los archivos

¿Cómo se ve la corrupción entre documentos en un proceso Delphi?

La corrupción entre documentos se ve como una mezcla aleatoria de fallos sin relación, y el daño sobrevive al código que lo causó. Antes de v3.125.1, ValidatePdfFilesParallel creaba un TPdf por hilo worker y corría Active := True más la construcción del reporte preflight concurrentemente sobre el módulo compartido. Los síntomas vistos en builds Delphi y Free Pascal por igual cubrían todo el rango:

  • Un archivo válido falla al cargar, o vuelve del lote como fallido cuando debía pasar
  • Un access violation asoma en una llamada posterior sin relación, a menudo en otra prueba u otro documento
  • External exception C000001D aparece en Delphi. Ese código es STATUS_ILLEGAL_INSTRUCTION, levantado por la instrucción ud2 que los macros internos CHECK y IMMEDIATE_CRASH de PDFium ejecutan cuando se rompe un invariante
  • El proceso sale con 0xC0000409 (fail-fast, reportado como stack buffer overrun) o 0xC0000374 (corrupción de heap), sin ninguna excepción Delphi

Los últimos dos puntos son la razón de que el bug fuera tan difícil de clavar. La validación paralela terminaba, el estado global corrupto se quedaba atrás, y el siguiente fixture en el mismo proceso se tropezaba con él. En una corrida de regresión Delphi Win64, una oleada de fallos C000001D golpeó pruebas que jamás tocaron la validación por lotes; eran simplemente el primer código en usar PDFium después del daño. Los números medidos dejan la escala clara. Una sonda Delphi que corría la misma muestra por dos workers falló 122 de 160 documentos en una corrida y 138 de 160 en otra, y una de esas corridas levantó External exception C000001D de plano. Un caso de estrés de 8 documentos, 4 workers y 5 rondas falló o crasheó en 5 de 5 corridas en Free Pascal Win64. Después de la corrección, la misma sonda falló 0 de 1,200 documentos

Cómo ValidatePdfFilesParallel se mantiene seguro desde v3.125.1

ValidatePdfFilesParallel ahora serializa la mitad nativa de cada trabajo y mantiene la mitad administrada en paralelo. Cada worker toma una sección crítica a nivel de unidad antes de crear su TPdf, y la conserva a través de FileName, Active := True, la construcción del reporte preflight, y Free. La creación y la destrucción están dentro del candado a propósito: cerrar un documento vuelve a llamar al módulo igual que cargarlo. Una vez que el worker tiene capturado un record TPdfPreflightReport, suelta el candado y evalúa las reglas de validación contra ese record, lo cual no toca estado de PDFium, así que la evaluación de reglas de un archivo se solapa con el trabajo PDFium del siguiente

Diagrama de ValidatePdfFilesParallel de PDFium Component mostrando a cada worker sosteniendo una sección crítica de todo el proceso a través de create, load, preflight y free de TPdf mientras la evaluación de reglas del reporte capturado corre fuera del candado en paralelo, así que la mitad PDFium del lote es serial por diseño
La creación y la destrucción se quedan dentro del candado porque cerrar un documento vuelve a llamar al módulo, mientras que la evaluación del reporte no toca estado de PDFium y se solapa con el siguiente archivo

Dos cambios menores vinieron con la corrección. Un fallo de carga ahora lanza EPdfError con LastLoadReport.ErrorMessage, así que el ErrorMessage del ítem nombra el problema real de parseo en vez de un error secundario de "no active document". Y el costo se enuncia con honestidad: la parte PDFium del lote ahora es serial, así que en un lote dominado por parseo y preflight, workers extra compran poco. Si está en una versión anterior a v3.125.1, ponga WorkerCount en 1; eso elimina la concurrencia y con ella la corrupción

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 = conteo de procesadores, acotado a 8
    Options.Standards := [ppsPdfA];
    // Con un registry explícito, escoja usted el perfil que corresponda.
    // Una lista Profiles vacía corre cada regla registrada, y las reglas de
    // estándares que usted no preflighteó reportan "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;

Pasar nil como registry es la ruta corta: ValidatePdfFilesParallel entonces crea el registry por defecto él mismo, deriva la lista de perfiles de Options.Standards, y libera el registry al retornar. Los resultados siempre vuelven en orden de entrada, sea cual sea el orden en que terminaron los workers. Para los formatos de reporte y el wrapper de línea de comandos sobre el mismo engine, vea reportes preflight PDF por lotes con el CLI de PDFium Component, y para qué cubren los chequeos PDF/A en sí, validación preflight PDF/A en Delphi

¿Cómo corre RenderPagesParallel páginas de verdad en paralelo?

TPdf.RenderPagesParallel corre en paralelo porque sus workers jamás comparten un módulo PDFium. El método primero guarda el documento activo en un almacén de origen en el hilo que llama. Cada worker después copia la DLL PDFium cargada a un archivo de nombre único en el directorio temporal, carga esa copia con LoadLibrary, y la inicializa. Windows trata una DLL cargada desde una ruta distinta como un módulo distinto, así que cada copia recibe sus propios globales: su propia caché de fuentes, su propio page module, su propio todo. El worker abre el documento guardado en su módulo privado, renderiza sus páginas progresivamente con chequeos de cancelación entre pasos, luego destruye la librería, descarga la copia y borra el archivo

Diagrama de RenderPagesParallel de PDFium Component donde el hilo que llama guarda un snapshot del documento, luego cada worker copia la DLL PDFium a un archivo temporal único, la carga como módulo separado con sus propios globales, renderiza sus páginas con chequeos de cancelación y descarga la copia
El paralelismo real sale del aislamiento de módulos: Windows trata cada copia de la DLL como un módulo distinto, así que los workers no comparten nada salvo el snapshot que el hilo que llama guardó bajo el candado

El aislamiento no es gratis, y los valores por defecto lo reflejan. Cada worker paga una copia de la DLL en disco, un segundo juego de globales PDFium en memoria, y un parseo fresco del documento. MaxWorkers = 0 significa a lo sumo 4 workers, MaxPixelsPerPage y MaxTotalOutputBytes acotan la salida cruda, y las opciones de render invertido y duotono nocturno se rechazan porque los buffers se devuelven crudos. El resultado es un TPdfParallelRenderReport cuyo array Results guarda un buffer de 32 bits top-down por página pedida, en orden de petición

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;                 // los números de página son base 1

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

  // El snapshot de origen se toma en el módulo compartido, así que tome el
  // candado PDFium de proceso si otros hilos también usan 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 el candado alrededor de la llamada. Los módulos de los workers son privados, pero el paso de snapshot al comienzo corre SaveAs sobre el módulo compartido desde el hilo que llama. Si nada más en su proceso toca TPdf concurrentemente puede quitar el candado; si algo lo toca, el snapshot necesita la misma protección que cualquier otra llamada al módulo compartido

PatrónSeguro entre documentosEl trabajo PDFium corre en paraleloCosto
Un TPdf por hilo, sin candado compartidoNoSí, hasta que corrompeCrashes intermitentes, estado de proceso dañado
Un candado de proceso alrededor de todas las llamadas PDFiumSíNoLa parte PDFium es serial
ValidatePdfFilesParallel desde v3.125.1SíNo; la evaluación de reglas es paralelaEl parseo y el preflight son seriales
TPdf.RenderPagesParallelSíSíCopia de DLL, memoria y un parseo fresco por worker

¿Cómo debe estructurar su propio código PDFium multihilo?

Sus propios hilos deben compartir un candado de todo el proceso y conservarlo durante toda la vida de cada TPdf que usen, o bien usar una API del componente que aisle el módulo por usted. El candado tiene que ser un solo objeto para todo el proceso, no uno por hilo, por formulario o por documento; un candado que dos hilos no comparten no protege nada. El patrón de abajo refleja lo que el componente hace internamente desde v3.125.1: crear, cargar, leer y liberar dentro del candado, y luego hacer todo lo que no toca PDFium fuera de él

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

var
  PdfiumLock: TCriticalSection;        // un candado para todo el proceso

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;                      // cerrar el documento también es trabajo PDFium
      end;
    finally
      PdfiumLock.Release;
    end;
    // Nada de PDFium bajo esta línea, así que esta parte corre en paralelo
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Unas cuantas reglas mantienen honesto el patrón en una aplicación real:

  • Ponga TPdf.Create y Free dentro del candado, no solo las llamadas obvias. Cargar, cerrar, lecturas de propiedades como PageCount, cambios de página, extracción de texto, renderizado y guardado todos alcanzan al módulo
  • Verifique Active después de asignarlo. Una carga fallida deja Active en False, y LastLoadReport.ErrorMessage dice por qué
  • Sostenga el candado por documento en vez de por llamada. Un bloqueo más fino es posible en principio, pero solo si ningún miembro de TPdf corre jamás fuera de él, y la versión gruesa es la que el propio componente usa
  • Mantenga el trabajo lento que no sea PDFium, como escrituras a base de datos, indexado y llamadas de red, fuera del candado, o un solo consumidor lento serializará todo
  • No trate el candado de render privado por instancia como un sustituto. Protege un TPdf contra sí mismo y nada más

La misma cautela aplica al código que usted no escribió como hilos crudos. Los futures en segundo plano son una buena manera de mantener los renders largos fuera del hilo de UI, como se describe en render PDF en segundo plano con futures cancelables, pero el executor de futures no agrega un candado global de PDFium propio. Si varios futures pueden manejar distintas instancias de TPdf al mismo tiempo, tome el mismo candado de proceso dentro de cada worker, y trate un visor en el hilo principal como un cliente más del módulo compartido. El uso entre instancias por las APIs asíncronas no ha sido auditado por separado, así que el supuesto conservador es que necesita la misma serialización que los hilos escritos a mano. Cuando necesite paralelismo PDFium real para algo que no sea renderizar páginas, procesos worker separados le dan a cada trabajo su propio módulo por construcción

Referencia rápida: reglas de hilos de PDFium para Delphi

  • El estado inseguro de PDFium es de todo el módulo: la caché de fuentes, el page module y otros globales son compartidos por cada documento del proceso
  • Un TPdf por hilo no aísla nada; dos instancias en dos hilos pueden corromperse entre sí igual
  • Los síntomas típicos son fallos de carga, access violations en código posterior, External exception C000001D, y salidas con 0xC0000409 o 0xC0000374
  • La corrupción persiste en el proceso, así que la llamada que falla a menudo no es la que la causó
  • ValidatePdfFilesParallel es seguro desde v3.125.1; en versiones anteriores use WorkerCount := 1
  • TPdf.RenderPagesParallel es genuinamente paralelo porque cada worker carga una copia aislada del módulo PDFium
  • Sus propios hilos, tareas y futures necesitan un candado de proceso que cubra cada TPdf de Create a Free

PDFium Component envuelve el engine PDFium para Delphi con preflight y validación por lotes, renderizado paralelo aislado, trabajo en segundo plano cancelable y diagnósticos de carga detallados. Detalles y ediciones están en la página de producto de PDFium Component