Concurrence

ExecutorService


Problématique

On cherche à effectuer en parallèle des tâches indépendantes (requêtes BdD, requêtes Web, traitement d'images, ...).

Problèmes :

  • thread.start() est lente pour les threads plateform,
  • c'est compliqué de récupérer une valeur calculée par un thread.

Solution : réutiliser les threads pour des tâches qui ont une valeur de retour.

  • Les tâches deviennent des Callable<T> :
    @FunctionalInterface
    public interface Callable<T> {
        T call() throws Exception;
    }    
    
  • Un objet de la classe Future<T> est renvoyé au moment de la soumission de la tâche. Il permettra d'obtenir le résultat (de type T).

ExecutorService

Construction d'un ExecutorService

On construit généralement les ExecutorService avec les méthodes factory de la classe Executors :

  • Executors.newFixedThreadPool(int poolSize) démarre poolSize worker-threads qui ne sont jamais arrêtés.
  • Executors.newCachedThreadPool() démarre autant de worker-threads que nécessaire, en essayant de réutiliser les threads déjà démarrés. Les worker-threads inactifs depuis 1 minute sont arrêtés.
  • Executors.newSingleThreadPool() démarre un seul thread qui n'est jamais arrêté.

Dans ces 3 cas, la BlockingQueue qui stocke les tâches est non-bornée.

Plus de finesse avec ThreadPoolExecutor

Le constructeur de ThreadPoolExecutor prend 5 paramètres:

  • int corePoolSize : nombre de threads qu'on s'attend à devoir utiliser en régime de croisière.
  • int maximumPoolSize : le nombre maximum de threads vivants.
  • long keepAliveTime : si il y a plus de corePoolSize threads vivants, ceux en trop sont terminés au bout de keepAliveTime unités de temps d'inactivité.
  • TimeUnit unit : l'unité de temps d'inactivité.
  • BlockingQueue<Runnable> workQueue : la file pour stocker les tâches si tous les worker-threads travaillent.

Principe de fonctionnement

On peut ajouter une tâche à effectuer avec la méthode submit.

<T> Future<T> submit(Callable<T> callable)
// ou
Future<?> submit(Runnable run) // le Future n'a pas de type car un Runnable ne renvoie rien.

Cette méthode n'est pas bloquante. Elle renvoie directement un objet de la classe Future<T> qui permettra de récupérer la valeur renvoyée par le Callable<T>.

java.util.concurrent.Future 1/2

Permet de demander un résultat et de contrôler l'exécution de la tâche.

Récupérer la valeur

  • future.get() bloque jusqu'à ce que le Callable correspondant ait été exécuté et renvoie la valeur calculée par le Callable.
  • Si le Callable lève une exception, l'appel à future.get() lèvera l'exception ExecutionException.
  • future.resultNow() renvoie la valeur à condition que le calcul soit fini.

java.util.concurrent.Future 2/2

Etat de la tâche

  • La méthode state() permet d'avoir l'état de la tâche (RUNNING, SUCCESS, CANCELED, FAILED)
    • RUNNING : en cours d'exécution ou dans la file,
    • SUCCESS : tâche terminée sans avoir levé d'exception,
    • FAILED : tâche terminée par une exception,
    • CANCELLED : tâche annulée par la méthode cancel.
  • La méthode cancel(boolean interrupt) permet de demander l'arrêt de l'exécution d'une tâche

Exemple de get()

public static int bigComputation(int j) { // Syracuse
	var i = 0;
	while (j > 1) {
		j = j % 2 == 0 ? j = j / 2 : 3 * j + 1;
		i++;
	}
	return i;
}

public static void main(String[] args) throws InterruptedException {
	var executorService = Executors.newFixedThreadPool(2);
	var future = executorService.submit(() -> bigComputation(3333));
	try {
		System.out.println(future.get());
	} catch (ExecutionException e) {
		throw new AssertionError(e.getCause());
	}
	...
}

invokeAll() et invokeAny()

En pratique, on utilise souvent l'une des deux méthodes suivantes d'un ExecutorService :

  • invokeAll qui prend une collection de Callable<T>, bloque jusqu'à ce qu'ils aient tous été exécutés (quel que soit le résultat de l’exécution). Elle renvoie la liste des Future<T> associées.
  • invokeAny qui prend une collection de Callable<T>, bloque jusqu'à ce que l'un d'entre eux ait été exécuté (sans lever d'exception) et renvoie la valeur renvoyée par le Callable<T> (ici, on n'a pas besoin d'un Future<T> pour gérer l'exception éventuelle, puisque l'on sait que le Callable s'est terminé correctement).

Exemple avant Java 19 (1/2)

var executorService = Executors.newFixedThreadPool(2);

var callables = new ArrayList<Callable<Integer>>();

IntStream.range(1, 100).forEach(i -> callables.add(() -> bigComputation(i)));

var futures = executorService.invokeAll(callables);

try {
	for (var future : futures){
		System.out.println(future.get());
	}
} catch (ExecutionException e) {
	throw new AssertionError(e.getCause());
}
...

Exemple après Java 19 (1/2)

var executorService = Executors.newFixedThreadPool(2);

var callables = new ArrayList<Callable<Integer>>();

IntStream.range(1, 100).forEach(i -> callables.add(() -> bigComputation(i)));

var futures = executorService.invokeAll(callables);

for (var future : futures) {
	switch (future.state()) {
      case RUNNING -> throw new AssertionError("should not be there");
      case SUCCESS -> System.out.println(future.resultNow());
      case FAILED -> System.out.println(future.exceptionNow());
      case CANCELLED -> System.out.println("cancelled");
	}
}
...

Exemple (2/2)

var executorService = Executors.newFixedThreadPool(2);

var callables = new ArrayList<Callable<Integer>>();

IntStream.range(1,100).forEach(i -> callables.add(() -> bigComputation(i)));

try {
    System.out.println(executorService.invokeAny(callables));
} catch (ExecutionException e) {
    throw new AssertionError(e);
}

Fermeture de l'ExecutorService

  • Un appel à la méthode shutdown() interdit la soumission de nouvelles tâches et arrête les threads quand toutes les tâches ont été traitées.
  • Un appel à la méthode shutdownNow() essaie d'interrompre tous les worker-threads. Si les tâches ne répondent pas à l'interrupt(), alors les worker-threads ne seront jamais arrêtés.