Où s’exécute le néant ?
Annexe de Rejeté
Voici une ligne de Swift qui plante sur macOS Sequoia :
completionHandler: { _, _ in }
Ce bloc de code ne fait absolument rien. Ni calcul, ni donnée, ni effet de bord. Il reçoit deux arguments, les ignore, et retourne void : l’équivalent d’un hochement de tête poli. Sur macOS Ventura, il s’exécutait sans histoire. Sur Sequoia, il tue l’application.
En programmation, ça s’appelle une closure (fermeture) : un bloc de code qu’on écrit maintenant et qu’on confie à quelqu’un d’autre pour qu’il l’exécute plus tard. Comme un mot glissé dans une enveloppe : le destinataire l’ouvre, lit les instructions, les suit. Celui-ci est vierge.
La dernière phrase de l’article qui décrivait ce bug disait : « Une fermeture vide n’est pas gratuite ; en Swift 6, même ne rien faire a un type, et ce type a son avis sur l’endroit où il s’exécute. »
Cet article déplie cette phrase.
La question fondamentale
Le plantage s’est produit à cause de l’endroit où le mot vierge a été lu, pas de ce qu’il disait.
Une application peut faire plusieurs choses simultanément : télécharger un fichier, répondre à un clic, animer un indicateur de chargement. Chacune de ces tâches tourne sur un thread, un ouvrier qui exécute des instructions en séquence. L’application dispose de plusieurs threads, chacun affecté à une tâche différente. L’un d’eux est spécial : le thread principal. Le thread principal possède l’écran. Chaque changement de pixel, chaque appui sur un bouton, chaque image d’animation passe par lui. Modifier l’écran depuis un autre thread casse quelque chose. Parfois un scintillement, parfois un plantage, parfois une corruption qui se manifeste trois écrans plus tard.
Quand le code demande à une API de faire quelque chose de lent (récupérer des données sur un serveur, lancer une autre application), l’API fait ce travail sur son propre thread. Une fois le travail terminé, l’API doit prévenir l’appelant. Elle le fait en invoquant la closure, le mot qu’on lui avait confié. C’est un completion handler : l’API rappelle quand elle a fini.
Mais sur quel thread l’API invoque-t-elle la fermeture ? Le sien ? Le nôtre ? Le thread principal ? C’est là que vit le plantage.
Trois questions cadrent la suite :
- Sur quel thread l’API exécute-t-elle la fermeture ?
- La fermeture porte-t-elle une étiquette indiquant où elle doit tourner ?
- Que se passe-t-il quand l’étiquette et la réalité divergent ?
L’enquête
La fermeture ne fait rien, ne capture rien, devrait être invisible pour le système de types. Elle ne l’est pas.
Voici le code :
@MainActor
class DiagnosticsViewModel {
func openApp() {
NSWorkspace.shared.openApplication(
at: url,
configuration: config,
completionHandler: { _, _ in } // ← le bug
)
}
}
Quatre couches, chacune anodine en apparence, qui se combinent pour produire le plantage. Une chaîne de traçabilité où chaque intervenant a suivi le protocole, et la preuve a quand même disparu.
Couche 1 : l’annotation. DiagnosticsViewModel est @MainActor. Toutes ses méthodes et propriétés sont isolées sur l’acteur principal (en pratique, le thread principal). C’est correct : le view model pilote l’interface, il appartient au main actor. Rien d’anormal ici.
Couche 2 : l’inférence. La fermeture ne fait rien, ne capture rien, devrait pouvoir tourner n’importe où. Mais Swift a une règle :1 si le type attendu par l’API pour la fermeture ne désactive pas explicitement l’affinité de thread, la fermeture hérite de l’isolation de son contexte englobant. Deux marqueurs la désactivent : @Sendable (sur la fermeture) et sending (sur le paramètre qui la reçoit), qui disent tous deux au compilateur « cette valeur peut passer d’un thread à l’autre ». Le paramètre completionHandler, ponté depuis Objective-C, ne porte aucun de ces marqueurs, parce que ces concepts n’existaient pas quand l’API a été écrite. Donc {'{'{'}'} _, _ in {'}'} devient @MainActor {'{'{'}'} _, _ in {'}'}.
Le compilateur infère par la position, pas par le contenu, pour éviter un piège sournois où une seule ligne ajoutée change l’isolation sans prévenir.2 La fermeture vide paie pour les péchés de ses sœurs non vides.
Couche 3 : l’API. NSWorkspace.openApplication(at:configuration:completionHandler:) est une API Objective-C pontée vers Swift. L’en-tête indique que le completionHandler est « appelé sur une file concurrente », sans préciser laquelle ni garantir de lien avec la file de l’appelant.
Comparons avec la plus ancienne recycleURLs:completionHandler:, dont l’en-tête indique explicitement « appelé sur la même dispatch queue que l’appel à recycleURLs: ». C’est un contrat que le système de types pourrait encoder. La méthode openApplication n’offre rien d’équivalent. « Une file concurrente » indique au développeur que ce ne sera pas la file principale, mais ne donne rien au système de types Swift : aucune annotation ne traduit « file concurrente » en nonisolated ou @Sendable.
Couche 4 : l’application. Le runtime de concurrence Swift livré avec macOS Sequoia impose désormais les vérifications d’isolation des acteurs à l’exécution. SE-0424 a étendu ce mécanisme aux executors personnalisés via checkIsolated, mais l’assertion sous-jacente, dispatch_assert_queue_fail, vient de Grand Central Dispatch. Quand une fermeture annotée @MainActor est invoquée sur une file qui n’est pas la file principale, dispatch_assert_queue_fail se déclenche. Sur les versions antérieures de l’OS, le runtime ne déclenchait pas cette vérification sur les chemins de code concernés : l’incohérence passait inaperçue.
Quatre couches, chacune correcte isolément, qui plantent une fois combinées. Le bug vit dans l’interstice entre le système de types Swift et le contrat de l’API Objective-C. L’interstice existe non pas parce que l’information est inconnaissable, mais parce que l’API Objective-C est antérieure au vocabulaire de types nécessaire pour l’exprimer. Apple annote les en-têtes hérités avec @Sendable et sending depuis Xcode 14, mais des milliers de paramètres de completion handler restent sans annotation.
Le compromis de conception
L’inférence de Swift 6 n’est pas une erreur. Elle existe parce que l’alternative est pire.
Considérons une fermeture qui accède à un état @MainActor :
@MainActor
class ViewModel {
var items: [Item] = []
func loadItems() {
api.fetch { result in
self.items = result // accède à un état @MainActor
}
}
}
Sans inférence, cette fermeture s’exécuterait sur le thread choisi par l’API, mutant un état @MainActor depuis un thread d’arrière-plan : une data race. L’inférence de Swift 6 l’empêche à la compilation, à condition que l’API déclare son contrat de thread dans le système de types. Quand l’API est correctement annotée, le compilateur attrape l’incohérence. Quand elle ne l’est pas, l’incohérence survit jusqu’à l’exécution.
L’inférence attrape le cas courant (les fermetures qui utilisent l’état de leur contexte) au prix du cas rare (les fermetures qui n’utilisent rien). La fermeture vide est un dommage collatéral.
Si le paramètre du completionHandler était marqué @Sendable ou sending, Swift inférerait la fermeture comme nonisolated.3 Mais la couche de pontage Objective-C n’ajoute aucune de ces annotations, parce que ces concepts n’existaient pas quand l’API a été écrite. La règle d’inférence est correcte ; c’est son entrée qui est incomplète.
C’est le problème de fond : la frontière Objective-C. Le système de types de Swift suppose que les contrats d’API sont exprimés par des types. Les API Objective-C précèdent cette hypothèse. Le compilateur Swift voit un completionHandler, infère l’isolation, et n’a aucun moyen de savoir que l’API violera cette isolation à l’exécution. Les garanties au niveau des types sont aussi solides que la frontière la plus faible qu’elles traversent.
macOS Sequoia a choisi de rendre la divergence bruyante plutôt que silencieuse. C’est le bon choix : silencieuse, elle produit des data races ; bruyante, elle produit un journal de plantage qu’on peut lire.
Le correctif
Dans Renala, le correctif a été de retirer purement et simplement le completion handler :
// Avant : la fermeture vide hérite de @MainActor, plante sur la file d'arrière-plan
NSWorkspace.shared.openApplication(at: bundleURL, configuration: config) { _, _ in }
// Après : pas de fermeture, pas d'inférence, pas de plantage
NSWorkspace.shared.openApplication(at: bundleURL, configuration: config)
L’API n’exige pas de completion handler. En passer un relevait du réflexe défensif : l’appel pouvait échouer, autant le prévoir. Mais un handler vide ne gère rien. C’était du cérémonial, et en Swift 6, le cérémonial a un type.
Quand la fermeture doit exister, deux autres options brisent la chaîne d’inférence. Marquer la fermeture @Sendable indique au compilateur qu’elle peut traverser les frontières d’isolation, ce qui désactive l’héritage de @MainActor. Envelopper l’appel dans un bloc Task.detached crée un contexte nonisolated où l’inférence repart de zéro. Les deux sont des déclarations explicites : le développeur dit au compilateur « je sais où ça s’exécute ».
Le banc des suspects
Voilà comment le plantage se produit. Mais est-il inévitable ? Les autres langages ont-ils le même problème, ou Swift 6 fait-il cavalier seul ? L’interactif ci-dessous permet de dispatcher la même fermeture vide dans chaque langage et d’observer ce qui se passe.
JavaScript : l’échappatoire
JavaScript n’a qu’un seul thread. La boucle d’événements traite les callbacks séquentiellement. Il n’y a pas de « mauvais thread » parce qu’il n’y en a pas d’autre.
fetch('/api/data').then(() => {
// S'exécute sur l'unique thread existant.
// Un callback vide : () => {}
// Coût : zéro. Risque : zéro.
});
Un callback vide en JavaScript est véritablement gratuit. Ni annotation de type, ni affinité de thread, ni assertion à l’exécution. Les Web Workers existent, mais ils communiquent par envoi de messages, pas par fermetures partagées. Le problème qui nous occupe ne peut tout simplement pas se poser.
C# / .NET : la main invisible
C# a popularisé le patron async/await que la plupart des langages ont ensuite adopté, et avec lui, un mécanisme nommé SynchronizationContext : la main invisible qui décide où reprend le code après un await.
// Dans une méthode UI WPF ou WinForms :
async Task LoadDataAsync()
{
var data = await FetchFromNetwork(); // suspension ici
label.Text = data.Title; // reprise sur le thread UI
}
Quand on fait un await dans une méthode UI, le framework capture le SynchronizationContext courant (qui, sur le thread UI, renvoie vers ce même thread). Une fois la tâche terminée, la continuation est redirigée vers ce contexte. C# a trouvé la limite de cette approche très tôt : le deadlock classique de .NET survient quand du code synchrone bloque le thread UI alors qu’une continuation d’await a besoin de ce même thread pour reprendre.4
Mais voici le point crucial : les lambdas simples ne portent aucune information de thread. Seules les continuations d’await capturent le contexte. Une lambda nue passée à une API n’est qu’une référence de fonction sans opinion sur le thread qui l’exécute.5
// Lambda vide : zéro coût, zéro affinité de thread.
DoSomethingAsync(callback: () => { });
Java : l’ignorance honnête
Java dispose de bibliothèques de concurrence riches pour gérer les threads et l’état partagé, mais aucun de ces mécanismes ne touche au type de la lambda. Le système de types ne sait rien des threads, et il ne prétend pas le contraire.
// Consumer<T> est un type fonctionnel. Point.
// Ni annotation de thread, ni capture de contexte.
executor.submit(() -> {
// S'exécute sur le thread fourni par l'executor.
});
// Sur Android, le threading est explicite :
runOnUiThread(() -> {
textView.setText("Mis à jour");
});
Le compilateur n’aide en rien côté threads, mais il n’induit pas non plus en erreur. Ni inférence à rater, ni assertion à violer. Une lambda vide est gratuite. La charge revient au développeur, qui dispatche vers le bon thread à la main.
Kotlin : la voie médiane
Kotlin a des coroutines : des fonctions qui peuvent se suspendre, céder le contrôle, et reprendre plus tard. On peut se les représenter comme une gestion automatique des ressources pour les tâches concurrentes : quand une tâche parent se termine, tous ses enfants sont annulés et nettoyés, un peu comme un try-with-resources en Java. Les dispatchers sont des pools de threads nommées qui décident où le code s’exécute.
// Dispatcher explicite : on choisit où ça tourne.
viewModelScope.launch(Dispatchers.Main) {
val data = withContext(Dispatchers.IO) {
fetchFromNetwork()
}
// Retour sur Main après withContext.
textView.text = data.title
}
La différence de conception cruciale avec Swift 6 : les lambdas simples en Kotlin n’héritent pas du dispatcher de leur contexte englobant. Dans une coroutine, launch {'{'{'}'}{'}'} hérite bien du dispatcher parent via la concurrence structurée, mais une lambda passée à une API non-coroutine reste une simple lambda, sans annotation Dispatchers.Main, sans affinité de thread implicite.
Kotlin et Swift 6 poursuivent tous deux la concurrence structurée, mais ils visent des problèmes différents. Swift 6 s’en sert pour éliminer les data races à la compilation ; Kotlin, pour gérer la durée de vie des tâches et l’annulation. Kotlin fait confiance au développeur pour choisir le bon thread ; Swift 6, au compilateur pour l’inférer.
Le spectre
Placés côte à côte, ces cinq langages forment un spectre allant de « aucun avis » à « avis tranché, imposé » :
| Langage | Le type de la fermeture porte-t-il une info de thread ? | Qui décide où elle s’exécute ? | Coût d’une fermeture vide | Mode de défaillance |
|---|---|---|---|---|
| JavaScript | Sans objet (un seul thread) | Boucle d’événements | Gratuit | Impossible |
| Java | Non | L’appelant (explicite) | Gratuit | Bugs de mauvais thread à l’exécution, aucune aide du compilateur |
| C# / .NET | Seulement à l’await | SynchronizationContext (implicite à l’await) | Gratuit | Deadlock si on bloque sur de l’async ; mauvais thread si le contexte est contourné |
| Kotlin | Non (dispatchers explicites) | Le développeur via withContext | Gratuit | Bugs de mauvais thread si le dispatcher ne correspond pas ; le dispatch explicite les rend traçables |
| Swift 6 | Oui (@MainActor inféré) | Inférence du compilateur + assertion à l’exécution | Pas gratuit | Plantage si l’isolation inférée contredit l’appelant |
Le spectre va de gauche à droite : plus de garanties de sécurité, plus d’occasions pour le système de types de contredire la réalité.
Le coût de ne rien faire
Dans tous les autres langages étudiés, une fermeture vide est gratuite. En Swift 6, elle a un type, ce type a des avis, et ces avis sont imposés.
Une fermeture vide est le callable le plus simple qui soit : ni état, ni effet de bord, ni calcul. C’est le « hello world » des fermetures. Et pourtant, dans un langage précis, sur une version précise de l’OS, passée à une API précise, elle plante. Non pas parce que la fermeture a tort, mais parce que le type a tort, et ce type n’a jamais été écrit par un humain.
L’ironie finale : une fermeture qui ne capture rien n’a rien sur quoi provoquer une data race. Par définition, elle peut tourner sur n’importe quel thread. Le système de types impose une affinité de thread à la seule fermeture du programme qui n’en a aucun besoin.
Références
- Swift Evolution : SE-0316 — Global Actors (introduit
@MainActoret les règles d’inférence des acteurs globaux, y compris l’héritage d’isolation des fermetures) - Swift Evolution : SE-0461 — Async Function Isolation (précise comment les fonctions async nonisolated héritent de l’isolation de l’appelant)
- Apple : Documentation MainActor
- Microsoft : Classe SynchronizationContext
- Kotlin : Contexte des coroutines et dispatchers
- Stephen Cleary : Async and Await (la référence .NET async)
Footnotes
-
La règle d’inférence d’isolation des fermetures vient de SE-0316, qui définit les acteurs globaux et leurs règles d’inférence. SE-0461 précise ensuite comment les fonctions async nonisolated héritent de l’isolation, et sa discussion éclaire davantage la sémantique d’isolation des fermetures. ↩
-
Le compilateur pourrait remarquer que cette fermeture ne capture rien et sauter l’inférence. Il ne le fait pas, délibérément. Deux approches sont possibles : décider par la position (dans une classe
@MainActor? alors la fermeture est@MainActor) ou par le contenu (touche-t-elle un état@MainActor? non ? on la laisse tranquille). Swift a choisi la position. L’approche par le contenu semble plus maligne, mais crée un piège sournois : ajoutez une seule ligne qui toucheself, et la fermeture change d’isolation sans prévenir. La règle par position est prévisible même quand elle est conservatrice. C’est un choix de conception, pas une limitation du compilateur. ↩ -
@Sendablemarque une fermeture comme pouvant être envoyée entre domaines de concurrence, exigeant que toutes ses valeurs capturées soient conformes au protocoleSendable. Si la fermeture capture un tableau mutable, ce tableau doit êtreSendable. Si elle ne capture rien, la question ne se pose pas. L’annotation voisinesendingfait de même pour les paramètres. L’une ou l’autre annotation ferait inférernonisolated, parce qu’une fermeture qui peut traverser les frontières d’isolation ne doit être liée à aucun acteur. ↩ -
L’exemple classique : un handler de bouton appelle
LoadDataAsync().Result, bloquant le thread UI. La continuation de l’awaitdansLoadDataAsynca besoin du thread UI pour reprendre, mais.Resultle retient en otage. Ni l’un ni l’autre ne peut avancer. C’est un schéma WPF/WinForms ; ASP.NET Core n’a pas deSynchronizationContextpar défaut, donc ce deadlock ne s’y produit pas. ↩ -
ConfigureAwait(false)indique à l’awaiter de ne pas ramener la continuation sur le contexte d’origine. C’est une optimisation de performance : le code bibliothèque n’a aucune raison de revenir sur le thread UI de l’appelant, il décline donc le saut de thread. Le code situé après unConfigureAwait(false)perd le filet de sécurité et doit dispatcher vers le thread UI manuellement. ↩
