CodeGym /Cours /JAVA 25 SELF /Synchroniseurs de haut niveau

Synchroniseurs de haut niveau

JAVA 25 SELF
Niveau 58 , Leçon 2
Disponible

1. CountDownLatch: départ sur signal

Dans le monde multithread, il faut souvent organiser le travail coordonné d’un groupe de threads — pour que tous commencent, finissent ou passent ensemble à l’étape suivante. Par exemple :

Imaginez une course. Les voitures sont sur la ligne de départ — certaines ont déjà chauffé le moteur, d’autres vérifient encore les pneus. Mais tant que l’arbitre n’agite pas le drapeau, personne ne bouge. C’est exactement un problème de coordination.

Autre exemple : vous préparez le dîner avec des amis — quelqu’un coupe les légumes, quelqu’un met l’eau à chauffer, quelqu’un cherche où est passée le sel. L’essentiel est que tout le monde termine la préparation avant de commencer à cuisiner.

Pour ces cas, Java nous offre des outils de synchronisation prêts à l’emploi — sûrs, compréhensibles et sans la douleur de wait() et notify(). L’un des plus utiles est CountDownLatch. Il fonctionne comme un verrou-compteur : tant qu’il n’est pas à zéro, la « porte » est fermée et personne n’avance. Et quand tout le monde s’est signalé — le latch s’ouvre et les threads partent en chœur.

CountDownLatch

CountDownLatch — c’est une « vanne à usage unique » qui permet à un ou plusieurs threads d’attendre que d’autres threads terminent un certain nombre d’opérations.

C’est comme le départ d’un marathon : tous les coureurs sont sur la ligne, ils attendent le coup de pistolet. Dès que l’arbitre tire (le compteur atteint zéro) — tous s’élancent.

Comment ça marche, au juste

CountDownLatch — c’est comme un coup de sifflet de départ pour les threads. À la création, vous donnez un nombre — par exemple, 3. Ce sont comme trois signaux à recevoir avant que la course ne commence.

Les threads qui doivent attendre le départ appellent await(). Ils sont sur la ligne et prêts à bondir, mais maintiennent encore les freins. Les autres threads, en effectuant la préparation, appellent au fur et à mesure countDown() — comme s’ils envoyaient le signal « Je suis prêt ! ».

Dès que le compteur atteint zéro — boum ! — tous les threads en attente démarrent simultanément.

Mais retenez bien : CountDownLatch est à usage unique. Une fois que le compteur est arrivé à zéro, on ne peut pas revenir en arrière. Ce n’est pas un revolver, mais un pétard : il a claqué — et c’est tout.

Exemple : attendre l’achèvement de N tâches

import java.util.concurrent.CountDownLatch;

public class LatchDemo {
    public static void main(String[] args) throws InterruptedException {
        int workers = 3;
        CountDownLatch latch = new CountDownLatch(workers);

        for (int i = 1; i <= workers; i++) {
            int id = i;
            new Thread(() -> {
                System.out.println("Travailleur " + id + " a commencé le travail");
                try { Thread.sleep(500 + id * 200); } catch (InterruptedException ignored) {}
                System.out.println("Travailleur " + id + " a terminé le travail");
                latch.countDown(); // on décrémente le compteur
            }).start();
        }

        System.out.println("Le thread principal attend la fin de tous les travailleurs...");
        latch.await(); // on attend que tous les travailleurs terminent
        System.out.println("Tous les travailleurs ont terminé ! On continue le travail principal.");
    }
}

Sortie :

Le thread principal attend la fin de tous les travailleurs...
Travailleur 1 a commencé le travail
Travailleur 2 a commencé le travail
Travailleur 3 a commencé le travail
Travailleur 1 a terminé le travail
Travailleur 2 a terminé le travail
Travailleur 3 a terminé le travail
Tous les travailleurs ont terminé ! On continue le travail principal.

Exemple : départ simultané « sur signal »

CountDownLatch startSignal = new CountDownLatch(1);

for (int i = 0; i < 5; i++) {
    new Thread(() -> {
        try {
            System.out.println(Thread.currentThread().getName() + " attend le départ");
            startSignal.await(); // on attend le signal
            System.out.println(Thread.currentThread().getName() + " démarre !");
        } catch (InterruptedException ignored) {}
    }).start();
}

