El renderizado de una página en PDFium es síncrono. Usted llama a la biblioteca, esta rasteriza en un mapa de bits que le ha entregado, y el control regresa cuando los píxeles están escritos. Para una sola página del tamaño de la pantalla a un nivel de zoom determinado, eso tarda unos pocos milisegundos y nadie se da cuenta. Para una exportación a 300 ppp de un documento de 200 páginas, o una tira de miniaturas que tiene que rasterizar cada página a la vez, la misma llamada cuesta segundos. Si hace 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 su barra de título. El trabajo es correcto. El lugar donde lo ejecutó es incorrecto
La solución consiste en 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 le impide hacer esto, pero el enlace tiene que hacer que el traspaso sea seguro, porque la superficie de errores en torno a "ejecutar en un trabajador, responder en la interfaz de usuario" es amplia y los fallos son intermitentes. La unidad FPdfAsync en 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 que un renderizado dura más que un fotograma. El renderizado por lotes recorre un rango de páginas y rasteriza cada página, normalmente en el disco. La exportación multipágina 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é, por lo que el mapa de bits se produce fuera del hilo y se muestra cuando está listo. Los tres comparten las mismas restricciones. Se ejecutan durante el tiempo suficiente como para que el hilo de la interfaz de usuario no pueda alojarlos, producen un resultado que el hilo de la interfaz de usuario acaba necesitando, y el usuario puede abandonarlos. 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 desea
Esa última restricción es la que da forma al diseño. Un renderizado que no puede cancelarse es un renderizado que mantiene abierto el documento y consume CPU después de que la respuesta haya dejado de importar. Por ello, la unidad está construida en torno a dos primitivas que se componen: un futuro que devuelve el resultado y un token que transmite la petición de cancelación
Un futuro de disparar y olvidar
TPdfFuture<T>.Run toma un trabajador, una respuesta y un token de cancelación opcional. Inicia el trabajador en un hilo en segundo plano, y cuando el trabajador termina entrega la respuesta en el hilo principal. El parámetro genérico T es lo que produzca el renderizado, a menudo un manejador de mapa de bits o un registro de estado. El trabajador 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 a la persona que llama 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 bloquear una interfaz de usuario: el trabajador necesita el hilo principal para ejecutar su respuesta a través de Synchronize, el hilo principal está aparcado dentro de Wait, y ninguna de las partes puede avanzar. Al negarse a ofrecer la primitiva, el futuro descarta el patrón que con más frecuencia derrota a la gente que intenta escribir esto por sí misma. El código que realmente necesita bloquearse debe usar un TThread simple y asumir las consecuencias. El futuro es para el caso de disparar y olvidar, que es lo que realmente es el renderizado en segundo plano
El resultado se envuelve en TPdfFutureResult<T>, un registro que le dice a la respuesta cuál de tres cosas ocurrió. IsSuccess significa que el trabajador regresó normalmente y Value contiene el renderizado. IsCancelled significa que el token se disparó y el trabajador se retiró en un punto de cancelación. IsFailure significa que el trabajador lanzó una excepción, y ErrorMessage conlleva 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 v1.61.0 que cambió la entrega de respuestas
La parte más instructiva de esta unidad es un cambio de una sola línea que llevó un tiempo entender. A través de las primeras versiones, el hilo del trabajador entregaba su respuesta con TThread.Queue. Queue publica la respuesta en la cola del hilo principal y regresa inmediatamente, lo que se lee exactamente como lo que quiere un futuro de disparar y olvidar. Era erróneo, y vale la pena explicar la razón porque es la clase de fallo que pasa todas las pruebas que a uno se le ocurren escribir
El hilo trabajador se crea con FreeOnTerminate := True. Eso significa que en el instante en que Execute retorna, el hilo se destruye a sí mismo, y TThread.Destroy llama a RemoveQueuedEvents(Self) como parte de la limpieza. RemoveQueuedEvents purga cualquier método en cola cuyo objetivo sea el hilo agonizante. Así que la secuencia era: el trabajador termina, pone en cola la respuesta contra sí mismo, Execute retorna, 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 en cola y empezaba a ejecutarla en el mismo momento en que el hilo se estaba liberando, la respuesta tocaba campos de un objeto medio destruido, lo que constituye un uso después de liberación (use-after-free)
La solución en la versión 1.61.0 fue entregar la respuesta con Synchronize en lugar de Queue. Synchronize bloquea el hilo trabajador hasta que el hilo principal ha ejecutado la respuesta hasta su finalización. El trabajador sigue vivo mientras se ejecuta su respuesta, por lo que no hay nada que liberar debajo de él, y el hilo no retorna de Execute (y por tanto no empieza a destruirse a sí mismo) hasta que se ha entregado la respuesta. La entrega está garantizada, y la ventana de uso después de liberación se cierra
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 perdura más que la solución específica. Las devoluciones de llamada asíncronas de disparar y olvidar son el patrón de concurrencia más fácil de hacer sutilmente mal, porque la ruta feliz funciona al primer intento y el fallo vive en la interacción entre el orden de desmontaje del hilo y la cola. No se reproduce bajo demanda. Depende de si el hilo principal casualmente vació la cola antes de que el trabajador terminara casualmente de destruirse a sí mismo, que es una sincronización que el programador decide de forma diferente en cada ejecución. Una primitiva que es correcta una vez, en el enlace, vale mucho más que el mismo código derivado de nuevo en cada aplicación que necesita un renderizado en segundo plano
Por qué las devoluciones de llamada son punteros a métodos
El trabajador 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 del compilador. PDFiumPas compila en Delphi XE5 y posteriores y en Free Pascal 3.2 en modo Delphi, y FPC 3.2 en ese modo no soporta métodos anónimos. Una devolución de llamada de referencia a procedimiento que capturara variables locales compilaría en Delphi y fallaría en FPC, de modo que la unidad utiliza el mínimo común denominador que aceptan ambos compiladores
La consecuencia práctica es dónde vive el estado. Un método anónimo se cierra sobre las variables locales; un puntero a método no. Por tanto, cualquier estado que el trabajador 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 posee. Esto no es una solución provisional impuesta 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 un cierre forzado
La cancelación aquí es cooperativa. No hay ninguna API que llegue hasta el hilo del trabajador y lo termine, porque terminar un hilo en mitad del renderizado deja a PDFium reteniendo bloqueos y mapas de bits parcialmente escritos, y el estado del proceso después de un cierre forzado no es algo sobre lo que se pueda razonar. En su lugar, se entrega al trabajador un token de solo lectura y se espera que lo compruebe, y el bucle de renderizado se escribe para comprobarlo entre páginas o entre mosaicos, donde la detención es limpia
El token ofrece tres formas de observar la cancelación. IsCancelled es una consulta booleana barata para un bucle que quiere probar y decidir por sí mismo. ThrowIfCancelled es el caso común: llámelo en un punto de cancelación natural y, si se ha solicitado la cancelación, lanza EPdfOperationCancelled, lo que desenrolla al trabajador 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 trabajador está bloqueado en algo que puede interrumpir en lugar de estar sentado en un bucle cerrado
La excepción es donde importa el límite del hilo. Cuando el trabajador lanza EPdfOperationCancelled, el futuro lo atrapa y lo convierte en un estado cancelado, por lo que la respuesta ve IsCancelled y no un fallo. El objeto de excepción en sí nunca se serializa al hilo principal. Vive y muere en el hilo del trabajador; solo su cadena de mensaje se copia a ErrorMessage. Serializar un objeto de excepción vivo a través de hilos significaría acceder a la memoria propiedad de un hilo que está terminando, que es la misma clase de error que la solución de Synchronize trata de evitar. Un código de estado y una cadena cruzan el límite limpiamente; un objeto no lo haría
Dos interfaces, para que un trabajador no pueda cancelarse a sí mismo
La cancelación se divide en dos interfaces a propósito. IPdfCancellationTokenSource es el lado de la escritura: tiene Cancel, y el propietario que lo crea, normalmente el formulario, lo guarda y llama a Cancel cuando el usuario hace clic en el botón o el formulario se cierra. IPdfCancellationToken es el lado de la lectura: tiene IsCancelled, ThrowIfCancelled, y RegisterCallback, y eso es todo lo que recibe el trabajador. Un objeto concreto implementa ambas, pero al trabajador solo se le entrega el token, de modo que no tiene forma de cancelar la operación que está ejecutando. La división es un guardarraíl a nivel de API. Un trabajador que pudiera alcanzar Cancel a través de su token invitaría a un fragmento de código confuso a cancelarse a sí mismo, y el sistema de tipos elimina esa posibilidad
Hay un detalle coincidente para el caso en el que el llamador quiere un renderizado pero nunca pretende cancelarlo. En lugar de forzar una fuente nueva por cada llamada, la unidad expone PdfNoCancellationToken, un token singleton que está permanentemente en el estado no cancelado. Run lo sustituye cuando el argumento del token se deja nulo. Ese singleton se construye tempranamente durante la inicialización de la unidad en lugar de perezosamente en el primer uso, y la razón es la concurrencia de nuevo. Si varias llamadas Run en diferentes hilos de trabajo intentaran acceder a la vez a un singleton creado perezosamente, podrían competir en su construcción, filtrar un duplicado o, por un momento, observar una instancia a medio inicializar. Construirlo antes de que pueda ejecutarse cualquier trabajador elimina la carrera por completo
Ejecución de un renderizado cancelable
En la práctica se crea una fuente, se guarda en el formulario, se pasa su Token a Run junto a un método trabajador y un método de respuesta, y se vincula el botón Cancelar a la fuente. El trabajador comprueba el token mientras renderiza; la respuesta actualiza la interfaz de usuario una vez que el resultado está de vuelta. Dado que las devoluciones de llamada son punteros a métodos, el trabajador 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 resultados porque los tres son alcanzables. Un renderizado terminado informa de éxito, un usuario que pulsó Cancelar ve la rama cancelada, y un archivo que no pudo escribirse o una página que no pudo analizarse llega como un fallo con un mensaje. Ninguna de esas ramas se bloquea, ninguna de ellas toca el hilo del trabajador, y el mapa de bits o estado que produjo el trabajador solo se lee después de que el futuro lo haya entregado en el hilo que posee la interfaz de usuario
La misma disciplina de hilos da sus frutos en otras partes de un visor. La forma en que los mapas de bits renderizados se mantienen y reutilizan en los cambios de zoom se cubre en nuestra nota sobre la caché de renderizado y el rendimiento del zoom, y la cuestión más amplia de mantener el límite de PDFium seguro bajo Delphi se encuentra en endurecimiento de la ABI VCL de PDFium para seguridad de memoria. La infraestructura asíncrona descrita aquí se distribuye como parte del PDFium Component para Delphi y C++Builder, junto a las API de renderizado, texto y formularios cubiertas en otras partes de este blog