Artículo técnico

Deadlock de WaitForIdle en renderizado async Delphi PDFium

Un renderizado por lotes se congela a mitad de camino porque el ejecutor de PDFium Component no considera una tarea terminada 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 worker espera al hilo principal mientras el hilo principal espera a estar inactivo

La imagen en el depurador es inconfundible una vez que la has visto. Pausa el proceso congelado y el hilo principal está sentado dentro de una espera sobre el evento de inactividad, varios frames por debajo de tu propio bucle por lotes. Cambia a cualquier hilo worker y está sentado dentro de TThread.Synchronize, sosteniendo un resultado terminado que no puede entregar. Nada está girando, ninguna CPU se está quemando, el proceso simplemente está aparcado. Este artículo trata sobre por qué existe ese estado en absoluto, y sobre tres reglas vecinas que deciden si un pool de workers Delphi sobre PDFium se comporta 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 PDFium

¿Por qué WaitForIdle cuelga el hilo principal?

Se cuelga porque la inactividad en TPdfAsyncExecutor está definida para incluir el despacho de la respuesta, no solo la finalización del worker. El contador de ejecución se incrementa en DequeueTask cuando un worker recoge una tarea, y se decrementa en TaskFinished, al que el worker llama solo después de que TPdfAsyncTaskOperation.Execute haya vuelto. Ese método ejecuta el cuerpo del worker, 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 la haya ejecutado

Delphi te pone a ti la segunda mitad de ese contrato. TThread.Synchronize añade el método a una cola global y bloquea el 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 bug es invisible durante el uso interactivo y aparece en el momento en que escribes un bucle por lotes que bloquea. Un hilo principal que bloquea 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

Diagrama del interbloqueo WaitForIdle en un pool de trabajadores Delphi de PDFium donde el hilo principal espera a idle mientras el trabajador se bloquea en TThread.Synchronize
Cada subproceso espera en una condición que solo el otro puede liberar, de modo que todo el lote se aparca a coste de CPU cero
uses
  System.Classes, FPdfAsync, PDFium;

// La forma que se cuelga: una respuesta sincronizada más un hilo principal bloqueante
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // el hilo principal ahora se aparca en una espera del kernel

// Mientras tanto TPdfAsyncTaskOperation.Execute ha llegado a:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// que pone DispatchReply en cola y espera a que el hilo principal la drene.
// El hilo principal no drena nada, así que ambos lados esperan para siempre.

Finalización de tarea e inactividad 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 solución. IPdfAsyncTask.WaitFor se satisface en el instante en que se decide el resultado del worker: Complete escribe el TPdfAsyncTaskState final y activa el evento de terminado antes de que se considere ninguna respuesta. TPdfAsyncExecutor.WaitForIdle se satisface más tarde, una vez que los contadores de en cola y en ejecución son ambos cero, y el de ejecución no baja hasta que la respuesta ha llegado. Así que una tarea puede estar en estado patsSucceeded y ser observable a través de Snapshot mientras el ejecutor sigue legítimamente ocupado

// TPdfAsyncExecutor.WaitForIdle ya bombea por ti: espera en el evento
// de inactividad en rodajas cortas y llama a CheckSynchronize(0) entre ellas.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Cualquier espera de hilo principal hecha a mano tiene que hacer lo mismo explícitamente.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // libera cualquier respuesta padSynchronize pendiente
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

Una consecuencia que merece 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 los determinó el worker. Un callback de UI que explota mientras pinta una miniatura por 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 con forma de callback en torno a una única operación en lugar de un pool, el renderizado en segundo plano con futuros cancelables cubre esa ruta

¿Acota QueueCapacity también a los workers en ejecución?

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

Diagrama que muestra QueueCapacity acotando ocho tareas PDFium en cola en la línea de espera mientras cuatro trabajadores Delphi corren como presupuesto separado
Capacity mide solo la fila de espera, y GetStats informa de los recuentos en cola y en ejecución por separado
var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit nunca genera una excepción: devuelve False cuando la línea de espera está llena o
  // el ejecutor ya se está apagando, e incrementa RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount es lo que acota QueueCapacity. RunningCount está acotado por
    // WorkerCount y nunca se cuenta contra la capacidad.
    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 petición interactiva una forma limpia de adelantarse a un lote que aún no ha empezado, pero nunca interrumpe trabajo ya en ejecución, y un llamante que sigue alimentando papCritical puede hacer pasar 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 gestionar

