Skip to content

Latest commit

 

History

History
293 lines (203 loc) · 17 KB

01-threads.md

File metadata and controls

293 lines (203 loc) · 17 KB

🔝 Retour au Sommaire

8.1 — Pourquoi la concurrence ; Thread, Runnable, cycle de vie

Chapitre 8 — Concurrence et parallélisme · Retour au chapitre
Prérequis : POO (chapitre 3), lambdas et interfaces fonctionnelles (chapitre 4).

Cette première section pose les fondations. Avant de parler de pools, de virtual threads ou de concurrence structurée, il faut comprendre ce qu'est un thread, comment en créer un, et comment il vit et meurt. C'est la brique de base sur laquelle tout le reste du chapitre est construit — même les abstractions modernes finissent par s'appuyer dessus.


Pourquoi se compliquer la vie ?

La concurrence ajoute de la complexité. Pourquoi l'accepter ? Parce qu'elle répond à trois besoins très concrets que le code séquentiel ne sait pas satisfaire.

1. La réactivité. Une interface graphique qui calcule pendant trois secondes se fige : plus aucun clic n'est pris en compte. Un serveur qui traite une requête à la fois fait patienter tous les autres clients. Déporter le travail long sur un autre thread permet de rester réactif pendant que le travail se fait ailleurs.

2. Le débit, surtout quand on attend. La plupart des applications passent l'essentiel de leur temps à attendre : une réponse réseau, un disque, une base de données. Prenons un programme qui interroge trois services web, chacun répondant en deux secondes. En séquentiel, on additionne les attentes : six secondes, pendant lesquelles le processeur ne fait pratiquement rien. En lançant les trois appels sur trois threads, les attentes se recouvrent : environ deux secondes au total. Même machine, même processeur, trois fois plus rapide — uniquement parce qu'on a cessé de rester inactif pendant les attentes.

3. Exploiter le matériel. Pour un travail réellement gourmand en calcul — agréger un million de lignes, traiter une image —, un seul cœur plafonne vite. Répartir le calcul sur les quatre, huit ou seize cœurs de la machine permet d'aller physiquement plus vite. Ce gain-là est plafonné par le nombre de cœurs disponibles (voir la distinction concurrence / parallélisme dans l'introduction du chapitre).

⚠️ La concurrence n'est jamais gratuite. Elle introduit des bugs subtils et non déterministes. On ne l'ajoute donc pas par réflexe : seulement lorsqu'un de ces trois besoins le justifie. Un programme séquentiel qui suffit est un bon programme.


Un processus, plusieurs threads