Thread.sleep(1000);
System.out.println("Signal de départ !");
startSignal.countDown(); // tous les threads démarrent simultanément

2. CyclicBarrier: phases multiples, actions de barrière

CyclicBarrier : rendez-vous au feu de camp

CyclicBarrier — c’est le point de rendez-vous des threads. Chacun suit sa route, fait ses tâches, puis tous se retrouvent à la « barrière » — comme autour d’un feu de camp en montagne. Quand tout le monde est là, la barrière s’ouvre et le groupe repart ensemble.

La différence principale avec CountDownLatch — cette barrière peut être utilisée encore et encore. Après chaque arrêt collectif, elle se « recharge » et l’équipe peut continuer vers l’étape suivante.

Imaginez : Un groupe de randonneurs suit un long itinéraire. Chacun avance à son rythme : l’un photographie des papillons, l’autre cherche du Wi‑Fi. Mais à chaque col, ils se retrouvent au feu de camp, s’attendent et décident de la suite. Voilà CyclicBarrier en action.

Comment ça fonctionne

Vous créez une barrière et indiquez combien de participants doivent se rassembler, par exemple 4. Chaque thread, arrivé au point de contrôle, appelle await() — et attend les autres. Quand les quatre sont là, la barrière « clique » et libère tout le monde.

On peut même définir une « action de barrière » — un morceau de code exécuté une seule fois lorsque le groupe est réuni. Par exemple, allumer ce fameux feu de camp ou écrire un log : « Étape terminée, on continue ». Pour cela, on passe un Runnable au constructeur.

Important : contrairement à CountDownLatch qui est à usage unique, CyclicBarrier est réutilisable. Après chaque « rassemblement », elle est de nouveau prête pour l’étape suivante — comme un feu de camp que l’on peut rallumer encore et encore.

Exemple : synchroniser des phases

import java.util.concurrent.CyclicBarrier;

public class BarrierDemo {
    public static void main(String[] args) {
        int parties = 3;
        CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
            System.out.println("Tout le monde est arrivé à la barrière ! Nous commençons une nouvelle phase.");
        });

        for (int i = 1; i <= parties; i++) {
            int id = i;
            new Thread(() -> {
                try {
                    System.out.println("Le thread " + id + " travaille dans la phase 1");
                    Thread.sleep(300 + id * 200);
                    System.out.println("Le thread " + id + " attend la barrière");
                    barrier.await(); // on attend les autres

                    System.out.println("Le thread " + id + " travaille dans la phase 2");
                    Thread.sleep(200 + id * 100);
                    System.out.println("Le thread " + id + " attend la barrière (2)");
                    barrier.await(); // on attend à nouveau

                    System.out.println("Le thread " + id + " a terminé son travail");
                } catch (Exception e) {
                    System.out.println("Erreur : " + e);
                }
            }).start();
        }
    }
}

Sortie :

Le thread 1 travaille dans la phase 1
Le thread 2 travaille dans la phase 1
Le thread 3 travaille dans la phase 1
Le thread 1 attend la barrière
Le thread 2 attend la barrière
Le thread 3 attend la barrière
Tout le monde est arrivé à la barrière ! Nous commençons une nouvelle phase.
Le thread 1 travaille dans la phase 2
...

Action de barrière

On peut passer au constructeur de CyclicBarrier une action (Runnable) qui s’exécutera une fois lorsque tous les threads auront atteint la barrière (par exemple, mettre à jour l’état, écrire un log).

Pièges : que se passe-t-il si un thread tombe en panne ?

Si l’un des threads lance une exception ou n’atteint pas la barrière, les autres attendront indéfiniment — ou recevront une BrokenBarrierException. La barrière se « casse » et il faut la recréer.

Voici comment on peut réécrire cette section de manière plus vivante, imagée et conversationnelle — pour qu’elle sonne comme la suite naturelle de la ligne « orchestre » :

3. Phaser: un chef d’orchestre habile pour un grand concert

Phaser — c’est une sorte de « super-barrière ». Il combine les meilleurs aspects de CountDownLatch et de CyclicBarrier, tout en étant bien plus flexible. C’est comme un orchestre où les musiciens peuvent entrer et sortir entre les parties du concert, et le chef veille quand même à ce que chaque partie commence quand tout le monde est prêt.

