Artigo Técnico

Deadlock de WaitForIdle na Renderização Assíncrona PDFium Delphi

Uma renderização em lote congela a meio porque o executor no PDFium Component só considera uma tarefa concluída depois de a sua resposta ter sido despachada. Sob padSynchronize essa resposta corre na thread principal. Se a thread principal bloquear sem processar CheckSynchronize, o worker fica à espera da thread principal enquanto a thread principal espera pela inatividade

A imagem no depurador é inconfundível assim que já a tenha visto. Pausar o processo bloqueado e a thread principal está numa espera sobre o evento de inatividade, várias frames abaixo do próprio ciclo de renderização em lote. Mudar para qualquer thread de trabalho e esta está dentro de TThread.Synchronize, a segurar um resultado concluído que não consegue entregar. Nada está a girar, nenhum CPU está a arder, o processo está simplesmente parado. Este artigo trata de por que motivo este estado existe, e de três regras vizinhas que decidem se um conjunto de workers Delphi sobre PDFium se comporta bem ou morde: o que QueueCapacity realmente limita, em que ordem o encerramento tem de cancelar, e o que o paralelismo não compra em matéria de posse de objetos PDFium

Porque é que WaitForIdle bloqueia a thread principal?

Bloqueia porque a inatividade em TPdfAsyncExecutor é definida como incluindo o despacho da resposta, não apenas a conclusão do worker. A contagem de execução é incrementada em DequeueTask quando um worker apanha uma tarefa, e é decrementada em TaskFinished, que o worker só chama depois de TPdfAsyncTaskOperation.Execute ter retornado. Esse método executa o corpo do worker, regista o resultado, e só depois despacha a resposta de acordo com TPdfAsyncDispatchMode. Com padSynchronize o despacho é uma chamada a TThread.Synchronize, pelo que Execute não retorna até a thread principal ter executado essa chamada

O Delphi coloca a segunda metade desse contrato do lado do programador. TThread.Synchronize acrescenta o método a uma fila global e bloqueia a thread que chama sobre um evento; algo na thread principal tem de chamar CheckSynchronize antes de esse evento alguma vez ser sinalizado. O ciclo de mensagens da VCL faz isto por si entre mensagens, o que explica exatamente por que motivo o bug é invisível durante uma utilização interativa e surge assim que se escreve um ciclo de lote bloqueante. Uma thread principal bloqueada é uma thread principal que abandonou o ciclo de mensagens, e uma thread principal fora do ciclo de mensagens não está a drenar ninguém

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.

Conclusão da tarefa e inatividade do executor são dois marcos diferentes

Estão separados de propósito, e saber qual deles se está a aguardar é toda a correção. IPdfAsyncTask.WaitFor fica satisfeito no instante em que o resultado do worker é decidido: Complete escreve o TPdfAsyncTaskState final e assinala o evento de concluído antes de qualquer resposta ser considerada. TPdfAsyncExecutor.WaitForIdle só fica satisfeito mais tarde, quando as contagens de fila e de execução estão ambas a zero, e a contagem de execução não desce enquanto a resposta não tiver chegado. Assim, uma tarefa pode estar patsSucceeded e observável através de Snapshot enquanto o executor ainda está legitimamente 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;

Uma consequência que vale a pena interiorizar: uma resposta que levanta uma exceção não reescreve a história. DispatchReply apanha a exceção e guarda-a em ReplyErrorMessage, deixando State, CancellationReason e ErrorMessage exatamente como o worker os determinou. Um callback de interface que rebenta ao pintar uma miniatura nunca transforma, por isso, uma renderização bem-sucedida numa falhada, e a sua telemetria continua a reportar o que o motor de renderização realmente fez. Se quiser a API em forma de callback à volta de uma única operação em vez de um conjunto, renderização em segundo plano com futures cancelável cobre esse caminho

QueueCapacity também limita os workers em execução?

Não. QueueCapacity no PDFium Component conta apenas tarefas em fila, nunca as que já estão a executar num worker. Isso é deliberado: a capacidade destina-se a exprimir a contrapressão real sobre a linha de espera, e juntar as posições fixas de concorrência ao mesmo número seria contá-las duas vezes. Com quatro workers e uma capacidade de oito, é possível ter doze tarefas em curso, e GetStats reporta a divisão com honestidade através de QueuedCount e 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;

As quatro faixas de TPdfAsyncPriority são estritas, não ponderadas. DequeueTask percorre de papCritical até papLow e apanha a primeira faixa não vazia, preservando a ordem FIFO dentro de cada uma. Isso dá a um pedido interativo uma forma limpa de passar à frente de um lote que ainda não começou, mas nunca interrompe trabalho já em execução, e um chamador que continue a alimentar papCritical pode esfomear papLow indefinidamente. Reserve as duas faixas de topo para coisas que uma pessoa está visivelmente à espera de ver concluídas, e deixe a exportação em massa em papNormal ou abaixo. Use Submit quando uma fila cheia for um erro de programação que merece um EPdfAsyncQueueFull, e TrySubmit quando for uma condição normal que pretende tratar

