Artigo Técnico

Renderização de PDF em Segundo Plano em Delphi com Futuros Canceláveis

Renderizar uma página no PDFium é síncrono. Chama-se a biblioteca, ela rasteriza para um bitmap que lhe entregou e o controlo regressa quando os píxeis estão escritos. Para uma única página do tamanho do ecrã num nível de zoom, isso demora alguns milissegundos e ninguém repara. Para uma exportação a 300 dpi de um documento de 200 páginas, ou para uma faixa de miniaturas que tem de rasterizar todas as páginas de uma vez, a mesma chamada custa segundos. Se fizer essa chamada a partir da thread principal, o loop de mensagens pára, a janela deixa de se redesenhar e o Windows pinta o temido «Não Responde» por cima da barra de título. O trabalho está correto. O sítio onde o correu é que está errado

A solução é mover a renderização longa para uma thread de segundo plano e trazer o resultado de volta à thread principal, onde o bitmap pode ser entregue a um controlo. O próprio PDFium não o impede de fazer isto, mas o binding tem de tornar a passagem segura, porque a superfície de bugs em torno de «correr num worker, responder na UI» é vasta e as falhas são intermitentes. A unidade FPdfAsync em PDFiumPas existe para dar a esse padrão uma implementação correta, com um modelo de cancelamento que se ajusta ao modo como uma renderização longa realmente se comporta

A forma 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 uma gama de páginas e rasteriza cada uma, normalmente para disco. A exportação multipágina faz o mesmo, mas reúne a saída num único ficheiro. A renderização de página em segundo plano é o que um visualizador faz quando o utilizador salta para uma página que ainda não está em cache, pelo que o bitmap é produzido fora da thread e mostrado quando estiver pronto. As três partilham as mesmas restrições. Correm tempo suficiente para que a thread da UI não as possa albergar, produzem um resultado de que a thread da UI acaba por precisar, e o utilizador pode abandoná-las. Fechar o documento, deslocar para além da página ou carregar em Cancelar deve parar o trabalho, em vez de obrigar o utilizador a esperar por uma saída que já não quer

Esta ú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 queima CPU depois de a resposta deixar de importar. Por isso a unidade é construída em torno de dois primitivos que se compõem: um futuro que transporta o resultado de volta e um token que transporta o pedido de cancelamento para a frente

Um futuro fire-and-forget

O TPdfFuture<T>.Run recebe um worker, uma reply e um token de cancelamento opcional. Inicia o worker numa thread de segundo plano e, quando o worker termina, entrega a reply na thread principal. O parâmetro genérico T é o que quer que a renderização produza, frequentemente um handle de bitmap ou um registo de estado. O worker corre fora da thread; a reply corre 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 espécie de Wait. Não há método para bloquear o chamador até o futuro completar, e isso não é um descuido. Um Wait chamado a partir da thread principal é a forma clássica de criar um deadlock numa UI: o worker precisa da thread principal para correr a sua reply através de Synchronize, a thread principal está estacionada dentro de Wait e nenhum dos lados pode prosseguir. Ao recusar oferecer o primitivo, o futuro exclui o padrão que mais frequentemente derrota quem tenta escrever isto por si próprio. Código que genuinamente precise de bloquear deve usar uma TThread simples e assumir as consequências. O futuro é para o caso fire-and-forget, que é o que a renderização em segundo plano realmente é

O resultado é embrulhado em TPdfFutureResult<T>, um registo que diz à 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 num ponto de cancelamento. IsFailure significa que o worker gerou uma exceção, e ErrorMessage transporta o texto. A reply inspeciona o estado 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 da reply

A parte mais instrutiva desta unidade é uma alteração de uma linha que demorou algum tempo a compreender. Ao longo das primeiras versões, a thread worker entregava a sua reply com TThread.Queue. O Queue posta a reply na fila da thread principal e regressa imediatamente, o que se lê exatamente como o que um futuro fire-and-forget quer. Estava errado, e a razão vale a pena ser explicitada porque é o tipo de bug que passa em todos os testes que se lembra de escrever