Contrairement à une barrière classique, Phaser travaille par étapes — les phases s’enchaînent les unes après les autres. Certains ne jouent que dans la première partie, d’autres rejoignent plus tard, d’autres encore partent plus tôt — tout cela, Phaser le gère sereinement.

Comment cela fonctionne

On crée d’abord un Phaser, généralement avec un nombre de participants défini — parties. Chaque thread s’enregistre (register()), exécute sa partie et, à la fin de la phase, appelle arriveAndAwaitAdvance() — il signale qu’il a terminé et attend les autres. Quand tout le monde est arrivé à ce point, le Phaser passe à la phase suivante et le processus se répète.

Si un participant n’est plus nécessaire — il peut faire un « salut » élégant et quitter la scène via arriveAndDeregister(). À l’inverse, de nouveaux participants peuvent rejoindre en plein concert — via register().

Quand Phaser est meilleur que Barrier

Phaser est à privilégier si votre programme ne vit pas à un seul rythme, mais à plusieurs :

  • le nombre de threads change à la volée,
  • il y a plusieurs étapes et tous les participants ne sont pas obligés de participer à chacune,
  • ou vous souhaitez simplement une flexibilité maximale sans se débattre avec une synchronisation manuelle.

En substance, Phaser — c’est un chef d’orchestre. Il ne fait pas que battre la mesure, il s’adapte aussi à la composition de l’orchestre, au nombre de parties du concert et même au fait que quelqu’un soit en retard ou parte plus tôt.

Exemple : traitement par étapes avec un nombre dynamique de threads

import java.util.concurrent.Phaser;

public class PhaserDemo {
    public static void main(String[] args) {
        Phaser phaser = new Phaser(1); // thread principal

        for (int i = 1; i <= 3; i++) {
            phaser.register(); // on enregistre un participant
            int id = i;
            new Thread(() -> {
                for (int phase = 1; phase <= 2; phase++) {
                    System.out.println("Le thread " + id + " travaille dans la phase " + phase);
                    try { Thread.sleep(200 + id * 100); } catch (InterruptedException ignored) {}
                    phaser.arriveAndAwaitAdvance(); // on attend les autres
                }
                System.out.println("Le thread " + id + " a terminé son travail");
                phaser.arriveAndDeregister(); // on se désinscrit du phaser
            }).start();
        }

        // Le thread principal participe aussi aux phases
        for (int phase = 1; phase <= 2; phase++) {
            phaser.arriveAndAwaitAdvance();
            System.out.println("Thread principal : phase " + phase + " terminée");
        }
        phaser.arriveAndDeregister();
        System.out.println("Toutes les phases sont terminées !");
    }
}

Particularités :

  • On peut ajouter/supprimer des participants à la volée.
  • On peut connaître le numéro de la phase courante : phaser.getPhase().
  • On peut terminer le phaser : phaser.forceTermination().

4. Exchanger: échanger des blocs de données entre threads

Exchanger<T> — c’est un synchroniseur pour échanger des données entre deux threads. Chaque thread appelle exchange(data), et lorsque les deux se rencontrent, ils échangent leurs données.

Analogie : Deux coursiers se rencontrent à un carrefour et échangent leurs colis.

Comment ça marche ?

  • Un thread appelle exchange(data1) — il attend le second.
  • Le second thread appelle exchange(data2) — tous deux reçoivent les données de l’autre.
  • Si le second thread n’arrive pas — le premier attend (on peut fixer un timeout).

Exemple : échange de buffers entre producer et consumer

import java.util.concurrent.Exchanger;

