Chaque application mobile exécute de nombreuses tâches simultanément : charger des données depuis le réseau, traiter les touches de l'utilisateur, animer l'interface et enregistrer des fichiers. Si tout ce code s'exécute dans un seul thread, l'application se fige au moindre délai réseau. Le multithreading et la concurrence sont des concepts clés qui permettent à l'application de rester réactive et efficace. Dans cet article, nous allons passer en revue tous les outils principaux : du Main Thread et RunLoop aux coroutines Kotlin et Combine sur iOS. Le contenu est basé sur la documentation officielle Apple GCD.
Points clés
Le multithreading est la capacité d'une application à exécuter plusieurs fragments de code simultanément. Chaque fragment s'exécute dans un thread séparé — un processus léger avec sa propre pile d'appels. Dans le développement mobile, les threads sont divisés en deux catégories : Main Thread (thread UI) et Background Threads (threads d'arrière-plan).
Le système d'exploitation gère lui-même la répartition des threads entre les cœurs du processeur. Les appareils modernes ont 6 à 8 cœurs, donc l'exécution parallèle peut réellement accélérer le travail. Cependant, la création de threads est une opération coûteuse, il n'est donc pas recommandé de travailler directement avec Thread. À la place, on utilise des abstractions de plus haut niveau : DispatchQueue, OperationQueue, CoroutineDispatcher.
La concurrence est un concept plus large que le multithreading. La concurrence signifie que les tâches peuvent être exécutées « simultanément » même sur un seul cœur grâce au changement de contexte. L'asynchronisme (Async/Await) est un modèle de programmation où une tâche ne bloque pas un thread, mais rend le contrôle en attendant un résultat. Les langages modernes (Kotlin, Swift, Dart) ont un support intégré pour Async/Await.
Chez IT Sectr, nous accordons une attention particulière à l'architecture de multithreading correcte dès le début du projet. Les erreurs commises à un stade précoce entraînent des bogues difficiles à détecter : conditions de course, blocages mutuels et instabilité de l'application sous charge. Chacun de nos projets fait l'objet d'une revue d'architecture de concurrence au stade de la planification.
Le Main Thread (thread principal) — le seul thread dans une application mobile qui a accès à l'interface utilisateur. Sur Android, on l'appelle UI Thread, sur iOS — Main Thread. Toutes les opérations d'interface — modification du texte, animations, traitement des touches — sont effectuées uniquement sur le Main Thread. Si une opération lourde (chargement de fichier, analyse JSON) est effectuée sur le thread principal, l'interface cesse de répondre. Sur Android, cela entraîne un ANR (Application Not Responding), sur iOS — un écran « gelé ».
Les Background Threads (threads d'arrière-plan) sont destinés à tout ce qui n'est pas lié à l'interface utilisateur : requêtes réseau, opérations de base de données, traitement d'images, cryptographie. Une fois terminé, le résultat est transmis au Main Thread pour affichage. Chaque plateforme fournit ses propres outils pour basculer entre les threads : DispatchQueue.main.async sous iOS, runOnUiThread ou withContext(Dispatchers.Main) sous Android.
RunLoop — la boucle de traitement des événements sur le thread principal d'iOS. RunLoop attend les événements (touches, minuteurs, notifications) et les distribue aux gestionnaires appropriés. Sur Android, l'équivalent est Looper, associé à chaque Main Thread. Main Looper extrait infiniment les messages de la file d'attente et les transmet à Handler pour traitement. Comprendre RunLoop et Looper permet d'éviter les fuites mémoire et le « bégaiement » de l'interface.
Grand Central Dispatch (GCD) — une bibliothèque Apple pour gérer le multithreading au niveau du langage C. GCD fonctionne avec DispatchQueue — des files d'attente de tâches. Le développeur ne crée pas de threads manuellement ; GCD gère un pool de threads, distribuant les tâches entre les cœurs disponibles du processeur. Les DispatchQueue sont de deux types : Serial Queue (file d'attente série — les tâches s'exécutent les unes après les autres) et Concurrent Queue (file d'attente concurrente — les tâches peuvent s'exécuter simultanément).
Main DispatchQueue — une file d'attente série liée au thread principal. Global Queues — des files d'attente concurrentes avec différentes priorités (QoS — Quality of Service) : userInteractive, userInitiated, utility, background. Choisir la bonne QoS est essentiel pour les performances : .userInteractive — pour les tâches affectant l'interface utilisateur (animations, rendu) ; .background — pour les tâches non critiques en temps (synchronisation, nettoyage du cache).
OperationQueue — une abstraction au-dessus de GCD avec des capacités supplémentaires : annulation de tâches, définition de dépendances entre opérations, contrôle du nombre maximum d'opérations concurrentes. Les opérations sont des objets de la classe Operation (ou BlockOperation). Exemple : si vous devez charger une image, puis appliquer un filtre, et seulement ensuite l'afficher — OperationQueue avec dépendances gère parfaitement cela. Dans GCD, vous devriez synchroniser manuellement ces étapes avec DispatchGroup ou un sémaphore.
Async/Await en Swift 5.5+ — une alternative moderne à GCD. Les mots-clés async et await rendent le code asynchrone linéaire et lisible. Les fonctions sont marquées comme async, et les appels sont attendus avec await. Le système gère lui-même le changement de contexte : par défaut, une fonction async s'exécute sur un thread d'arrière-plan, tandis que la mise à jour de l'interface utilisateur s'exécute sur MainActor. @MainActor — un attribut qui garantit l'exécution du code sur le thread principal.
Les coroutines sont des threads légers pour Kotlin développés par JetBrains. Contrairement aux threads classiques, les coroutines ne sont pas liées à un Thread spécifique. Des milliers de coroutines peuvent s'exécuter sur plusieurs threads sans surcharge significative. CoroutineScope gère le cycle de vie des coroutines : viewModelScope est lié à ViewModel, lifecycleScope — à Activity/Fragment. Lorsque le scope est détruit, toutes les coroutines enfants sont automatiquement annulées.
Les Dispatchers déterminent sur quel pool de threads la coroutine s'exécute : Dispatchers.Main — thread UI ; Dispatchers.IO — pour les requêtes réseau et les opérations disque ; Dispatchers.Default — pour les calculs intensifs en CPU. Pour changer de dispatcher, on utilise withContext. Les coroutines supportent la concurrence structurée : chaque coroutine a un parent, et lorsque le parent est annulé, toutes les coroutines enfants sont annulées. Cela évite les fuites mémoire et les tâches suspendues.
Flow — un flux de données asynchrone froid de la bibliothèque de coroutines. Flow émet des valeurs séquentiellement : (1) le producteur génère des données, (2) les opérateurs transforment le flux, (3) le collecteur consomme le résultat. Contrairement à LiveData, Flow supporte des chaînes complexes d'opérateurs (map, filter, flatMapConcat, catch) et est complètement thread-safe. StateFlow et SharedFlow — des variantes chaudes de Flow, idéales pour l'état de l'interface utilisateur et les événements uniques (Snackbar, navigation).
Channel — une autre abstraction de coroutines pour passer des données entre coroutines. Channel fonctionne comme une file d'attente : un expéditeur (send) et un ou plusieurs destinataires (receive). Les canaux avec tampon (Channel(UNLIMITED), Channel(BUFFERED)) permettent de configurer le comportement en cas de débordement. Channel est souvent utilisé avec Flow pour faire le pont entre les API basées sur les callbacks et les coroutines : callbackFlow { … }.
Chez IT Sectr, nous utilisons activement les coroutines et Flow dans tous les projets Android. Cela permet d'écrire du code asynchrone qui ressemble à du code synchrone, est facile à tester (runTest, TestDispatcher) et ne nécessite pas de gestion manuelle des threads. Exemple d'une coroutine simple avec chargement de données :
class UserRepository(
private val api: UserApi,
private val dao: UserDao
) {
suspend fun getUsers(): List<User> = withContext(Dispatchers.IO) {
return@withContext try {
val users = api.fetchUsers()
dao.insertAll(users)
users
} catch (e: Exception) {
dao.getAll()
}
}
}
La programmation réactive — un paradigme où les données se propagent sous forme de flux asynchrones (Observable, Publisher). RxJava/RxKotlin — l'implémentation la plus populaire pour Android, portée depuis .NET Rx. RxSwift — une bibliothèque similaire pour iOS. Composants principaux : Observable (source d'événements), Observer (abonné), Scheduler (gestion des threads), Operators (transformation de flux).
Combine — un framework Apple pour la programmation réactive introduit dans iOS 13. Combine utilise les protocoles Publisher (éditeur) et Subscriber (abonné). Contrairement à RxSwift, Combine est intégré au SDK et étroitement intégré à SwiftUI. Les opérateurs dans Combine : map, filter, combineLatest, zip, debounce, throttle — couvrent la plupart des scénarios : de la liaison de données à l'interface utilisateur au debounce de requête de recherche.
Future et Promise — des patrons pour travailler avec un seul résultat asynchrone. Future représente une valeur qui sera disponible plus tard. Promise est une promesse de fournir une valeur. Dans Rx, c'est Single (une réponse réussie ou une erreur), dans Combine — Future Publisher. En pratique, Future/Promise sont pratiques pour les requêtes uniques à une API, tandis qu'Observable/Publisher — pour les flux continus (géolocalisation, saisie de texte).
Callback et Delegate — des patrons classiques pour les opérations asynchrones. Callback — une fonction passée comme argument et appelée à la fin de l'opération. Delegate — un objet implémentant un protocole avec des méthodes de gestion d'événements. Inconvénient : « l'enfer des callbacks » (callbacks imbriqués) et la complexité de la gestion des erreurs. NotificationCenter (iOS) et EventBus (Android) — des mécanismes de diffusion d'événements, utiles pour une communication faiblement couplée, mais menant à des dépendances implicites.
Le multithreading ouvre la porte à des performances élevées, mais crée en même temps un risque d'erreurs difficiles à détecter. Les plus courantes : Race Condition (condition de course), Deadlock (blocage mutuel), Livelock (blocage actif) et Starvation (famine de thread). Comprendre ces problèmes est une compétence essentielle pour tout développeur mobile.
Une Race Condition se produit lorsque deux threads ou plus lisent et écrivent simultanément les mêmes données sans synchronisation. Le résultat dépend du thread qui s'exécute en premier. Exemple classique : deux threads incrémentent un compteur. L'opération « lire → incrémenter → écrire » n'est pas atomique, donc lorsqu'elle est exécutée simultanément, un incrément est « perdu ». La solution — utiliser des opérations atomiques (AtomicInteger, AtomicReference) ou des verrous (Mutex, Semaphore, synchronized).
Deadlock — une situation où chaque thread détient une ressource et attend une ressource détenue par un autre thread. Aucun thread ne peut continuer. Conditions d'apparition : exclusion mutuelle, détention et attente, pas de préemption, attente circulaire. Prévention : établir un ordre unique d'acquisition des verrous, utiliser tryLock avec un délai d'attente, appliquer des algorithmes Lock-Free (ConcurrentHashMap, CopyOnWriteArrayList).
Livelock — les threads ne sont pas bloqués mais se « passent » constamment des ressources sans effectuer de travail utile. Exemple : deux personnes se rencontrent dans un couloir et toutes deux s'écartent, se déplaçant dans la même direction. Starvation — un thread n'obtient pas l'accès à une ressource parce que d'autres threads l'interceptent constamment. Solution : des verrous équitables (fair locks), des priorités de threads avec précaution.
Pour prévenir les problèmes de multithreading, on utilise des primitives de synchronisation : Mutex (exclusion mutuelle), Semaphore (limitation du nombre d'accès simultanés), Lock (interface avec tryLock), Synchronized (verrou au niveau JVM), @MainActor (Swift — garantie d'exécution sur le thread principal). Sur Android, ThreadPool est également disponible via Executors.newFixedThreadPool, newCachedThreadPool. Cependant, la gestion manuelle des pools est la prérogative des projets legacy ; dans les nouveaux projets, il est préférable d'utiliser des coroutines.
| Outil | Plateforme | Type | Caractéristiques |
|---|---|---|---|
| DispatchQueue (GCD) | iOS | File d'attente de tâches | Série/Concurrente, priorités QoS, Thread Pool géré par le système |
| OperationQueue | iOS | File d'attente d'opérations | Dépendances, annulation, maxConcurrentOperationCount |
| Coroutines + Flow | Android | Coroutines | Légères, concurrence structurée, StateFlow, Channel |
| RxJava / RxKotlin | Android | Flux réactif | Observable, Schedulers, ensemble riche d'opérateurs |
| Combine | iOS | Flux réactif | Publisher/Subscriber, intégration SwiftUI |
| Async/Await + Task | iOS / Android | Modèle asynchrone | Code linéaire, @MainActor, concurrence structurée |
Questions fréquentes
Le Main Thread (thread UI) est responsable du rendu de l'interface et du traitement des touches. Le Background Thread exécute des tâches d'arrière-plan — chargement de données, calculs, travail réseau. Bloquer le Main Thread provoque le gel de l'interface (ANR sur Android, frozen UI sur iOS).
La Race Condition — une condition de course lorsque deux threads accèdent simultanément à des données partagées et que le résultat dépend de l'ordre d'exécution. On l'évite par la synchronisation : Mutex, Semaphore, Lock, Synchronized, @MainActor ou des opérations atomiques.
Les coroutines sont le standard moderne pour Android (JetBrains, supporté par Google). RxJava/RxKotlin est une approche réactive avec un riche ensemble d'opérateurs. Les coroutines sont plus simples pour les appels asynchrones, RxJava est plus puissant pour les flux de données complexes. Chez IT Sectr, nous utilisons Coroutines + Flow pour les nouveaux projets.
Deadlock — un blocage mutuel où deux threads attendent les ressources l'un de l'autre. Livelock — les threads ne sont pas bloqués mais se passent constamment des ressources sans travail utile. Les deux problèmes sont résolus par un ordre de verrouillage approprié et des délais d'attente.
DispatchQueue est une abstraction de Grand Central Dispatch (GCD) pour la gestion des threads. Main Queue exécute les tâches sur le thread principal, Global Queues — sur les threads d'arrière-plan. Serial Queue garantit une exécution séquentielle, Concurrent Queue — parallèle. Dans les projets modernes, GCD est souvent remplacé par Async/Await et Task.
Résumé
Nous développerons une application mobile clé en main
IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.