Technical Article

Rendering PDF in background in Delphi con future annullabili

Il rendering di una pagina in PDFium è sincrono. Chiami la libreria, rasterizza in una bitmap che le hai passato e il controllo ritorna quando i pixel sono scritti. Per una singola pagina delle dimensioni dello schermo a un certo livello di zoom, l'operazione richiede pochi millisecondi e nessuno se ne accorge. Per un'esportazione a 300 dpi di un documento di 200 pagine, o per una striscia di miniature che deve rasterizzare ogni pagina contemporaneamente, la stessa chiamata costa secondi. Se si effettua tale chiamata dal thread principale, il ciclo dei messaggi si ferma, la finestra smette di ridisegnarsi e Windows dipinge il temuto "Non risponde" sulla barra del titolo. Il lavoro è corretto. È il luogo in cui lo hai eseguito a essere sbagliato

La soluzione consiste nello spostare il rendering lungo su un thread in background e riportare il risultato sul thread principale, dove la bitmap può essere passata a un controllo. PDFium in sé non impedisce di farlo, ma il binding deve rendere il passaggio sicuro, perché la superficie di bug attorno al "esegui su un worker, rispondi sulla UI" è ampia e i guasti sono intermittenti. L'unità FPdfAsync in PDFiumPas esiste per dare a quel pattern una corretta implementazione, con un modello di annullamento che si adatta a come si comporta effettivamente un lungo rendering

La forma del lavoro

Tre operazioni dominano i casi in cui un rendering dura più a lungo di un frame. Il rendering in batch percorre un intervallo di pagine e rasterizza ciascuna pagina, solitamente su disco. L'esportazione multipagina fa lo stesso ma assembla l'output in un unico file. Il rendering della pagina in background è ciò che fa un visualizzatore quando l'utente passa a una pagina che non è ancora in cache, perciò la bitmap viene prodotta in un thread separato e mostrata quando è pronta. Tutte e tre le operazioni condividono gli stessi vincoli. Eseguono un'elaborazione abbastanza lunga da non poter essere ospitata dal thread dell'interfaccia utente (UI), producono un risultato di cui il thread dell'interfaccia utente alla fine ha bisogno, e l'utente potrebbe abbandonarle. Chiudere il documento, scorrere oltre la pagina o premere Annulla dovrebbe interrompere il lavoro invece di costringere l'utente ad attendere un output che non desidera più

Quest'ultimo vincolo è quello che dà forma al design. Un rendering che non può essere annullato è un rendering che tiene aperto il documento e consuma CPU dopo che la risposta ha smesso di avere importanza. Pertanto l'unità è costruita attorno a due primitive che si compongono: un future che riporta indietro il risultato e un token che porta avanti la richiesta di annullamento

Un future spara-e-dimentica (fire-and-forget)

TPdfFuture<T>.Run accetta un worker, una risposta e un token di annullamento facoltativo. Avvia il worker su un thread in background e quando il worker finisce consegna la risposta sul thread principale. Il parametro generico T è qualsiasi cosa produca il rendering, spesso un handle di bitmap o un record di stato. Il worker viene eseguito fuori thread; la risposta viene eseguita dove è sicuro interagire con la VCL

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

L'omissione deliberata è qualsiasi tipo di Wait. Non esiste alcun metodo per bloccare il chiamante finché il future non è completato, e non si tratta di una svista. Una Wait chiamata dal thread principale è il modo classico di bloccare in stallo (deadlock) una UI: il worker ha bisogno del thread principale per eseguire la sua risposta tramite Synchronize, il thread principale è parcheggiato all'interno di Wait, e nessuna delle due parti può procedere. Rifiutandosi di offrire la primitiva, il future esclude il pattern che più spesso sconfigge chi tenta di scriverlo da solo. Il codice che ha sinceramente bisogno di bloccarsi dovrebbe utilizzare un semplice TThread e assumersene le conseguenze. Il future è per il caso fire-and-forget, che è ciò che è in realtà il rendering in background

Il risultato è avvolto in TPdfFutureResult<T>, un record che dice alla risposta quale di tre cose è successa. IsSuccess significa che il worker è ritornato normalmente e Value contiene il rendering. IsCancelled significa che il token è scattato e il worker si è interrotto in un punto di annullamento. IsFailure significa che il worker ha generato un'eccezione, e ErrorMessage porta il testo. La risposta ispeziona lo stato una volta e si dirama, invece di indovinare da un valore sentinella se una bitmap restituita sia reale

La race condition della v1.61.0 che ha cambiato la consegna delle risposte

