La representación de una página en PDFium es sincrónica. Usted llama a la biblioteca, la rasteriza en un mapa de bits que usted le entregó, y el control regresa 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 cada página 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 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 es mover la representación larga 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 enlace tiene que hacer que el traspaso sea seguro, porque la superficie de error en torno a "ejecutar en un trabajador, responder en la interfaz de usuario" 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 adapta a cómo se comporta realmente una representación larga
La forma del trabajo
Tres operaciones dominan los casos en los que una representación dura más que un fotograma. La representación por lotes recorre un rango de páginas y rasteriza cada página, generalmente en el disco. La exportación de varias páginas hace lo mismo, pero ensambla la salida en un solo archivo. La representación 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 la memoria 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 para que el hilo de la interfaz de usuario no pueda alojarlos, producen un resultado que el hilo de la interfaz de usuario eventualmente necesita y el usuario puede abandonarlos. Cerrar el documento, desplazarse más allá de la página o presionar 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. Una representación que no se puede cancelar es una representación que mantiene el documento abierto y quema la CPU después de que la respuesta dejó de importar. Por lo tanto, la unidad se basa en dos primitivas que componen: un futuro que devuelve el resultado y un token que lleva adelante la solicitud 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 la representación, 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 ningún método para bloquear a la persona que llama 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 realmente necesita bloquear 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 la representación en segundo plano
El resultado está envuelto en TPdfFutureResult<T>, un registro que le dice a la respuesta cuál de tres cosas sucedió. IsSuccess significa que el trabajador regresó normalmente y Value contiene la representación. IsCancelled significa que el token se disparó y el trabajador se retiró en un punto de cancelación. IsFailure significa que el trabajador se elevó, 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 sola línea que tomó un tiempo entender. A través de las primeras versiones, el hilo de trabajo entregó 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 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 usted piensa escribir
El hilo de trabajo se crea con FreeOnTerminate := True. Eso significa que en el instante en que regresa Execute, 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 donde el hilo principal sacó la respuesta en cola y comenzó a ejecutarla en el mismo momento en que se liberaba el hilo, la respuesta tocó campos de un objeto medio destruido, lo cual es un uso después de la liberación
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 su finalización. El trabajador sigue vivo mientras se ejecuta su respuesta, por lo que no hay nada que liberar de debajo de él, y el hilo no regresa de Execute (y por lo tanto no comienza a destruirse a sí mismo) hasta que se ha entregado la respuesta. La entrega está garantizada y la ventana de uso después de la liberación está 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 solución específica. Las devoluciones de llamada asincrónicas de disparar y olvidar son el patrón de concurrencia más fácil de hacer mal sutilmente, porque la ruta feliz funciona en el primer intento y el error vive en la interacción entre el orden de desmontaje del hilo y la cola. No se reproduce a pedido. Depende de si el hilo principal logró vaciar la cola antes de que el trabajador terminara de destruirse a sí mismo, que es una sincronización que el programador decide de manera 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 nuevamente en cada aplicación que necesita una representación en segundo plano
Por qué las devoluciones de llamada son punteros de método
El trabajador y la respuesta no son métodos anónimos. Son tipos de procedure of object, TPdfFutureWorker<T> y TPdfFutureReply<T>, y esa elecció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 admite 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 utiliza el mínimo común denominador que aceptan ambos compiladores
La consecuencia práctica es donde vive el estado. Un método anónimo se cierra sobre los locales; un puntero de método no lo hace. Por lo tanto, cualquier estado que necesite el trabajador, el índice de la página, el zoom, la ruta de salida y cualquier estado que la respuesta deba actualizar, el control de la imagen de destino o la etiqueta de progreso, debe colgar del objeto cuyo método se está pasando. En un visor, ese objeto suele ser el formulario o un controlador de representación que posee. Esto no es una solución alternativa 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
Cancelación cooperativa, no una muerte dura
La cancelación aquí es cooperativa. No hay ninguna API que alcance el hilo de trabajo y lo termine, porque terminar un hilo en medio de la representación deja a PDFium con bloqueos y mapas de bits parcialmente escritos, y el estado del proceso después de una muerte forzada no es algo sobre lo que se pueda razonar. En su lugar, al trabajador se le entrega un token de solo lectura y se espera que lo verifique, y el bucle de representación se escribe para verificarlo 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 encuesta 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, genera EPdfOperationCancelled, lo que devuelve el trabajador directamente al futuro. RegisterCallback adjunta una notificación de un solo disparo que se dispara una vez cuando se cancela la fuente, útil cuando un trabajador está bloqueado en algo que puede interrumpir en lugar de sentarse 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 una falla. El objeto de excepción en sí nunca se serializa en el hilo principal. Vive y muere en el hilo del trabajador; solo su cadena de mensaje se copia en ErrorMessage. Serializar un objeto de excepción en vivo a través de subprocesos significaría llegar a la memoria que es propiedad de un hilo que está terminando, que es la misma clase de error que existe para evitar la solución de Synchronize. 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 el formulario se cierra. 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 al trabajador solo se le entrega el token, por lo que no tiene forma de cancelar la operación que está ejecutando. La división es una barandilla a nivel de API. Un trabajador que pudiera alcanzar Cancel a través de su token invitaría a una pieza de código confusa a cancelarse a sí misma, y el sistema de tipos elimina la posibilidad
Hay un detalle coincidente para el caso en el que una persona que llama quiere una representación pero nunca tiene la intención de cancelarla. En lugar de forzar una nueva fuente por llamada, la unidad expone PdfNoCancellationToken, un token singleton que está permanentemente en 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 perezosa en el primer uso, y la razón es nuevamente la concurrencia. Si varias llamadas a Run en diferentes hilos de trabajo alcanzaran todas un singleton creado perezosamente a la vez, podrían correr en su construcción, filtrar un duplicado u observar brevemente una instancia a medio inicializar. Construirlo antes de que pueda ejecutarse cualquier trabajador elimina la carrera por completo
Ejecutar una representación 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 comprueba el token mientras representa; la respuesta actualiza la interfaz de usuario una vez que el resultado regresa. Debido a que las devoluciones de llamada son punteros de método, 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 los tres son accesibles. Una representación terminada informa éxito, un usuario que presionó Cancelar ve la rama cancelada, y un archivo que no se pudo escribir o una página que no se pudo analizar llega como una falla con un mensaje. Ninguna de esas ramas se bloquea, ninguna de ellas toca el hilo de trabajo, 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 representados se guardan y reutilizan a lo largo de los cambios de zoom se trata en nuestra nota sobre el caché de representación y el rendimiento del zoom, y la pregunta más amplia sobre cómo mantener seguro el límite de PDFium bajo Delphi está en el endurecimiento de la ABI VCL de PDFium para la seguridad de la memoria. La infraestructura asincrónica descrita aquí se incluye como parte del Componente PDFium para Delphi y C++Builder, junto con las API de representación, texto y formularios que se tratan en otras partes de este blog