Les Threads

Cours 1


Concurrence

La concurrence intervient quand on exécute plusieurs morceaux de code en même temps.

La concurrence est utile, voire nécessaire, dans deux situations :

  • Votre code doit effectuer des appels I/O bloquants (requêtes BDD, ...)
  • Votre code effectue énormément de calculs et vous voulez exploiter tous les cœurs de votre CPU.

Processus vs. threads

L'orsque l'on exécute un programme comme Firefox ou Eclipse, l'OS créé un processus qui a sa propre mémoire ainsi qu'un fil d'exécution appelé thread.

Un processus peut contenir plusieurs threads.

Chaque thread :

  • possède sa propre pile d'exécution (là où résident les paramètres et les variables locales en Java)
  • et partage le tas (là où se trouvent les champs des objets en Java) avec les autres threads.

Tous ces threads s'exécutent en même temps et vont lire et écrire (les champs) dans la même zone mémoire.

C'est toute la problématique de ce cours !

Application mono-thread

Application multi-threads

Le scheduler demande l'exécution des différents threads sur les cœurs du CPU pour un certain laps de temps.

La classe java.lang.Thread

L'objet Java Thread existe avant et après le moment où le thread (le fil d'exécution) existe.

L'interface Runnable

Pour créer un thread, il faut uniquement le code qu'il doit exécuter.

Un code est un objet implémentant l'interface Runnable.

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

Création et démarrage d'un thread

  • Ancienne façon : on crée un Thread, puis on le démarre :
      Runnable runnable = ...
      Thread thread = new Thread(runnable);
      thread.start();
    

  • Nouvelle façon (la seule authorisée dans ce cours), depuis Java 19 :

    Thread.ofPlatform() est un builder qui va créer l'objet Thread, et c'est la méthode start() qui démarre le fil d'exécution sous-jacent.

      Runnable runnable = ...
      Thread thread = Thread.ofPlatform().start(runnable);
    

Création d'un Runnable

Il y a plusieurs façons de créer un Runnable :

  • créer un objet d'une classe implémentant Runnable ;
  • créer une lambda qui ne prend aucun argument et qui ne renvoie rien ;
  • utiliser une méthode existante ayant cette signature.

 

On pourrait, techniquement, hériter de la classe Thread, mais on ne le fait jamais.

Exemples de création d'un thread

public class Application {
	private static void myRun() {
		System.out.println("Hello Thread");
	}
	
	public static void main(String[] args) {
		Runnable runnable = () -> {
			System.out.println("Hello Thread");
		};

		var thread1 = Thread.ofPlatform().start(Application::myRun);

		var thread2 = Thread.ofPlatform().start(runnable);
	}	
}

Naissance d'un thread

Avant le thread :

Naissance d'un thread

Après le thread :

Démarrage vs. création

La création d'un objet Thread ne créé pas le fil d'exécution

