Artículo técnico

Renderizado de PDF en segundo plano en Delphi con futuros cancelables

Renderizar una página en PDFium es síncrono. Llamas a la biblioteca, esta rasteriza en un mapa de bits que le has pasado y el control regresa cuando los píxeles están escritos. Para una única página del tamaño de la pantalla a un nivel de zoom, eso lleva unos pocos milisegundos y nadie lo nota. Para una exportación a 300 ppp de un documento de 200 páginas, o una tira de miniaturas que tiene que rasterizar todas las páginas a la vez, la misma llamada cuesta segundos. Si haces esa llamada desde el hilo principal, el bucle de mensajes se detiene, la ventana deja de repintarse y Windows pinta el temido «No responde» sobre tu barra de título. El trabajo es correcto. El lugar donde lo ejecutaste es incorrecto

La solución es trasladar el renderizado largo a un hilo en segundo plano y devolver el resultado al hilo principal, donde el mapa de bits puede entregarse a un control. PDFium en sí no te impide hacerlo, pero el binding tiene que hacer que la entrega sea segura, porque la superficie de errores en torno a «ejecutar en un worker, responder en la interfaz» es amplia y los fallos son intermitentes. La unit FPdfAsync de PDFiumPas existe para dar a ese patrón una implementación correcta, con un modelo de cancelación que se ajusta a cómo se comporta realmente un renderizado largo

La forma del trabajo

Tres operaciones dominan los casos en los que un renderizado sobrevive a un fotograma. El renderizado por lotes recorre un rango de páginas y rasteriza cada una, normalmente a disco. La exportación de varias páginas hace lo mismo, pero ensambla la salida en un único archivo. El renderizado de páginas en segundo plano es lo que hace un visor cuando el usuario salta a una página que aún no está en caché, de modo que el mapa de bits se produce fuera del hilo y se muestra cuando está listo. Las tres comparten las mismas restricciones. Se ejecutan el tiempo suficiente como para que el hilo de la interfaz no pueda albergarlas, producen un resultado que el hilo de la interfaz acaba necesitando y el usuario puede abandonarlas. Cerrar el documento, desplazarse más allá de la página o pulsar Cancelar debería detener el trabajo en lugar de obligar al usuario a esperar una salida que ya no quiere

Esa última restricción es la que da forma al diseño. Un renderizado que no se puede cancelar es un renderizado que mantiene el documento abierto y quema CPU después de que la respuesta dejó de importar. Así que la unit está construida en torno a dos primitivas que se componen: un futuro que transporta el resultado de vuelta y un token que transporta la solicitud de cancelación hacia delante

Un futuro fire-and-forget

TPdfFuture<T>.Run recibe un worker, una respuesta y un token de cancelación opcional. Inicia el worker en un hilo en segundo plano y, cuando el worker termina, entrega la respuesta en el hilo principal. El parámetro genérico T es lo que sea que produzca el renderizado, a menudo un handle de mapa de bits o un registro de estado. El worker se ejecuta fuera del hilo; la respuesta se ejecuta donde es seguro tocar la VCL

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

La omisión deliberada es cualquier tipo de Wait. No hay ningún método para bloquear al llamador hasta que el futuro se complete, y eso no es un descuido. Un Wait llamado desde el hilo principal es la forma clásica de provocar un deadlock en una interfaz: el worker necesita el hilo principal para ejecutar su respuesta mediante Synchronize, el hilo principal está aparcado dentro de Wait y ninguna de las dos partes puede avanzar. Al negarse a ofrecer la primitiva, el futuro descarta el patrón que con más frecuencia derrota a quienes intentan escribir esto por su cuenta. El código que realmente necesita bloquear debería usar un TThread normal y asumir las consecuencias. El futuro es para el caso fire-and-forget, que es lo que realmente es el renderizado en segundo plano

El resultado se envuelve en TPdfFutureResult<T>, un registro que le indica a la respuesta cuál de tres cosas ocurrió. IsSuccess significa que el worker regresó con normalidad y Value contiene el renderizado. IsCancelled significa que el token se disparó y el worker abandonó en un punto de cancelación. IsFailure significa que el worker lanzó una excepción, y ErrorMessage transporta el texto. La respuesta inspecciona el estado una vez y se ramifica, en lugar de adivinar a partir de un valor centinela si un mapa de bits devuelto es real

La condición de carrera de v1.61.0 que cambió la entrega de la respuesta

La parte más instructiva de esta unit es un cambio de una sola línea que tardó un tiempo en comprenderse. A lo largo de las primeras versiones, el hilo worker entregaba su respuesta con TThread.Queue. Queue publica la respuesta en la cola del hilo principal y regresa de inmediato, lo que se lee exactamente como lo que quiere un futuro fire-and-forget. Era incorrecto, y vale la pena explicar el motivo porque es el tipo de error que pasa todas las pruebas que se te ocurre escribir

El hilo worker se crea con FreeOnTerminate := True. Eso significa que, en el instante en que Execute regresa, el hilo se desmantela a sí mismo, y TThread.Destroy llama a RemoveQueuedEvents(Self) como parte de la limpieza. RemoveQueuedEvents purga cualquier método encolado cuyo destino sea el hilo moribundo. Así que la secuencia era: el worker termina, encola la respuesta contra sí mismo, Execute regresa, el hilo se destruye a sí mismo y RemoveQueuedEvents elimina la respuesta que el hilo principal aún no había ejecutado. El resultado simplemente se desvanecía. Peor aún, en la estrecha ventana en la que el hilo principal sacaba la respuesta encolada y empezaba a ejecutarla en el mismo momento en que el hilo estaba siendo liberado, la respuesta tocaba campos de un objeto medio destruido, lo que es un use-after-free

La corrección en v1.61.0 consistió en entregar la respuesta con Synchronize en lugar de Queue. Synchronize bloquea el hilo worker hasta que el hilo principal ha ejecutado la respuesta por completo. El worker sigue vivo mientras se ejecuta su respuesta, así que no hay nada que liberar bajo sus pies, y el hilo no regresa de Execute (y por tanto no empieza a destruirse a sí mismo) hasta que la respuesta se ha entregado. La entrega está garantizada y la ventana de use-after-free queda cerrada

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;

La lección general sobrevive a la corrección concreta. Los callbacks asíncronos fire-and-forget son el patrón de concurrencia más fácil de equivocar de forma sutil, porque el camino feliz funciona al primer intento y el error vive en la interacción entre el orden de desmantelamiento del hilo y la cola. No se reproduce a voluntad. Depende de si el hilo principal resultó vaciar la cola antes de que el worker resultara terminar de destruirse a sí mismo, una temporización que el planificador decide de forma distinta en cada ejecución. Una primitiva que es correcta una vez, en el binding, vale mucho más que el mismo código rederivado en cada aplicación que necesita un renderizado en segundo plano

Por qué los callbacks son punteros a método

El worker y la respuesta no son métodos anónimos. Son tipos procedure of object, TPdfFutureWorker<T> y TPdfFutureReply<T>, y esa elección viene forzada por la matriz de compiladores. PDFiumPas compila en Delphi XE5 y posteriores y en Free Pascal 3.2 en modo Delphi, y FPC 3.2 en ese modo no admite métodos anónimos. Un callback reference-to-procedure que capturara variables locales compilaría en Delphi y fallaría en FPC, así que la unit usa el mínimo común denominador que ambos compiladores aceptan

La consecuencia práctica está en dónde vive el estado. Un método anónimo se cierra sobre las variables locales; un puntero a método no. Así que cualquier estado que el worker necesite, el índice de página, el zoom, la ruta de salida, y cualquier estado que la respuesta necesite actualizar, el control de imagen de destino o la etiqueta de progreso, tiene que colgar del objeto cuyo método se está pasando. En un visor ese objeto suele ser el formulario o un controlador de renderizado que le pertenece. Esto no es un apaño impuesto a regañadientes; mantiene la propiedad de ese estado explícita y visible en el objeto receptor en lugar de oculta dentro de una clausura

Cancelación cooperativa, no una terminación brusca

La cancelación aquí es cooperativa. No hay ninguna API que entre en el hilo worker y lo termine, porque terminar un hilo a mitad de un renderizado deja a PDFium reteniendo bloqueos y mapas de bits escritos parcialmente, y el estado del proceso tras una terminación forzada no es algo sobre lo que se pueda razonar. En su lugar, al worker se le entrega un token de solo lectura y se espera que lo compruebe, y el bucle de renderizado está escrito para comprobarlo entre páginas o entre teselas, donde detenerse es limpio

El token ofrece tres maneras de observar la cancelación. IsCancelled es un sondeo booleano barato para un bucle que quiere comprobar y decidir por sí mismo. ThrowIfCancelled es el caso común: llámalo en un punto de cancelación natural y, si se ha solicitado la cancelación, lanza EPdfOperationCancelled, que desenrolla el worker directamente de vuelta al futuro. RegisterCallback adjunta una notificación de un solo disparo que se activa una vez cuando se cancela la fuente, útil cuando un worker está bloqueado en algo que puede interrumpir en lugar de estar en un bucle apretado

La excepción es donde importa la frontera entre hilos. Cuando el worker lanza EPdfOperationCancelled, el futuro la captura y la convierte en un estado cancelado, de modo que la respuesta ve IsCancelled y no un fallo. El objeto de excepción en sí nunca se marshalla al hilo principal. Vive y muere en el hilo worker; solo su cadena de mensaje se copia en ErrorMessage. Marshallar un objeto de excepción vivo entre hilos significaría entrar en memoria propiedad de un hilo que está terminando, que es la misma clase de error que la corrección con Synchronize existe para evitar. Un código de estado y una cadena cruzan la frontera limpiamente; un objeto no lo haría

Dos interfaces, para que un worker no pueda cancelarse a sí mismo

La cancelación se reparte entre dos interfaces a propósito. IPdfCancellationTokenSource es el lado de escritura: tiene Cancel, y el propietario que lo crea, normalmente el formulario, lo conserva y llama a Cancel cuando el usuario hace clic en el botón o el formulario se cierra. IPdfCancellationToken es el lado de lectura: tiene IsCancelled, ThrowIfCancelled y RegisterCallback, y eso es todo lo que el worker recibe jamás. Un único objeto concreto implementa ambas, pero al worker solo se le entrega el token, así que no tiene forma de cancelar la operación que está ejecutando. La división es una barrera de protección a nivel de API. Un worker que pudiera alcanzar Cancel a través de su token invitaría a un fragmento de código confundido a cancelarse a sí mismo, y el sistema de tipos elimina esa posibilidad

Hay un detalle complementario para el caso en que un llamador quiere un renderizado pero nunca pretende cancelarlo. En lugar de forzar una fuente nueva por llamada, la unit expone PdfNoCancellationToken, un token singleton que está permanentemente en el estado no cancelado. Run lo sustituye cuando el argumento del token se deja en nil. Ese singleton se construye de forma anticipada (eager) durante la inicialización de la unit en lugar de perezosa (lazy) en el primer uso, y el motivo es de nuevo la concurrencia. Si varias llamadas a Run en distintos hilos worker alcanzaran a la vez un singleton creado de forma perezosa, podrían competir en su construcción, filtrar un duplicado u observar brevemente una instancia medio inicializada. Construirlo antes de que cualquier worker pueda ejecutarse elimina la condición de carrera por completo

Ejecutar un renderizado cancelable

En la práctica creas una fuente, la conservas en el formulario, pasas su Token a Run junto con un método worker y un método de respuesta, y conectas el botón Cancelar a la fuente. El worker comprueba el token mientras renderiza; la respuesta actualiza la interfaz una vez que el resultado está de vuelta. Como los callbacks son punteros a método, el worker y la respuesta leen lo que necesiten de los campos del formulario

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;

La respuesta gestiona los tres desenlaces porque los tres son alcanzables. Un renderizado terminado informa de éxito, un usuario que ha pulsado Cancelar ve la rama de cancelación, y un archivo que no se pudo escribir o una página que no se pudo analizar llega como un fallo con un mensaje. Ninguna de esas ramas bloquea, ninguna de ellas toca el hilo worker, y el mapa de bits o el estado que el worker produjo solo se lee después de que el futuro lo haya entregado en el hilo que es dueño de la interfaz

La misma disciplina de hilos da frutos en otras partes de un visor. La manera en que los mapas de bits renderizados se conservan y reutilizan a lo largo de los cambios de zoom se trata en nuestra nota sobre la caché de renderizado y el rendimiento del zoom, y la cuestión más amplia de mantener segura la frontera de PDFium bajo Delphi está en la fortificación de la ABI del PDFium Component para la seguridad de memoria. La infraestructura asíncrona descrita aquí se distribuye como parte del PDFium Component para Delphi y C++Builder, junto con las API de renderizado, texto y formularios tratadas en otros lugares de este blog