Uma renderização em lote congela na metade porque o executor no PDFium Component não considera uma tarefa concluída até que sua resposta tenha sido despachada. Sob padSynchronize essa resposta roda na thread principal. Se a thread principal bloquear sem bombear CheckSynchronize, o worker espera pela thread principal enquanto a thread principal espera por idle
O quadro no debugger é inconfundível depois que você já viu uma vez. Pause o processo travado e a thread principal está parada dentro de uma espera no evento de idle, vários frames abaixo do seu próprio loop de lote. Mude para qualquer thread worker e ela está parada dentro de TThread.Synchronize, segurando um resultado concluído que não consegue entregar. Nada está girando, nenhuma CPU está queimando, o processo está simplesmente parado. Este artigo trata de por que esse estado existe, e de três regras vizinhas que decidem se um pool de workers Delphi sobre o PDFium se comporta bem ou morde: o que QueueCapacity realmente limita, em que ordem o shutdown precisa cancelar, e o que o paralelismo não garante quando o assunto é a posse de objetos do PDFium
Por que o WaitForIdle trava a thread principal?
Ele trava porque idle em TPdfAsyncExecutor é definido para incluir o despacho da resposta, não apenas a conclusão do worker. A contagem de execução é incrementada em DequeueTask quando um worker pega uma tarefa, e é decrementada em TaskFinished, que o worker chama somente depois que TPdfAsyncTaskOperation.Execute retornou. Esse método executa o corpo do worker, registra o resultado e então despacha a resposta de acordo com TPdfAsyncDispatchMode. Com padSynchronize o despacho é uma chamada TThread.Synchronize, então Execute não retorna até que a thread principal a tenha executado
O Delphi coloca a segunda metade desse contrato sob sua responsabilidade. TThread.Synchronize anexa o método a uma fila global e bloqueia a thread chamadora em um evento; algo na thread principal precisa chamar CheckSynchronize antes que esse evento seja sinalizado. O loop de mensagens da VCL faz isso por você entre uma mensagem e outra, e é exatamente por isso que o bug é invisível no uso interativo e aparece no momento em que você escreve um loop de lote bloqueante. Uma thread principal bloqueante é uma thread principal que saiu do loop de mensagens, e uma thread principal fora do loop de mensagens não está drenando 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 de tarefa e idle do executor são dois marcos diferentes
Eles são separados de propósito, e saber qual dos dois você está esperando é a correção inteira. IPdfAsyncTask.WaitFor é satisfeito no instante em que o resultado do worker é decidido: Complete grava o TPdfAsyncTaskState final e sinaliza o evento de conclusão antes que qualquer resposta seja considerada. TPdfAsyncExecutor.WaitForIdle é satisfeito depois, quando as contagens de fila e de execução chegam a zero, e a contagem de execução não cai até que a resposta tenha 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 internalizar: uma resposta que lança exceção não reescreve a história. DispatchReply captura a exceção e a armazena em ReplyErrorMessage, deixando State, CancellationReason e ErrorMessage exatamente como o worker os determinou. Um callback de UI que explode enquanto desenha uma miniatura, portanto, nunca transforma uma renderização bem-sucedida em uma falha, e sua telemetria continua relatando o que o motor de renderização realmente fez. Se você quiser a API no formato de callback em torno de uma única operação em vez de um pool, renderização em segundo plano com futures canceláveis aborda esse caminho
O QueueCapacity também limita os workers em execução?
Não. O QueueCapacity no PDFium Component conta apenas as tarefas na fila, nunca as que já estão em execução em um worker. Isso é proposital: a capacidade deve expressar a contrapressão real na linha de espera, e dobrar os slots fixos de concorrência no mesmo número os contaria duas vezes. Com quatro workers e uma capacidade de oito, você pode ter doze tarefas em voo, e o GetStats relata a divisão honestamente 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 pega a primeira faixa não vazia, preservando a ordem FIFO dentro de cada uma. Isso dá a uma requisição interativa uma forma limpa de passar à frente de um lote que ainda não começou, mas nunca interrompe um trabalho já em execução, e um chamador que continua alimentando papCritical pode deixar papLow faminta indefinidamente. Reserve as duas faixas superiores para coisas que um humano está visivelmente esperando, e deixe a exportação em massa em papNormal ou abaixo. Use Submit quando uma fila cheia for um erro de programação que mereça um EPdfAsyncQueueFull, e TrySubmit quando for uma condição normal que você pretende tratar
Por que o Shutdown cancela fora do lock do executor?
Porque cancelar dentro dele inverteria a ordem dos locks e travaria o próprio shutdown que você está tentando realizar. Shutdown(True) pega o lock do executor, vira a flag de shutdown, e anexa toda tarefa pendente a um array de snapshot local através de AppendSnapshot. Depois libera o lock e só então percorre o snapshot chamando Cancel em cada entrada. Cancelar uma tarefa dispara callbacks de usuário registrados em sua token source, e esses callbacks são código de aplicação comum: podem consultar GetStats, submeter trabalho compensatório, ou esperar por idle. Cada um desses reentra no lock do executor, e um callback invocado enquanto esse lock está retido causaria um deadlock contra si mesmo
A token source obedece à mesma disciplina um nível abaixo. CancelWithReason pega o lock da fonte, decide o único cancelador vencedor, escreve Reason, CancellationMessage e CancelledAtTick, e só então vira a flag de cancelado atomicamente. Publicar antes de virar é 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 chamadores posteriores perdem a corrida, retornam False, e não conseguem sobrescrever a primeira razão. Os callbacks registrados são capturados em snapshot e limpos dentro do lock, mas invocados fora dele, cada um envolto de forma que um handler com falha não consiga suprimir os demais. Tarefas que já estão em execução nunca são mortas; elas terminam cooperativamente quando o corpo do worker chama ThrowIfCancelled na próxima vez, o que é o motivo de Shutdown terminar com um WaitForIdle que bombeia antes de unir as threads
Workers paralelos relaxam a posse de objetos do PDFium?
Não relaxam, e este é o limite mais provável de ser mal interpretado. TPdfAsyncExecutor agenda trabalho; ele não faz nenhuma afirmação sobre a afinidade de thread de nada que você toque dentro desse trabalho. Uma instância viva de TPdf não se torna concorrentemente acessível porque dois workers acabam chamando-a, e o lock interno de renderização é uma guarda contra chamadas de renderização sobrepostas, não uma licença para compartilhar um documento entre threads. Renderização ou exportação paralela significa um TPdf por worker, criado e destruído dentro do job
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 nomear: cada worker paga seu próprio parse e seu próprio cache de páginas, então a memória escala com o número de workers, não com o número de documentos. Esse é o preço de um modelo em que um worker pode ser cancelado ou travar sem corromper ninguém mais. Se seus workers de fato compartilham um documento do lado do visualizador, as regras de lock em torno disso estão cobertas em o lock de renderização e as chamadas que o ignoram, e o caminho cancelável de documento único está em renderização progressiva cancelável
Estendendo uma interface publicada sem quebrar a vtable
IPdfCancellationToken e IPdfCancellationTokenSource são interfaces no estilo COM que binários externos já podem consumir, então anexar um método a qualquer uma delas deslocaria todo slot posterior na vtable e desviaria silenciosamente chamadas compiladas contra o layout antigo. As capacidades de diagnóstico, espera, callback removível e cancelamento atômico, portanto, vivem em IPdfCancellationTokenEx e IPdfCancellationTokenSourceEx, que herdam em vez de modificar. New e Run mantêm sua semântica original para os chamadores existentes; código novo recorre a NewEx, NewTimeout e RunEx quando quer CancelWithReason, WaitForCancellation ou um IPdfCancellationRegistration gerenciado. Herança é a única forma segura de expandir uma interface publicada, e custa um tipo extra por geração
NewTimeout merece uma nota honesta. Cada fonte de timeout possui uma thread leve que espera pelo evento de cancelamento ou pelo prazo, o que vier primeiro. Para um punhado ou algumas dezenas de prazos, isso é simples, de baixa latência e idêntico em Delphi, Lazarus e C++Builder. Para milhares de prazos curtos, é a forma errada, e você deveria conduzir o cancelamento a partir de um único timer no nível da aplicação em vez de manter milhares de threads em espera
Nenhuma dessas regras é exótica depois de escrita, mas cada uma delas é um incidente de produção quando não está. Espere pelo marco certo e deixe algo bombear a fila de synchronize, leia QueueCapacity apenas como um limite na linha de espera, cancele fora dos seus locks, e dê a cada worker seu próprio documento. A camada assíncrona descrita aqui é fornecida como parte do PDFium Component para Delphi, junto com as APIs de renderização, texto e formulário que ela agenda