Artículo técnico

Bloqueo de WaitForIdle en renderizado async con PDFium

Un renderizado por lotes se congela a la mitad porque el ejecutor en PDFium Component no considera terminada una tarea hasta que su respuesta se ha despachado. Bajo padSynchronize esa respuesta se ejecuta en el hilo principal. Si el hilo principal se bloquea sin bombear CheckSynchronize, el trabajador espera al hilo principal mientras el hilo principal espera estar en reposo

La imagen en el depurador es inconfundible una vez que la has visto. Pausa el proceso congelado y el hilo principal se encuentra dentro de una espera sobre el evento de reposo, varios marcos por debajo de tu propio bucle por lotes. Cambia a cualquier hilo trabajador y se encuentra dentro de TThread.Synchronize, sosteniendo un resultado terminado que no puede entregar. Nada gira, ninguna CPU se está quemando, el proceso simplemente está estacionado. Este artículo trata sobre por qué ese estado existe en absoluto, y sobre tres reglas vecinas que deciden si un pool de trabajadores en Delphi sobre PDFium se comporta bien o muerde: qué acota realmente QueueCapacity, en qué orden tiene que cancelar el apagado, y qué no te compra el paralelismo en lo que respecta a la propiedad de objetos de PDFium

Por qué WaitForIdle cuelga al hilo principal

Cuelga porque el reposo en TPdfAsyncExecutor está definido para incluir el despacho de respuesta, no solo la finalización del trabajador. El contador en ejecución se incrementa en DequeueTask cuando un trabajador toma una tarea, y se decrementa en TaskFinished, que el trabajador llama solo después de que TPdfAsyncTaskOperation.Execute ha retornado. Ese método ejecuta el cuerpo del trabajador, registra el resultado, y luego despacha la respuesta según TPdfAsyncDispatchMode. Con padSynchronize el despacho es una llamada a TThread.Synchronize, así que Execute no retorna hasta que el hilo principal lo ha ejecutado

Delphi pone la segunda mitad de ese contrato sobre ti. TThread.Synchronize anexa el método a una cola global y bloquea al hilo que llama sobre un evento; algo en el hilo principal tiene que llamar a CheckSynchronize antes de que ese evento se señalice jamás. El bucle de mensajes de la VCL hace esto por ti entre mensajes, que es exactamente por qué el error es invisible durante el uso interactivo y aparece en el momento en que escribes un bucle por lotes bloqueante. Un hilo principal bloqueante es un hilo principal que ha abandonado el bucle de mensajes, y un hilo principal fuera del bucle de mensajes no está drenando a nadie

uses
  System.Classes, FPdfAsync, PDFium;

// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // the main thread now parks in a kernel wait

// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.

Finalización de tarea y reposo del ejecutor son dos hitos distintos

Están separados a propósito, y saber cuál de los dos estás esperando es toda la corrección. IPdfAsyncTask.WaitFor se satisface en el instante en que se decide el resultado del trabajador: Complete escribe el TPdfAsyncTaskState final y activa el evento de terminado antes de que se considere ninguna respuesta. TPdfAsyncExecutor.WaitForIdle se satisface después, una vez que tanto los conteos en cola como en ejecución son cero, y el conteo en ejecución no baja hasta que la respuesta ha aterrizado. Así que una tarea puede estar en patsSucceeded y ser observable a través de Snapshot mientras el ejecutor todavía está legítimamente ocupado

// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // release any pending padSynchronize reply
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

Una consecuencia que vale la pena interiorizar: una respuesta que lanza una excepción no reescribe la historia. DispatchReply captura la excepción y la almacena en ReplyErrorMessage, dejando State, CancellationReason y ErrorMessage exactamente como el trabajador los determinó. Un callback de interfaz de usuario que explota mientras pinta una miniatura, por lo tanto, nunca convierte un renderizado exitoso en uno fallido, y tu telemetría sigue reportando lo que el motor de renderizado realmente hizo. Si quieres la API en forma de callback alrededor de una sola operación en lugar de un pool, el renderizado en segundo plano con futuros cancelables cubre esa ruta

Acota QueueCapacity también a los trabajadores en ejecución

No. QueueCapacity en PDFium Component cuenta solo las tareas en cola, nunca las que ya se están ejecutando en un trabajador. Eso es deliberado: la capacidad está pensada para expresar contrapresión real sobre la línea de espera, y plegar los espacios fijos de concurrencia en el mismo número los contaría dos veces. Con cuatro trabajadores y una capacidad de ocho puedes tener doce tareas en vuelo, y GetStats reporta la división honestamente a través de QueuedCount y RunningCount

var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit never raises: it returns False when the waiting line is full or
  // the executor is already shutting down, and bumps RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
    // WorkerCount and is never charged against the capacity.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

Los cuatro carriles de TPdfAsyncPriority son estrictos, no ponderados. DequeueTask recorre desde papCritical hacia abajo hasta papLow y toma el primer carril no vacío, preservando el orden FIFO dentro de cada uno. Eso le da a una solicitud interactiva una forma limpia de saltar por delante de un lote que todavía no ha comenzado, pero nunca interrumpe trabajo que ya se está ejecutando, y quien invoca sigue alimentando papCritical puede matar de hambre a papLow indefinidamente. Reserva los dos carriles superiores para cosas que un humano está esperando visiblemente, y deja la exportación masiva en papNormal o por debajo. Usa Submit cuando una cola llena es un error de programación que merece un EPdfAsyncQueueFull, y TrySubmit cuando es una condición normal que pretendes manejar

