Net-Base Magazine

15.05.2026

TThread et Synchronize sans interblocages de l'interface utilisateur : modèles robustes pour VCL et code hérité

Comment travailler de manière fiable avec TThread, Synchronize et Queue sans que l'UI ne se bloque : causes typiques d'impasse (deadlock), un modèle pragmatique de dispatcher UI (incl. timeout), protection lors de l'arrêt, stratégies de verrouillage et vérifications de débogage pour des applications Delphi évoluées.

15.05.2026

Quiconque travaille avec des threads dans Delphi finit tôt ou tard par utiliser TThread.Synchronize. Et c’est précisément là que surviennent les problèmes désagréables : blocages sporadiques, « l’UI ne répond plus », deadlocks apparemment aléatoires lors de la fermeture ou de l’ouverture d’une boîte de dialogue. La cause fondamentale n’est que rarement « Delphi est cassé », mais presque toujours un mélange défavorable de Synchronize, d’opérations d’attente bloquantes et d’un UI-Thread qui n’exécute plus proprement sa Message Loop (le traitement des événements de la VCL). Cet article présente des patterns robustes, praticables dans un contexte legacy, pour TThread et Synchronize sans deadlocks de l’UI – incluant une variante avec timeout, une propagation d’erreur propre, des règles de shutdown et des conseils de debug utiles dans de vraies applications en production.

Pourquoi des deadlocks autour de Synchronize surviennent en pratique

Synchronize signifie : un worker-thread place une procédure dans une file d’attente qui sera exécutée dans le Main Thread, et attend typiquement que cette procédure se termine. Dans les applications VCL, le Main Thread est en même temps le UI-Thread (fenêtres, contrôles, événements). De plus, dans de nombreuses installations, des objets COM fonctionnent en STA-Modell (Single-Threaded Apartment : les appels COM doivent être traités dans le même thread), ce qui renforce encore la dépendance à une Message Loop fonctionnelle.

Les deadlocks apparaissent typiquement dans l’une des configurations suivantes :

  • WaitFor dans le Main Thread : le UI-Thread attend un worker (p. ex. MyThread.WaitFor) alors que le worker a besoin du UI-Thread via Synchronize. Les deux attendent — fin.
  • Lock-Inversion : le worker tient un verrou (p. ex. TCriticalSection ou TMonitor) et appelle Synchronize. La procédure synchronisée côté UI tente de prendre le même verrou (directement ou indirectement, souvent via du logging, un cache ou des singletons) — deadlock classique.
  • Shutdown/Destroy : lors de la fermeture d’une form, un thread est arrêté alors que des appels Synchronize sont encore en attente. Particulièrement sournois : les appels synchronisés référencent des contrôles qui sont en cours de destruction.
  • Message Loop bloquée : boîtes de dialogue modales, opérations UI longues, un appel COM bloquant ou un handler qui « fait rapidement » une opération DB/REST ralentissent le Main Thread. Les tâches Synchronize sont retardées ou ne sont pas exécutées du tout.

La conséquence principale pour l’architecture et l’exploitation : Synchronize est un point de blocage. Dans un logiciel d’entreprise sur mesure avec des imports, une BDE-remplacement avec connexion native-requêtes, des jobs d’intégration ou des services en arrière-plan combinés à une composante UI, il faut contrôler consciemment ce point — sinon le « rarement » devient « toujours quand ça urge ».

Règle de base : ne jamais laisser le UI-Thread attendre un worker (si Synchronize est en jeu)

Si un worker utilise Synchronize quelque part, le Main Thread ne doit pas attendre de façon bloquante ce worker. Cela paraît trivial, mais dans du code legacy c’est une des causes les plus fréquentes : « on attend un peu à la fermeture » ou « la boîte de progression attend la fin » sont des raccourcis rapides qui causent des problèmes.

Conséquences pratiques :

  • Aucune invocation de WaitFor dans le UI-Thread dès qu’il existe dans le Worker un chemin qui utilise Synchronize.
  • Signaler la fin du thread via un événement/callback : l’UI reste réactive et n’effectue le nettoyage qu’après réception du signal.
  • Poster les mises à jour de l’UI systématiquement via TThread.Queue ou un dispatcher, afin que les Worker ne bloquent pas.

TThread.Queue est souvent la meilleure option par défaut : le Worker poste du travail vers le thread principal, poursuit son exécution et ne bloque pas. Cela évite de nombreux deadlocks. Cela ne résout toutefois pas tous les cas limites – par exemple lorsque vous avez besoin de manière impérative d’un résultat produit dans le thread principal (p. ex. accès à une ressource liée à l’UI ou à un composant lié à un thread).

