Renderizar uma página no PDFium é síncrono. Você chama a biblioteca, ela rasteriza em um bitmap que você entregou e o controle volta quando os pixels estão escritos. Para uma única página do tamanho da tela em um nível de zoom, isso leva alguns milissegundos e ninguém percebe. Para uma exportação a 300 dpi de um documento de 200 páginas, ou para uma faixa de miniaturas que precisa rasterizar todas as páginas de uma vez, a mesma chamada custa segundos. Se você fizer essa chamada a partir do thread principal, o loop de mensagens para, a janela deixa de se repintar e o Windows desenha o temido "Não Respondendo" sobre a barra de título. O trabalho está correto. O lugar onde você o executou é que está errado
A solução é mover a renderização longa para um thread de segundo plano e trazer o resultado de volta ao thread principal, onde o bitmap pode ser entregue a um controle. O próprio PDFium não impede você de fazer isso, mas o binding precisa tornar a transferência segura, porque a superfície de bugs em torno de "executar em um worker, responder na UI" é ampla e as falhas são intermitentes. A unit FPdfAsync do PDFiumPas existe para dar a esse padrão uma única implementação correta, com um modelo de cancelamento que se ajusta ao modo como uma renderização longa realmente se comporta
O formato do trabalho
Três operações dominam os casos em que uma renderização ultrapassa a duração de um frame. A renderização em lote percorre um intervalo de páginas e rasteriza cada uma, geralmente para o disco. A exportação de múltiplas páginas faz o mesmo, mas reúne a saída em um único arquivo. A renderização de página em segundo plano é o que um visualizador faz quando o usuário pula para uma página que ainda não está em cache, de modo que o bitmap é produzido fora do thread e exibido quando fica pronto. As três compartilham as mesmas restrições. Elas duram o suficiente para que o thread de UI não possa hospedá-las, produzem um resultado de que o thread de UI acaba precisando e o usuário pode abandoná-las. Fechar o documento, rolar para além da página ou pressionar Cancelar deve interromper o trabalho em vez de forçar o usuário a esperar por uma saída que ele não quer mais
Essa última restrição é a que molda o design. Uma renderização que não pode ser cancelada é uma renderização que mantém o documento aberto e consome CPU depois que a resposta deixou de importar. Por isso a unit é construída em torno de dois primitivos que se combinam: um future que traz o resultado de volta e um token que leva a solicitação de cancelamento adiante
Um future fire-and-forget
TPdfFuture<T>.Run recebe um worker, um reply e um token de cancelamento opcional. Ele inicia o worker em um thread de segundo plano e, quando o worker termina, entrega o reply no thread principal. O parâmetro genérico T é o que quer que a renderização produza, frequentemente um handle de bitmap ou um registro de status. O worker roda fora do thread; o reply roda onde é seguro tocar na VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
A omissão deliberada é qualquer tipo de Wait. Não há método para bloquear o chamador até que o future termine, e isso não é um descuido. Um Wait chamado a partir do thread principal é a forma clássica de causar deadlock em uma UI: o worker precisa do thread principal para executar seu reply via Synchronize, o thread principal está estacionado dentro de Wait, e nenhum dos lados consegue avançar. Ao se recusar a oferecer o primitivo, o future elimina o padrão que mais costuma derrotar quem tenta escrever isso por conta própria. Código que realmente precisa bloquear deve usar um TThread simples e assumir as consequências. O future serve para o caso fire-and-forget, que é o que a renderização em segundo plano de fato é
O resultado vem encapsulado em TPdfFutureResult<T>, um registro que informa ao reply qual das três coisas aconteceu. IsSuccess significa que o worker retornou normalmente e Value contém a renderização. IsCancelled significa que o token disparou e o worker desistiu em um ponto de cancelamento. IsFailure significa que o worker levantou uma exceção, e ErrorMessage carrega o texto. O reply inspeciona o status uma vez e ramifica, em vez de adivinhar, a partir de um valor sentinela, se um bitmap retornado é real
A condição de corrida da v1.61.0 que mudou a entrega do reply
A parte mais instrutiva desta unit é uma alteração de uma linha que levou algum tempo para ser entendida. Nas primeiras versões, o thread worker entregava seu reply com TThread.Queue. O Queue posta o reply na fila do thread principal e retorna imediatamente, o que parece exatamente o que um future fire-and-forget quer. Estava errado, e o motivo merece ser detalhado, porque é o tipo de bug que passa por todos os testes que você se lembra de escrever
O thread worker é criado com FreeOnTerminate := True. Isso significa que, no instante em que Execute retorna, o thread se desfaz, e TThread.Destroy chama RemoveQueuedEvents(Self) como parte da limpeza. RemoveQueuedEvents remove qualquer método enfileirado cujo alvo seja o thread que está morrendo. Então a sequência era: o worker termina, ele enfileira o reply contra si mesmo, Execute retorna, o thread se destrói e RemoveQueuedEvents apaga o reply que o thread principal ainda não havia executado. O resultado simplesmente sumia. Pior: na janela estreita em que o thread principal retirava o reply enfileirado e começava a executá-lo no exato momento em que o thread estava sendo liberado, o reply tocava campos de um objeto parcialmente destruído, o que é um use-after-free
A correção na v1.61.0 foi entregar o reply com Synchronize em vez de Queue. Synchronize bloqueia o thread worker até que o thread principal tenha executado o reply por completo. O worker continua vivo enquanto seu reply executa, então não há nada para liberar por baixo dele, e o thread não retorna de Execute (e, portanto, não começa a se destruir) até que o reply tenha sido entregue. A entrega fica garantida e a janela de use-after-free é fechada
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // já cancelado? pula o worker
FResult.Value := FWorker(FToken);
except
on E: EPdfOperationCancelled do
begin
FResult.Status := pfsCancelled;
FResult.ErrorMessage := E.Message;
end;
on E: Exception do
begin
FResult.Status := pfsFailure;
FResult.ErrorMessage := E.Message;
end;
end;
if Assigned(FReply) then
// Synchronize, não Queue: este thread é FreeOnTerminate, então um reply enfileirado
// poderia ser descartado por RemoveQueuedEvents antes de o thread principal executá-lo.
Synchronize(DispatchReply);
end;
A lição geral sobrevive à correção específica. Callbacks assíncronos fire-and-forget são o padrão de concorrência mais fácil de errar de forma sutil, porque o caminho feliz funciona logo na primeira tentativa e o bug mora na interação entre a ordem de desmontagem do thread e a fila. Ele não se reproduz sob demanda. Depende de o thread principal ter, por acaso, drenado a fila antes de o worker ter, por acaso, terminado de se destruir, um timing que o escalonador decide de modo diferente a cada execução. Um primitivo que é correto uma única vez, no binding, vale muito mais do que o mesmo código re-derivado em cada aplicação que precisa de uma renderização em segundo plano
Por que os callbacks são ponteiros de método
O worker e o reply não são métodos anônimos. São tipos procedure of object, TPdfFutureWorker<T> e TPdfFutureReply<T>, e essa escolha é imposta pela matriz de compiladores. O PDFiumPas compila no Delphi XE5 e versões posteriores e no Free Pascal 3.2 em modo Delphi, e o FPC 3.2 nesse modo não suporta métodos anônimos. Um callback do tipo reference-to-procedure que captura variáveis locais compilaria no Delphi e falharia no FPC, então a unit usa o menor denominador comum que ambos os compiladores aceitam
A consequência prática é onde o estado fica. Um método anônimo captura variáveis locais; um ponteiro de método não. Portanto, qualquer estado de que o worker precise (o índice da página, o zoom, o caminho de saída) e qualquer estado que o reply precise atualizar (o controle de imagem alvo ou o rótulo de progresso) tem que ficar pendurado no objeto cujo método está sendo passado. Em um visualizador, esse objeto costuma ser o formulário ou um controlador de renderização que ele possui. Isso não é uma gambiarra imposta a contragosto; mantém a posse desse estado explícita e visível no objeto receptor, em vez de escondida dentro de um closure
Cancelamento cooperativo, não um kill forçado
O cancelamento aqui é cooperativo. Não há API que alcance o thread worker e o encerre, porque encerrar um thread no meio de uma renderização deixa o PDFium segurando locks e bitmaps parcialmente escritos, e o estado do processo após um kill forçado não é algo sobre o qual se possa raciocinar. Em vez disso, o worker recebe um token somente leitura e espera-se que o verifique, e o loop de renderização é escrito para verificá-lo entre páginas ou entre tiles, onde parar é limpo
O token oferece três formas de observar o cancelamento. IsCancelled é uma consulta booleana barata para um loop que quer testar e decidir por si mesmo. ThrowIfCancelled é o caso comum: chame-o em um ponto de cancelamento natural e, se o cancelamento tiver sido solicitado, ele levanta EPdfOperationCancelled, que desenrola o worker direto de volta para o future. RegisterCallback anexa uma notificação one-shot que dispara uma vez quando a source é cancelada, útil quando um worker está bloqueado em algo que ele pode interromper, em vez de estar preso em um loop apertado
A exceção é onde o limite de thread importa. Quando o worker levanta EPdfOperationCancelled, o future a captura e a transforma em um status cancelado, de modo que o reply veja IsCancelled e não uma falha. O objeto de exceção em si nunca é submetido a marshaling para o thread principal. Ele vive e morre no thread worker; apenas sua string de mensagem é copiada para ErrorMessage. Fazer marshaling de um objeto de exceção vivo entre threads significaria acessar memória de propriedade de um thread que está terminando, que é a mesma classe de erro que a correção do Synchronize existe para prevenir. Um código de status e uma string cruzam o limite de forma limpa; um objeto não cruzaria
Duas interfaces, para que um worker não possa cancelar a si mesmo
O cancelamento é dividido em duas interfaces de propósito. IPdfCancellationTokenSource é o lado de escrita: tem Cancel, e o proprietário que a cria, normalmente o formulário, a mantém e chama Cancel quando o usuário clica no botão ou o formulário fecha. IPdfCancellationToken é o lado de leitura: tem IsCancelled, ThrowIfCancelled e RegisterCallback, e isso é tudo o que o worker chega a receber. Um único objeto concreto implementa ambas, mas o worker só recebe o token, então não tem como cancelar a operação que está executando. A divisão é uma proteção em nível de API. Um worker que pudesse alcançar Cancel por meio de seu token convidaria um trecho de código confuso a cancelar a si mesmo, e o sistema de tipos elimina essa possibilidade
Há um detalhe correspondente para o caso em que um chamador quer uma renderização mas nunca pretende cancelá-la. Em vez de forçar uma source nova a cada chamada, a unit expõe PdfNoCancellationToken, um token singleton permanentemente no estado não cancelado. Run o substitui quando o argumento de token é deixado como nil. Esse singleton é construído de forma eager durante a inicialização da unit, em vez de de forma lazy no primeiro uso, e o motivo é, novamente, a concorrência. Se várias chamadas de Run em diferentes threads worker buscassem ao mesmo tempo um singleton criado de forma lazy, elas poderiam competir em sua construção, vazar uma duplicata ou observar por um breve instante uma instância parcialmente inicializada. Construí-lo antes que qualquer worker possa rodar elimina completamente a corrida
Executando uma renderização cancelável
Na prática, você cria uma source, a mantém no formulário, passa seu Token para Run junto com um método worker e um método reply, e conecta o botão Cancelar à source. O worker verifica o token enquanto renderiza; o reply atualiza a UI assim que o resultado volta. Como os callbacks são ponteiros de método, o worker e o reply leem o que precisam dos campos do formulário
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // campo, vive no formulário
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // o worker observa isto em seu próximo ponto de cancelamento
end;
// Roda em um thread de segundo plano. Lê FPageRange / FOutputDir do formulário.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // parada limpa entre páginas
RenderOnePage(PageIndex); // rasterização síncrona do PDFium
end;
Result := True;
end;
// Roda no thread principal. Aqui é seguro tocar na VCL.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
if AResult.IsSuccess then
StatusLabel.Caption := 'Render complete'
else if AResult.IsCancelled then
StatusLabel.Caption := 'Cancelled'
else
StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;
O reply lida com os três desfechos porque todos os três são alcançáveis. Uma renderização concluída reporta sucesso, um usuário que pressionou Cancelar vê o ramo cancelado, e um arquivo que não pôde ser escrito ou uma página que falhou ao ser analisada chega como uma falha com uma mensagem. Nenhum desses ramos bloqueia, nenhum deles toca o thread worker, e o bitmap ou status que o worker produziu só é lido depois que o future o entregou no thread que possui a UI
A mesma disciplina de threading compensa em outros pontos de um visualizador. A forma como os bitmaps renderizados são mantidos e reutilizados entre mudanças de zoom é abordada em nossa nota sobre o cache de renderização e o desempenho do zoom, e a questão mais ampla de manter o limite do PDFium seguro sob o Delphi está em reforçando a ABI do PDFium Component para segurança de memória. A infraestrutura assíncrona descrita aqui é fornecida como parte do PDFium Component para Delphi e C++Builder, ao lado das APIs de renderização, texto e formulário abordadas em outros pontos deste blog