Il rendering di una pagina in PDFium è sincrono. Chiami la libreria, questa rasterizza in una bitmap che le hai passato e il controllo ritorna quando i pixel sono stati scritti. Per una singola pagina delle dimensioni dello schermo a un solo 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 tutta insieme, la stessa chiamata costa secondi. Se effettui quella 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. È sbagliato il luogo in cui lo hai eseguito
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 consegnata a un controllo. PDFium di per sé non ti impedisce di farlo, ma il binding deve rendere sicuro il passaggio di consegna, 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 sola implementazione corretta, con un modello di annullamento che si adatta al modo in cui un rendering lungo si comporta realmente
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, di solito 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 salta a una pagina che non è ancora in cache, perciò la bitmap viene prodotta fuori dal thread e mostrata quando è pronta. Tutte e tre condividono gli stessi vincoli. Eseguono un lavoro abbastanza lungo da non poter essere ospitato dal thread della UI, producono un risultato di cui il thread della UI 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 aspettare un output che non desidera più
È quest'ultimo vincolo a dare forma al design. Un rendering che non può essere annullato è un rendering che tiene aperto il documento e brucia 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 fire-and-forget
TPdfFuture<T>.Run accetta un worker, una reply e un token di annullamento facoltativo. Avvia il worker su un thread in background e, quando il worker termina, consegna la reply sul thread principale. Il parametro generico T è qualunque cosa produca il rendering, spesso un handle di bitmap o un record di stato. Il worker viene eseguito fuori dal thread; la reply viene eseguita dove è sicuro toccare 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 si è completato, e non è una svista. Una Wait chiamata dal thread principale è il modo classico di mandare in deadlock una UI: il worker ha bisogno del thread principale per eseguire la sua reply tramite Synchronize, il thread principale è parcheggiato dentro 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 scriverselo da sé. Il codice che ha genuinamente bisogno di bloccarsi dovrebbe usare un semplice TThread e assumersene le conseguenze. Il future è per il caso fire-and-forget, che è esattamente ciò che il rendering in background è in realtà
Il risultato è incapsulato in TPdfFutureResult<T>, un record che dice alla reply quale di tre cose è accaduta. IsSuccess significa che il worker è ritornato normalmente e Value contiene il rendering. IsCancelled significa che il token è scattato e il worker si è ritirato a un punto di annullamento. IsFailure significa che il worker ha sollevato un'eccezione, ed ErrorMessage ne porta il testo. La reply ispeziona lo stato una volta sola e si dirama, invece di indovinare da un valore sentinella se una bitmap restituita sia reale
La race della v1.61.0 che ha cambiato la consegna della reply
La parte più istruttiva di questa unità è una modifica di una sola riga che ha richiesto un po' di tempo per essere compresa. Nelle prime versioni il thread worker consegnava la sua reply con TThread.Queue. Queue inserisce la reply nella coda del thread principale e ritorna immediatamente, il che sembra esattamente ciò che un future fire-and-forget vuole. Era sbagliato, e vale la pena chiarire 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 smonta da solo, 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 reply contro sé stesso, Execute ritorna, il thread si distrugge e RemoveQueuedEvents cancella la reply che il thread principale non aveva ancora eseguito. Il risultato semplicemente svaniva. Peggio ancora, nella stretta finestra in cui il thread principale prelevava la reply accodata e iniziava a eseguirla nello stesso momento in cui il thread veniva liberato, la reply toccava campi di un oggetto mezzo distrutto, ovvero una use-after-free
La correzione nella v1.61.0 è stata consegnare la reply con Synchronize anziché Queue. Synchronize blocca il thread worker finché il thread principale non ha eseguito la reply fino al completamento. Il worker è ancora vivo mentre la sua reply viene eseguita, quindi non c'è nulla da liberare sotto di esso, e il thread non ritorna da Execute (e perciò non comincia a distruggersi) finché la reply non è stata consegnata. La consegna è garantita e la finestra di use-after-free è chiusa
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // già annullato? salta il 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, non Queue: questo thread è FreeOnTerminate, quindi una reply accodata
// potrebbe essere scartata da RemoveQueuedEvents prima che il thread principale la esegua.
Synchronize(DispatchReply);
end;
La lezione generale sopravvive alla correzione specifica. I callback asincroni fire-and-forget sono il pattern di concorrenza più facile da sbagliare in modo sottile, perché il percorso felice funziona al primo tentativo e il bug vive nell'interazione tra l'ordine di smantellamento del thread e la coda. Non si riproduce a comando. Dipende dal fatto che il thread principale abbia svuotato la coda prima che il worker finisse di distruggere sé stesso, un tempismo che lo scheduler decide in modo diverso a ogni esecuzione. Una primitiva corretta una volta sola, nel binding, vale molto di più dello stesso codice ri-derivato in ogni applicazione che ha bisogno di un rendering in background
Perché i callback sono puntatori a metodo
Il worker e la reply non sono metodi anonimi. Sono tipi procedure of object, TPdfFutureWorker<T> e TPdfFutureReply<T>, e questa scelta è imposta dalla matrice dei compilatori. PDFiumPas compila su Delphi XE5 e successivi e su Free Pascal 3.2 in modalità Delphi, e FPC 3.2 in quella modalità non supporta i metodi anonimi. Un callback reference-to-procedure che cattura variabili locali compilerebbe su Delphi e fallirebbe su FPC, perciò l'unità usa il minimo comune denominatore che entrambi i compilatori accettano
La conseguenza pratica riguarda dove risiede lo stato. Un metodo anonimo forma una closure attorno alle variabili locali; un puntatore a metodo no. Quindi qualunque stato di cui il worker abbia bisogno (l'indice di pagina, lo zoom, il percorso di output) e qualunque stato che la reply debba aggiornare (il controllo immagine di destinazione o l'etichetta di avanzamento) deve dipendere dall'oggetto di cui viene passato il metodo. In un visualizzatore quell'oggetto è solitamente la form o un controller di rendering da essa posseduto. Questo non è un espediente imposto a malincuore; mantiene la proprietà di quello stato esplicita e visibile sull'oggetto ricevente invece che nascosta dentro una closure
Annullamento cooperativo, non un'uccisione brutale
L'annullamento qui è cooperativo. Non c'è alcuna API che entri nel thread worker e lo termini, perché terminare un thread a metà rendering lascia PDFium con lock trattenuti e bitmap scritte solo in parte, e lo stato del processo dopo un'uccisione forzata non è qualcosa su cui si possa ragionare. Al worker viene invece passato un token in sola lettura e ci si aspetta che lo controlli, e 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 sé. ThrowIfCancelled è il caso comune: lo chiami a un punto di annullamento naturale e, se l'annullamento è stato richiesto, solleva EPdfOperationCancelled, che riavvolge il worker direttamente fino al future. RegisterCallback collega una notifica one-shot che scatta una volta sola quando la sorgente viene annullata, utile quando un worker è bloccato in qualcosa che può interrompere anziché stare in un ciclo serrato
L'eccezione è dove conta il confine del thread. Quando il worker solleva EPdfOperationCancelled, il future la cattura e la trasforma in uno stato di annullamento, così che la reply veda IsCancelled e non un fallimento. L'oggetto eccezione in sé non viene mai marshalato sul thread principale. Vive e muore sul thread worker; solo la sua stringa di messaggio viene copiata in ErrorMessage. Marshalare un oggetto eccezione vivo attraverso i thread significherebbe accedere a memoria di proprietà di un thread che sta terminando, che è la stessa classe di errore che la correzione di Synchronize esiste per prevenire. Un codice di stato e una stringa attraversano il confine in modo pulito; un oggetto no
Due interfacce, così un worker non può annullare sé stesso
L'annullamento è suddiviso in due interfacce di proposito. IPdfCancellationTokenSource è il lato scrittura: ha Cancel, e il proprietario che la crea (di solito la form) la 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 riceve mai. Un solo oggetto concreto implementa entrambe, ma al worker viene sempre passato solo il token, quindi non ha alcun modo di annullare l'operazione che sta eseguendo. La suddivisione è un guard rail a livello di API. Un worker che potesse raggiungere Cancel tramite il proprio token inviterebbe un pezzo di codice confuso ad annullare sé stesso, e il sistema dei tipi rimuove tale possibilità
C'è un dettaglio corrispondente per il caso in cui un chiamante voglia un rendering ma non intenda mai annullarlo. Invece di costringere a una nuova sorgente per ogni chiamata, l'unità espone PdfNoCancellationToken, un token singleton che si trova permanentemente nello stato non annullato. Run lo sostituisce quando l'argomento del token viene lasciato a nil. Quel singleton viene costruito in modo eager durante l'inizializzazione dell'unità anziché in modo lazy al primo utilizzo, e il motivo è di nuovo la concorrenza. Se diverse chiamate a Run su thread worker diversi tentassero tutte insieme di raggiungere un singleton creato in modo lazy, potrebbero gareggiare 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 race
Esecuzione di un rendering annullabile
In pratica crei una sorgente, la mantieni sulla form, passi il suo Token a Run insieme a un metodo worker e a un metodo reply, e colleghi il pulsante Annulla alla sorgente. Il worker controlla il token mentre esegue il rendering; la reply aggiorna la UI una volta che il risultato è tornato. Poiché i callback sono puntatori a metodo, il worker e la reply leggono ciò di cui hanno bisogno dai campi della form
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // campo, vive sulla form
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // il worker lo osserva al prossimo punto di annullamento
end;
// Eseguito su un thread in background. Legge FPageRange / FOutputDir dalla form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // arresto pulito tra una pagina e l'altra
RenderOnePage(PageIndex); // rasterizzazione PDFium sincrona
end;
Result := True;
end;
// Eseguito sul thread principale. Qui è sicuro toccare la VCL.
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 reply gestisce tutti e tre gli esiti perché tutti e tre sono raggiungibili. Un rendering terminato 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 è riuscita ad analizzare arriva come fallimento con un messaggio. Nessuno di questi rami si blocca, nessuno di essi tocca il thread worker, e la bitmap o lo stato che il worker ha prodotto viene letto solo dopo che il future l'ha consegnato sul thread che possiede la UI
La stessa disciplina sui thread ripaga anche altrove in un visualizzatore. Il modo in cui le bitmap renderizzate vengono conservate e riutilizzate tra i cambi di zoom è trattato nella nostra nota sulla render cache e le prestazioni dello zoom, e la questione più ampia di mantenere sicuro il confine di PDFium sotto Delphi si trova in rafforzare l'ABI del PDFium Component per la sicurezza della memoria. 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