Le rendu d'une page dans PDFium est synchrone. Vous appelez la bibliothèque, elle pixellise dans une image bitmap que vous lui avez fournie, et le contrôle revient lorsque les pixels sont écrits. Pour une seule page de la taille d'un écran à un certain niveau de zoom, cela prend quelques millisecondes et personne ne le remarque. Pour une exportation à 300 dpi d'un document de 200 pages, ou une bande de miniatures qui doit pixelliser chaque page en même temps, le même appel coûte des secondes. Si vous effectuez cet appel à partir du fil principal (main thread), la boucle de messages s'arrête, la fenêtre cesse de se redessiner, et Windows peint le redouté "Ne répond pas" (Not Responding) sur votre barre de titre. Le travail est correct. L'endroit où vous l'avez exécuté est incorrect
La solution consiste à déplacer le rendu long sur un fil d'arrière-plan (background thread) et à ramener le résultat vers le fil principal, où l'image bitmap peut être transmise à un contrôle. PDFium lui-même ne vous empêche pas de le faire, mais la liaison (binding) doit rendre le transfert sûr, car la surface de bogue autour de "exécuter sur un travailleur (worker), répondre sur l'interface utilisateur" est large et les échecs sont intermittents. L'unité FPdfAsync dans PDFiumPas existe pour donner à ce modèle une implémentation correcte, avec un modèle d'annulation qui correspond à la façon dont un rendu long se comporte réellement
La forme du travail
Trois opérations dominent les cas où un rendu dure plus longtemps qu'une trame (frame). Le rendu par lots parcourt une plage de pages et pixellise chaque page, généralement sur le disque. L'exportation de plusieurs pages fait la même chose mais assemble la sortie dans un seul fichier. Le rendu de page en arrière-plan est ce qu'une visionneuse fait lorsque l'utilisateur saute à une page qui n'est pas encore en cache, de sorte que l'image bitmap est produite hors du fil principal et affichée lorsqu'elle est prête. Ces trois opérations partagent les mêmes contraintes. Elles s'exécutent suffisamment longtemps pour que le fil de l'interface utilisateur ne puisse pas les héberger, elles produisent un résultat dont le fil de l'interface utilisateur a finalement besoin, et l'utilisateur peut les abandonner. Fermer le document, faire défiler au-delà de la page, ou appuyer sur Annuler devrait arrêter le travail au lieu de forcer l'utilisateur à attendre une sortie dont il ne veut plus
Cette dernière contrainte est celle qui façonne la conception. Un rendu qui ne peut pas être annulé est un rendu qui maintient le document ouvert et consomme du CPU une fois que la réponse a cessé d'importer. L'unité est donc construite autour de deux primitives qui se composent : un futur (future) qui ramène le résultat en arrière, et un jeton (token) qui transmet la demande d'annulation en avant
Un futur "fire-and-forget" (lancer et oublier)
TPdfFuture<T>.Run prend un travailleur, une réponse et un jeton d'annulation facultatif. Il démarre le travailleur sur un fil d'arrière-plan, et lorsque le travailleur a terminé, il délivre la réponse sur le fil principal. Le paramètre générique T représente ce que le rendu produit, souvent un descripteur (handle) d'image bitmap ou un enregistrement d'état (status record). Le travailleur s'exécute hors du fil principal ; la réponse s'exécute là où il est sûr de toucher à la VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
L'omission délibérée est toute forme de Wait. Il n'y a pas de méthode pour bloquer l'appelant jusqu'à ce que le futur soit terminé, et ce n'est pas un oubli. Un Wait appelé depuis le fil principal est la façon classique de bloquer (deadlock) une interface utilisateur : le travailleur a besoin du fil principal pour exécuter sa réponse via Synchronize, le fil principal est parqué à l'intérieur de Wait, et aucun des deux côtés ne peut avancer. En refusant d'offrir la primitive, le futur exclut le modèle qui met le plus souvent en échec ceux qui essaient de l'écrire eux-mêmes. Le code qui a véritablement besoin de bloquer devrait utiliser un TThread simple et en assumer les conséquences. Le futur est destiné au cas "lancer et oublier" (fire-and-forget), ce qui correspond précisément au rendu en arrière-plan
Le résultat est enveloppé dans TPdfFutureResult<T>, un enregistrement qui indique à la réponse laquelle de trois choses s'est produite. IsSuccess signifie que le travailleur est revenu normalement et que Value contient le rendu. IsCancelled signifie que le jeton a été déclenché et que le travailleur a abandonné à un point d'annulation. IsFailure signifie que le travailleur a levé une exception, et ErrorMessage en porte le texte. La réponse inspecte l'état une fois et bifurque (branches), au lieu de deviner à partir d'une valeur sentinelle (sentinel value) si une image bitmap renvoyée est réelle
La course critique (race) de la version 1.61.0 qui a modifié la livraison des réponses
La partie la plus instructive de cette unité est une modification d'une seule ligne qui a pris un certain temps à être comprise. Dans les premières versions, le fil du travailleur délivrait sa réponse avec TThread.Queue. Queue poste la réponse dans la file d'attente du fil principal et revient immédiatement, ce qui ressemble exactement à ce que souhaite un futur "lancer et oublier". C'était faux, et la raison vaut la peine d'être détaillée, car c'est le genre de bogue qui passe tous les tests que vous pensez à écrire
Le fil du travailleur est créé avec FreeOnTerminate := True. Cela signifie qu'à l'instant où Execute revient, le fil se démonte de lui-même, et TThread.Destroy appelle RemoveQueuedEvents(Self) dans le cadre du nettoyage. RemoveQueuedEvents purge toute méthode mise en file d'attente dont la cible est le fil mourant. La séquence était donc la suivante : le travailleur termine, il met la réponse en file d'attente contre lui-même, Execute revient, le fil se détruit de lui-même, et RemoveQueuedEvents supprime la réponse que le fil principal n'avait pas encore exécutée. Le résultat s'est tout simplement volatilisé. Pire encore, dans la fenêtre étroite où le fil principal a retiré la réponse de la file d'attente et a commencé à l'exécuter au moment même où le fil était libéré, la réponse a touché les champs d'un objet à moitié détruit, ce qui est une utilisation après libération (use-after-free)
La correction dans la version 1.61.0 a consisté à délivrer la réponse avec Synchronize au lieu de Queue. Synchronize bloque le fil du travailleur jusqu'à ce que le fil principal ait exécuté la réponse jusqu'à son terme. Le travailleur est toujours en vie pendant l'exécution de sa réponse, il n'y a donc rien à libérer sous lui, et le fil ne revient pas d'Execute (et ne commence donc pas à se détruire) tant que la réponse n'a pas été délivrée. La livraison est garantie et la fenêtre d'utilisation après libération est fermée
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // déjà annulé ? ignorer le travailleur
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, et non Queue : ce thread est FreeOnTerminate, une réponse mise en file d'attente
// pourrait donc être supprimée par RemoveQueuedEvents avant que le thread principal ne l'exécute.
Synchronize(DispatchReply);
end;
La leçon générale survit à la correction spécifique. Les rappels (callbacks) asynchrones "lancer et oublier" sont le modèle de concurrence (concurrency pattern) le plus facile à rater subtilement, car le chemin idéal (happy path) fonctionne du premier coup et le bogue réside dans l'interaction entre l'ordre de démontage des fils et la file d'attente. Il ne se reproduit pas sur demande. Il dépend de la question de savoir si le fil principal a eu l'occasion de vider la file d'attente avant que le travailleur n'ait eu l'occasion de terminer de se détruire, ce qui est un minutage que l'ordonnanceur (scheduler) décide différemment à chaque exécution. Une primitive qui est correcte une fois, dans la liaison, vaut bien plus que le même code redéduit dans chaque application nécessitant un rendu en arrière-plan
Pourquoi les rappels sont des pointeurs de méthode
Le travailleur et la réponse ne sont pas des méthodes anonymes. Ce sont des types procedure of object, TPdfFutureWorker<T> et TPdfFutureReply<T>, et ce choix est imposé par la matrice des compilateurs. PDFiumPas compile sur Delphi XE5 et versions ultérieures et sur Free Pascal 3.2 en mode Delphi, et FPC 3.2 dans ce mode ne prend pas en charge les méthodes anonymes. Un rappel de référence à une procédure (reference-to-procedure) qui capture les variables locales se compilerait sur Delphi et échouerait sur FPC, de sorte que l'unité utilise le plus petit dénominateur commun que les deux compilateurs acceptent
La conséquence pratique est l'endroit où vit l'état (state). Une méthode anonyme se ferme sur les variables locales (closes over locals) ; un pointeur de méthode ne le fait pas. Ainsi, tout état dont le travailleur a besoin (l'index de la page, le zoom, le chemin de sortie) et tout état que la réponse a besoin de mettre à jour (le contrôle d'image cible ou l'étiquette de progression) doivent être rattachés à l'objet dont la méthode est transmise. Dans une visionneuse, cet objet est généralement le formulaire ou un contrôleur de rendu qu'il possède. Il ne s'agit pas d'une solution de contournement imposée à contrecœur ; elle maintient la propriété de cet état explicite et visible sur l'objet récepteur au lieu d'être cachée à l'intérieur d'une fermeture (closure)
Annulation coopérative, et non une élimination (kill) brutale
L'annulation ici est coopérative. Il n'y a pas d'API qui s'immisce dans le fil du travailleur pour le terminer, car terminer un fil en plein rendu laisse PDFium détenir des verrous (locks) et des images bitmap partiellement écrites, et l'état du processus après une élimination forcée n'est pas quelque chose sur lequel vous pouvez raisonner. À la place, le travailleur reçoit un jeton en lecture seule et est censé le vérifier, et la boucle de rendu est écrite pour le vérifier entre les pages ou entre les tuiles, là où l'arrêt est propre
Le jeton offre trois façons d'observer l'annulation. IsCancelled est une interrogation (poll) booléenne peu coûteuse pour une boucle qui souhaite tester et décider par elle-même. ThrowIfCancelled est le cas courant : appelez-le à un point d'annulation naturel et, si l'annulation a été demandée, il lève EPdfOperationCancelled, ce qui déroule (unwinds) le travailleur directement vers le futur. RegisterCallback attache une notification unique (one-shot notification) qui se déclenche une fois lorsque la source est annulée, utile lorsqu'un travailleur est bloqué dans quelque chose qu'il peut interrompre plutôt que de rester dans une boucle serrée (tight loop)
L'exception est l'endroit où la limite du fil (thread boundary) compte. Lorsque le travailleur lève EPdfOperationCancelled, le futur l'attrape et le transforme en un état annulé, de sorte que la réponse voit IsCancelled et non un échec. L'objet exception lui-même n'est jamais sérialisé (marshaled) vers le fil principal. Il vit et meurt sur le fil du travailleur ; seule sa chaîne de message est copiée dans ErrorMessage. Sérialiser un objet exception actif à travers les fils signifierait atteindre la mémoire détenue par un fil qui se termine, ce qui est la même classe d'erreur que la correction Synchronize vise à empêcher. Un code d'état et une chaîne traversent proprement la frontière ; un objet ne le ferait pas
Deux interfaces, de sorte qu'un travailleur ne peut pas s'annuler lui-même
L'annulation est divisée intentionnellement sur deux interfaces. IPdfCancellationTokenSource est le côté écriture : il possède Cancel, et le propriétaire qui le crée, généralement le formulaire, le conserve et appelle Cancel lorsque l'utilisateur clique sur le bouton ou que le formulaire se ferme. IPdfCancellationToken est le côté lecture : il possède IsCancelled, ThrowIfCancelled et RegisterCallback, et c'est tout ce que le travailleur reçoit jamais. Un objet concret implémente les deux, mais le travailleur ne se voit jamais remettre que le jeton, il n'a donc aucun moyen d'annuler l'opération qu'il exécute. La division est un garde-fou (guard rail) au niveau de l'API. Un travailleur qui pourrait atteindre Cancel par son jeton inviterait un morceau de code confus à s'annuler lui-même, et le système de types supprime cette possibilité
Il y a un détail correspondant pour le cas où un appelant souhaite un rendu mais n'a jamais l'intention de l'annuler. Plutôt que de forcer une nouvelle source par appel, l'unité expose PdfNoCancellationToken, un jeton singleton qui est en permanence à l'état non annulé. Run le substitue lorsque l'argument du jeton est laissé à nil. Ce singleton est construit de manière anticipée (eagerly) lors de l'initialisation de l'unité plutôt que de manière paresseuse (lazily) lors de la première utilisation, et la raison en est encore la concurrence. Si plusieurs appels à Run sur différents fils travailleurs essayaient d'atteindre un singleton créé paresseusement en même temps, ils pourraient faire la course sur sa construction, faire fuir (leak) un doublon, ou observer brièvement une instance à moitié initialisée. Le construire avant qu'un travailleur puisse s'exécuter élimine complètement la course critique
Exécuter un rendu annulable
En pratique, vous créez une source, vous la conservez sur le formulaire, vous transmettez son Token à Run avec une méthode de travail et une méthode de réponse, et vous reliez le bouton Annuler à la source. Le travailleur vérifie le jeton pendant qu'il effectue le rendu ; la réponse met à jour l'interface utilisateur une fois le résultat de retour. Les rappels étant des pointeurs de méthode, le travailleur et la réponse lisent ce dont ils ont besoin dans les champs du formulaire
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // champ, vit sur le formulaire
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // le travailleur l'observe à son prochain point d'annulation
end;
// S'exécute sur un thread en arrière-plan. Lit FPageRange / FOutputDir à partir du formulaire.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // arrêt propre entre les pages
RenderOnePage(PageIndex); // pixellisation synchrone PDFium
end;
Result := True;
end;
// S'exécute sur le thread principal. Sûr de toucher la VCL ici.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
if AResult.IsSuccess then
StatusLabel.Caption := 'Rendu termine'
else if AResult.IsCancelled then
StatusLabel.Caption := 'Annule'
else
StatusLabel.Caption := 'Echec : ' + AResult.ErrorMessage;
end;
La réponse gère les trois résultats car les trois sont atteignables. Un rendu terminé signale un succès, un utilisateur qui a appuyé sur Annuler voit la branche annulée, et un fichier qui n'a pas pu être écrit ou une page dont l'analyse a échoué arrive comme un échec avec un message. Aucune de ces branches ne bloque, aucune d'elles ne touche au fil du travailleur, et l'image bitmap ou l'état que le travailleur a produit n'est lu qu'après que le futur l'a délivré sur le fil qui possède l'interface utilisateur
La même discipline de filage (threading discipline) s'avère payante ailleurs dans une visionneuse. La manière dont les images bitmap rendues sont conservées et réutilisées lors des changements de zoom est couverte dans notre note sur le cache de rendu et les performances du zoom, et la question plus large du maintien de la sécurité de la frontière de PDFium sous Delphi se trouve dans le durcissement de l'ABI du Composant PDFium pour la sécurité de la mémoire. L'infrastructure asynchrone décrite ici est livrée dans le cadre du Composant PDFium pour Delphi et C++Builder, aux côtés des API de rendu, de texte et de formulaire abordées ailleurs sur ce blog