Un battement de cœur pour le thread principal

Ce qu’on ne mesure pas, on ne le corrige pas.

La barre latérale ramait. Pas tout le temps. Pas de façon prévisible. Pendant les gros scans, quand la détection de doublons tournait, elle saccadait. Le défilement accrochait. Les sélections mettaient une éternité à répondre. L’application travaillait, mais l’interface ne suivait plus.

« Lent », ce n’est pas un rapport de bogue. On ne corrige pas « lent ». On corrige « thread principal bloqué 340 ms à chaque résolution de hash de doublon ».


PerfLogWriter est la première chose que j’ai construite face à ce problème. Pas un correctif : un instrument de mesure.

C’est un logger fichier tout bête. Les événements sont écrits sur disque avec horodatage et catégorie. Les fichiers tournent quand ils atteignent une certaine taille, pour ne pas enfler indéfiniment au fil d’une longue session. Des entrées structurées, une par ligne, exploitables par les scripts d’analyse dans Scripts/Performance/.

L’ajout décisif, c’est le battement de cœur.

Un timer répétitif sur DispatchQueue.main se déclenche toutes les 1,5 secondes. À chaque déclenchement, il note combien de temps s’est réellement écoulé. Sur un thread principal libre, le timer se déclenche à peu près à l’heure. Quand le thread principal est bloqué, le timer arrive en retard. L’écart entre l’intervalle prévu et l’intervalle réel, c’est la durée du blocage.

L’entrée de log ressemble à ça :

2024-xx-xx 14:32:17.842 [MAIN_THREAD_BLOCKED] 340ms

Si cette ligne n’apparaît pas, le thread principal n’a pas été bloqué. Si elle est là, on sait exactement quand et pendant combien de temps. Une impression vague devient un événement daté.

Cliquez sur « Lancer un travail en arrière-plan » pour simuler un blocage du thread principal. Le timer de battement de cœur détecte le retard et le consigne.

Les logs ont désigné la cause sans ambiguïté.

La détection de doublons lance un TaskGroup à six workers. Chaque worker calcule le hash d’un fichier, produit un condensat et renvoie un résultat. Six workers qui débitent aussi vite que le NVMe peut les nourrir. L’implémentation d’origine rappelait le ViewModel à chaque résultat :

// Bad: per-result mutation triggers O(N) SwiftUI diffs
for await result in group {
    self.cleanupResult = result  // triggers re-render every time
}

Avec des milliers de candidats doublons, ça faisait des milliers de sauts forcés vers le thread principal. Chacun était minuscule. Mais ils arrivaient si vite que SwiftUI relançait un diff complet de la vue après chaque affectation, et le thread principal n’avait jamais un instant de répit pour traiter les interactions.

Le remède : regrouper par lots.

// Good: batch updates every 250ms
var buffer: [Result] = []
let flushTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
    Task { @MainActor in
        self.results.append(contentsOf: buffer)
        buffer.removeAll()
    }
}
for await result in group {
    buffer.append(result)
}

Au lieu de mettre à jour l’état à chaque résultat, on accumule dans un tampon local. Toutes les 250 ms, on vide le tampon vers l’état @MainActor en une seule fournée. SwiftUI fait un diff par vidage, pas un par résultat. Le thread principal respire entre les lots.

Après cette correction, les entrées MAIN_THREAD_BLOCKED ont disparu de la phase de détection de doublons. La barre latérale défilait sans accroc pendant les scans.

Cliquez sur « Lancer le scan » pour comparer : les mises à jour par résultat affament le thread principal, tandis que le vidage par lots garde l’interface réactive.

Les logs ont aussi fait remonter une métrique que j’avais mal lue au départ.

sidebar body_eval selection_roundtrip=Xms enregistre le temps écoulé depuis la dernière évaluation de la barre latérale. En voyant des valeurs comme 17000ms, j’ai d’abord cru à un problème : aucun calcul de rendu ne devrait prendre 17 secondes.

Ce n’est pas ce que ça veut dire. Le chiffre mesure le temps écoulé depuis la dernière évaluation, pas la durée de l’évaluation elle-même. 17 000 ms signifie que la barre latérale n’a pas été réévaluée pendant 17 secondes, parce que rien n’y avait changé. Quand le scan tourne et que la barre latérale n’a pas encore reçu de nouvelles données, il n’y a rien à re-rendre. L’absence de re-rendus n’est pas un bogue : c’est le système de suivi @Observable qui fonctionne correctement, en sautant les mises à jour quand l’état n’a pas bougé.

Mal lire cette métrique m’aurait expédié dans un détour de deux jours à optimiser du code parfaitement fonctionnel. La métrique de performance la plus dangereuse, c’est celle qu’on croit comprendre.


Un détail sur l’emplacement des logs : l’application est sandboxée. Les logs atterrissent dans ~/Library/Containers/com.renala.app/Data/Library/Application Support/Renala/perf-logs/, pas dans le ~/Library/Application Support/ classique. C’est le chemin du conteneur, celui vers lequel le répertoire personnel de l’application sandboxée résout. Si vous surveillez les logs et qu’ils ne bougent pas, vous regardez probablement au mauvais endroit.

Pour activer le battement de cœur, il faut positionner une clé de préférences : defaults write com.renala.app enablePerfLogging -bool true. Par défaut, cette clé s’écrit dans le domaine hors sandbox, mais l’application sandboxée lit depuis son propre conteneur de préférences sous ~/Library/Containers/com.renala.app/Data/Library/Preferences/. Écrivez au bon endroit, redémarrez l’application, le battement de cœur démarre.


Le motif de vidage par lots s’applique partout où un producteur haute fréquence alimente un consommateur d’interface. La règle : ne jamais laisser le travail d’arrière-plan dicter la cadence des mises à jour visuelles. 250 ms, c’est imperceptible pour un humain, et ça laisse au thread principal la place de traiter les interactions.

Sans le log de battement de cœur, j’aurais optimisé le rendu de la barre latérale. J’avais déjà commencé à examiner les performances de List, les structures de données, le chargement paresseux. J’avais des théories. Elles étaient toutes fausses. Le rendu était irréprochable. Le problème, c’est qu’il se déclenchait des milliers de fois par seconde.

Instrumenter d’abord. L’intuition est moins fiable qu’on ne le croit.

References