La parte più istruttiva di questa unità è una modifica di una riga che ha richiesto un po' di tempo per essere compresa. Nelle prime versioni il thread worker consegnava la sua risposta con TThread.Queue. Queue (accoda) inserisce la risposta nella coda del thread principale e ritorna immediatamente, che sembra esattamente ciò che un future fire-and-forget desidera. Era sbagliato e vale la pena spiegare il motivo perché è il tipo di bug che supera ogni test che pensi di scrivere

Il thread worker viene creato con FreeOnTerminate := True. Ciò significa che nell'istante in cui Execute ritorna, il thread si disassembla, e TThread.Destroy chiama RemoveQueuedEvents(Self) come parte della pulizia. RemoveQueuedEvents elimina qualsiasi metodo accodato il cui bersaglio sia il thread morente. Quindi la sequenza era: il worker finisce, accoda la risposta contro se stesso, Execute ritorna, il thread si distrugge e RemoveQueuedEvents elimina la risposta che il thread principale non aveva ancora eseguito. Il risultato semplicemente svaniva. Peggio ancora, nella stretta finestra temporale in cui il thread principale ha prelevato la risposta accodata e ha iniziato a eseguirla nello stesso momento in cui il thread veniva liberato, la risposta toccava i campi di un oggetto mezzo distrutto, il che costituisce un use-after-free

La correzione nella v1.61.0 è stata di consegnare la risposta con Synchronize anziché Queue. Synchronize blocca il thread worker finché il thread principale non ha eseguito la risposta fino al completamento. Il worker è ancora vivo mentre la sua risposta viene eseguita, quindi non c'è nulla da liberare da sotto di esso, e il thread non ritorna da Execute (e di conseguenza non inizia a distruggersi) finché la risposta non è stata consegnata. La consegna è garantita e la finestra use-after-free è chiusa

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 lezione generale sopravvive alla singola correzione. I callback asincroni fire-and-forget sono il modello di concorrenza più facile da sbagliare in modo sottile, perché il percorso felice (happy path) funziona al primo tentativo e il bug si annida nell'interazione tra l'ordine di smantellamento del thread e la coda. Non si riproduce su richiesta. Dipende dal fatto che il thread principale abbia svuotato la coda prima che il worker finisse di distruggere se stesso, un tempismo che lo schedulatore decide in modo diverso a ogni esecuzione. Una primitiva corretta una volta per tutte, nel binding, vale molto di più dello stesso codice re-derivato in ogni applicazione che necessiti di un rendering in background

Perché i callback sono puntatori a metodi

Il worker e la risposta non sono metodi anonimi. Sono tipi procedure of object, TPdfFutureWorker<T> e TPdfFutureReply<T>, e questa scelta è dettata dalla matrice dei compilatori. PDFiumPas viene compilato su Delphi XE5 e versioni successive, e su Free Pascal 3.2 in modalità Delphi, e FPC 3.2 in tale modalità non supporta i metodi anonimi. Un callback con riferimento a procedura che cattura variabili locali verrebbe compilato su Delphi ma fallirebbe su FPC, pertanto l'unità utilizza il minimo comune denominatore accettato da entrambi i compilatori