Quand on lance un programme Java, le système d'exploitation crée un processus : une instance isolée de la JVM, avec sa propre mémoire. À l'intérieur de ce processus vivent un ou plusieurs threads (fils d'exécution).

Le point essentiel à retenir : les threads d'un même processus partagent la mémoire (le tas, heap), mais chacun possède sa propre pile d'appels (stack). Ce partage de mémoire est à la fois la grande force de la concurrence — communiquer est immédiat — et sa principale source de danger : deux threads qui modifient la même donnée en même temps peuvent la corrompre. C'est tout l'objet des sections 8.6 et 8.7.

Tout programme Java démarre avec un thread déjà actif : le thread main. C'est lui qui exécute votre méthode main. La JVM fait aussi tourner discrètement des threads de service (ramasse-miettes, compilation JIT…).

public static void main(String[] args) {
    String nom = Thread.currentThread().getName();
    System.out.println("J'exécute dans le thread : " + nom); // affiche "main"
}

Créer un thread : Runnable et Thread

Un thread a besoin d'une chose : du code à exécuter. En Java, ce code se décrit avec l'interface Runnable, une interface fonctionnelle réduite à une seule méthode :

@FunctionalInterface
public interface Runnable {
    void run();
}

Comme c'est une interface fonctionnelle, on l'écrit le plus souvent avec une lambda. On confie ensuite ce Runnable à un objet Thread, puis on appelle start().

Runnable tache = () -> {
    System.out.println("Bonjour depuis " + Thread.currentThread().getName());
};

Thread t = new Thread(tache, "mon-thread"); // on nomme le thread : utile pour le débogage
t.start();                                   // lance l'exécution dans un NOUVEAU thread

Il existe une seconde manière, plus ancienne : étendre la classe Thread et redéfinir run().

class Salutation extends Thread {
    @Override
    public void run() {
        System.out.println("Bonjour depuis " + getName());
    }
}

new Salutation().start();

Préférez Runnable à l'héritage de Thread

Les deux fonctionnent, mais Runnable est nettement préférable, pour des raisons solides :

  • Séparation des responsabilités. Runnable décrit quoi faire (la tâche), Thread décrit comment l'exécuter (le mécanisme). En héritant de Thread, on mélange les deux.
  • Liberté d'héritage. Java n'autorise qu'un seul héritage de classe. Étendre Thread consomme cette unique cartouche ; implémenter Runnable la laisse libre.
  • Réutilisabilité. Un même Runnable peut être exécuté par un Thread, soumis à un ExecutorService (section 8.2), ou porté par un thread virtuel (section 8.4). La tâche n'est pas couplée à un mode d'exécution.

🔧 Astuce Eclipse. Nommez toujours vos threads (new Thread(tache, "import-csv")). Dans la vue Debug, des threads nommés sont infiniment plus lisibles que Thread-0, Thread-1… On y reviendra en section 8.8.


Le piège classique : start() ≠ run()

C'est l'erreur de débutant la plus fréquente en concurrence Java. Les deux méthodes existent et compilent, mais ne font pas du tout la même chose :

  • start() demande à la JVM de créer un nouveau thread, qui appellera run() en parallèle.
  • run() est une méthode ordinaire : l'appeler directement exécute le code dans le thread courant, sans aucune concurrence.
Thread t = new Thread(() ->
    System.out.println(Thread.currentThread().getName()));

t.run();   // ❌ affiche "main"     : exécution dans le thread courant, séquentielle
t.start(); // ✅ affiche "Thread-0" : exécution dans un nouveau thread

Retenez la règle simple : pour de la concurrence, on appelle toujours start(), jamais run().


L'API moderne : le Thread.Builder 🆕

Depuis Java 21, une fabrique fluide complète le constructeur historique. Elle prépare aussi le terrain pour les threads virtuels (section 8.4) en unifiant la création des deux familles.

// Thread « plateforme » (un vrai thread du système), via le builder
Thread t = Thread.ofPlatform()
                 .name("worker-", 0)        // worker-0, worker-1, ... (compteur)
                 .daemon(true)              // configuré avant le démarrage
                 .start(() -> travailler());

// Thread virtuel — léger, géré par la JVM (détaillé en 8.4)
Thread v = Thread.ofVirtual()
                 .name("vt-import")
                 .start(() -> importer());

// Raccourci équivalent
Thread.startVirtualThread(() -> importer());

Le new Thread(runnable) reste parfaitement valide et omniprésent dans le code existant : c'est la forme à connaître en premier. Le builder est l'idiome moderne et recommandé pour les nouveaux développements.


Le cycle de vie d'un thread

Au cours de sa vie, un thread passe par une série d'états bien définis, exposés par l'énumération Thread.State. Les comprendre est indispensable pour déboguer : la vue Debug d'Eclipse affiche précisément l'état de chaque thread.

stateDiagram-v2
    [*] --> NEW : new Thread(...)
    NEW --> RUNNABLE : start()
    RUNNABLE --> BLOCKED : attente d'un verrou (synchronized)
    BLOCKED --> RUNNABLE : verrou obtenu
    RUNNABLE --> WAITING : wait() / join() / park()
    WAITING --> RUNNABLE : notify() / thread joint terminé / unpark()
    RUNNABLE --> TIMED_WAITING : sleep(ms) / wait(ms) / join(ms)
    TIMED_WAITING --> RUNNABLE : délai écoulé ou réveil
    RUNNABLE --> TERMINATED : sortie de run()
    TERMINATED --> [*]
Loading

Les six états en détail :

  • NEW — le thread est créé mais pas encore démarré (start() n'a pas été appelé).
  • RUNNABLE — le thread est éligible à s'exécuter. Java ne distingue pas « en cours d'exécution » de « prêt mais en attente d'un cœur » : les deux sont RUNNABLE. C'est l'ordonnanceur du système qui décide quel thread occupe réellement un cœur à un instant donné.
  • BLOCKED — le thread est bloqué en attente d'acquérir un verrou de moniteur, c'est-à-dire l'entrée dans un bloc synchronized déjà détenu par un autre thread (voir 8.6).
  • WAITING — le thread attend indéfiniment qu'un événement se produise : Object.wait(), Thread.join() sans délai, ou LockSupport.park().
  • TIMED_WAITING — comme WAITING, mais avec un délai maximal : Thread.sleep(ms), wait(ms), join(ms).
  • TERMINATED — la méthode run() est terminée (normalement ou par exception). Un thread terminé ne peut pas être relancé : appeler start() une seconde fois lève une IllegalThreadStateException.

💡 Subtilité. L'état BLOCKED concerne spécifiquement les moniteurs synchronized. Les verrous de java.util.concurrent (par ex. ReentrantLock) reposent en réalité sur park() : un thread en attente sur un tel verrou apparaît donc en WAITING, pas en BLOCKED. Ce détail surprend lors du débogage ; on y revient en section 8.6.


Les méthodes essentielles

Quelques méthodes pilotent ce cycle de vie au quotidien :

  • start() — démarre le thread (NEW → RUNNABLE).
  • Thread.sleep(millis) (statique) — met en pause le thread courant pour la durée indiquée (TIMED_WAITING). Attention : sleep() ne libère aucun verrou détenu — contrairement à wait(). Elle peut lever InterruptedException.
  • join() / join(millis) — fait attendre le thread courant jusqu'à ce que le thread visé se termine. Indispensable pour récupérer un résultat avant de continuer.
  • interrupt() — envoie une demande d'interruption (voir ci-dessous).
  • isInterrupted() (d'instance) / Thread.interrupted() (statique, qui efface le drapeau) — testent l'état d'interruption.
  • isAlive() — true entre start() et la terminaison.
  • getName() / setName(), et threadId() (qui remplace getId(), déprécié depuis Java 19).
Thread t = new Thread(() -> chargerDonnees(), "chargeur");
t.start();
t.join();   // le thread main attend la fin de "chargeur"
System.out.println("Données chargées, on peut continuer.");

Arrêter un thread : l'interruption coopérative ⚠️

On ne peut pas — et on ne doit pas — tuer un thread de force. C'est un point crucial.

Les anciennes méthodes Thread.stop(), Thread.suspend() et Thread.resume() étaient prévues pour cela. Elles sont dépréciées depuis 1998 car intrinsèquement dangereuses (stop pouvait laisser des données à moitié modifiées) ou sujettes aux interblocages (suspend/resume). Depuis Java 20, elles lèvent purement et simplement UnsupportedOperationException : elles n'existent donc plus que comme un vestige du passé. Ne les utilisez jamais.

Le bon modèle est la coopération : on demande au thread de s'arrêter, et le thread vérifie régulièrement cette demande pour s'interrompre proprement, à un moment sûr. Deux mécanismes coexistent.

Avec le drapeau d'interruption (recommandé) :

class Travailleur implements Runnable {
    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()) {
            traiterUneUnite();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                // sleep() a consommé le drapeau : on le restaure...
                Thread.currentThread().interrupt();
                break; // ...puis on sort de la boucle proprement
            }
        }
        System.out.println("Arrêt propre du travailleur.");
    }
}

Thread t = new Thread(new Travailleur(), "travailleur");
t.start();
// ... plus tard, quand on veut l'arrêter :
t.interrupt(); // simple demande, pas un ordre brutal

⚠️ Ne jamais avaler une InterruptedException. Un catch vide masque la demande d'arrêt et rend le thread impossible à interrompre. La règle : soit on propage l'exception, soit on restaure le drapeau avec Thread.currentThread().interrupt(). Beaucoup de méthodes bloquantes (sleep, join, wait, les E/S…) lèvent cette exception : c'est le canal standard de l'interruption.

Avec un drapeau volatile (alternative, lorsque le travail ne bloque pas) :

class Service implements Runnable {
    private volatile boolean actif = true; // volatile : visible par tous les threads

    public void arreter() { actif = false; }

    @Override
    public void run() {
        while (actif) {
            traiterUneUnite();
        }
    }
}

Le mot-clé volatile garantit que la modification de actif est visible immédiatement par le thread qui boucle — un point de visibilité mémoire approfondi en section 8.6.


Threads démon, priorités et exceptions

Trois notions complémentaires, à connaître sans s'y attarder :

Threads démon (daemon). Un thread est soit utilisateur (par défaut), soit démon. La JVM s'arrête dès qu'il ne reste plus que des threads démon : ces derniers ne maintiennent pas l'application en vie. On les réserve aux tâches de fond accessoires (surveillance, nettoyage périodique). Le statut doit être fixé avant start() :