A thread worker é criada com FreeOnTerminate := True. Isso significa que, no instante em que Execute retorna, a thread desmonta-se a si própria, e TThread.Destroy chama RemoveQueuedEvents(Self) como parte da limpeza. O RemoveQueuedEvents purga qualquer método em fila cujo alvo seja a thread moribunda. Assim, a sequência era: o worker termina, põe a reply em fila contra si próprio, Execute retorna, a thread destrói-se a si própria e RemoveQueuedEvents apaga a reply que a thread principal ainda não tinha corrido. O resultado simplesmente desaparecia. Pior, na janela estreita em que a thread principal retirava a reply da fila e começava a executá-la no mesmo momento em que a thread estava a ser libertada, a reply tocava em campos de um objeto meio destruído, o que é um use-after-free

A correção na v1.61.0 foi entregar a reply com Synchronize em vez de Queue. O Synchronize bloqueia a thread worker até a thread principal ter corrido a reply até ao fim. O worker ainda está vivo enquanto a sua reply executa, pelo que não há nada que se possa libertar por baixo dele, e a thread não retorna de Execute (e portanto não começa a destruir-se a si própria) até a reply ter sido entregue. A entrega fica garantida e a janela de use-after-free fica fechada

procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // já cancelado? salta 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: esta thread é FreeOnTerminate, por isso uma reply
    // em fila poderia ser descartada por RemoveQueuedEvents antes de a thread
    // principal a correr.
    Synchronize(DispatchReply);
end;

A lição geral sobrevive à correção específica. Os callbacks assíncronos fire-and-forget são o padrão de concorrência mais fácil de errar subtilmente, porque o caminho feliz funciona à primeira tentativa e o bug vive na interação entre a ordem de desmontagem da thread e a fila. Não se reproduz a pedido. Depende de a thread principal ter drenado a fila antes de o worker ter terminado de se destruir, o que é um timing que o scheduler decide de forma diferente em cada execução. Um primitivo que está correto uma vez, no binding, vale muito mais do que o mesmo código rederivado em cada aplicação que precisa de uma renderização em segundo plano

Porque é que os callbacks são ponteiros de método

O worker e a 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 em Delphi XE5 e posteriores e em Free Pascal 3.2 em modo Delphi, e o FPC 3.2 nesse modo não suporta métodos anónimos. Um callback reference-to-procedure que capture variáveis locais compilaria em Delphi e falharia em FPC, por isso a unidade usa o menor denominador comum que ambos os compiladores aceitam

A consequência prática é onde o estado vive. Um método anónimo fecha sobre as variáveis locais; um ponteiro de método não. Por isso qualquer estado de que o worker precise — o índice da página, o zoom, o caminho de saída — e qualquer estado que a reply precise de atualizar — o controlo de imagem alvo ou a etiqueta de progresso — tem de estar pendurado no objeto cujo método está a ser passado. Num visualizador, esse objeto é normalmente o formulário ou um controlador de renderização que ele possui. Isto não é uma solução de recurso imposta a contragosto; mantém a propriedade desse estado explícita e visível no objeto recetor, em vez de escondida dentro de um fecho

Cancelamento cooperativo, não uma terminação forçada

O cancelamento aqui é cooperativo. Não há API que alcance a thread worker e a termine, porque terminar uma thread a meio de uma renderização deixa o PDFium a segurar locks e bitmaps parcialmente escritos, e o estado do processo após uma terminação forçada não é algo sobre o qual se possa raciocinar. Em vez disso, é entregue ao worker um token só de leitura e espera-se que ele o verifique, e o loop de renderização é escrito para o verificar entre páginas ou entre tiles, onde parar é limpo

O token oferece três formas de observar o cancelamento. IsCancelled é um poll booleano barato para um loop que quer testar e decidir por si próprio. ThrowIfCancelled é o caso comum: chama-se num ponto de cancelamento natural e, se o cancelamento tiver sido pedido, gera EPdfOperationCancelled, que desenrola o worker diretamente de volta ao futuro. RegisterCallback anexa uma notificação de disparo único que dispara uma vez quando a origem é cancelada, útil quando um worker está bloqueado em algo que pode interromper, em vez de estar sentado num loop apertado