La conseguenza pratica è dove risiede lo stato. Un metodo anonimo forma una closure attorno alle variabili locali; un puntatore a metodo no. Quindi, qualsiasi stato di cui il worker abbia bisogno (l'indice della pagina, lo zoom, il percorso di output) e qualsiasi stato che la risposta debba aggiornare (il controllo dell'immagine di destinazione o un'etichetta di avanzamento) deve dipendere dall'oggetto di cui viene passato il metodo. In un visualizzatore, tale oggetto è solitamente la form o un controller di rendering da essa posseduto. Questo non è un ripiego imposto a malincuore; mantiene la proprietà di quello stato esplicita e visibile sull'oggetto ricevente invece di nasconderla all'interno di una closure

Annullamento cooperativo, non un'uccisione netta

L'annullamento qui è cooperativo. Non esiste un'API che penetri nel thread worker e lo termini, perché l'interruzione di un thread a metà del rendering lascia a PDFium blocchi trattenuti e bitmap scritte a metà, e lo stato del processo dopo un'uccisione forzata (hard kill) non è qualcosa su cui si possa ragionare. Piuttosto, al worker viene passato un token di sola lettura e ci si aspetta che lo controlli; il ciclo di rendering è scritto in modo da controllarlo tra una pagina e l'altra o tra le tile, dove l'arresto è pulito

Il token offre tre modi per osservare l'annullamento. IsCancelled è un economico sondaggio booleano per un ciclo che vuole testare e decidere da solo. ThrowIfCancelled è il caso comune: si chiama a un naturale punto di annullamento e, se è stato richiesto l'annullamento, solleva EPdfOperationCancelled, che riavvolge il worker direttamente al future. RegisterCallback collega una notifica one-shot che scatta una sola volta quando la sorgente viene annullata, utile quando un worker è bloccato in qualcosa che può interrompere piuttosto che sedere in un ciclo serrato

L'eccezione è il luogo in cui il confine del thread conta. Quando il worker solleva EPdfOperationCancelled, il future lo intercetta e lo trasforma in uno stato di annullamento, in modo che la risposta veda IsCancelled e non un fallimento. L'oggetto eccezione in sé non viene mai inviato (marshaled) al thread principale. Vive e muore sul thread worker; solo la sua stringa di messaggio viene copiata in ErrorMessage. Inviare un oggetto eccezione vivo attraverso i thread significherebbe accedere alla memoria di proprietà di un thread in fase di completamento, che è la stessa classe di errore che la correzione di Synchronize mira a prevenire. Un codice di stato e una stringa attraversano il confine in modo pulito; un oggetto non lo farebbe

Due interfacce, così un worker non può annullare se stesso

L'annullamento è diviso appositamente in due interfacce. IPdfCancellationTokenSource è il lato scrittura: possiede Cancel e il proprietario che lo crea (di solito la form) lo conserva e chiama Cancel quando l'utente fa clic sul pulsante o la form si chiude. IPdfCancellationToken è il lato lettura: ha IsCancelled, ThrowIfCancelled e RegisterCallback, ed è tutto ciò che il worker riceverà mai. Un singolo oggetto concreto implementa entrambe, ma al worker viene passato solo il token, quindi non ha modo di annullare l'operazione che sta eseguendo. La divisione è un guard rail a livello di API. Un worker in grado di raggiungere Cancel tramite il proprio token inviterebbe un pezzo di codice confuso ad annullare se stesso, e il sistema dei tipi rimuove tale possibilità

Esiste un dettaglio corrispondente per il caso in cui un chiamante desideri un rendering ma non intenda mai annullarlo. Invece di forzare una nuova sorgente per ogni chiamata, l'unità espone PdfNoCancellationToken, un token singleton che si trova permanentemente in stato non annullato. Run lo sostituisce quando l'argomento del token viene lasciato nil. Quel singleton viene costruito preventivamente (eagerly) durante l'inizializzazione dell'unità piuttosto che in modo ritardato al primo utilizzo, e la ragione è ancora una volta la concorrenza. Se diverse chiamate a Run su thread worker diversi tentassero contemporaneamente di raggiungere un singleton creato pigramente (lazily), potrebbero concorrere sulla sua costruzione, perdere un duplicato o osservare brevemente un'istanza mezza inizializzata. Costruirlo prima che qualsiasi worker possa essere eseguito rimuove del tutto la corsa (race)

Esecuzione di un rendering annullabile

In pratica si crea una sorgente, la si mantiene sulla form, si passa il suo Token a Run insieme a un metodo worker e a un metodo reply, e si collega il pulsante Annulla alla sorgente. Il worker controlla il token durante il rendering; la risposta aggiorna la UI una volta che il risultato è tornato. Poiché i callback sono puntatori a metodo, il worker e la risposta leggono ciò di cui hanno bisogno dai campi della form

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 risposta gestisce tutti e tre gli esiti poiché tutti e tre sono raggiungibili. Un rendering completato riporta il successo, un utente che ha premuto Annulla vede il ramo annullato, e un file che non è stato possibile scrivere o una pagina che non è stata analizzata (parsed) arriva come un fallimento con un messaggio. Nessuno di questi rami blocca, nessuno di essi tocca il thread worker, e la bitmap o lo stato prodotto dal worker viene letto solo dopo che il future l'ha consegnato sul thread proprietario della UI

La stessa disciplina dei thread ripaga anche altrove in un visualizzatore. Il modo in cui le bitmap renderizzate vengono conservate e riutilizzate tra i cambiamenti di zoom è trattato nella nostra nota sulla cache di rendering e le prestazioni dello zoom, e la questione più ampia di mantenere sicuro il confine PDFium in Delphi si trova in rafforzare la sicurezza della memoria dell'ABI VCL di PDFium. L'infrastruttura asincrona qui descritta viene fornita come parte del PDFium Component per Delphi e C++Builder, insieme alle API di rendering, testo e form trattate altrove su questo blog