public class ExchangerDemo {
    public static void main(String[] args) {
        Exchanger<String> exchanger = new Exchanger<>();

        // Producer
        new Thread(() -> {
            String data = "Données du producer";
            try {
                System.out.println("Producer: envoie des données");
                String response = exchanger.exchange(data);
                System.out.println("Producer: a reçu la réponse : " + response);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        // Consumer
        new Thread(() -> {
            try {
                String received = exchanger.exchange("Réponse du consumer");
                System.out.println("Consumer: a reçu les données : " + received);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

Sortie :

Producer: envoie des données
Consumer: a reçu les données : Données du producer
Producer: a reçu la réponse : Réponse du consumer

Cas d’usage :

  • Échange de buffers entre threads (par exemple, l’un lit depuis un fichier, l’autre écrit sur le réseau).
  • Synchronisation de phases entre deux threads.

5. Pratique: pipeline parallèle

Tâche : « tick » de jeu (phases)

Supposons que nous ayons plusieurs threads, chacun responsable d’une partie du monde de jeu (par exemple, physique, IA, rendu). Tous doivent se synchroniser à chaque « tick » (phase) pour éviter toute désynchronisation.

Solution : Utiliser CyclicBarrier ou Phaser.

import java.util.concurrent.CyclicBarrier;

public class GameTickDemo {
    public static void main(String[] args) {
        int subsystems = 3;
        CyclicBarrier barrier = new CyclicBarrier(subsystems, () -> {
            System.out.println("Tous les sous-systèmes ont terminé le tick. On commence le suivant.");
        });

        for (int i = 1; i <= subsystems; i++) {
            int id = i;
            new Thread(() -> {
                for (int tick = 1; tick <= 5; tick++) {
                    System.out.println("Le sous-système " + id + " travaille au tick " + tick);
                    try { Thread.sleep(100 + id * 50); } catch (InterruptedException ignored) {}
                    try {
                        barrier.await();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }
}

Tâche : « vanne » pour un grand nombre de workers

Supposons que nous ayons 100 threads workers qui doivent démarrer simultanément après préparation (par exemple, un test de charge).

Solution : Utiliser CountDownLatch.

import java.util.concurrent.CountDownLatch;

public class MassStartDemo {
    public static void main(String[] args) throws InterruptedException {
        int workers = 100;
        CountDownLatch ready = new CountDownLatch(workers);
        CountDownLatch start = new CountDownLatch(1);

        for (int i = 0; i < workers; i++) {
            new Thread(() -> {
                System.out.println("Le thread est prêt au départ");
                ready.countDown(); // on signale l'état prêt
                try {
                    start.await(); // on attend le signal général
                    System.out.println("Le thread démarre !");
                } catch (InterruptedException ignored) {}
            }).start();
        }

        ready.await(); // on attend que tous les threads soient prêts
        System.out.println("Tous prêts ! DÉPART !");
        start.countDown(); // on donne le signal de départ
    }
}

6. Erreurs typiques avec les synchroniseurs

Erreur n° 1 : utiliser CountDownLatch comme barrière réutilisable.
CountDownLatch est à usage unique ! Une fois à zéro, on ne peut pas le « recharger ». Pour des phases réutilisables, utilisez CyclicBarrier ou Phaser.

Erreur n° 2 : exceptions non traitées (InterruptedException, BrokenBarrierException).
Les méthodes await() peuvent lancer des exceptions — traitez-les toujours, sinon un thread peut « se bloquer » ou se terminer en erreur. Surveillez InterruptedException et BrokenBarrierException.

Erreur n° 3 : un des threads n’atteint pas la barrière.
Si un thread « tombe » ou n’appelle pas await(), les autres attendront indéfiniment (ou recevront une BrokenBarrierException). Assurez-vous que tous les participants atteignent la barrière.

Erreur n° 4 : oubli de deregister() avec Phaser.
Si un thread a terminé son travail mais n’appelle pas arriveAndDeregister(), le Phaser attendra un participant « mort ». Retirez toujours correctement les threads du Phaser.

Erreur n° 5 : utiliser Exchanger pour plus de deux threads.
Exchanger ne fonctionne que pour l’échange entre deux threads. S’il y en a plus — deadlock assuré.

Erreur n° 6 : mélanger différents synchroniseurs sans comprendre leur fonctionnement.
Évitez d’utiliser simultanément plusieurs barrières/latches différents pour le même groupe de threads — cela peut conduire à la confusion et aux blocages.

1
Mission
JAVA 25 SELF, niveau 58, leçon 2
Bloqué
Symphonie des flux : Première théâtrale 🎭
Symphonie des flux : Première théâtrale 🎭
1
Mission
JAVA 25 SELF, niveau 58, leçon 2
Bloqué
Échange Secret: Opération "Courrier Instantané" 🕵️
Échange Secret: Opération "Courrier Instantané" 🕵️
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION