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

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 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 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 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

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 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

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 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