Android (Kotlin) Avancé

Maîtriser la Coroutine Architecture : Des Internals aux Patterns Résilients

Plongez dans les mécanismes avancés des coroutines Kotlin pour construire des architectures asynchrones performantes et déboguer les problèmes de concurrence les plus complexes.

Preparetoi.academy 30 min

1. Fondamentaux Avancés des Coroutines et Continuations

Définition
Une coroutine est une fonction suspendue qui peut être mise en pause et reprise sans bloquer le thread. Contrairement aux threads classiques, les coroutines sont léères et plusieurs milliers peuvent coexister. Elles reposent sur le concept de continuation, un objet qui encapsule l'état d'exécution et permet la reprise du code à partir du point de suspension.

Analogie
Imaginez un restaurant avec une seule commande à la fois : sans coroutines, le serveur attend que le client termine avant de servir le suivant (thread bloqué). Avec les coroutines, le serveur prend la commande (suspend), s'occupe d'autres clients (contexte commuté), puis revient quand la cuisine est prête (resume).

Tableau Comparatif

Aspect Threads Coroutines
Coût mémoire ~1-2 MB ~100 bytes
Contexte switch Système d'exploitation Runtime Kotlin
Stacktrace Complexe multi-niveaux Linéaire et traceable
Nombre concurrent 1000 max 100,000+
Exception handling Try-catch compliqué Propagation naturelle
Débogage Outils spécialisés Debugger standard

Les continuations en Kotlin utilisent la transformation en état machine. Chaque fonction suspend est compilée en une classe implémentant Continuation<T>, contenant les états et transitions entre points de suspension. Le compilateur génère une machine à états finis qui gère les appels à suspendCancellableCoroutine ou équivalents.

Astuce Expert
Utilisez -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading pour observer comment le compilateur transforme vos fonctions suspend en classes de continuation. Activez également kotlinx.coroutines.debug pour obtenir des noms de coroutines dans les stacktraces et identifier précisément les chemins de suspension.

Attention Critique
Les continuations ne sont JAMAIS thread-safe par défaut. Une continuation ne peut être reprise qu'une fois, et son appel depuis plusieurs threads provoquera une IllegalStateException. De plus, les variables locales capturées dans une coroutine peuvent être optimisées différemment selon le contexte de compilation, causant des bugs subtils en release.


2. Dispatchers : Sélection et Optimisation du Contexte d'Exécution

Définition
Un Dispatcher est un composant qui détermine sur quel thread (ou groupe de threads) une coroutine sera exécutée. Kotlin fournit plusieurs implémentations : Dispatchers.Main (thread UI), Dispatchers.IO (pool de threads pour I/O), Dispatchers.Default (CPU-bound), et Dispatchers.Unconfined (pas de changement de contexte). Chaque dispatcher a une stratégie d'ordonnancement unique et des implications de performance distinctes.

Analogie
Les dispatchers sont comme les files d'attente d'une gare : Main est le guichet express (une seule personne à la fois), IO est le guichet standard (plusieurs guichets, attente courte), et Default est la file pour les calculs lourds (optimisée pour le CPU).

Tableau des Dispatchers

Dispatcher Threads Cas d'Usage Sécurité Overhead
Main 1 (UI thread) Mise à jour UI Non-thread-safe Minimal
IO Pool limité (~64) Réseau, fichiers Thread-safe Moyen
Default Pool = CPU cores Tri, parsing JSON Thread-safe Moyen
Unconfined Hérité du parent Code métier pur Dépend Minimal
SingleThreadDispatcher 1 (dédié) État partagé sécurisé Oui Élevé

Le pool IO utilise une stratégie lazy-initialization : les threads sont créés à la demande jusqu'à atteindre la limite (Int.MAX_VALUE.coerceAtMost(128) * nthreads). À la différence de Default, qui maintient Runtime.getRuntime().availableProcessors() threads chauds, IO peut hiberner les threads inutilisés après une période d'inactivité.

Astuce Expert
Pour profiler l'efficacité de vos dispatchers, injectez des println(Thread.currentThread().name) ou utilisez withContext(Dispatchers.Default) { ... } avec des instrumentations OpenTelemetry. Mesurez le switchCount entre contextes : un nombre élevé indique un mauvais placement de withContext. Configurez un CoroutineDispatcher personnalisé avec ExecutorService pour des cas spécialisés (ex: priorités différentes).