¿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 estás intentando realizar. Shutdown(True) toma el lock del ejecutor, activa el flag de apagado, y añade cada tarea pendiente a un array local instantáneo mediante AppendSnapshot. Luego libera el lock y solo después recorre el instantáneo llamando a Cancel sobre cada entrada. Cancelar una tarea dispara los 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 a la inactividad. Cada uno de ellos reentra en el lock del ejecutor, y un callback invocado mientras ese lock está retenido provocaría un deadlock contra sí mismo

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 flag 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, y los llamantes posteriores pierden la carrera, devuelven False, y no pueden sobrescribir la primera razón. Los callbacks registrados se capturan en un instantáneo y se limpian dentro del lock pero se invocan fuera de él, cada uno envuelto para que un manejador que falla no pueda suprimir el resto. Las tareas que ya están en ejecución nunca se matan; terminan cooperativamente cuando el cuerpo de su worker llama a continuación a ThrowIfCancelled, razón por la cual Shutdown termina con un WaitForIdle que bombea antes de unir los hilos

¿Relaja el paralelismo la propiedad de objetos PDFium?

No lo hace, y este es el límite más propenso a malinterpretarse. TPdfAsyncExecutor planifica trabajo; no hace ninguna afirmación sobre la afinidad de hilo de nada que toques dentro de ese trabajo. Una instancia TPdf viva no se vuelve accesible concurrentemente porque dos workers resulte que la invocan, y el lock interno de renderizado es una guardia contra llamadas de renderizado solapadas, no una licencia para compartir un documento entre hilos. Renderizado o exportación paralelos significa un TPdf por worker, creado y destruido dentro del trabajo

Diagrama de trabajadores Delphi de PDFium creando y liberando cada uno una instancia TPdf privada en lugar de compartir un documento vivo entre hilos
El aislamiento cuesta a cada worker su propio análisis y caché de páginas, pero un bloqueo o una cancelación no pueden corromper un documento compartido
type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // una instancia de documento por worker, nunca compartida
  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);   // la propiedad se mueve a la etapa de respuesta
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

El coste es real y merece nombrarse: cada worker paga su propio parseo y su propia caché de páginas, así que la memoria escala con el número de workers en lugar de con el número de documentos. Ese es el precio de un modelo en el que un worker puede cancelarse o fallar sin corromper a nadie más. Si tus workers en cambio sí comparten una instancia de documento del lado del visor, las reglas de bloqueo en torno a eso se cubren en el lock de renderizado y las llamadas que se lo saltan, y la ruta cancelable de un solo documento está en renderizado progresivo cancelable

Ampliar una interfaz publicada sin romper la vtable

IPdfCancellationToken e IPdfCancellationTokenSource son interfaces de estilo COM que binarios externos ya pueden estar consumiendo, así que añadir un método a cualquiera de las dos desplazaría cada slot posterior de la vtable y desencaminaría silenciosamente las llamadas compiladas contra el layout antiguo. Las capacidades de diagnóstico, espera, callback eliminable y cancelación atómica por tanto viven en IPdfCancellationTokenEx e IPdfCancellationTokenSourceEx, que heredan en lugar de modificar. New y Run conservan su semántica original para los llamantes existentes; 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 ampliar una interfaz publicada, y cuesta un tipo extra por generación

NewTimeout merece una nota honesta. Cada fuente de timeout posee un hilo ligero que espera sobre el evento de cancelación o el plazo, lo que llegue 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 pilotar la cancelación desde un único 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 synchronize, lee QueueCapacity como un límite solo sobre la línea de espera, cancela fuera de tus locks, y dale a cada worker su propio documento. La capa asíncrona descrita aquí se incluye como parte del componente Delphi PDFium, junto con las APIs de renderizado, texto y formularios que planifica