TThread et Synchronize sans deadlocks UI : modèle conceptuel pour des transferts maîtrisés

Un modèle conceptuel robuste est le suivant : il n’existe que peu de remises synchrones légitimes vers le thread principal. Tout le reste relève de l’état, de l’affichage ou de la télémétrie — et doit donc être asynchrone.

Une classification simple aide lors des revues et pour la stabilisation de projets existants :

  • « Affichage uniquement » : progression, ligne de log, compteur, témoin, activation/désactivation — toujours Queue.
  • « Transmettre l’état » : le Worker fournit un objet de données/DTO, l’UI rend — Queue, mais avec copie/immutabilité (donc pas de structures mutées en commun).
  • « L’UI doit décider » : c’est uniquement ici que vous avez besoin d’une sémantique synchrone (p. ex. une interrogation utilisateur). La véritable question est alors : le Worker doit-il vraiment attendre, ou le flux de travail peut-il être réorganisé (machine à états, annulation du job, reprise ultérieure) ?

La troisième catégorie est précisément un piège à deadlock : si le Worker attend un résultat de l’UI, l’UI est vite tentée d’attendre le Worker (ou indirectement via des verrous). Cela bascule beaucoup plus facilement sous charge, avec des bases de données lentes ou en environnements Remote Desktop.

Extrait de source : UI-Dispatcher avec Queue, timeout optionnel et arrêt propre

Le schéma suivant encapsule les remises vers l’UI dans une petite classe utilitaire. Vous obtenez :

  • Post : fire-and-forget via TThread.Queue (typique pour les mises à jour d’état).
  • Call : appel synchrone avec Timeout (inhabituel, mais utile en contextes legacy), sans utiliser directement Synchronize comme point de blocage.
  • Protection d’arrêt : ne plus accepter de nouveaux jobs UI et laisser les jobs en file vérifier un indicateur avant de manipuler les contrôles.

Positionnement technique : nous utilisons Queue plus TEvent (un événement noyau) pour la rétroaction. Le Worker n’attend pas Synchronize, mais attend un événement qui est signalé dans le thread principal après exécution de l’action mise en file. Le timeout évite un blocage « éternel » si, pour une raison quelconque, le UI-Thread ne peut plus traiter la file.

Delphi
unit UiDispatch;

interface

uses
  System.SysUtils,
  System.Classes,
  System.SyncObjs;

type
  EUiDispatchTimeout = class(Exception);
  EUiDispatchShuttingDown = class(Exception);

  /// <summary>
  ///  Kapselt UI-Aufrufe aus Worker-Threads.
  ///  Post: asynchron (Queue).
  ///  Call: synchron mit Timeout, ohne TThread.Synchronize direkt zu blocken.
  /// </summary>
  TUiDispatcher = class
  strict private
    class var FShuttingDown: Integer;
  public
    class procedure BeginShutdown; static;
    class function IsShuttingDown: Boolean; static;

    class procedure Post(const AProc: TProc); static;
    class procedure Call(const AProc: TProc; ATimeoutMs: Cardinal = 5000); static;
  end;

implementation

{ TUiDispatcher }

class procedure TUiDispatcher.BeginShutdown;
begin
  TInterlocked.Exchange(FShuttingDown, 1);
end;

class function TUiDispatcher.IsShuttingDown: Boolean;
begin
  Result := TInterlocked.CompareExchange(FShuttingDown, 0, 0) = 1;
end;

class procedure TUiDispatcher.Post(const AProc: TProc);
begin
  if not Assigned(AProc) then
    Exit;

  // Im Shutdown keine neuen UI-Jobs mehr annehmen.
  if IsShuttingDown then
    Exit;

  // Queue blockiert den Worker nicht.
  TThread.Queue(nil,
    procedure
    begin
      if IsShuttingDown then
        Exit;
      AProc();
    end);
end;

class procedure TUiDispatcher.Call(const AProc: TProc; ATimeoutMs: Cardinal);
var
  DoneEvent: TEvent;
  RaisedObj: TObject;
begin
  if not Assigned(AProc) then
    Exit;

  if IsShuttingDown then
    raise EUiDispatchShuttingDown.Create('UI-Dispatcher ist im Shutdown.');

  DoneEvent := TEvent.Create(nil, True, False, '');
  try
    RaisedObj := nil;

    TThread.Queue(nil,
      procedure
      begin
        try
          if not IsShuttingDown then
            AProc();
        except
          // Exception-Objekt über die Thread-Grenze reichen.
          // Achtung: Kein "raise" hier, sonst landet es im Main Thread.
          RaisedObj := AcquireExceptionObject;
        end;
        DoneEvent.SetEvent;
      end);

    case DoneEvent.WaitFor(ATimeoutMs) of
      wrSignaled:
        begin
          if Assigned(RaisedObj) then
            raise Exception(RaisedObj);
        end;
      wrTimeout:
        raise EUiDispatchTimeout.CreateFmt(
          'Timeout nach %d ms: Main Thread hat UI-Aufruf nicht abgearbeitet.',
          [ATimeoutMs]);
    else
      raise Exception.Create('Unerwarteter WaitFor-Status im UI-Dispatcher.');
    end;
  finally
    DoneEvent.Free;
  end;
