Artículo técnico

Renderización de páginas PDF en paralelo en Delphi: seguridad de hilos

Renderizar páginas PDF en paralelo desde Delphi se reduce a una sola regla: dar a cada hilo de trabajo (worker thread) su propio renderizador. losLab PDF Library expone RenderPagesToFilesParallel exactamente para esa tarea, distribuyendo un rango de páginas en un grupo de TTask con una instancia de TPDFlib por cada trabajador, de modo que un equipo multinúcleo convierte un trabajo de rasterización por lotes en un rendimiento que escala cerca del número de núcleos. Si en su lugar comparte una sola instancia entre hilos, la ejecución no se ralentizará de forma progresiva, sino que corromperá la memoria y fallará

Este es el artículo al que debe acudir cuando un trabajo nocturno tiene que convertir un PDF de 500 páginas en 500 imágenes PNG, el equipo tiene 16 núcleos inactivos y su primer intento honesto de implementar hilos falló dentro de GDI+. La versión corta es que la seguridad de hilos aquí es una propiedad estructural, no una bandera que se configure, y el resto de este artículo explica por qué el esquema seguro tiene la forma que tiene y dónde se encuentra realmente el límite del aumento de velocidad

¿Es TPDFlib seguro para hilos en la renderización paralela?

No, y vale la pena comprender la razón antes de diseñar en torno a ella. Una sola instancia de TPDFlib se declara para uso en un solo hilo, y el punto crítico es TPDFPageTree.GetPage: escribe un campo compartido FPagePointer en la instancia como un efecto secundario de seleccionar una página. Dos hilos que llaman a la misma instancia compiten por ese campo, por lo que el trabajador A puede estar a la mitad de la página 3 cuando el trabajador B vuelve a apuntar el árbol de páginas a la página 40. Nada en la API le impide escribir el código siguiente, e incluso se ejecutará durante unas pocas páginas antes de fallar, lo cual es la peor manera en que se puede comportar un error como este

// DO NOT do this: one shared instance, many threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // every thread reenters the same instance -> data race on FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

La falla no es determinista, que es exactamente por qué sobrevive a una prueba rápida y luego surge en una máquina cliente con una cantidad diferente de núcleos y un documento más pesado. Tampoco hay ningún bloqueo que pueda aplicar a RenderPageToFile para solucionar esto de manera sencilla, porque mantener una exclusión mutua en toda la llamada de renderización serializa el trabajo y descarta el paralelismo buscado

¿Por qué cada trabajador de renderización necesita su propia instancia de TPDFlib?

Porque la instancia es la unidad de aislamiento. Una vez que cada trabajador posee una instancia privada de TPDFlib que cargó el archivo de manera independiente, cada uno tiene su propio árbol de páginas, su propio FPagePointer y su propio estado de renderización, por lo que no hay nada compartido por lo cual competir. Esa seguridad tiene un precio que debe evaluar de antemano: cada trabajador analiza el documento completo en la memoria, por lo que el consumo máximo es de aproximadamente N veces el costo de una sola instancia. Ocho trabajadores en un PDF de 300 MB significan ocho análisis completos residentes al mismo tiempo, y en entradas muy grandes esa es la limitación que decide su cantidad de trabajadores, no la CPU. Cuando el documento es enorme y está limitado por la memoria en lugar de la CPU, la ruta de acceso directo en procesamiento de grandes archivos PDF sin análisis completo del documento suele ser una mejor opción que agregar más hilos de renderización

La API de una sola llamada: RenderPagesToFilesParallel

losLab PDF Library empaqueta todo el patrón seguro detrás de un solo método, por lo que para el caso común usted no tiene que implementar nada manualmente. RenderPagesToFilesParallel toma el nombre del archivo y la contraseña, una resolución DPI, una página de inicio y fin inclusivas, un valor de Options que se pasa directamente a la ruta de rasterización por página, un patrón de salida donde %p se reemplaza por el número de página, y un límite de trabajadores donde cualquier valor igual o inferior a cero significa automático. Devuelve la cantidad de páginas renderizadas con éxito, y es una ruta exclusiva para Windows porque depende de CoInitialize y GDI+

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxWorkers = 0 -> auto: min(page count, CPU cores)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

¿Por qué CoInitialize en cada hilo de trabajo?