La création du fil d'exécution se fait avec la méthode start() qui :

  • alloue une nouvelle pile,
  • puis demande la création d'un fil d'exécution (thread de l'OS) ;
  • le thread de l'OS exécute run() du Runnable ;
  • une fois l'exécution de run() finie, le thread de l'OS meurt.

L'objet Java Thread ne meurt que lorsqu'il est garbage collecté.

Le scheduler de l'OS

Comme il y a moins de cœurs de CPU que de threads potentiels, le scheduler de l'OS est chargé de trouver un cœur disponible pour exécuter chaque thread.

Un thread occupe un cœur :

  • jusqu'au prochain appel bloquant (e.g. entrée/sortie) ou,
  • au maximum pendant un quantum de temps.

Le scheduler essaye de donner le même temps de CPU à chaque thread, on dit que le scheduler est fair (équitable).

On ne contrôle rien :

  • ni l'ordre dans lequel les threads sont sélectionnés,
  • ni le temps pendant lequel ils vont s'exécuter.

Thread plateform vs. Thread virtuel

Les threads plateform sont schedulés par le scheduler de l'OS, ce qui requiert une commutation de contexte (context switch : userland / kernel).

Les threads virtuels (depuis Java 19) sont schedulés par le scheduler de la machine virtuelle Java ce qui réduit les problèmes de latence.

Remarque : c'est la même classe Java qui contrôle et manipule les threads plateform ou virtuels.

Pour un thread virtuel, on utilise le builder Thread.ofVirtual().

Runnable runnable = ...
Thread thread = Thread.ofVirtual().start(runnable);

Dans ce cours, on utilisera principalement des threads plateform, on reviendra sur les threads virtuels en fin de semestre.

Le scheduler de la JVM

Il créé par défaut autant de threads plateform que de cœurs et schedule les threads virtuels sur ces threads plateform.

Un thread virtuel occupe un thread plateform :

  • jusqu'au prochain appel bloquant (e.g. entrée/sortie) ou,
  • si un thread virtuel ne fait pas d'appel bloquant, alors un nouveau thread plateform est créé pour que le scheduler reste fair.

Comme avec le scheduler de l'OS, on ne contrôle rien.

Propriétés d'un thread

Pour des raisons de debuggage, on peut attribuer un nom avant la création du thread avec la méthode builder.name(String name).

var thread = Thread.ofPlatform().name("Listener thread").start(runnable);

On peut aussi utiliser setName(String name) sur le thread.

On peut récupérer l'objet Thread qui est en train d'exécuter notre code grâce à la méthode statique Thread.currentThread().

Thread.ofPlatform().name("Listener thread").start(() -> {
	var groot = Thread.currentThread();
	System.out.println("I am " + groot);
	System.out.println("My name is " + groot.getName());
});
I am Thread[#20,Listener thread,5,main]
My name is Listener thread

Exemple de scheduling

public static void main(String[] args) {
	var runnable = () -> {
		System.out.println(Thread.currentThread().getName());
	};

	Thread.ofPlatform().name("Thread 1").start(runnable);
	Thread.ofPlatform().name("Thread 2").start(runnable);

	System.out.println(Thread.currentThread().getName());
}

Que peut afficher ce programme (dans quel ordre) ?

Ce code peut afficher les noms des threads dans n'importe quel ordre.

Mort d'un thread

Il n'y a aucun moyen de tuer un thread. On verra plus tard qu'il est possible de lui demander (gentiment) de s'arrêter.


Un thread meurt quand sa méthode run se termine :

  • soit parce qu'on est arrivé à la fin de la méthode,
  • soit parce qu'on a fait un return,
  • soit parce qu'une exception a été levée.


On ne peut pas redémarrer un thread.

Arrêt de la JVM

La JVM (et donc votre programme) ne s'arrête que quand tous les threads (non-daemon) sont terminés.

La fin du thread main n'implique pas nécessairement l'arrêt de la JVM.

Thread.ofPlatform().start(() -> {
	for (;;) {
		System.out.println("Top");
	}
});

System.out.println("Fini");

Le thread main est terminé (on affiche "Fini"), mais la JVM ne s'arrête pas, on continue d'afficher "Top".

Thread Deamon

Un thread daemon est un thread dont l’exécution n’empêche pas la JVM de s'arrêter (comme celui du garbage collector).

Thread.ofPlatform().daemon().start(() -> {
	for (;;) {
		System.out.println("Top");
	}
});

System.out.println("Fini");

Le thread main est terminé (on affiche "Fini"), et la JVM s'arrête.

Attendre la fin d'un thread

Le thread courant peut attendre la mort d'un autre thread en utilisant la méthode join de cet autre thread (otherThread) :

otherThread.join();

Cela permet d'introduire un ordre d'exécution sur le code.

Après l'appel à join(), on est sûr que le code de la méthode run() de otherThread est fini.

Exemple d'utilisation de join()

public static void main(String[] args) throws InterruptedException {
	var thread1 = Thread.ofPlatform().start(() -> {
		System.out.println("Thread 1");
	});
	
	var thread2 = Thread.ofPlatform().start(() -> {
		System.out.println("Thread 2");
	});

	
	thread1.join();
	thread2.join();

    System.out.println("Thread main");
}

Ce code ne peut plus afficher les noms des threads dans n'importe quel ordre.

Le détail qui vous a peut-être échappé sur le slide précédent

public static void main(String[] args) throws InterruptedException {
	
	...

L'appel thread.join() peut lever une InterruptedException.

Contrairement à ce que son nom indique, cette exception sera déclenchée par votre code, si vous demandez au thread de s’arrêter (on verra plus tard comment) et qu'il est en attente dans sa méthode join. On dit que cette méthode est bloquante.

Pour l'instant, cette exception ne sera jamais levée et ne nécessite donc pas de traitement (d'où l'utilisation de throws).

Thread.sleep

La méthode statique Thread.sleep(int milliseconds) permet au thread qui l'exécute d’attendre un certain nombre de millisecondes, sans rien faire.

Tout comme join(), c'est un exemple de méthode bloquante.

Thread.ofPlatform().start(() -> {
	try {
		Thread.sleep(5000); // wait for 5 seconds
	} catch (InterruptedException e) {
		throw new AssertionError(e);
	}
	System.out.println("Done");
});

Comme pour thread.join, l'exception InterruptedException ne sera pour l'instant jamais levée mais il faut la gérer avec un bloc try-catch car la méthode run ne peut pas lever d'exception checked (ça ne correspond pas au contrat de l'interface fonctionnelle).

Pour conclure

Objectifs du cours

Concevoir et mettre en œuvre des applications logicielles, en Java, qui utilisent de la concurrence.

  • comprendre les problèmes liés à la concurrence,
  • mettre en œuvre des méthodes de synchronisation basées sur les verrous,
  • concevoir des classes utilisables dans un contexte concurrent (on parle de classes thread-safe),
  • étudier les design patterns liés à la concurrence (producteur-consommateur,...),
  • utiliser l'API concurrente Java,
  • utiliser les opérations atomiques.

Pour aller plus loin ...

Les principes et les compétences de ce cours vous permettront de réaliser des applications concurrentes en se basant sur les verrous et les opérations atomiques.

Java concurrency in practice