Thread Pool — est un mécanisme de gestion des threads dans lequel un pool de threads pré-créé est réutilisé pour exécuter des tâches, évitant ainsi les frais généraux de création et de destruction de threads. Dans le développement mobile, le thread pool est utilisé pour les opérations en arrière-plan : requêtes réseau, traitement d'images, travail avec des bases de données. Selon la documentation Google Android (2025), ExecutorService est la méthode recommandée pour gérer les threads en arrière-plan sous Android. Sous iOS, OperationQueue et GCD DispatchQueue avec des files d'attente concurrentes globales jouent un rôle similaire.
Points clés
Thread Pool (pool de threads) — est un modèle architectural dans lequel un nombre fixe de threads est créé à l'avance et réutilisé pour exécuter plusieurs tâches. Au lieu de créer un nouveau thread pour chaque opération (ce qui est coûteux : environ 1 Mo de pile par thread dans la JVM), les tâches sont placées dans une file d'attente et exécutées par les threads disponibles du pool. Dans le développement mobile, le thread pool est critique pour les performances — Android et iOS limitent le nombre de threads par application.
Créer un thread est une opération coûteuse : allocation de pile, enregistrement système, changement de contexte. Sur les appareils mobiles aux ressources limitées, la création incontrôlée de threads conduit à OOM (OutOfMemoryError) sous Android et à un étranglement sous iOS. Thread Pool résout les deux problèmes : il limite le nombre maximum de threads s'exécutant simultanément et réutilise les threads déjà créés. Google recommande ExecutorService plutôt que raw Thread(), Apple recommande OperationQueue plutôt que Thread.
| Paramètre | Sans pool (raw Thread) | Avec Thread Pool |
|---|---|---|
| Création de thread | Pour chaque tâche | Une fois à la création du pool |
| Maximum de threads | Illimité (risque OOM) | Limité par core/max pool size |
| Utilisation | Faible (le thread meurt après la tâche) | Élevée (le thread est réutilisé) |
| Gestion | Manuelle (join, interrupt) | Automatique (ExecutorService) |
| Consommation mémoire | Augmente avec chaque tâche | Fixe |
Le thread pool fonctionne sur le principe Producteur-Consommateur : les tâches (Runnable/Callable) sont placées dans une file d'attente bloquante (BlockingQueue). Les threads du pool attendent les tâches dans la file et les récupèrent pour exécution. Algorithme : si le nombre de threads libres est inférieur à corePoolSize, un nouveau thread est créé. Si corePoolSize est atteint, la tâche est placée dans la file. Si la file est pleine et que le nombre de threads est inférieur à maximumPoolSize, un thread supplémentaire est créé. Si maximumPoolSize est dépassé, la tâche est rejetée via RejectedExecutionHandler.
Core pool size — le nombre de threads qui sont conservés dans le pool même inactifs. Maximum pool size — le nombre maximum de threads pouvant être créés lors du débordement de la file. La différence entre eux correspond aux threads supplémentaires (overflow) créés temporairement et terminés après le délai d'inactivité. Sur les appareils mobiles, il est recommandé de définir corePoolSize égal à maximumPoolSize pour éviter les pics de charge liés à la création de threads.
BlockingQueue stocke les tâches en attente d'exécution. Les implémentations les plus populaires : LinkedBlockingQueue (illimitée), ArrayBlockingQueue (limitée) et SynchronousQueue (sans stockage — la tâche est transmise directement à un thread). Lorsque la file et le pool sont pleins, RejectedExecutionHandler est déclenché. Les politiques standard : AbortPolicy (lève RejectedExecutionException), CallerRunsPolicy (exécute dans le thread de l'appelant), DiscardPolicy et DiscardOldestPolicy.
// Création de Thread Pool sous Android
val threadPool = ThreadPoolExecutor(
corePoolSize = 2, // Minimum 2 threads
maximumPoolSize = 4, // Maximum 4 threads
keepAliveTime = 30L, // Durée de vie du thread de débordement
unit = TimeUnit.SECONDS,
workQueue = LinkedBlockingQueue<Runnable>(16),
threadFactory = Executors.defaultThreadFactory(),
handler = ThreadPoolExecutor.CallerRunsPolicy()
)
// Soumission des tâches
threadPool.execute {
val result = api.fetchData()
runOnUiThread { showData(result) }
}
// Arrêt du pool
threadPool.shutdown()
// Attente de la fin de toutes les tâches
threadPool.awaitTermination(10, TimeUnit.SECONDS)
Android fournit plusieurs implémentations de thread pool via java.util.concurrent. Executors — une fabrique avec des configurations prêtes à l'emploi : newFixedThreadPool(n) (pool fixe), newCachedThreadPool() (illimité, threads créés selon les besoins), newSingleThreadExecutor() (thread unique — exécution séquentielle). Pour les projets mobiles, newFixedThreadPool avec une limite raisonnable (2-4 threads) est recommandé, car le pool mis en cache peut créer trop de threads.
ThreadPoolExecutor (TPE) — une implémentation complète d'ExecutorService avec des paramètres configurables. Sous Android, TPE est utilisé à l'intérieur d'AsyncTask, IntentService et JobIntentService. Les paramètres corePoolSize, maximumPoolSize, keepAliveTime, BlockingQueue et RejectedExecutionHandler permettent d'affiner le comportement du pool. Recommandations pour Android : corePoolSize = nombre de cœurs CPU - 1 (pour les tâches IO-bound) ou nombre de cœurs (pour les tâches CPU-bound). Pour les applications typiques : 2-4 threads.
// Configurations prêtes d'Executors
// 1. Pool fixe de 3 threads
val fixedPool = Executors.newFixedThreadPool(3)
// 2. Pool mis en cache (non recommandé pour mobile)
val cachedPool = Executors.newCachedThreadPool()
// 3. Thread unique (sérialisation)
val singlePool = Executors.newSingleThreadExecutor()
// 4. Planificateur (tâches périodiques)
val scheduler = Executors.newScheduledThreadPool(2)
// Utilisation avec Callable et Future
val future: Future<String> = fixedPool.submit(Callable {
"Result: ${api.call()}"
})
// Obtention du résultat (bloque le thread)
val result = future.get(5, TimeUnit.SECONDS)
// Arrêt du pool
fixedPool.shutdownNow()
Les coroutines Kotlin fournissent CoroutineDispatcher — une abstraction similaire au thread pool. Dispatchers.IO utilise un pool de 64 threads (limité). Dispatchers.Default — un pool égal au nombre de cœurs CPU. CoroutineDispatcher ne nécessite pas d'arrêt manuel et est géré automatiquement. Pour un réglage fin, créez un ExecutorCoroutineDispatcher personnalisé via Executors.newFixedThreadPool(2).asCoroutineDispatcher(). Les coroutines ne remplacent pas le thread pool, mais l'encapsulent.
iOS fournit deux mécanismes principaux pour gérer le thread pool : OperationQueue (API de haut niveau basée sur GCD) et GCD DispatchQueue (API de bas niveau en C). OperationQueue encapsule un thread pool via la propriété maxConcurrentOperationCount. Par défaut, OperationQueue utilise un maximum défini par le système (dépend de la charge système). DispatchQueue.global() fournit une file d'attente concurrente avec un pool de threads système.
OperationQueue gère le pool de threads via maxConcurrentOperationCount. La valeur 1 crée une file d'attente sérialisée (analogue à un pool à thread unique). Une valeur supérieure à 1 crée un pool concurrent avec la limite spécifiée. Par défaut, maxConcurrentOperationCount = NSOperationQueueDefaultMaxConcurrentOperationCount (optimum système, généralement 4-8 threads). Operation prend en charge les dépendances, les priorités et l'annulation. Chaque opération s'exécute sur n'importe quel thread disponible du pool système.
// OperationQueue avec un pool de 3 threads
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 3
queue.qualityOfService = .utility
// Création d'opérations
let operation1 = BlockOperation {
let data = fetchData(from: url1)
DispatchQueue.main.async { updateUI(data) }
}
let operation2 = BlockOperation {
let data = fetchData(from: url2)
DispatchQueue.main.async { updateUI(data) }
}
// Dépendance : operation2 attend operation1
operation2.addDependency(operation1)
// Ajout à la file
queue.addOperations([operation1, operation2], waitUntilFinished: false)
// Annulation de toutes les opérations
queue.cancelAllOperations()
DispatchQueue — est le pool de threads d'Apple. Une file d'attente concurrente (qos: .utility) utilise le pool de threads système, optimisé pour la charge actuelle de l'appareil. Différents niveaux de QoS (userInteractive, userInitiated, utility, background) correspondent à différents pools avec différentes priorités. DispatchGroup permet de synchroniser plusieurs tâches. DispatchWorkItem prend en charge l'annulation et qualityOfService. Pour un contrôle précis, créez des files d'attente concurrentes personnalisées via DispatchQueue(label: qos: attributes: .concurrent).
// GCD DispatchQueue comme thread pool
let customQueue = DispatchQueue(
label: "com.app.background",
qos: .utility,
attributes: .concurrent,
autoreleaseFrequency: .workItem
)
// Soumission des tâches au pool
customQueue.async { self.processFile(file1) }
customQueue.async { self.processFile(file2) }
// DispatchGroup pour synchronisation
let group = DispatchGroup()
let pool = DispatchQueue.global(qos: .utility)
pool.async(group: group) { fetchData() }
pool.async(group: group) { processImage() }
group.notify(queue: .main) {
self.showResult() // Les deux tâches terminées
}
// Limitation de la concurrence via sémaphore
let semaphore = DispatchSemaphore(value: 3)
for url in urls {
pool.async {
semaphore.wait()
download(url)
semaphore.signal()
}
}
La configuration du thread pool affecte directement les performances de l'application. Des paramètres incorrects entraînent une sous-utilisation du CPU (trop peu de threads) ou une surcharge du système (trop nombreux). Pour les applications mobiles, les valeurs optimales diffèrent de celles du côté serveur en raison des ressources limitées et de la consommation d'énergie. Les principaux paramètres sont : corePoolSize, maxPoolSize, capacité de la file d'attente et keepAliveTime.
Formule pour les tâches IO-bound : corePoolSize = nombre de cœurs CPU × 2 (les threads attendent les E/S). Pour les tâches CPU-bound : corePoolSize = nombre de cœurs CPU (les threads sont constamment occupés par des calculs). Sur les appareils mobiles modernes (6-8 cœurs), cela donne 6-8 threads pour le CPU-bound et 12-16 pour l'IO-bound. Les tests pratiques montrent que pour une application mobile typique, 3-4 threads sont optimaux — plus de threads augmentent la consommation d'énergie sans gain de performances.
La taille de la file d'attente de travail (work queue) détermine combien de tâches peuvent attendre leur exécution. File illimitée (LinkedBlockingQueue sans limite) peut entraîner un OOM en cas d'arrivée rapide de tâches. File limitée (ArrayBlockingQueue de taille fixe) rejette les tâches lorsqu'elle est pleine. Pour les applications mobiles, ArrayBlockingQueue d'une capacité de 16-32 tâches est recommandée. CallerRunsPolicy est le meilleur RejectedExecutionHandler pour les mobiles : il ralentit l'appelant (contre-pression) plutôt que de perdre la tâche.
| Paramètre | Recommandation mobile | Justification |
|---|---|---|
| corePoolSize | 2-4 | Ressources limitées de l'appareil mobile |
| maxPoolSize | corePoolSize (ou +1-2) | Éviter les pics de charge de création de threads |
| keepAliveTime | 15-30 secondes | Libération rapide de mémoire sans création fréquente |
| Capacité de file | 16-32 | Équilibre entre mise en mémoire tampon et risque OOM |
| Handler | CallerRunsPolicy | Contre-pression sans perte de tâches |
Les développeurs d'applications mobiles commettent souvent des erreurs lors de l'utilisation du thread pool qui entraînent des plantages, des fuites mémoire et un fonctionnement instable. Les plus courantes : ne pas appeler shutdown() pour ExecutorService, créer un nouveau pool pour chaque opération, un pool trop grand, un deadlock entre tâches, utiliser CachedThreadPool sous Android.
Un deadlock se produit lorsqu'une tâche dans le pool attend le résultat d'une autre tâche du même pool, mais que tous les threads sont occupés à attendre. Exemple : la tâche A soumet la tâche B au même pool et appelle future.get() — si le pool est épuisé, la tâche A attend la tâche B, et la tâche B ne peut pas s'exécuter car il n'y a pas de threads libres. Solution : utilisez des pools séparés pour différents niveaux de tâches ou des callbacks asynchrones au lieu de .get() bloquant.
// Deadlock dans Thread Pool
val pool = Executors.newFixedThreadPool(1)
// La tâche A attend la tâche B — deadlock !
val futureA = pool.submit {
// Cette tâche ne s'exécutera jamais
val futureB = pool.submit { 42 }
futureB.get() // Se bloque pour toujours
}
// Solution : pools séparés
val workerPool = Executors.newFixedThreadPool(2)
val callbackPool = Executors.newSingleThreadExecutor()
workerPool.submit {
callbackPool.submit {
// Exécuté dans un pool séparé — deadlock impossible
}
}
// Ou utilisez CompletableFuture
workerPool.submit {
CompletableFuture
.supplyAsync { 42 }
.thenAccept { result ->
println(result)
}
}
Un ExecutorService créé dans une Activity doit être arrêté dans onDestroy(). Si cela n'est pas fait, les threads resteront en mémoire même après la destruction de l'Activity. Solution : conservez le pool dans la portée de l'Application ou du ViewModel, pas dans l'Activity. Pour les coroutines, utilisez viewModelScope ou lifecycleScope. Si le pool est créé dans une Activity, assurez-vous d'appeler pool.shutdown() dans onDestroy(). Pour les tests, utilisez es.shutdownNow() pour un arrêt immédiat.
Questions fréquentes
Thread Pool réutilise des threads déjà créés pour exécuter plusieurs tâches. Un thread normal (raw Thread) est créé, exécute une tâche et est détruit. La création d'un thread prend environ 1 Mo de mémoire et ~1 ms de temps. Thread Pool réduit les frais généraux, limite le nombre maximum de threads et fournit une API de gestion (shutdown, awaitTermination).
Pour une application mobile typique, 2-4 threads est optimal. Pour les tâches CPU-bound — le nombre de cœurs CPU. Pour les tâches IO-bound — nombre de cœurs × 2. Plus de threads augmentent la consommation d'énergie et les changements de contexte sans gain de performances. Sous Android, utilisez Process.availableProcessors() pour déterminer les cœurs. Sous iOS — ProcessInfo.processInfo.processorCount.
CachedThreadPool crée des threads selon les besoins et réutilise les existants. Le problème : il ne limite pas le nombre maximum de threads. Si 100 tâches arrivent simultanément, 100 threads seront créés. Cela entraîne un OOM sous Android (chaque thread ~1 Mo). Utilisez newFixedThreadPool(n) avec une limite explicite. CachedThreadPool n'est acceptable que pour des tâches courtes en rafale avec un volume réduit garanti.
Oui, si le pool n'appartient pas à un conteneur géré (comme les coroutines). shutdown() arrête l'acceptation de nouvelles tâches et termine les threads après l'achèvement des tâches en cours. Sans shutdown(), les threads restent en mémoire et l'application ne se termine pas. Pour une Activity, appelez-le dans onDestroy(). Pour un ViewModel, utilisez coroutineScope. L'arrêt du pool est une partie obligatoire de la gestion des ressources, similaire à la fermeture d'un Cursor ou d'un InputStream.
Oui, OperationQueue et DispatchQueue sont des pools de threads fournis par iOS. OperationQueue limite la concurrence via maxConcurrentOperationCount. DispatchQueue.global() utilise le pool de threads système sans contrôle direct. Contrairement à Java ThreadPoolExecutor, vous ne gérez pas corePoolSize ni la capacité de la file — le système optimise le pool automatiquement en fonction de la charge actuelle et de la consommation d'énergie de l'appareil.
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.
Lisez aussi