Por qué Shutdown cancela fuera del lock del ejecutor

Porque cancelar dentro de él invertiría el orden de los locks y colgaría el propio apagado que intentas realizar. Shutdown(True) toma el lock del ejecutor, activa el indicador de apagado, y anexa cada tarea pendiente a un arreglo local de instantánea a través de AppendSnapshot. Luego libera el lock y solo después recorre la instantánea llamando a Cancel en cada entrada. Cancelar una tarea dispara callbacks de usuario registrados en su fuente de token, y esos callbacks son código de aplicación ordinario: pueden consultar GetStats, enviar trabajo compensatorio, o esperar el reposo. Cada uno de ellos vuelve a entrar en el lock del ejecutor, y un callback invocado mientras ese lock está sostenido se autobloquearía

La fuente de token obedece la misma disciplina un nivel más abajo. CancelWithReason toma el lock de la fuente, decide el único cancelador ganador, escribe Reason, CancellationMessage y CancelledAtTick, y solo entonces activa el indicador de cancelado de forma atómica. Publicar antes de activar es lo que hace segura la lectura de los metadatos: cualquier hilo que observe IsCancelled como True tiene garantizado encontrar una razón completa detrás de él, y quienes llaman después pierden la carrera, devuelven False, y no pueden sobrescribir la primera razón. Los callbacks registrados se capturan en instantánea y se limpian dentro del lock pero se invocan fuera de él, cada uno envuelto para que un manejador que falla no pueda suprimir al resto. Las tareas que ya están en ejecución nunca se matan; terminan de forma cooperativa cuando el cuerpo de su trabajador llama a continuación a ThrowIfCancelled, que es por qué Shutdown termina con un WaitForIdle que bombea antes de unirse a los hilos

Relajan los trabajadores en paralelo la propiedad de objetos de PDFium

No lo hacen, y este es el límite que más probablemente se malinterprete. TPdfAsyncExecutor programa trabajo; no hace ninguna afirmación sobre la afinidad de hilo de nada que toques dentro de ese trabajo. Una instancia viva de TPdf no se vuelve concurrentemente accesible porque dos trabajadores den la casualidad de llamarla, y el lock de renderizado interno es una protección contra llamadas de renderizado superpuestas, no una licencia para compartir un documento entre hilos. Renderizado o exportación en paralelo significa un TPdf por trabajador, creado y destruido dentro del trabajo

type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // one document instance per worker, never shared
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ownership moves to the reply stage
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

El costo es real y vale la pena nombrarlo: cada trabajador paga su propio análisis y su propia caché de páginas, así que la memoria escala con la cantidad de trabajadores en lugar de con la cantidad de documentos. Ese es el precio de un modelo donde un trabajador puede cancelarse o fallar sin corromper a nadie más. Si tus trabajadores sí comparten en cambio una instancia de documento del lado del visor, las reglas de bloqueo alrededor de eso se cubren en el lock de renderizado y las llamadas que lo omiten, y la ruta cancelable de un solo documento está en el renderizado progresivo cancelable

Extender una interfaz publicada sin romper la vtable

IPdfCancellationToken e IPdfCancellationTokenSource son interfaces al estilo COM que binarios externos ya pueden estar consumiendo, así que anexar un método a cualquiera de las dos desplazaría cada ranura posterior en la vtable y desviaría en silencio llamadas compiladas contra el diseño antiguo. Las capacidades de diagnóstico, espera, callback removible y cancelación atómica, por lo tanto, viven en IPdfCancellationTokenEx e IPdfCancellationTokenSourceEx, que heredan en lugar de modificar. New y Run mantienen su semántica original para quienes ya los invocan; el código nuevo recurre a NewEx, NewTimeout y RunEx cuando quiere CancelWithReason, WaitForCancellation o un IPdfCancellationRegistration gestionado. La herencia es la única forma segura de hacer crecer una interfaz publicada, y cuesta un tipo extra por generación

NewTimeout merece una nota honesta. Cada fuente de tiempo de espera posee un hilo ligero que espera sobre el evento de cancelación o el plazo, lo que ocurra primero. Para un puñado o unas pocas docenas de plazos eso es simple, de baja latencia e idéntico entre Delphi, Lazarus y C++Builder. Para miles de plazos cortos es la forma equivocada, y deberías impulsar la cancelación desde un solo temporizador a nivel de aplicación en lugar de mantener miles de hilos en espera

Ninguna de estas reglas es exótica una vez escrita, pero cada una de ellas es un incidente de producción cuando no lo está. Espera sobre el hito correcto y deja que algo bombee la cola de sincronización, lee QueueCapacity como un límite solo sobre la línea de espera, cancela fuera de tus locks, y dale a cada trabajador su propio documento. La capa asíncrona descrita aquí se incluye como parte del PDFium Component para Delphi, junto con las API de renderizado, texto y formularios que programa