A exceção é onde a fronteira de thread importa. Quando o worker gera EPdfOperationCancelled, o futuro apanha-a e transforma-a num estado de cancelado, pelo que a reply vê IsCancelled e não uma falha. O próprio objeto de exceção nunca é encaminhado (marshaled) para a thread principal. Vive e morre na thread worker; apenas a sua string de mensagem é copiada para ErrorMessage. Encaminhar um objeto de exceção vivo entre threads significaria alcançar memória pertencente a uma thread que está a terminar, que é a mesma classe de erro que a correção Synchronize existe para prevenir. Um código de estado e uma string cruzam a fronteira de forma limpa; um objeto não

Duas interfaces, para que um worker não se possa cancelar a si próprio

O cancelamento está dividido por duas interfaces de propósito. IPdfCancellationTokenSource é o lado de escrita: tem Cancel, e o proprietário que a cria, normalmente o formulário, mantém-na e chama Cancel quando o utilizador clica no botão ou o formulário fecha. IPdfCancellationToken é o lado de leitura: tem IsCancelled, ThrowIfCancelled e RegisterCallback, e é tudo o que o worker alguma vez recebe. Um único objeto concreto implementa ambas, mas ao worker só é entregue o token, pelo que não tem forma de cancelar a operação que está a correr. A divisão é uma proteção ao nível da API. Um worker que conseguisse alcançar Cancel através do seu token convidaria um pedaço de código confuso a cancelar-se a si próprio, e o sistema de tipos remove essa possibilidade

Há um detalhe correspondente para o caso em que um chamador quer uma renderização mas nunca tenciona cancelá-la. Em vez de forçar uma origem nova por chamada, a unidade expõe PdfNoCancellationToken, um token singleton que está permanentemente no estado de não-cancelado. O Run substitui-o quando o argumento do token é deixado nil. Esse singleton é construído de forma ávida (eager) durante a inicialização da unidade, em vez de preguiçosa (lazy) no primeiro uso, e a razão é novamente a concorrência. Se várias chamadas Run em diferentes threads worker tentassem todas alcançar um singleton criado de forma preguiçosa ao mesmo tempo, poderiam entrar em corrida na sua construção, perder (leak) um duplicado, ou observar brevemente uma instância meio inicializada. Construí-lo antes de qualquer worker poder correr remove a corrida por completo

Correr uma renderização cancelável

Na prática, cria-se uma origem, mantém-se no formulário, passa-se o seu Token para o Run a par de um método worker e de um método reply, e liga-se o botão Cancelar à origem. O worker verifica o token enquanto renderiza; a reply atualiza a UI assim que o resultado regressa. Como os callbacks são ponteiros de método, o worker e a reply leem o que precisarem 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 no próximo ponto de cancelamento
end;

// Corre numa 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;        // paragem limpa entre páginas
    RenderOnePage(PageIndex);       // rasterização síncrona do PDFium
  end;
  Result := True;
end;

// Corre na thread principal. Seguro tocar na VCL aqui.
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;

A reply trata os três desfechos porque os três são alcançáveis. Uma renderização concluída reporta sucesso, um utilizador que carregou em Cancelar vê o ramo de cancelado, e um ficheiro que não pôde ser escrito ou uma página que falhou na análise chega como uma falha com uma mensagem. Nenhum desses ramos bloqueia, nenhum deles toca na thread worker, e o bitmap ou estado que o worker produziu só é lido depois de o futuro o ter entregado na thread que possui a UI

A mesma disciplina de threading compensa noutros pontos de um visualizador. A forma como os bitmaps renderizados são mantidos e reutilizados ao longo das mudanças de zoom é abordada na nossa nota sobre a cache de renderização e o desempenho do zoom, e a questão mais ampla de manter a fronteira do PDFium segura sob Delphi está em reforçar a ABI do Componente PDFium para a segurança de memória. A infraestrutura assíncrona aqui descrita é entregue como parte do PDFium Component para Delphi e C++Builder, a par das APIs de renderização, texto e formulários abordadas noutros artigos deste blog