Attention Critique
Dispatchers.Unconfined est un piège : il reprend l'exécution sur n'importe quel thread appelant resume(), créant souvent des blocages sur le thread UI. Ne l'utilisez jamais en production sauf pour des coroutines de test. De plus, commuter constamment entre dispatchers via withContext ajoute un overhead microscopique mais cumulatif : limitez les switches à moins de 10 par fonction.


3. Gestion Avancée des Exceptions et Supervisors

Définition
Les exceptions dans les coroutines ne suivent pas les règles classiques des threads. Une exception levée dans une coroutine enfant annule (cancelle) toute la hiérarchie parente par défaut. Le SupervisorJob brise cette propagation : chaque enfant est isolé et les exceptions ne remontent pas. La stratégie de gestion dépend du CoroutineExceptionHandler et de la structure du scope.

Analogie
Un scope normal est une équipe de projet : si un développeur échoue, tout le projet s'arrête. Avec SupervisorJob, chaque développeur est indépendant ; l'échec d'un ne paralyse pas les autres. Le CoroutineExceptionHandler est le manager qui décide comment réagir aux problèmes.

Tableau Hiérarchie des Exceptions

Scope Propagation Cancellation Résumé
Job normal Remonte au parent Cascade complète Atomique, all-or-nothing
SupervisorJob Pas de remontée Indépendante par enfant Isolation, résilience
ViewModelScope Job + handler Try-catch intégré Sûr pour UI
GlobalScope Pas de hiérarchie Manuel (dangereux) À proscrire
CoroutineScope(Job) Custom Dépend du Job Flexible

Quand une exception non capturée émerge, le CoroutineExceptionHandler global capture les exceptions racines (non CancellationException). Les exceptions dans les enfants de SupervisorJob ne déclenchent le handler que si aucun try-catch n'intervient. Un launch dans un scope capture l'exception, un async la stocke et la propage au await().

Astuce Expert
Combinez SupervisorJob + CoroutineExceptionHandler pour une résilience maximale :

val handler = CoroutineExceptionHandler { _, exception -> 
    Log.e("Coroutine", exception.message, exception) 
}
val scope = CoroutineScope(SupervisorJob() + handler + Dispatchers.Main)

Utilisez launch pour les tâches fire-and-forget (exceptions loggées), et async uniquement si vous exploitez le résultat. Instrumentez les stacktraces avec exception.suppressedExceptions pour détecter les chaînes d'annulation.

Attention Critique
CancellationException est traitée spécialement : elle n'est JAMAIS passée au handler global et elle annule la coroutine sans signaler d'erreur. Les attraper explicitement avec catch (e: CancellationException) puis les relancer est une violation du protocole. De plus, GlobalScope ignore complètement la hiérarchie ; ses exceptions ne sont jamais captures, rendant le débogage impossible. Enfin, les exceptions levées dans les finally blocks d'une coroutine annulée ajoutent des suppressed exceptions qui compliquent les stacktraces.


4. Tuning de Performance et Gestion de la Mémoire

Définition
Les coroutines Kotlin réduisent la mémoire consommée et le temps d'allocation, mais l'optimisation fine demande de comprendre l'inlining, l'escape analysis et les allocations implicites. Chaque withContext, chaque suspension crée des objets temporaires. L'object pooling, la réutilisation de contextes et l'élimination de suspensions inutiles peuvent doubler la performance d'une application hautement concurrente.

Analogie
La gestion mémoire des coroutines est comme le recyclage : créer mille coroutines sans les réutiliser c'est produire mille tonnes de déchets. Avec un pool d'objets et des continuations réutilisées, vous réduisez l'empreinte carbone (CPU) et le nettoyage (GC pauses).

Tableau Profil Mémoire

Objet Taille (bytes) Allocation Cycle de Vie
Coroutine Job ~200 Heap De création à completion
Continuation ~300 Stack (optimisé) Une seule suspension
CoroutineContext ~100 Reused Hérité du parent
Dispatcher Task ~150 Pool Entre suspensions
CancellationException ~500 Cached Réutilisée