end;

end.

But du code et pourquoi il est délibérément « inhabituel »

Le pattern ne remplace pas complètement Synchronize, mais il rend les passages synchrones contrôlables : le worker n’attend pas le mécanisme Synchronize, mais un événement. Cela permet d’imposer des timeouts, de détecter en exploitation qu’un UI-Thread est bloqué, et de refuser systématiquement de nouveaux jobs UI pendant une phase de shutdown.

La partie « inhabituelle » n’est pas l’événement, mais la décision de modéliser la sémantique synchrone par Queue + Event. Cette approche est pertinente précisément lorsque vous devez améliorer progressivement la stabilité d’applications existantes sans pouvoir refondre immédiatement chaque occurrence de Synchronize au niveau architectural.

Contraintes et pièges

  • Visibilité mémoire: DoneEvent est la frontière de synchronisation. Ainsi, la lecture de RaisedObj après WaitFor est cohérente. Néanmoins, RaisedObj doit rester local à chaque appel (comme ici), jamais global.
  • Exception-Handling : AcquireExceptionObject empêche que l’exception « disparaisse » dans le thread principal. Lors du relancer dans le worker, le stacktrace n’est pas identique à l’origine, mais le message d’erreur reste dans le log du worker et le job peut échouer proprement.
  • Le timeout est diagnostic et protection : il ne « répare » pas un Main Thread bloqué. Il empêche en revanche que des workers retiennent indéfiniment des ressources (p. ex. des transactions BDE-Ablosung mit nativer Anbindung ouvertes) et il rend la classe d’erreur mesurable.
  • Le shutdown doit commencer tôt : BeginShutdown appartient à une séquence de shutdown centrale (p. ex. très tôt dans OnCloseQuery de la formulaire principale). Sinon, des UI-jobs peuvent encore être mis en file alors que des fenêtres sont déjà détruites.

Lock-Strategie: so vermeiden Sie Lock-Inversionen mit UI-Callbacks

Beaucoup d’interblocages ne proviennent pas de WaitFor, mais d’un ordre de verrouillage ambigu. Déroulé typique : le worker verrouille le « modèle de données », lance une mise à jour UI via Synchronize, la mise à jour UI accède à son tour au « modèle de données ». C’est compréhensible sur le plan logique, mais techniquement fatal.

Règles pratiques qui s’imposent dans les équipes :

  • Ne pas garder de locks au-delà des frontières de thread : avant qu’un worker ne mette quoi que ce soit en file/synchronise vers l’UI, les verrous fonctionnels doivent être libérés.
  • L’UI lit des snapshots : les callbacks UI ne devraient pas consulter « en direct » les structures du worker, mais afficher des copies/snapshots (p. ex. DTO, Record, valeurs simples).
  • Le logging est un candidat au lock : si le logging utilise en interne une queue, un verrou de fichier ou un singleton, il peut faire partie d’un interblocage. Les callbacks UI doivent limiter le logging ou écrire via une pipeline de log séparée et non bloquante.

Si vous avez déjà une architecture Layer-3 (UI, services/domaine, infrastructure comme l’accès aux données) : les callbacks UI devraient idéalement ne faire que l’UI. Tout ce qui relève du « Service » n’appartient pas au callback. Cela réduit sensiblement les effets de réentrance.

Shutdown ohne Hänger: „nicht WaitFor, sondern kooperatives Stoppen“

Lors de l’arrêt, ça coince souvent : l’UI se ferme, un thread doit s’arrêter, mais des UI-jobs en file sont encore ouverts. Un shutdown propre n’est pas un « kill » de thread, mais une petite chorégraphie :

  1. Poser un flag de shutdown (p. ex. TUiDispatcher.BeginShutdown) : à partir de ce moment plus aucun UI-job.
  2. Stop coopératif des workers : le worker vérifie un flag d’annulation (p. ex. TEvent ou équivalent TCancellationToken) et termine boucles/attentes.
  3. Ne pas bloquer l’UI : éviter les boucles d’attente du Main Thread. Si vous devez « attendre », faites-le seulement avec une boucle de messages active (ou mieux : évitez en traitant la fin via un callback).
  4. Derniers nettoyages UI uniquement si fenêtres/contrôles existent encore. En VCL le timing est critique : dès que le Handle a disparu, les jobs en file ne doivent plus cibler les contrôles.

