Renderizar una página en PDFium es síncrono. Usted llama a la biblioteca, esta se rasteriza en un mapa de bits que le proporcionó y el control vuelve cuando se escriben los píxeles. Para una sola página del tamaño de la pantalla en un nivel de zoom, eso toma unos pocos milisegundos y nadie se da cuenta. Para una exportación de 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 realiza esa llamada desde el hilo principal, el bucle de mensajes se detiene, la ventana deja de volver a pintarse y Windows pinta el temido "No responde" sobre su barra de título. El trabajo es correcto. El lugar donde lo ejecutó está equivocado
La solución es mover el renderizado largo a un hilo en segundo plano y devolver el resultado al hilo principal, donde el mapa de bits se puede entregar a un control. PDFium en sí no le impide hacer esto, pero el binding tiene que hacer que el traspaso sea seguro, porque la superficie de error alrededor de "ejecutar en un trabajador, responder en la UI" es amplia y las fallas son intermitentes. La unidad FPdfAsync en PDFiumPas existe para darle 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 dura más que un fotograma. El renderizado por lotes recorre un rango de páginas y rasteriza cada página, generalmente al disco. La exportación de múltiples páginas hace lo mismo, pero ensambla la salida en un archivo. El renderizado de la página en segundo plano es lo que hace un visor cuando el usuario salta a una página que aún no está en la 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 necesita con el tiempo y el usuario puede abandonarlos. Cerrar el documento, pasar la página u oprimir 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 consume CPU después de que la respuesta dejó de importar. Por lo tanto, la unidad se construye alrededor de dos primitivas que se componen: un futuro que devuelve el resultado y un token que lleva la solicitud de cancelación hacia adelante
Un futuro de disparar y olvidar
TPdfFuture<T>.Run toma un trabajador, una respuesta y un token de cancelación opcional. Inicia al 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 sea que produzca el renderizado, a menudo un identificador 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 método para bloquear al autor de la llamada hasta que se complete el futuro, 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á estacionado dentro de Wait, y ninguna de las partes puede continuar. Al negarse a ofrecer la primitiva, el futuro descarta el patrón que con mayor frecuencia derrota a las personas que intentan escribir esto por sí mismas. El código que genuinamente necesita bloquearse debe usar un TThread simple y asumir las consecuencias. El futuro es para el caso de disparar y olvidar (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 dice a la respuesta cuál de las tres cosas sucedió. 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 lleva 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 carrera v1.61.0 que cambió la entrega de respuestas
La parte más instructiva de esta unidad es un cambio de una línea que tomó un tiempo comprender. A través de las primeras versiones, el hilo del trabajador entregó 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. Estaba equivocado, y vale la pena explicar la razón porque es el tipo de error que pasa todas las pruebas que se le ocurra escribir
El hilo de trabajo se crea con FreeOnTerminate := True. Eso significa que en el instante en que Execute regresa, 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 moribundo. Así que la secuencia fue: el trabajador termina, pone en cola 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 desvaneció. Peor aún, en la estrecha ventana en la que el hilo principal sacó la respuesta en cola y comenzó a ejecutarla en el mismo momento en que se estaba liberando el hilo, la respuesta tocó campos de un objeto medio destruido, que es un uso después de la liberación (use-after-free)
La solución en v1.61.0 fue entregar la respuesta con Synchronize en lugar de Queue. Synchronize bloquea el hilo del trabajador hasta que el hilo principal haya ejecutado la respuesta hasta el final. El trabajador sigue vivo mientras se ejecuta su respuesta, por lo que no hay nada que liberar debajo de él, y el hilo no regresa de Execute (y, por lo tanto, no comienza a destruirse) hasta que se ha entregado la respuesta. Se garantiza la entrega y se cierra la ventana de uso después de la liberación
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 dura 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 equivocar sutilmente, porque el camino feliz funciona en el primer intento y el error vive en la interacción entre el orden de destrucción del hilo y la cola. No se reproduce bajo demanda. Depende de si el hilo principal pudo drenar la cola antes de que el trabajador terminara de destruirse a sí mismo, que es una sincronización que el programador (scheduler) decide de manera diferente en cada ejecución. Una primitiva que es correcta una vez, en el binding, vale mucho más que el mismo código derivado nuevamente 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 opción es 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 es compatible con métodos anónimos. Una devolución de llamada de referencia a procedimiento que captura variables locales se compilaría en Delphi y fallaría en FPC, por lo que la unidad usa el mínimo común denominador que ambos compiladores aceptan
La consecuencia práctica es dónde vive el estado. Un método anónimo se cierra sobre los locales; un puntero a método no lo hace. Así que cualquier estado que necesite el trabajador, el índice de la página, el zoom, la ruta de salida y cualquier estado que la respuesta necesite actualizar, el control de la 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 de su propiedad. Esta no es una solución impuesta a regañadientes; mantiene la propiedad de ese estado explícita y visible en el objeto receptor en lugar de oculta dentro de un cierre (closure)
Cancelación cooperativa, no una matanza forzada
La cancelación aquí es cooperativa. No hay una API que acceda al hilo del trabajador y lo termine, porque terminar un hilo a mitad del renderizado deja a PDFium con bloqueos y mapas de bits parcialmente escritos, y el estado del proceso después de una eliminación forzada no es algo sobre lo que se pueda razonar. En cambio, el trabajador recibe un token de solo lectura y se espera que lo verifique, y el bucle de renderizado está escrito para verificarlo entre páginas o entre mosaicos, donde detenerse es limpio
El token ofrece tres formas de observar la cancelación. IsCancelled es un sondeo booleano barato 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, genera EPdfOperationCancelled, lo que desenrolla al trabajador directamente al futuro. RegisterCallback adjunta una notificación de una sola vez 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 genera EPdfOperationCancelled, el futuro lo atrapa y lo convierte en un estado cancelado, de modo que la respuesta ve IsCancelled y no un fracaso. El objeto de excepción en sí nunca se calcula en función del hilo principal. Vive y muere en el hilo del trabajador; solo su cadena de mensaje se copia a ErrorMessage. Calcular en función de un objeto de excepción en vivo a través de hilos significaría llegar a la memoria que es propiedad de un hilo que está terminando, que es la misma clase de error que la corrección de Synchronize existe para prevenir. 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, generalmente el formulario, lo guarda y llama a Cancel cuando el usuario hace clic en el botón o se cierra el formulario. IPdfCancellationToken es el lado de la lectura: tiene IsCancelled, ThrowIfCancelled y RegisterCallback, y eso es todo lo que el trabajador recibe. Un objeto concreto implementa ambos, pero el trabajador solo recibe el token, por lo que no tiene forma de cancelar la operación que está ejecutando. La división es una barrera de seguridad a nivel de API. Un trabajador que pudiera llegar a Cancel a través de su token invitaría a un código confundido a cancelarse a sí mismo, y el sistema de tipos elimina la posibilidad
Hay un detalle coincidente para el caso en el que un autor de llamada quiere un renderizado pero nunca tiene la intención de cancelarlo. En lugar de forzar una fuente nueva por 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 ansiosamente durante la inicialización de la unidad en lugar de forma diferida en el primer uso, y la razón vuelve a ser la simultaneidad. Si varias llamadas a Run en diferentes hilos de trabajadores alcanzan un singleton creado de forma diferida a la vez, podrían correr en su construcción, filtrar un duplicado o observar brevemente una instancia semiinicializada. Construirlo antes de que cualquier trabajador pueda ejecutar elimina la carrera por completo
Ejecutar un renderizado cancelable
En la práctica, usted crea una fuente, la guarda en el formulario, pasa su Token a Run junto con un método de trabajador y un método de respuesta, y conecta el botón Cancelar a la fuente. El trabajador verifica el token mientras renderiza; la respuesta actualiza la interfaz de usuario una vez que regresa el resultado. Debido a que las devoluciones de llamada son punteros de métodos, el trabajador y la respuesta leen todo lo que necesitan 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 maneja los tres resultados porque se puede acceder a los tres. Un renderizado finalizado informa éxito, un usuario que oprimió Cancelar ve la rama cancelada, y un archivo que no se pudo escribir o una página que no se pudo analizar llega como un error con un mensaje. Ninguna de esas ramas bloquea, ninguna de ellas toca el hilo del trabajador, y el mapa de bits o el 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 subprocesos vale la pena en otras partes de un visor. La forma en que los mapas de bits renderizados se conservan y se reutilizan en los cambios de zoom se cubre en nuestra nota sobre el caché de renderizado y el rendimiento del zoom, y la pregunta más amplia de mantener seguro el límite de PDFium bajo Delphi está en el endurecimiento de la ABI del PDFium Component para la seguridad de la memoria en Delphi. La infraestructura asíncrona que se describe aquí se distribuye como parte del PDFium Component para Delphi y C++Builder, junto con las API de renderizado, texto y formulario cubiertas en otras partes de este blog