Un heap profiler révèle que les champs state et continuation des coroutines représentent 60% de l'allocation. Kotlin inligne les lambdas simples, éliminant l'allocation, mais les lambdas capturant des variables créent des classes synthétiques. Le -XX:+TieredCompilation et -XX:TieredStopAtLevel=4 permettent au JIT de compiler les hot paths des coroutines.

Astuce Expert
Profilez avec Android Studio > Profiler > Memory, activez l'allocation tracking et filtrez par "Coroutine". Utilisez DebugProbes.dumpCoroutines() pour lister toutes les coroutines actives et leurs stacktraces. Minimisez les withContext imbriquées : chacune alloue une continuation temporaire. Préférez withContext(CoroutineExceptionHandler(...)) une seule fois en haut, plutôt que de la répéter. Utilisez coroutineScope { ... } pour réutiliser le contexte du parent au lieu de CoroutineScope(Job()).

Attention Critique
Les coroutines ne nettoient PAS automatiquement les ressources à la suspension : si vous conservez une référence à un InputStream dans une coroutine suspendue, il restera ouvert. Les try-finally ne garantissent pas l'exécution immédiate à l'annulation (il faut coopérer via ensureActive()). De plus, les grandes chaînes de withContext sans cancellation token explicite peuvent créer des fuites mémoire si la coroutine est jamais réveillée. Enfin, stocker le contexte d'une coroutine dans une variable globale peut empêcher le GC de nettoyer la closure.


5. Débogage et Tracing Avancé des Coroutines

Définition
Déboguer les coroutines exige des outils spécialisés car la stacktrace ne reflète pas l'ordre d'exécution réel. Le CoroutineDebugger intégré à Kotlin affiche les points de suspension, les contextes et les hiérarchies de jobs. Combiné avec le tracing distribué (OpenTelemetry), il permet de suivre une requête à travers plusieurs coroutines et threads.

Analogie
Déboguer une coroutine sans outils, c'est comme suivre un film avec des scènes mélangées : vous voyez les événements mais pas l'ordre narratif réel. Avec le DebugProbes, vous obtenez un chronologie linéaire et complète.

Tableau Outils et Techniques

Outil Fonction Coût Recommandation
Debugger Android Studio Breakpoints, variables Nul en debug Toujours
DebugProbes.dumpCoroutines() Liste coroutines actives Minimal A l'appel problématique
BlockingCoroutineDispatcher Detect suspensions Moyen Tests unitaires
Sentry/Crashlytics Stacktraces enrichies Faible Production
Brave/Zipkin Distributed tracing Moyen Architectures cloud
ThreadSanitizer Race conditions Élevé CI/CD

Les coroutines compilées en machines à états rendent les stacktraces complexes. Un simple delay(1000) se transforme en état 0, une reprise en état 1, sans contexte visible. Le CoroutineDebugger intercept chaque resumeWith et construite une arborescence synthetique des suspensions.

Astuce Expert
Activez la détection des blocages : intégrez BlockingCoroutineDispatcher dans les tests pour catcher les Thread.sleep() cachés dans les dépendances.

val testDispatcher = BlockingCoroutineDispatcher()
runTest(testDispatcher) { delay(1000) } // ✓ passe

Ajoutez des logs temporalisés avec System.nanoTime() autour des withContext pour mesurer les latences de context switch. Utilisez coroutineContext[CoroutineName]?.name ?: "unnamed" pour tracer les chemins. Intégrez OpenTelemetry avec un intercepteur personnalisé du dispatcher pour exporter les traces au serveur de monitoring.

Attention Critique
DebugProbes active un surcoût de 5-10% en production ; ne l'activez que temporairement avec DebugProbes.install(). Les println dans les coroutines masquent souvent les vrais problèmes car plusieurs coroutines écrivent simultanément sur stdout ; utilisez un Logger thread-safe. Les breakpoints dans une coroutine suspendue ne fonctionnent qu'une seule fois, lors de la reprise ; les reprises ultérieures ne les déclenchent pas. Enfin, les stack traces tronquées (limit < 100 frames) cachent les chemins d'appel imbriqués : augmentez java.util.logging.SimpleFormatter.format pour capturer le contexte complet.

Accédez à des centaines d'examens QCM — Découvrir les offres Premium