Porque é que Shutdown cancela fora do bloqueio do executor?

Porque cancelar dentro dele inverteria a ordem dos bloqueios e bloquearia o próprio encerramento que se pretende realizar. Shutdown(True) obtém o bloqueio do executor, ativa a flag de encerramento, e acrescenta cada tarefa pendente a um array local de instantâneo através de AppendSnapshot. Depois liberta o bloqueio e só então percorre o instantâneo chamando Cancel em cada entrada. Cancelar uma tarefa dispara callbacks do utilizador registados na sua fonte de token, e esses callbacks são código de aplicação vulgar: podem consultar GetStats, submeter trabalho compensatório, ou esperar pela inatividade. Cada um deles volta a entrar no bloqueio do executor, e um callback invocado enquanto esse bloqueio está detido causaria um deadlock contra si mesmo

A fonte de token obedece à mesma disciplina um nível abaixo. CancelWithReason obtém o bloqueio da fonte, decide o único cancelador vencedor, escreve Reason, CancellationMessage e CancelledAtTick, e só depois ativa a flag de cancelado de forma atómica. Publicar antes de ativar é o que torna os metadados seguros para leitura: qualquer thread que observe IsCancelled como True tem a garantia de encontrar uma razão completa por trás disso, e os chamadores posteriores perdem a corrida, devolvem False, e não conseguem sobrescrever a primeira razão. Os callbacks registados são capturados em instantâneo e limpos dentro do bloqueio mas invocados fora dele, cada um envolvido de forma a que um handler falhado não consiga suprimir os restantes. As tarefas já em execução nunca são mortas; terminam de forma cooperativa quando o seu corpo de worker chama a seguir ThrowIfCancelled, razão pela qual Shutdown termina com um WaitForIdle que continua a processar antes de reunir as threads

Os workers paralelos relaxam a posse de objetos PDFium?

Não, e esta é a fronteira mais provável de ser mal interpretada. TPdfAsyncExecutor agenda trabalho; não faz qualquer afirmação sobre a afinidade de thread de nada que se toque dentro desse trabalho. Uma instância viva de TPdf não se torna acessível em simultâneo só porque dois workers acontecem chamá-la; o bloqueio interno de renderização é uma proteção contra chamadas de renderização sobrepostas, não uma licença para partilhar um documento entre threads. Renderização ou exportação em paralelo significa um TPdf por worker, criado e destruído dentro do trabalho

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;

O custo é real e vale a pena nomeá-lo: cada worker paga a sua própria análise e a sua própria cache de páginas, pelo que a memória escala com o número de workers e não com o número de documentos. Esse é o preço de um modelo em que um worker pode ser cancelado ou falhar sem corromper mais ninguém. Se os seus workers partilharem antes uma instância de documento do lado do visualizador, as regras de bloqueio à volta disso estão cobertas em o bloqueio de renderização e as chamadas que o ignoram, e o caminho cancelável de documento único está em renderização progressiva cancelável

Estender uma interface publicada sem quebrar a vtable

IPdfCancellationToken e IPdfCancellationTokenSource são interfaces de estilo COM que binários externos já podem consumir, pelo que acrescentar um método a qualquer uma deslocaria todas as posições posteriores da vtable e desviaria silenciosamente chamadas compiladas contra o esquema antigo. As capacidades de diagnóstico, espera, callback removível e cancelamento atómico vivem, por isso, em IPdfCancellationTokenEx e IPdfCancellationTokenSourceEx, que herdam em vez de modificar. New e Run mantêm a sua semântica original para os chamadores existentes; o código novo recorre a NewEx, NewTimeout e RunEx quando quer CancelWithReason, WaitForCancellation ou um IPdfCancellationRegistration gerido. A herança é a única forma segura de fazer crescer uma interface publicada, e custa um tipo extra por geração

NewTimeout merece uma nota honesta. Cada fonte de temporização possui uma thread leve que espera pelo evento de cancelamento ou pelo prazo, o que ocorrer primeiro. Para uma dúzia ou algumas dezenas de prazos, isso é simples, de baixa latência e idêntico entre Delphi, Lazarus e C++Builder. Para milhares de prazos curtos é a forma errada de o fazer, e deve antes conduzir o cancelamento a partir de um único temporizador ao nível da aplicação em vez de manter milhares de threads à espera

Nenhuma destas regras é exótica depois de escrita, mas cada uma delas é um incidente de produção quando não o é. Espere pelo marco certo e deixe algo processar a fila de sincronização, leia QueueCapacity apenas como um limite sobre a linha de espera, cancele fora dos seus bloqueios, e dê a cada worker o seu próprio documento. A camada assíncrona aqui descrita faz parte do PDFium Component para Delphi, ao lado das APIs de renderização, texto e formulários que agenda