Thread veille = new Thread(this::surveiller, "veille");
veille.setDaemon(true);
veille.start();

Priorités. setPriority(1..10) n'est qu'une suggestion à l'ordonnanceur, au comportement variable selon le système d'exploitation. En pratique, on ne s'appuie jamais dessus pour la correction d'un programme.

Exceptions non rattrapées. Une exception qui s'échappe de run() termine ce thread-là (et non toute l'application), souvent silencieusement. Pour ne pas la perdre, on installe un gestionnaire :

Thread t = new Thread(() -> { throw new IllegalStateException("boom"); }, "fragile");
t.setUncaughtExceptionHandler((thread, ex) ->
    System.err.println(thread.getName() + " a échoué : " + ex.getMessage()));
t.start();

Les limites du thread « brut »

Manipuler des objets Thread à la main fonctionne pour comprendre les mécanismes, mais ne passe pas à l'échelle dans le vrai code :

  • créer un thread plateforme est coûteux (mémoire, ressource système) ; en lancer un par tâche devient vite intenable ;
  • on se retrouve à réécrire soi-même la mutualisation (pools), la gestion des erreurs, la récupération des résultats, la coordination… autant de code répétitif et propice aux bugs.

C'est exactement ce que résolvent les abstractions des sections suivantes. Le ExecutorService (8.2) sépare la soumission d'une tâche de la gestion des threads. Pour les charges dominées par les E/S, les threads virtuels (8.4) suppriment carrément le coût du thread, rendant le modèle « un thread par tâche » de nouveau viable.

En une phrase : dans le code de production, on crée rarement un Thread directement — on confie ses tâches à un exécuteur ou on utilise des threads virtuels. Mais on comprend ce qui se passe en dessous, et c'est l'objet de cette section.


À retenir

  • La concurrence sert à rester réactif, à augmenter le débit (surtout quand on attend des E/S) et à exploiter le multicœur — jamais à ajouter gratuitement.
  • Les threads d'un processus partagent la mémoire (tas) mais ont chacun leur pile : c'est la source à la fois de leur efficacité et de leurs dangers.
  • On décrit le travail avec Runnable (à préférer à l'héritage de Thread), puis on appelle start() — jamais run() directement.
  • Le Thread.Builder (Thread.ofPlatform() / Thread.ofVirtual(), depuis Java 21) est l'idiome moderne de création.
  • Un thread traverse les états NEW → RUNNABLE → (BLOCKED / WAITING / TIMED_WAITING) → TERMINATED ; un thread terminé ne se relance pas.
  • On n'interrompt jamais un thread de force (stop/suspend/resume lèvent UnsupportedOperationException depuis Java 20) : on pratique l'interruption coopérative via le drapeau d'interruption ou un booléen volatile, et on ne jamais avale une InterruptedException.

⬅️ Chapitre 8 — Introduction · Sommaire · 8.2 ExecutorService, pools de threads, Future ➡️

⏭ ExecutorService, pools de threads, Future