Technical Article

Rendu PDF en arrière-plan dans Delphi avec des contrats annulables

Le rendu d'une page dans PDFium est synchrone. Vous appelez la bibliothèque, elle pixellise en un bitmap que vous lui avez remis, et le contrôle revient lorsque les pixels sont écrits. Pour une seule page de la taille de l'écran à un niveau de zoom, cela prend quelques millisecondes et personne ne le remarque. Pour une exportation à 300 ppp d'un document de 200 pages, ou une bande de vignettes qui doit pixelliser chaque page à la fois, le même appel prend des secondes. Si vous effectuez cet appel à partir du thread principal, la boucle de messages s'arrête, la fenêtre cesse de se repeindre et Windows affiche le redouté "Ne répond pas" 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 long rendu vers un thread d'arrière-plan et à ramener le résultat sur le thread principal, où le bitmap peut être remis à un contrôle. PDFium lui-même ne vous empêche pas de le faire, mais la liaison doit rendre le transfert sûr, car la surface de bogues autour de "l'exécution sur un thread de travail, réponse sur l'interface utilisateur" est large et les pannes sont intermittentes. L'unité FPdfAsync dans PDFiumPas existe pour donner à ce modèle une implémentation correcte, avec un modèle d'annulation qui correspond au comportement réel d'un long rendu

La forme du travail

Trois opérations dominent les cas où un rendu dure plus longtemps qu'une image. Le rendu par lots parcourt une plage de pages et pixellise chaque page, généralement sur le disque. L'exportation multipage 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 passe à une page qui n'est pas encore dans le cache, de sorte que le bitmap est produit hors thread et affiché lorsqu'il est prêt. Tous les trois partagent les mêmes contraintes. Ils durent assez longtemps pour que le thread de l'interface utilisateur ne puisse pas les héberger, ils produisent un résultat dont le thread de l'interface utilisateur a finalement besoin, et l'utilisateur peut les abandonner. Fermer le document, faire défiler la page ou appuyer sur Annuler devrait arrêter le travail au lieu d'obliger l'utilisateur à attendre une sortie qu'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 brûle le processeur après que la réponse n'a plus d'importance. Ainsi, l'unité est construite autour de deux primitives qui composent : un futur (contrat) qui ramène le résultat et un jeton qui fait avancer la demande d'annulation

Un contrat fire-and-forget

TPdfFuture<T>.Run prend un exécutant (worker), une réponse et un jeton d'annulation optionnel. Il lance l'exécutant sur un thread d'arrière-plan, et lorsque l'exécutant a terminé, il délivre la réponse sur le thread principal. Le paramètre générique T est tout ce que le rendu produit, souvent un handle de bitmap ou un enregistrement d'état. L'exécutant s'exécute hors du thread 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 n'importe quel type de Wait. Il n'y a pas de méthode pour bloquer l'appelant jusqu'à ce que le futur se termine, et ce n'est pas un oubli. Un Wait appelé depuis le thread principal est le moyen classique de bloquer une interface utilisateur : l'exécutant a besoin du thread principal pour exécuter sa réponse via Synchronize, le thread principal est garé à l'intérieur de Wait, et aucune des parties ne peut avancer. En refusant de proposer la primitive, le futur exclut le modèle qui bat le plus souvent les personnes qui essaient d'écrire cela elles-mêmes. Le code qui doit vraiment bloquer doit utiliser un simple TThread et en assumer les conséquences. Le futur est pour le cas fire-and-forget, ce qu'est réellement le rendu en arrière-plan

Le résultat est enveloppé dans TPdfFutureResult<T>, un enregistrement qui indique à la réponse laquelle des trois choses s'est produite. IsSuccess signifie que l'exécutant a renvoyé normalement et que Value contient le rendu. IsCancelled signifie que le jeton s'est déclenché et que l'exécutant s'est arrêté à un point d'annulation. IsFailure signifie que l'exécutant a levé une exception, et ErrorMessage contient le texte. La réponse inspecte l'état une fois et se ramifie, au lieu de deviner à partir d'une valeur sentinelle si un bitmap renvoyé est réel

La course v1.61.0 qui a changé la livraison des réponses

La partie la plus instructive de cette unité est un changement d'une ligne qui a mis un certain temps à être compris. Dans les premières versions, le thread de travail délivrait sa réponse avec TThread.Queue. Queue publie la réponse dans la file d'attente du thread principal et revient immédiatement, ce qui se lit exactement comme ce que veut un futur fire-and-forget. C'était faux, et la raison vaut la peine d'être expliquée car c'est le genre de bogue qui passe tous les tests que vous pensez écrire

Le thread de travail est créé avec FreeOnTerminate := True. Cela signifie qu'à l'instant où Execute est renvoyé, le thread se détruit lui-même, et TThread.Destroy appelle RemoveQueuedEvents(Self) dans le cadre du nettoyage. RemoveQueuedEvents purge toute méthode en file d'attente dont la cible est le thread mourant. La séquence était donc la suivante : l'exécutant termine, il met la réponse en file d'attente contre lui-même, Execute revient, le thread se détruit, et RemoveQueuedEvents supprime la réponse que le thread principal n'avait pas encore exécutée. Le résultat a tout simplement disparu. Pire encore, dans la fenêtre étroite où le thread principal a retiré la réponse en file d'attente et a commencé à l'exécuter au même moment où le thread était libéré, la réponse a touché des champs d'un objet à moitié détruit, ce qui est une utilisation après libération

