Article technique

Interblocage WaitForIdle en rendu asynchrone Delphi PDFium

Un rendu par lots se fige à mi-chemin parce que l'exécuteur de PDFium Component ne considère une tâche comme terminée qu'une fois sa réponse distribuée. Sous padSynchronize, cette réponse s'exécute sur le thread principal. Si le thread principal se bloque sans pomper CheckSynchronize, le worker attend le thread principal pendant que le thread principal attend l'inactivité

Le tableau dans le débogueur est sans équivoque une fois qu'on l'a vu. Suspendez le processus figé et le thread principal siège dans une attente sur l'événement d'inactivité, plusieurs images sous votre propre boucle de traitement par lots. Basculez vers n'importe quel thread worker et il siège à l'intérieur de TThread.Synchronize, tenant un résultat terminé qu'il ne peut pas remettre. Rien ne tourne en boucle, aucun CPU ne brûle, le processus est simplement garé. Cet article explique pourquoi cet état existe du tout, et trois règles voisines qui décident si un pool de workers Delphi au-dessus de PDFium se comporte bien ou mord : ce que QueueCapacity borne réellement, dans quel ordre l'arrêt doit annuler les tâches, et ce que le parallélisme ne vous achète pas en matière de propriété d'objet PDFium

Pourquoi WaitForIdle bloque-t-il le thread principal ?

Il se bloque parce que l'inactivité dans TPdfAsyncExecutor est définie pour inclure la distribution de réponse, pas seulement l'achèvement du worker. Le compte d'exécution est incrémenté dans DequeueTask quand un worker prend une tâche, et il est décrémenté dans TaskFinished, que le worker appelle seulement après que TPdfAsyncTaskOperation.Execute soit revenu. Cette méthode exécute le corps du worker, enregistre le résultat, puis distribue la réponse selon TPdfAsyncDispatchMode. Avec padSynchronize, la distribution est un appel TThread.Synchronize, si bien qu'Execute ne revient pas tant que le thread principal ne l'a pas exécuté

Delphi vous laisse la seconde moitié de ce contrat. TThread.Synchronize ajoute la méthode à une file globale et bloque le thread appelant sur un événement ; quelque chose sur le thread principal doit appeler CheckSynchronize avant que cet événement ne soit jamais signalé. La boucle de messages VCL fait cela pour vous entre les messages, ce qui explique exactement pourquoi le bogue est invisible en usage interactif et apparaît dès qu'on écrit une boucle de traitement par lots bloquante. Un thread principal bloquant est un thread principal qui a quitté la boucle de messages, et un thread principal en dehors de la boucle de messages ne vidange personne

uses
  System.Classes, FPdfAsync, PDFium;

// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // the main thread now parks in a kernel wait

// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.

Achèvement de tâche et inactivité de l'exécuteur sont deux jalons différents

Ils sont séparés délibérément, et savoir lequel des deux vous attendez est toute la correction. IPdfAsyncTask.WaitFor est satisfait à l'instant où le résultat du worker est décidé : Complete écrit le TPdfAsyncTaskState final et positionne l'événement terminé avant qu'aucune réponse ne soit considérée. TPdfAsyncExecutor.WaitForIdle est satisfait plus tard, une fois que les comptes en file et en exécution sont tous deux à zéro, et le compte en exécution ne descend pas tant que la réponse n'a pas atterri. Une tâche peut donc être patsSucceeded et observable via Snapshot pendant que l'exécuteur est encore légitimement occupé

// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // release any pending padSynchronize reply
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

Une conséquence qui mérite d'être intériorisée : une réponse qui lève une exception ne réécrit pas l'histoire. DispatchReply attrape l'exception et la stocke dans ReplyErrorMessage, laissant State, CancellationReason et ErrorMessage exactement tels que le worker les a déterminés. Un callback d'interface utilisateur qui explose en peignant une vignette ne transforme donc jamais un rendu réussi en échec, et votre télémétrie continue de rapporter ce que le moteur de rendu a réellement fait. Si vous voulez l'API en forme de callback autour d'une seule opération plutôt qu'un pool, le rendu en arrière-plan avec futures annulables couvre ce chemin

QueueCapacity borne-t-elle aussi les workers en cours d'exécution ?

Non. QueueCapacity dans PDFium Component ne compte que les tâches en file, jamais celles déjà en cours d'exécution sur un worker. C'est délibéré : la capacité est censée exprimer une contre-pression réelle sur la ligne d'attente, et intégrer les emplacements de concurrence fixes dans le même nombre les compterait deux fois. Avec quatre workers et une capacité de huit, vous pouvez avoir douze tâches en vol, et GetStats rapporte la scission honnêtement via QueuedCount et RunningCount

var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit never raises: it returns False when the waiting line is full or
  // the executor is already shutting down, and bumps RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
    // WorkerCount and is never charged against the capacity.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

Les quatre voies de TPdfAsyncPriority sont strictes, pas pondérées. DequeueTask parcourt de papCritical jusqu'à papLow et prend la première voie non vide, préservant l'ordre FIFO à l'intérieur de chacune. Cela donne à une requête interactive un moyen propre de dépasser un lot qui n'a pas encore démarré, mais cela n'interrompt jamais un travail déjà en cours, et un appelant qui alimente continuellement papCritical peut affamer papLow indéfiniment. Réservez les deux voies du haut aux choses qu'un humain attend visiblement, et laissez l'export en masse sur papNormal ou en dessous. Utilisez Submit quand une file pleine est une erreur de programmation méritant un EPdfAsyncQueueFull, et TrySubmit quand c'est une condition normale que vous comptez gérer

Pourquoi Shutdown annule-t-il en dehors du verrou de l'exécuteur ?

Parce qu'annuler à l'intérieur inverserait l'ordre des verrous et bloquerait l'arrêt même que vous essayez d'effectuer. Shutdown(True) prend le verrou de l'exécuteur, bascule l'indicateur d'arrêt, et ajoute chaque tâche en attente à un tableau instantané local via AppendSnapshot. Il libère ensuite le verrou et ce n'est qu'après qu'il parcourt l'instantané en appelant Cancel sur chaque entrée. Annuler une tâche déclenche les callbacks utilisateur enregistrés sur sa source de jeton, et ces callbacks sont du code applicatif ordinaire : ils peuvent interroger GetStats, soumettre un travail compensatoire, ou attendre l'inactivité. Chacun d'eux rentre à nouveau dans le verrou de l'exécuteur, et un callback invoqué pendant que ce verrou est tenu se bloquerait contre lui-même

La source de jeton obéit à la même discipline un niveau plus bas. CancelWithReason prend le verrou de la source, décide du seul annulateur gagnant, écrit Reason, CancellationMessage et CancelledAtTick, et ce n'est qu'ensuite qu'il bascule l'indicateur annulé de façon atomique. Publier avant de basculer est ce qui rend les métadonnées sûres à lire : tout thread qui observe IsCancelled à True est garanti de trouver une raison complète derrière, et les appelants ultérieurs perdent la course, retournent False, et ne peuvent pas écraser la première raison. Les callbacks enregistrés sont capturés en instantané et effacés à l'intérieur du verrou mais invoqués en dehors, chacun enveloppé pour qu'un gestionnaire défaillant ne puisse pas supprimer les autres. Les tâches déjà en cours d'exécution ne sont jamais tuées ; elles se terminent de façon coopérative quand le corps de leur worker appelle ensuite ThrowIfCancelled, ce qui explique pourquoi Shutdown se termine par un WaitForIdle qui pompe avant de joindre les threads

Les workers parallèles assouplissent-ils la propriété d'objet PDFium ?

Non, et c'est la limite la plus susceptible d'être mal comprise. TPdfAsyncExecutor planifie du travail ; il ne fait aucune affirmation sur l'affinité de thread de quoi que ce soit que vous touchez à l'intérieur de ce travail. Une instance TPdf vivante ne devient pas accessible simultanément parce que deux workers se trouvent l'appeler, et le verrou de rendu interne est une garde contre les appels de rendu qui se chevauchent, pas une licence pour partager un document entre threads. Le rendu ou l'export parallèle signifie un TPdf par worker, créé et détruit à l'intérieur du travail

type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // one document instance per worker, never shared
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ownership moves to the reply stage
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

Le coût est réel et mérite d'être nommé : chaque worker paie sa propre analyse et son propre cache de page, si bien que la mémoire s'échelonne avec le nombre de workers plutôt qu'avec le nombre de documents. C'est le prix d'un modèle où un worker peut être annulé ou planter sans corrompre personne d'autre. Si vos workers partagent plutôt un document côté visualiseur, les règles de verrouillage autour de cela sont couvertes dans le verrou de rendu et les appels qui le manquent, et le chemin annulable à document unique se trouve dans le rendu progressif annulable

Étendre une interface publiée sans casser la vtable

IPdfCancellationToken et IPdfCancellationTokenSource sont des interfaces de style COM que des binaires externes peuvent déjà consommer, si bien qu'ajouter une méthode à l'une ou l'autre décalerait chaque emplacement ultérieur dans la vtable et réacheminerait silencieusement les appels compilés contre l'ancienne disposition. Les capacités de diagnostic, d'attente, de callback amovible et d'annulation atomique vivent donc dans IPdfCancellationTokenEx et IPdfCancellationTokenSourceEx, qui héritent plutôt que de modifier. New et Run conservent leur sémantique d'origine pour les appelants existants ; le nouveau code se tourne vers NewEx, NewTimeout et RunEx quand il veut CancelWithReason, WaitForCancellation ou un IPdfCancellationRegistration géré. L'héritage est le seul moyen sûr de faire croître une interface publiée, et cela coûte un type supplémentaire par génération

NewTimeout mérite une note honnête. Chaque source de délai possède un thread léger qui attend l'événement d'annulation ou l'échéance, selon ce qui arrive en premier. Pour une poignée ou quelques dizaines d'échéances, c'est simple, à faible latence et identique entre Delphi, Lazarus et C++Builder. Pour des milliers d'échéances courtes, c'est la mauvaise forme, et vous devriez piloter l'annulation depuis un seul minuteur au niveau applicatif plutôt que de tenir des milliers de threads en attente

Aucune de ces règles n'est exotique une fois couchée sur le papier, mais chacune d'elles est un incident de production quand elle ne l'est pas. Attendez le bon jalon et laissez quelque chose pomper la file de synchronisation, lisez QueueCapacity comme une borne sur la seule ligne d'attente, annulez en dehors de vos verrous, et donnez à chaque worker son propre document. La couche asynchrone décrite ici fait partie du PDFium Component pour Delphi, aux côtés des API de rendu, de texte et de formulaire qu'elle planifie