Article technique

Deadlock du callback du file monitor HotXLS en Delphi

HotXLS surveille un classeur sur disque avec TXLSFileMonitor et déclenche OnFileChanged quand un autre processus enregistre par-dessus le fichier. Mettre Monitor.Active := False depuis l'intérieur de ce handler se bloquait pour toujours, parce que le thread worker était encore bloqué à attendre le callback qu'il venait de distribuer. Le correctif consiste à laisser l'objet thread porter l'identité du callback qu'il exécute actuellement

Pourquoi arrêter un file monitor dans son propre handler se bloque-t-il ?

Parce que l'appel de désactivation et le callback tournent sur deux threads différents, et chacun attend que l'autre finisse. TXLSFileMonitor est livré avec SynchronizeEvent à True, ce qui signifie que le thread de polling n'appelle pas votre handler lui-même. Il appelle TThread.Synchronize, confie la méthode au thread principal, puis se bloque jusqu'à ce que le thread principal l'ait exécutée et signalé la fin. C'est exactement ce que vous voulez pour un handler qui recharge une grille ou repeint un formulaire, et c'est aussi ce qui fait de l'appel de désactivation évident un piège

Deux visages du deadlock du file monitor de HotXLS : avec SynchronizeEvent à True, le thread principal se bloque dans Worker.WaitFor pendant que le worker se bloque dans TThread.Synchronize, et avec False le handler tourne sur le thread de polling, si bien que SetActive False attend sur le thread lui-même, laissant OnFileChanged incapable de retourner
Avec SynchronizeEvent activé, le thread principal attend dans Worker.WaitFor pendant que le worker attend dans Synchronize ; désactivé, le handler tourne sur le thread de polling et attend sa propre sortie — un seul cycle fermé, deux modes de distribution
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // Nous sommes sur le thread principal ici, grâce à TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // L'utilisateur ne voulait qu'un seul rechargement, alors on arrête de surveiller
  FMonitor.Active := False;   // ne retournait jamais autrefois
end;

Tracez les deux stacks et le cycle devient évident. Le thread principal est dans SetActive(False), qui termine le worker puis appelle Worker.WaitFor pour être sûr que le thread est vraiment sorti avant que l'objet soit libéré. Pendant ce temps, le thread worker est garé dans TThread.Synchronize, attendant que le thread principal retourne du handler même qui appelle SetActive. Aucun des deux ne peut bouger. L'application ne plante pas, elle ne lève rien, elle cesse simplement de repeindre, ce qui est la pire signature de diagnostic qu'un bug puisse avoir

Le même deadlock porte un second visage

Couper la synchronisation n'évite pas le problème, ça le déplace seulement. Avec SynchronizeEvent à False — le choix sensé dans un service ou un outil console sans boucle de messages — TXLSFileMonitor invoque le handler directement sur le thread de polling. Votre handler tourne maintenant sur le worker, donc un appel à Monitor.Active := False depuis l'intérieur atteint Worker.WaitFor sur le thread worker lui-même. Un thread ne peut pas attendre sa propre sortie, alors il se bloque jusqu'à ce que quelque chose d'extérieur tue le processus. Les deux variantes sont le même bug : SetActive(False) supposait qu'il était toujours appelé d'ailleurs que du callback. Cette hypothèse tient pour un bouton Fermer ou un destructeur de formulaire et échoue pour le site d'appel auquel les gens pensent en premier, ce qui explique pourquoi il a survécu si longtemps. Tout composant qui possède un thread et distribue du code utilisateur depuis celui-ci hérite du même danger, et la résolution est la même dans chaque cas — le chemin de démontage doit pouvoir dire s'il est exécuté depuis l'intérieur d'un callback que le thread qu'il s'apprête à joindre tient encore

Laisser le thread worker porter l'identité du callback

HotXLS le résout en enregistrant, sur l'objet thread lui-même, quel thread OS exécute actuellement le handler. TXLSFileMonitorThread.FireEvent tamponne FCallbackThreadID avec GetCurrentThreadId immédiatement avant d'invoquer le handler et le remet à zéro dans un bloc finally ensuite. Ce champ unique suffit au chemin de désactivation pour distinguer « appelé depuis du code sans rapport » de « appelé depuis mon propre callback », et il fonctionne à l'identique pour les deux modes de distribution, parce que dans le cas synchronisé GetCurrentThreadId dans FireEvent rapporte déjà le thread principal

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // Un callback en file peut être distribué par WaitFor pendant la désactivation
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // Le handler a peut-être détruit Owner, mais ce thread reste vivant
    FCallbackThreadID := 0;
  end;
end;

La branche de désactivation de SetActive fait ensuite trois choses dans un ordre délibéré. Elle détache le thread du moniteur en effaçant FThread d'abord, pour que GetActive rapporte False à partir de cet instant et qu'un Active := False réentrant dans le même handler sorte immédiatement au lieu d'essayer de démonter le thread deux fois. Elle appelle Terminate. Ce n'est qu'ensuite qu'elle décide comment récupérer l'objet thread

Comment HotXLS résout le démontage du file monitor : TXLSFileMonitorThread.FireEvent tamponne FCallbackThreadID avec GetCurrentThreadId avant d'invoquer le handler et le remet à zéro dans un bloc finally, pour que SetActive puisse détacher FThread, appeler Terminate, et choisir entre l'auto-libération FreeOnTerminate dans un callback et le classique WaitFor plus Free
FireEvent tamponne FCallbackThreadID avant d'invoquer le handler et le remet à zéro dans un bloc finally ; SetActive choisit ensuite entre FreeOnTerminate quand l'identité correspond et le classique WaitFor plus Free quand l'appelant est sans rapport
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // le moniteur est inactif à partir de ce point
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // Les handlers directs et synchronisés doivent retourner avant que le
  // worker puisse sortir ; il se libère après le déroulement du callback
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

Quand l'identité correspond, HotXLS n'attend jamais. Il met FreeOnTerminate := True et retourne, laissant le handler finir, l'appel Synchronize se compléter, Execute observer Terminated, et la RTL libérer l'objet thread une fois la stack du callback entièrement déroulée. Quand l'identité ne correspond pas — un bouton Fermer, un destructeur, un job d'arrière-plan — le classique WaitFor plus Free reste la bonne chose à faire, parce que ça vous donne une garantie dure qu'aucun callback supplémentaire ne peut se déclencher après le retour de Active := False. Deux chemins de sortie, un champ pour choisir entre eux. Une conséquence mérite d'être dite explicitement : sur le chemin d'auto-libération, le worker survit au handler. Rien dans le chemin de retour de FireEvent ne peut retoucher l'objet moniteur, parce que le handler a le droit de l'avoir libéré. C'est pourquoi FireEvent met Owner et Handler en cache dans des locales au début et ne touche que son propre champ dans la clause finally. Un FOwner.Something égaré après l'appel du handler transformerait un deadlock corrigé en use-after-free, ce qui est un résultat strictement pire

Pourquoi FireEvent doit-il vérifier Terminated avant de faire quoi que ce soit ?

Parce que WaitFor n'est pas une attente passive. Sur le thread principal, l'implémentation RTL de TThread.WaitFor continue de vider la file de synchronisation pendant qu'elle se bloque, précisément pour qu'un thread garé dans Synchronize puisse se compléter et sortir plutôt que de deadlocker contre l'arrêt. C'est une fonctionnalité, et cela signifie qu'un callback mis en file quelques microsecondes avant que vous appeliez Active := False depuis un bouton Fermer peut encore être distribué pendant le démontage, après que le moniteur a déjà lâché sa référence FThread. La garde en haut de FireEvent fait de cette distribution un no-op. Sans elle, une application qui ferme son formulaire principal pendant qu'une sauvegarde atterrit sur le fichier surveillé peut exécuter un handler de rechargement contre un état à moitié disposé, et la violation d'accès qui en résulte ne ressemble en rien à un problème d'ordre d'arrêt. Si votre propre composant distribue du travail via Synchronize, la règle se généralise : chaque méthode synchronisée a besoin d'une vérification de Terminated comme première instruction, non pas parce que le thread pourrait avoir disparu mais parce que la file survit à la décision de s'arrêter

La même forme de bug dans le chemin d'import CSV