Le correctif de la v1.61.0 consistait à délivrer la réponse avec Synchronize au lieu de Queue. Synchronize bloque le thread de travail jusqu'à ce que le thread principal ait exécuté la réponse jusqu'à son terme. L'exécutant est toujours en vie pendant que sa réponse s'exécute, il n'y a donc rien à libérer sous lui, et le thread ne revient pas de 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;          // 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 leçon générale survit au correctif spécifique. Les rappels asynchrones fire-and-forget sont le modèle de concurrence le plus facile à rater subtilement, car le chemin heureux fonctionne du premier coup et le bogue réside dans l'interaction entre l'ordre de démontage du thread et la file d'attente. Il ne se reproduit pas à la demande. Cela dépend de si le thread principal a eu l'occasion de vider la file d'attente avant que l'exécutant ait eu l'occasion de finir de se détruire, ce qui est un timing que le planificateur 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érivé dans chaque application qui a besoin d'un rendu en arrière-plan

Pourquoi les rappels sont des pointeurs de méthode

L'exécutant et la réponse ne sont pas des méthodes anonymes. Ce sont des types de procedure of object, TPdfFutureWorker<T> et TPdfFutureReply<T>, et ce choix est imposé par la matrice du compilateur. PDFiumPas se 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 reference-to-procedure qui capture des 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. Une méthode anonyme se ferme sur les variables locales ; un pointeur de méthode ne le fait pas. Ainsi, tout état dont l'exécutant a besoin, l'index de la page, le zoom, le chemin de sortie, et tout état que la réponse doit mettre à jour, le contrôle de l'image cible ou l'étiquette de progression, doit s'accrocher à l'objet dont la méthode est passée. 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 ; cela permet de garder la propriété de cet état explicite et visible sur l'objet récepteur au lieu de la cacher à l'intérieur d'une fermeture

Une annulation coopérative, pas un arrêt brutal

Ici, l'annulation est coopérative. Il n'y a pas d'API qui pénètre dans le thread de travail et le termine, car la terminaison d'un thread en plein rendu laisse PDFium détenir des verrous et des bitmaps partiellement écrits, et l'état du processus après un arrêt forcé n'est pas quelque chose sur lequel vous pouvez raisonner. Au lieu de cela, l'exécutant 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, où l'arrêt est propre

Le jeton offre trois façons d'observer l'annulation. IsCancelled est une interrogation 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 ramène directement l'exécutant au futur. RegisterCallback attache une notification unique qui se déclenche une fois lorsque la source est annulée, utile lorsqu'un exécutant est bloqué dans quelque chose qu'il peut interrompre plutôt que d'être assis dans une boucle serrée

L'exception est l'endroit où la limite du thread a de l'importance. Lorsque l'exécutant 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 marshallé vers le thread principal. Il vit et meurt sur le thread de travail ; seule sa chaîne de message est copiée dans ErrorMessage. Le marshalling d'un objet d'exception actif entre les threads signifierait atteindre la mémoire appartenant à un thread qui se termine, ce qui est la même classe d'erreur que le correctif Synchronize existe pour empêcher. Un code d'état et une chaîne franchissent proprement la limite ; un objet ne le ferait pas

Deux interfaces, pour qu'un exécutant ne puisse pas s'annuler lui-même

L'annulation est volontairement répartie sur deux interfaces. IPdfCancellationTokenSource est le côté écriture : il a 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 a IsCancelled, ThrowIfCancelled et RegisterCallback, et c'est tout ce que l'exécutant reçoit. Un objet concret implémente les deux, mais l'exécutant ne reçoit jamais que le jeton, il n'a donc aucun moyen d'annuler l'opération qu'il exécute. La division est une barrière de sécurité au niveau de l'API. Un exécutant qui pourrait atteindre Cancel via son jeton inviterait un morceau de code confus à s'annuler lui-même, et le système de types supprime la 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 dans l'état non annulé. Run le substitue lorsque l'argument jeton est laissé à nil. Ce singleton est construit de manière anticipée lors de l'initialisation de l'unité plutôt que de manière paresseuse lors de la première utilisation, et la raison en est à nouveau la concurrence. Si plusieurs appels Run sur différents threads de travail atteignaient tous un singleton créé paresseusement en même temps, ils pourraient faire la course sur sa construction, divulguer un doublon ou observer brièvement une instance à moitié initialisée. Le construire avant qu'un exécutant ne puisse s'exécuter supprime entièrement la course

Exécution d'un rendu annulable

En pratique, vous créez une source, vous la conservez sur le formulaire, vous passez son Token à Run à côté d'une méthode d'exécution et d'une méthode de réponse, et vous connectez le bouton Annuler à la source. L'exécutant 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. Étant donné que les rappels sont des pointeurs de méthode, l'exécutant et la réponse lisent tout ce dont ils ont besoin dans les champs du formulaire

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 réponse gère les trois résultats car tous les trois sont accessibles. Un rendu terminé signale le 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'entre elles ne touche le thread de travail, et le bitmap ou l'état produit par l'exécutant n'est lu qu'après que le futur l'a délivré sur le thread qui possède l'interface utilisateur

La même discipline de thread est payante ailleurs dans une visionneuse. La façon dont les bitmaps rendus sont conservés et réutilisés lors des changements de zoom est couverte dans notre note sur le cache de rendu et les performances de zoom, et la question plus large du maintien de la sécurité de la frontière PDFium sous Delphi se trouve dans le durcissement de l'ABI VCL de PDFium pour la sécurité de la mémoire. L'infrastructure asynchrone décrite ici est livrée dans le composant PDFium pour Delphi et C++Builder, aux côtés des API de rendu, de texte et de formulaire couvertes ailleurs sur ce blog