Multitâche sans thread 5/5 – async/await


Programmation réactive

Après avoir étudié quatre approches permettant de faire du multitâche sans threads, nous allons voir la dernière, probablement la plus sympathique pour le développeur. C’est une évolution syntaxique des langages permettant de porter le principe d’un pool de hard-threads unique à tout un programme, et donc de porter dans la syntaxe de quoi programmer facilement avec le modèle réactif.

Il s’agit de proposer deux nouvelles instructions : async et await. Ces mots-clefs ont été popularisés par Microsoft dans C# 5.0 ou F#. Depuis, une macro pour Scala permet de proposer une syntaxe équivalente. Java n’est pas encore équipé.

L’idée est de découper le code en tranches retournant des Future[T] en Scala, des CompletableFuture<T> en Java8, des Task<T> en .Net, etc. Le code découpé doit lui-même retourner un future.

async permet d’indiquer une portion de code devant être découpée. await indique les frontières de la découpe.

Voici un exemple en C# 5.0

1
2
3
4
5
6
7
8
// .NET
async Task WebAsync() {
  HttpClient client = new HttpClient();
  Task getStringTask = client.GetStringAsync("http://go.gl");
  DoIndependentWork();
  string urlContents = await getStringTask;
  return urlContents.Length;
}

Et un autre en Scala

1
2
3
4
5
6
7
8
9
10
11
12
13
// Scala
val future = async {
  val f1 = async {
    ...;
    true
  }
  val f2 = async {
    ...;
    42
  }
  return
    await(f1) + await(f2))
}

C’est le compilateur du langage qui va transformer ce code linéaire en un automate à état. Chaque état correspond à un bloc de code. Chaque bloc est automatiquement enregistré dans les callbacks des futures, pour pouvoir reprendre les traitements au fur et à mesure.

Dans l’exemple Scala, il faut voir chaque bloc async comme une closure retournant un Future[]. L’instruction await est un enregistrement du bloc de code dans une callback onComplete. La ligne return est donc convertie par le compilateur en un Future[] qui sera valorisé lorsque f1 et f2 seront valorisés.

Finalement ce code est une façon plus élégante d’écrire un enchaînement de callback de onComplete.

Là où la magie opère, c’est que les Future<>, les Task<> ou les CompletableFuture<> peuvent être alimentés par des événements.

La base de données vient de publier le premier enregistrement de la requête ? Mon moteur d’événement informe le programme, ce qui alimente la valeur d’un future. Cela débloque un traitement await. Un hard-thread disponible est alors utilisé pour traiter l’événement.

Il faut bien comprendre dans cette approche que chaque bloc de code peut être exécuté sur un hard-thread différent. Il ne faut pas utiliser de variables de threads avec ce modèle (pour les transactions ou l’authentification par exemple). Les piles d’appels des exceptions sont également étranges.

Avec cette approche, il est facile de rédiger un code linéaire classique, tout en fonctionnant sur un mode réactif. Attention, pour en bénéficier pleinement, il ne faut utiliser que des API non-bloquantes. Les frameworks alimentent le CompletableFuture<> lorsque l’OS dispose des données.

Pour invoquer des API bloquantes il faut utiliser soit un autre pool de thread dédié (comme dans le backgroud pool de Vert.X) ou augmenter la taille du pool global (c’est pas bien, car il n’est pas fait pour cela).

Dans .NET 5, Microsoft s’est chargé de proposer de nouvelles API asynchrones pour les fichiers, le réseau ou le calcul d’image. Tout ce qui prend du temps et endort normalement le thread courant.

Java8, avec le CompletableFuture<> est sur la bonne voie pour pouvoir généraliser ce modèle.

Comme les futures utilisent un pool de hard-threads unique au programme, chaque segment de code peut s’exécuter réellement en parallèle au mieux de la plate-forme. L’augmentation du nombre de cœurs entraîne réellement une amélioration des performances, car les traitements sont vraiment parallèles. Il n’y a pas de contention ou de verrous limitant le gain. La loi d’Amdahl indique que le gain maximum possible par l’augmentation du parallélisme est fortement limité par la proportion de code non parallélisable. Avec ce modèle également, le gain obtenu  n’est limité que par les portions de codes non parallélisables.

Le programme est un ensemble de portions de code très courtes, non bloquantes. Les cœurs exécutent ces blocs de code en parallèle. La notion de soft-thread n’existe plus.

Le tableau suivant résume les avantages et inconvénients des différentes approches proposées dans cette série d’articles.

Générateur Continuation Coroutine Pipeline Async/await
Usages Dans une boucle Pour continuer après un traitement Mise en pause d’un traitement Enchaînement en pipe Distribution des traitements sur les coeurs
Limitations Retourne un Itérateur Local à une fonction Pile / Exception
Boucles
JVM Pile /
Gestion des Exceptions
Pile / Exception
Retourne un Future
Points forts Généré Généré Garde la pile d’appel
Compatible exceptions
Élégance
Multi-nœud
Généré

Plusieurs approches permettent de gérer différemment les threads. Les trois premières s’occupent de maintenir le contexte d’appel, pour le reprendre plus tard à la demande (sur événement dans une architecture réactive). Les deux dernières approches se focalisent sur la simplification de la syntaxe, pour exploiter un pool de thread unique pour tout le process.

Malheureusement, ces technologies ne sont pas présentes dans tous les langages de développement, limitant ainsi les choix pour les développeurs. Dans l’état actuel des technologies, idéalement, notre conviction est de privilégier, pour la facilité du débuggage et de la rédaction du code, dans l’ordre :

  1. Coroutine
  2. Async/await
  3. Pipeline
  4. Continuation
  5. Générateur

Par ailleurs, pour des critères de performance :

  1. Async/await
  2. Pipeline
  3. Coroutine
  4. Continuation
  5. Générateur

Dans chaque technologie, il manquerait des frameworks réactifs pour exploiter ces différentes propositions. En effet, il faut injecter les événements asynchrones dans ces technologies, pour réveiller les traitements. Mais, comme nous l’avons montré dans le premier article, une centaine de lignes de code peuvent résoudre rapidement cela.

Cela achève notre analyse des différentes approches permettant de proposer du multi-tâche sans gérer les threads, qu’ils soient soft ou hard. Les langages évoluent pour aider à l’utilisation du modèle réactif.

Notre conviction est qu’il faut dorénavant bannir les soft-threads et privilégier un modèle de développement réactif, exploitant uniquement des hard-threads portés par un pool unique à tout le programme. Quelquefois, pour des scénarios spécifiques, les soft-threads feront de la résistance.

Ce n’est pas un hasard si les langages évoluent pour faciliter ce modèle de développement. Il évident que le modèle réactif va se généraliser.

Philippe PRADOS et l’équipe « Réactive »