Ce déroulé est pertinent pour l’exploitation et le support : « l’application se bloque à la fermeture » est un problème d’acceptation classique, même si tout a été traité correctement sur le plan fonctionnel. Un shutdown défini fait gagner du temps réel.

Debugging: Wie Sie den Deadlock greifbar machen (ohne Rätselraten)

Quand ça bloque, la question centrale est : qui attend qui ? Quelques approches qui ont fait leurs preuves dans des projets existants :

  • Inventorier tous les points d’attente: Volltextsuche nach WaitFor, Sleep in Schleifen, TEvent.WaitFor, INFINITE. Beaucoup de problèmes sont des attentes « cachées » (auch in Bibliotheken).
  • État des threads dans le log : consignez aux frontières des threads : « Job startet », « queued UI », « UI ausgeführt », « Job fertig ». Ainsi vous verrez si le thread principal traite réellement les jobs en file d’attente.
  • Vérifier le soupçon de boucle de messages : si le blocage n’apparaît qu’avec des dialogues modaux ou certaines interactions COM, la boucle de messages est souvent le goulot d’étranglement. L’objectif est alors : alléger les handlers UI, isoler les appels COM, ne pas exécuter d’opérations longues dans l’UI.
  • Rendre les verrous visibles : pour TCriticalSection/TMonitor, un build de debug avec des métadonnées « Owner » (p. ex. l’ID du thread à l’Enter) et une mesure temporelle est utile. Ainsi vous verrez quel verrou le thread principal détient pendant que les workers attendent l’UI.

L’attitude est importante : les deadlocks sont rarement « accidentels ». Ce sont des cycles déterministes qui se déclenchent rarement. Une fois le cycle correctement identifié, la correction est généralement claire.

Variantes pour l’accès aux données et les jobs d’interface (FireDAC, REST, système de fichiers)

Particulièrement pour FireDAC (ou autres accès DB) : connexion, transaction et datasets sont en pratique liés au thread. Un Worker-Thread doit posséder exclusivement son propre contexte DB. Les appels UI doivent se limiter à la présentation, pas aux opérations DB. Un schéma robuste est :

  1. Le Worker exécute la requête/REST-call, calcule le résultat, produit un DTO.
  2. Le Worker poste le DTO via Queue/TUiDispatcher.Post vers l’UI.
  3. L’UI récupère le DTO et met à jour les contrôles (sans recours aux objets du Worker).

Si vous avez des hybrides issus de l’histoire (« UI déclenche la DB, DB-callback déclenche l’UI »), une découplage progressif vaut la peine : isoler d’abord les points de passage (Dispatcher), puis déplacer les états vers des services/modèle. C’est moins risqué qu’une refonte majeure, mais réduit sensiblement les deadlocks.

Conclusion : éviter les deadlocks, c’est contrôler les transferts

TThread et Synchronize sans deadlocks UI n’est pas tant une technique individuelle qu’une discipline : minimiser les blocages, maintenir l’ordre des locks, définir le shutdown et réduire les dépendances UI synchrones. Le UI-Dispatcher présenté est particulièrement utile en contexte legacy, car il utilise Queue par défaut, mais ajoute pour les transferts synchrones nécessaires un Timeout et des règles de shutdown claires.

Les limites s’appliquent : si le thread principal est bloqué de manière persistante (par une logique UI lourde, des chaînes de dialogues modaux ou des appels COM-STA), un Dispatcher ne peut que diagnostiquer et interrompre de façon contrôlée. La solution durable est alors d’alléger l’UI et de séparer les responsabilités. Si vous avez besoin d’assistance pour une application existante Delphi — des pièges du threading à la stabilisation progressive — vous pouvez inscrire le projet ici : discuter le projet ou la modernisation avec Net-Base.

Dans le contexte métier, le multithreading Delphi et les Synchronize Deadlocks jouent aussi un rôle important lorsque les intégrations, les flux de données et l’évolution doivent fonctionner proprement ensemble.

Discuter d’un projet ou d’une modernisation avec Net-Base.

Partager l'article

Partager directement cette publication

LinkedIn, X, XING, Facebook, WhatsApp et e-mail sont immédiatement disponibles. Pour Instagram, nous préparons directement le lien et un court texte.

Courriel

Instagram s'ouvre dans un nouvel onglet. Le lien et le court texte sont préalablement copiés dans le presse-papiers.