L'audit qui a produit le correctif du moniteur a fait remonter un défaut frère dans l'importeur de texte, et il vaut la peine d'être lu côte à côte parce que l'erreur sous-jacente est identique : un morceau de configuration consommé comme s'il s'agissait d'un état temporaire. TXLSCSVLoader expose une propriété SkipRows pour les lignes de bandeau qui siègent au-dessus du vrai en-tête dans les rapports exportés. Le parser a besoin d'un compte à rebours pendant qu'il traverse ces lignes, et l'implémentation d'origine décomptait sur le champ de stockage de la propriété lui-même. Importez un fichier et SkipRows devenait silencieusement zéro ; réutilisez le même loader pour un second fichier et les lignes de bandeau atterrissaient dans la grille

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // deux lignes de bandeau au-dessus de l'en-tête
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows vaut toujours 2 ici : FSkipRemaining est remis à zéro à chaque analyse
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS garde maintenant FSkipRows comme réglage déclaré et remet à zéro un compteur séparé FSkipRemaining au début de chaque analyse, dans les deux chemins, délimité et largeur fixe. La règle générale est bon marché à appliquer et se rentabilise : si une boucle le décrémente, ce n'est pas un champ de stockage de propriété. La même fournée a aussi resserré XlsCsvTryParseISODateTime, qui acceptait autrefois un préfixe analysable comme date complète. Elle valide maintenant le champ entier — longueur, positions des séparateurs et chaque chiffre — pour qu'une valeur comme une référence de commande qui commence seulement par quelque chose en forme de date reste du texte au lieu de devenir un TDateTime faux. La coercition de type silencieuse à l'import est bien plus coûteuse à débugger en aval qu'un champ rejeté, un thème qui traverse aussi la façon dont HotXLS protège un classeur contre les lecteurs et écrivains concurrents

Le défaut du loader CSV derrière l'audit de HotXLS : décompter SkipRows sur le champ de stockage de la propriété le mettait silencieusement à zéro après une analyse, si bien qu'un second fichier laissait fuir les lignes de bandeau dans la grille, tandis que le modèle corrigé garde FSkipRows comme réglage déclaré et remet un compteur séparé FSkipRemaining à zéro au début de chaque appel ParseFile
Décompter sur le champ de stockage de la propriété transforme la configuration en état temporaire : la première analyse met SkipRows à zéro, tandis que le modèle corrigé garde FSkipRows déclaré et remet FSkipRemaining à zéro au début de chaque analyse

Ce que ce correctif vous donne et ne vous donne pas

Soyez précis sur la garantie. Le changement de propriété de thread rend exactement un schéma auparavant fatal sûr : désactiver ou libérer un TXLSFileMonitor depuis l'intérieur de son propre handler OnFileChanged, dans l'un ou l'autre mode de distribution. Ce n'est pas une conception de concurrence générale, et cela ne rend pas le composant sûr à piloter depuis des threads arbitraires

  • Les écritures de propriétés ne sont toujours pas thread-safe. Réglez FileName, IntervalMs et SynchronizeEvent depuis un seul thread, normalement celui qui a créé le moniteur
  • IntervalMs s'applique à partir de la prochaine activation, et le worker dort par tranches courtes pour que Terminate soit honoré rapidement même avec une longue période de polling
  • La détection se fait par heure de dernière écriture plus taille, donc un changement qui préserve les deux est invisible ; un fichier qui disparaît en pleine sauvegarde ne se déclenche pas, et l'événement arrive une fois le nouveau contenu en place
  • Avec SynchronizeEvent à False, votre handler tourne sur le thread worker, et toutes les règles sur le fait de toucher des contrôles VCL depuis un thread d'arrière-plan s'appliquent à ce que vous faites dedans
  • Recharger un classeur depuis le handler exige toujours sa propre discipline, surtout si d'autre code tient une référence au livre en cours de remplacement

En pratique, le handler de rechargement est la partie qui mérite le plus de soin, pas le moniteur. Un événement de changement arrive d'habitude pendant que le processus d'écriture se stabilise encore, donc un chargement qui ouvre le fichier immédiatement peut tomber sur une sauvegarde partiellement committée ; réessayer une fois après un court délai est plus robuste que de se fier à la première estampille. Si votre propre code est celui qui écrit le fichier surveillé, le fichier temporaire en étapes et le renommage atomique décrits dans la façon dont HotXLS rend chaque sauvegarde crash-safe donnent aussi au moniteur un événement propre à transition unique à observer plutôt qu'un flux de tailles intermédiaires. Et si plusieurs watchers alimentent la même routine de rechargement, les préoccupations de sérialisation dans la porte de lecture autour de l'inflation ZIP concurrente sont la prochaine chose à lire. La leçon plus large survit au composant spécifique. Toute classe qui possède un thread et appelle du code utilisateur a besoin d'un chemin de démontage capable de reconnaître sa propre frame de callback, parce que le handler est l'endroit le plus naturel du monde pour qu'un utilisateur dise stop. Enregistrer l'id du thread de callback coûte un DWORD et transforme un blocage inexplicable en une question ordinaire de durée de vie d'objet. La surveillance de fichiers, les sauvegardes atomiques et l'import de texte sont tous livrés dans le HotXLS Delphi Component pour Delphi et C++Builder, sans configuration supplémentaire pour activer quoi que ce soit