GDI+ es el rasterizador debajo de la renderización de páginas, y GDI+ es de hilos de apartamento (apartment-threaded): espera que COM esté inicializado en cualquier hilo que realice llamadas a él. El hilo principal de una aplicación VCL usualmente ya tiene esto configurado, pero un trabajador de TTask recién creado no, y llamar a la ruta de renderización desde un hilo no inicializado es una forma segura de fallar. Por lo tanto, cada trabajador empareja un CoInitialize(nil) al entrar con un CoUninitialize al salir, delimitando toda su vida útil. Esta es la misma disciplina que requiere cualquier trabajo de GDI+ o COM fuera del hilo principal, y es la segunda mitad de lo que hace que el aislamiento por trabajador realmente se mantenga, siendo la primera mitad la instancia privada. La misma ruta de rasterización de GDI+ controla los motores de un solo hilo cubiertos en elección de un motor de renderización para salida PDF

Fragmentación estática frente a reclamo dinámico de páginas

La forma obvia de dividir 500 páginas entre 8 trabajadores es entregar a cada uno una porción fija de aproximadamente 62 páginas. losLab PDF Library no hace eso, y la razón es el balance de carga. El costo de procesar una página varía enormemente: una página de texto de cuerpo se renderiza en milisegundos, mientras que una página de mapas vectoriales densos o una imagen escaneada a sangrado completo puede tardar cincuenta veces más. Si divide el trabajo en porciones fijas, el trabajador al que le toque dibujar la porción pesada se ejecutará mucho después de que los demás hayan quedado inactivos, por lo que su tiempo total estará determinado por la porción más desfavorable, no por el promedio. En su lugar, cada trabajador reclama la página siguiente de un contador compartido bajo una sección crítica corta, la renderiza y regresa por otra, lo que mantiene ocupados a todos los núcleos hasta que se agote todo el rango

// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ is apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // one private instance per worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // claim the next page atomically
          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;

Registro esctructurado a través de hilos de trabajo

Depurar un lote que falla en la página 213 de 500 es problemático sin un registro, y un registro básico representa su propio error de concurrencia. losLab PDF Library incluye TPDFlibLogger, asociado a través de la propiedad TPDFlib.Logger y establecido en nil por defecto para que la ruta sin registro se mantenga con costo cero. Funciona primero por devoluciones de llamada (callbacks): usted configura OnLog y enruta los registros a donde el host lo requiera, filtrados por un nivel llDebug / llInfo / llWarn / llError, y PDFlibErrorMessage convierte los códigos numéricos brutos en texto legible para humanos de modo que un registro de tipo Error se lee como algo más que un entero simple. El destino de archivo (sink) opcional es el único recurso compartido y está protegido por una sección crítica TCriticalSection precisamente para que varios trabajadores puedan agregar información a un solo archivo de registro de manera segura. Tenga en cuenta el límite real: solo ese destino de archivo está sincronizado, por lo que si comparte un registro en un grupo implementado manualmente y su OnLog interactúa con la interfaz de usuario, usted seguirá teniendo que coordinar ese regreso al hilo principal por su cuenta

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // optional shared sink (lock-guarded)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // marshal to the UI thread yourself; OnLog fires on worker threads
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil by default; zero-cost when unset
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

¿Cuánto aumento de velocidad debería esperar realmente?

Sea honest consigo mismo sobre a dónde va el tiempo, porque la renderización paralela solo vale la pena cuando el trabajo está genuinamente limitado por la CPU. La salida de alta resolución DPI y las páginas complejas con vectores o sombreados consumen muchos recursos de cómputo, y se escalan de manera casi lineal con el número de núcleos hasta que se satura la CPU. Las páginas sencillas son una historia diferente: allí la sobrecarga de LoadFromFile por trabajador, más el costo del disco de escribir los archivos de salida, pueden absorber la renderización misma, y ocho trabajadores saturando un disco lento pueden terminar más lento que un bucle secuencial limpio. Establezca MaxWorkers en su número físico de núcleos en lugar de algo ambicioso, observe la memoria cuando el PDF de origen sea grande y si un lote resulta estar limitado por la E/S, la solución es un almacenamiento más rápido o menos trabajadores, no más hilos. Utilizada en los trabajos para los que fue creada, la ruta de renderización por lotes que se muestra aquí es parte de la versión estándar de losLab PDF Library para Delphi y C++Builder, y convierte núcleos inactivos en páginas terminadas sin ninguno de los problemas de seguridad de hilos que de otro modo tendría que resolver por su cuenta