Artigo Técnico

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

Renderizar uma página no PDFium é síncrono. Você chama a biblioteca, ela rasteriza em um bitmap que você entregou, e o controle retorna quando os pixels são escritos. Para uma única página no tamanho de 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 uma faixa de miniaturas que precisa rasterizar cada página 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 para de repintar, e o Windows pinta o temido "Sem Resposta" sobre sua barra de título. O trabalho está correto. O lugar onde você o executou está errado

A correçã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 PDFium em si 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 no PDFiumPas existe para dar a esse padrão uma implementação correta, com um modelo de cancelamento que se encaixa em como uma renderização longa realmente se comporta

A forma do trabalho

Três operações dominam os casos em que uma renderização dura mais do que 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 monta 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, para que o bitmap seja produzido fora do thread e mostrado quando estiver pronto. Todos os três compartilham as mesmas restrições. Eles são executados por tempo suficiente para que o thread de UI não possa hospedá-los, eles produzem um resultado que o thread de UI eventualmente precisa, e o usuário pode abandoná-los. Fechar o documento, rolar além da página ou pressionar Cancelar deve parar 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 é o 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 que a resposta deixou de importar. Portanto, a unit é construída em torno de dois primitivos que se compõem: um futuro que carrega o resultado de volta, e um token que carrega a solicitação de cancelamento adiante

Um futuro fire-and-forget

TPdfFuture<T>.Run recebe um worker, uma reply e um token de cancelamento opcional. Ele inicia o worker em um thread de segundo plano, e quando o worker termina, entrega a reply no thread principal. O parâmetro genérico T é o que quer que a renderização produza, geralmente um identificador de bitmap ou um registro de status. O worker executa fora do thread; a reply executa onde é seguro tocar no 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 futuro seja concluído, e isso não é um descuido. Um Wait chamado a partir do thread principal é a maneira clássica de criar um deadlock na UI: o worker precisa do thread principal para executar sua reply via Synchronize, o thread principal está parado dentro de Wait, e nenhum dos dois pode prosseguir. Ao se recusar a oferecer o primitivo, o futuro exclui o padrão que com mais frequência derrota as pessoas que tentam escrever isso por conta própria. Código que genuinamente precisa bloquear deve usar um TThread simples e arcar com as consequências. O futuro é para o caso de fire-and-forget, que é o que a renderização em segundo plano realmente é

O resultado é encapsulado em TPdfFutureResult<T>, um registro que informa à 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 saiu em um ponto de cancelamento. IsFailure significa que o worker levantou uma exceção, e ErrorMessage carrega o texto. A 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 da reply

A parte mais instrutiva desta unit é uma alteração de uma linha que demorou um pouco para ser compreendida. Nas versões anteriores, o thread worker entregava sua reply com TThread.Queue. Queue posta a reply na fila do thread principal e retorna imediatamente, o que parece exatamente o que um futuro fire-and-forget quer. Estava errado, e o motivo vale a pena explicar porque é o tipo de bug que passa em todo teste que você pensa em escrever

O thread worker é criado com FreeOnTerminate := True. Isso significa que no instante em que Execute retorna, o thread se destrói, e TThread.Destroy chama RemoveQueuedEvents(Self) como parte da limpeza. RemoveQueuedEvents limpa qualquer método em fila cujo alvo é o thread que está morrendo. Então a sequência era: o worker termina, ele coloca a reply em fila contra si mesmo, Execute retorna, o thread se destrói, e RemoveQueuedEvents deleta a reply que o thread principal ainda não havia executado. O resultado simplesmente desaparecia. Pior, na janela estreita onde o thread principal retirava a reply da fila e começava a executá-la ao mesmo tempo em que o thread estava sendo liberado, a reply tocava campos de um objeto semidestruído, o que é um use-after-free

A correção na v1.61.0 foi entregar a reply com Synchronize em vez de Queue. Synchronize bloqueia o thread worker até que o thread principal tenha executado a reply até a conclusão. O worker ainda está vivo enquanto sua reply é executada, portanto não há nada a ser liberado sob ele, e o thread não retorna de Execute (e portanto não começa a se destruir) até que a reply tenha sido entregue. A entrega é garantida, e a janela use-after-free é fechada

procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // already cancelled? skip the 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, not Queue: this thread is FreeOnTerminate, so a queued reply
    // could be dropped by RemoveQueuedEvents before the main thread ran it.
    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 sutilmente, porque o caminho feliz funciona na primeira tentativa e o bug vive na interação entre a ordem de desmontagem do thread e a fila. Ele não se reproduz sob demanda. Depende de se o thread principal por acaso drenava a fila antes de o worker por acaso terminar de se destruir, o que é um timing que o escalonador decide de forma diferente a cada execução. Um primitivo correto uma 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 a reply não são métodos anônimos. São tipos procedure of object, TPdfFutureWorker<T> e TPdfFutureReply<T>, e essa escolha é forçada 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 de referência para procedimento que captura variáveis locais compilaria no Delphi e falharia no FPC, portanto 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 fecha sobre locais; um ponteiro de método não. Portanto, qualquer estado de que o worker precisa - o índice de página, o zoom, o caminho de saída - e qualquer estado de que a reply precisa para atualizar - o controle de imagem alvo ou o rótulo de progresso - tem que estar pendurado no objeto cujo método está sendo passado. Em um visualizador, esse objeto geralmente é o formulário ou um controlador de renderização que ele possui. Isso não é uma solução de contorno imposta a contragosto; mantém a propriedade 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 você pode 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 é seguro

O token oferece três maneiras de observar o cancelamento. IsCancelled é uma pesquisa 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 foi solicitado, ele levanta EPdfOperationCancelled, que desfaz o worker de volta ao futuro. RegisterCallback anexa uma notificação one-shot que dispara uma vez quando a fonte é cancelada, útil quando um worker está bloqueado em algo que ele pode interromper em vez de estar em um loop apertado

A exceção é onde o limite do thread importa. Quando o worker levanta EPdfOperationCancelled, o futuro a captura e a transforma em um status cancelado, para que a reply veja IsCancelled e não uma falha. O objeto de exceção em si nunca é marshaled para o thread principal. Ele vive e morre no thread worker; apenas sua string de mensagem é copiada para ErrorMessage. Fazer marshal de um objeto de exceção ativo entre threads significaria acessar memória de propriedade de um thread que está terminando, o que é a mesma classe de erro que a correção de 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 propositalmente. IPdfCancellationTokenSource é o lado de escrita: ele tem Cancel, e o proprietário que o cria - geralmente o formulário - o mantém e chama Cancel quando o usuário clica no botão ou o formulário fecha. IPdfCancellationToken é o lado de leitura: ele tem IsCancelled, ThrowIfCancelled e RegisterCallback, e isso é tudo que o worker recebe. Um objeto concreto implementa ambos, mas o worker nunca recebe mais do que o token, portanto não tem como cancelar a operação que está executando. A divisão é um guard rail no nível da API. Um worker que pudesse alcançar Cancel através de seu token convidaria um trecho de código confuso a cancelar a si mesmo, e o sistema de tipos remove a 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 nova source por chamada, a unit expõe PdfNoCancellationToken, um token singleton que está permanentemente no estado não cancelado. Run o substitui quando o argumento de token é deixado como nil. Esse singleton é construído eagerly durante a inicialização da unit em vez de lazily no primeiro uso, e o motivo é concorrência novamente. Se várias chamadas Run em diferentes threads worker alcançassem um singleton criado lazily ao mesmo tempo, eles poderiam concorrer em sua construção, vazar uma duplicata, ou brevemente observar uma instância semi-inicializada. Construí-lo antes que qualquer worker possa executar remove a condição de corrida completamente

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; a reply atualiza a UI uma vez que o resultado está de volta. Como os callbacks são ponteiros de método, o worker e a reply leem o que precisam dos campos do formulário

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // field, lives on the form
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // worker observes this at its next cancel point
end;

// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // clean stop between pages
    RenderOnePage(PageIndex);       // synchronous PDFium rasterisation
  end;
  Result := True;
end;

// Runs on the main thread. Safe to touch the VCL here.
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 lida com todos os três resultados porque todos os três são alcançáveis. Uma renderização concluída relata 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 futuro o entregou no thread que possui a UI

A mesma disciplina de threading compensa em outros lugares em um visualizador. A forma como bitmaps renderizados são mantidos e reutilizados em mudanças de zoom é abordada em nossa nota sobre o cache de renderização e desempenho de zoom, e a questão mais ampla de manter o limite PDFium seguro sob Delphi está em endurecendo o VCL ABI do PDFium 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 lugares neste blog