Lorsque ThreadPoolExecutor saturent ses threads principaux et sa file bornée, CallerRunsPolicy délègue la tâche rejetée au fil de soumission pour une exécution immédiate. Si ce fil de soumission a invoqué Future.get() pour attendre synchroniquement le résultat de la tâche qu'il vient de soumettre, et que la logique de la tâche soumise soumet en interne des tâches supplémentaires au même exécuteur et attend leur achèvement, un attente circulaire en résulte.
Le fil de soumission ne peut pas revenir de get() tant que sa tâche ne se termine pas, mais la tâche ne peut pas se terminer car elle attend des sous-tâches qui restent en queue derrière elle. Aucun fil de travail n'est disponible pour vider la file car tous sont occupés avec d'autres tâches. Cela bloque effectivement le soumissionnaire, car il est à la fois le seul fil capable d'exécuter les sous-tâches en attente (via la politique) et simultanément bloqué en attendant leur achèvement.
Nous avons rencontré cela dans un pipeline de traitement de documents distribué où un ThreadPoolExecutor avec CallerRunsPolicy gérait des tâches de rendu PDF. Chaque tâche de document analysait des métadonnées et générait des sous-tâches pour l'extraction d'images, puis appelait immédiatement Future.get() sur ces sous-tâches pour assembler le résultat final.
Sous forte charge, la file s'est saturée, déclenchant CallerRunsPolicy pour exécuter la tâche de document dans le fil de gestion de requêtes web. Ce fil a ensuite soumis des tâches d'extraction d'images et s'est bloqué sur get(), mais tous les fils de travail étaient occupés avec d'autres documents. Les nouvelles sous-tâches étaient à la fin de la file, non assignées.
Le fil de gestion ne pouvait pas exécuter les sous-tâches car il était bloqué en attendant celles-ci, et les sous-tâches ne pouvaient pas s'exécuter car aucun fil n'était libre. Cela a créé un blocage auto-renforçant qui a paralysé le service jusqu'à ce qu'une intervention manuelle redémarre la JVM.
Le code suivant illustre le modèle dangereux :
ExecutorService executor = new ThreadPoolExecutor( 2, 2, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(2), new ThreadPoolExecutor.CallerRunsPolicy() ); // Soumis depuis le fil principal du gestionnaire de requêtes Future<?> parent = executor.submit(() -> { // Lorsque le pool est saturé, cela s'exécute dans le fil de gestion (CallerRunsPolicy) Future<?> child = executor.submit(() -> "image extraite"); // Le fil de gestion se bloque ici, attendant l'enfant // Mais l'enfant est en queue, et aucun fil de travail n'est libre // Le gestionnaire ne peut pas exécuter l'enfant car il est bloqué return child.get(); }); parent.get(); // Blocage : le fil de gestion attend éternellement
Nous avons évalué quatre solutions architecturales distinctes. La première approche a remplacé CallerRunsPolicy par AbortPolicy et mis en œuvre une boucle de tentatives de retour avec backoff exponentiel dans le client. Cela a préservé la disponibilité du fil appelant mais a introduit des échecs transitoires et une logique de nouvelle tentative complexe qui a compliqué les garanties d'idempotence.
La deuxième solution a élargi pour une LinkedBlockingQueue illimitée afin d'éviter toute saturation. Bien que cela ait éliminé le rejet, cela a risqué un OutOfMemoryError lors des pics de trafic et a masqué les signaux de contre-pression, entraînant une latence excessive plutôt qu'un échec explicite.
La troisième option a maintenu la file bornée mais a considérablement augmenté maximumPoolSize au-dessus de corePoolSize, s'appuyant sur la prolifération de threads pour absorber la charge. Cela a amélioré le débit au prix d'un commutement de contexte excessif et d'une consommation de mémoire, dégradant finalement les performances en raison de l'agitation du cache CPU.
La quatrième approche a restructuré le flux de travail en utilisant ExecutorCompletionService et des rappels asynchrones au lieu de Future.get() synchronisé. Cela a permis à la tâche de document originale de libérer le fil de travail lors de la soumission de la sous-tâche et de reprendre uniquement lorsque CompletionService a signalé l'achèvement.
Nous avons choisi la quatrième solution car elle découplait fondamentalement la soumission de l'achèvement. Cela a préservé la contre-pression de la file bornée tout en éliminant la condition d'attente circulaire, permettant aux fils de travail de se recycler pour traiter les sous-tâches pendant que la tâche originale attendait une notification sur une variable de condition légère.
Ce changement a résolu les blocages, réduit la latence moyenne de quarante pour cent, et maintenu des empreintes mémoire stables sous une charge de pointe sans sacrifier les sémantiques d'échec de la file bornée.
Pourquoi ThreadPoolExecutor refuse-t-il d'instancier des threads au-delà de corePoolSize lorsqu'il est configuré avec une BlockingQueue illimitée ?
L'exécuteur tente uniquement de créer de nouveaux threads lorsque execute() ne peut pas immédiatement remettre la tâche à un fil de travail en attente ou l'insérer dans la file. La méthode offer() d'une file illimitée ne retourne jamais false, donc l'exécuteur ne perçoit jamais de saturation et par conséquent n'alloue jamais de threads au-delà du nombre de base. Ce design suppose que mettre en file d'attente est préférable à la création de threads pour la gestion des ressources, mais cela crée un point aveugle où le pool semble sous-utilisé malgré un travail en attente. Les candidats supposent souvent incorrectement que maximumPoolSize agit comme un plafond dur indépendamment de la capacité de la file, ne réalisant pas que la borne de la file agit comme le gardien pour l'expansion des threads.
Comment CallerRunsPolicy fonctionne-t-il comme un mécanisme de contrôle de flux implicite plutôt que simplement un gestionnaire de rejet ?
En exécutant la tâche dans le fil de soumission, la politique force ce fil à ralentir son taux de soumission et à effectuer un travail, throttlant naturellement le flux entrant pour correspondre à la capacité de traitement du pool. Cette contre-pression se propage jusqu'à la pile d'appels au producteur original, le ralentissant sans code de limitation explicite. De nombreux candidats considèrent la politique uniquement comme une sécurité pour les tâches abandonnées, manquant qu'elle bloque intentionnellement le producteur pour éviter l'épuisement des ressources. Comprendre cette distinction sémantique est crucial pour concevoir des systèmes où la latence est préférable à un rejet complet en cas de pics de charge.
Quelle interaction subtile entre shutdown() et CallerRunsPolicy empêche une dégradation en douceur lors de la terminaison de l'exécuteur ?
Une fois que shutdown() est invoqué, l'exécuteur passe à un état où les nouvelles soumissions sont rejetées par RejectedExecutionException, contournant entièrement la politique de rejet configurée. Les candidats supposent souvent que CallerRunsPolicy continuerait à exécuter des tâches dans l'appelant pendant l'arrêt, mais l'exécuteur vérifie l'état d'arrêt avant de consulter la politique. Cela signifie que les tâches soumises pendant la phase d'arrêt en douceur échouent immédiatement plutôt qu'être exécutées par l'appelant, perdant potentiellement du travail en cours si le client ne gère pas l'exception. Un bon séquençage d'arrêt nécessite de vider la file via awaitTermination() ou de capturer les tâches rejetées dans une structure de secours, car le mécanisme de politique est désactivé une fois que le drapeau d'arrêt est défini.