Artículo técnico

Renderizado paralelo de páginas PDF en Delphi: seguridad de hilos

El renderizado de páginas PDF en paralelo desde Delphi se reduce a una regla: dar a cada hilo de trabajo 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 hilo de trabajo, de modo que una máquina multinúcleo convierte un trabajo de rasterización por lotes en un rendimiento que se escala casi al número de núcleos. Comparta una sola instancia entre hilos en su lugar, y la ejecución no se ralentizará de forma controlada, sino que corromperá la memoria y fallará

Este es el artículo al que debe recurrir cuando un trabajo nocturno tenga que convertir un PDF de 500 páginas en 500 imágenes PNG, la máquina tenga 16 núcleos inactivos y su primer intento honesto de implementarlo con 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 active, y el resto de este artículo analiza por qué la estructura segura tiene la forma que tiene y dónde se encuentra realmente el límite del aumento de velocidad

¿Es TPDFlib seguro para hilos en renderizado paralelo?

No, y vale la pena comprender la razón antes de diseñar en torno a ella. Una sola instancia de TPDFlib está declarada para su uso en un solo hilo, y el punto crítico es TPDFPageTree.GetPage: escribe en 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 hilo A puede estar a la mitad de la página 3 cuando el hilo 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 algunas páginas antes de fallar, que es la peor forma en que puede comportarse un error de este tipo

// 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 es no de manera determinante, que es exactamente por qué sobrevive a una prueba rápida y luego surge en una máquina cliente con un recuento de núcleos diferente y un documento más pesado. No hay bloqueo que pueda aplicar a RenderPageToFile para solucionar esto de manera económica, porque mantener un mutex a lo largo de toda la llamada de renderizado serializa el trabajo y descarta el paralelismo para el que vino

¿Por qué cada hilo de trabajo de renderizado necesita su propia instancia de TPDFlib?

Porque la instancia es la unidad de aislamiento. Una vez que cada hilo de trabajo posee un TPDFlib privado que cargó el archivo de forma independiente, cada uno tiene su propio árbol de páginas, su propio FPagePointer y su propio estado de renderizado, por lo que no hay nada compartido por lo que competir. Esa seguridad tiene un precio que debe evaluar de antemano: cada hilo de trabajo analiza todo el documento en la memoria, por lo que el consumo máximo es de aproximadamente N veces el costo de una sola instancia. Ocho hilos de trabajo en un PDF de 300 MB representan ocho análisis completos residentes a la vez, y en entradas muy grandes esa es la limitación que decide su cantidad de hilos de trabajo, 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 archivos PDF grandes sin análisis de documento completo suele ser una mejor opción que agregar más hilos de renderizado

La API de una sola llamada: RenderPagesToFilesParallel

losLab PDF Library empaqueta todo el patrón seguro detrás de un único método, por lo que para el caso común no tiene que escribir nada de forma manual. 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 hilos de trabajo 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 se basa en 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é ejecutar CoInitialize en cada hilo de trabajo?

GDI+ es el rasterizador debajo del renderizado de páginas, y GDI+ utiliza hilos de tipo apartment (apartment-threaded): espera que COM se inicialice en cualquier hilo que lo llame. El hilo principal de una aplicación VCL generalmente ya tiene esto configurado, pero un hilo de trabajo TTask recién creado no, y llamar a la ruta de renderizado desde un hilo no inicializado es una forma segura de provocar un fallo. Así, cada hilo de trabajo empareja un CoInitialize(nil) al entrar con un CoUninitialize al salir, delimitando toda su vida útil. Esta es la misma disciplina que necesita cualquier trabajo de GDI+ or COM fuera del hilo principal, y es la segunda mitad de lo que hace que el aislamiento por hilo de trabajo realmente se mantenga, siendo la primera mitad la instancia privada. La misma ruta de rasterización GDI+ impulsa los motores de un solo hilo cubiertos en elección de un motor de renderizado 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 hilos de trabajo es entregar a cada uno una porción fija de unas 62 páginas. losLab PDF Library no hace eso, y la razón es el equilibrio de carga. El costo por página varía enormemente: una página de texto de cuerpo se renderiza en milisegundos, una página de mapas vectoriales densos o una imagen escaneada a sangre completa puede tardar cincuenta veces más. Divida el trabajo en fragmentos fijos y el hilo de trabajo al que le toque 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 el fragmento más desafortunado, no por el promedio. En su lugar, cada hilo de trabajo reclama la siguiente página de un contador compartido bajo una sección crítica corta, la renderiza y regresa por otra, lo que mantiene cada núcleo ocupado hasta que se agota 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 estructurado entre hilos de trabajo

Depurar un lote que muere en la página 213 de 500 es terrible sin un registro, y un registro ingenuo es su propio error de concurrencia. losLab PDF Library incluye TPDFlibLogger, adjunto a través de la propiedad TPDFlib.Logger y establecido en nil de forma predeterminada para que la ruta sin registro no tenga costo. Es de tipo callback: usted configura OnLog y enruta los registros a donde el host desee, filtrados por un nivel llDebug / llInfo / llWarn / llError, y PDFlibErrorMessage convierte los códigos numéricos brutos en texto legible para que un registro de Error se lea como algo más que un número entero básico. El destino de archivo opcional es el único recurso compartido, y está protegido por una TCriticalSection precisamente para que varios hilos de trabajo puedan añadir datos a un único archivo de registro de forma segura. Tenga en cuenta el límite real: solo ese destino de archivo está sincronizado, por lo que si comparte un único registro a través de un grupo personalizado y su OnLog interactúa con la interfaz de usuario, aún tendrá que dirigir eso de 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 se debería esperar realmente?

Sea honesto consigo mismo sobre a dónde se va el tiempo, porque el renderizado en paralelo solo compensa cuando el trabajo está genuinamente limitado por la CPU. La salida de alta resolución (DPI) y las páginas vectoriales complejas o con degradados consumen mucho procesamiento, y se escalan de manera casi lineal con el número de núcleos hasta que se satura la CPU. Las páginas simples son una historia diferente: ahí la sobrecarga de LoadFromFile por hilo de trabajo, más el costo de disco al escribir los archivos de salida, pueden superar el renderizado en sí, y ocho hilos de trabajo sobrecargando un solo disco lento pueden terminar más tarde que un bucle secuencial limpio. Establezca MaxWorkers en el número de núcleos físicos de su CPU en lugar de en algo idealista, vigile la memoria cuando el PDF de origen sea grande y, si un lote resulta estar limitado por la E/S (I/O-bound), la solución es un almacenamiento más rápido o menos hilos de trabajo, no más hilos. Utilizado en los trabajos para los que fue diseñado, la ruta de renderizado por lotes mostrada aquí forma parte de la losLab PDF Library estándar para Delphi y C++Builder, y convierte núcleos inactivos en páginas finalizadas sin ninguna de las trampas de seguridad de hilos que de otro modo tendría que resolver por sí mismo