Multitâche sans thread 2/5 – Continuation


Programmation réactive

Le modèle réactif propose de ne plus utiliser des soft-threads (simulation d’un multitâche réel) mais uniquement des hard-threads (multitâche réel exploitant les différents cœurs des processeurs). Les langages évoluent pour proposer différents modèles permettant de s’affranchir des threads sans pour autant rédiger avec une cascade de call-backs.

Dans un article précédent, nous avons cherché des solutions pour éviter l’empilement de callback et nous avons regardé les générateurs. Nous allons maintenant étudier le pattern continuation.

Continuation

Le pattern continuation est fondé sur le principe suivant : donner une call-back avec le traitement à  invoquer pour continuer après un traitement long. L’idée est de découper un flow de traitement linéaire en blocs de code. Chaque bloc de code est donné en paramètre aux fonctions invoquées. Chaque fonction respectant ce pattern n’effectue pas de return, mais invoque la callback avec le résultat du traitement.

Techniquement, cela se découpe en plusieurs phases :

  • isoler la suite de l’invocation d’une méthode dans une closure
  • la donner à la méthode appelée
  • la méthode appelée se charge d’invoquer la continuité (la suite du traitement)

Pour être plus clair, prenons un fragment de code avec deux méthodes. f() invoque g() mais g() prend du temps. Après avoir invoqué g(), f souhaite afficher le résultat de g().

// Scala
def g() = {
  val rc=… // Long traitement
  rc  // Mon return
}
def f() = {
  val rc=g()
  // La suite
  println("hello " + rc)
}

Pour faciliter la compréhension de l’évolution du code, nous utilisons des couleurs pour chaque élément clef.
Il y a trois blocs de code à considérer :

  • La fonction g()
  • Le code de f() jusqu’à l’appel de g()
  • Le code de f() après l’appel de g()

Pour respecter le modèle « continuation », nous allons encapsuler le code après l’invocation de g() dans une fonction, la passer lors de l’invocation de g() dans la variable k (c’est la convention pour ce pattern). g() se charge de l’invoquer à la place du return.

// Scala
def g(k: String => Unit) = {
  val rc=… // Long traitement
  k(rc) // A la place de return rc
}
def f() = {
  g(continueF)
  def continueF(rc:String) = {
    // La suite
    println("hello " + rc)
  }
}

Avec une syntaxe utilisant les closures, cela donne :

def g(k: String => Unit) = {
  val rc=… // Long traitement
  k(rc) // A la place de return rc
}
def f() = {
  g(
    { (rc:String) =>
      println("hello " + rc)
    }
  )
}

Il existe des extensions des langages permettant de mettre en place ce pattern plus simplement. Par exemple, Scala propose une transformation automatique d’un style direct en un Continuation-Passing-Style (CPS) avec le paramètre -P:continuations:enable.

Cette extension permet d’écrire un code plus simple. L’exemple suivant invoque deux fois la méthode g(). On retrouve tous les éléments.

def g():String @suspendable = {
  shift {
    (k : String => Unit) => {
      val rc=…
      k(rc) // A la place de return rc
    }
  }
}
def f():Unit = {
  reset {
    val rc=g()
    println("hello " + rc)
    // Profitons-en pour rajouter un appel à g()...
    println("bye " + g() )
  }
}

La fonction f() illustre la puissance de ce pattern : le développeur conserve une écriture linéaire du code, c’est le compilateur qui se charge de la tuyauterie.

Le bloc de code qui sera découpé en tranches est identifié par le mot-clef reset. À l’intérieur de ce code, tout ce qui suit l’invocation d’une fonction @suspendable est encapsulé dans une closure et injecté dans la méthode sous le paramètre k. Dans l’exemple suivant, nous avons un bloc avec le premier println() et un deuxième bloc avec le deuxième, car il invoque également la fonction g().

La méthode g() utilise shift pour encadrer le code ayant pour vocation à continuer. Le paramètre k permet alors de recevoir le bloc de continuation dans toutes les méthodes @suspendable.

Pour le moment, ce pattern n’est pas très clair. A quoi cela sert ? Les choses deviennent plus sympathiques si l’on imagine que g() ne va pas invoquer immédiatement k mais le garder pour plus tard.

var pourplustard: (String) => Unit

def g():String @suspendable = {
  shift {
    (k : String => Unit) => {
      val rc=…
      // Ici on n'invoque pas k()
      pourplustard=k
    }
  }
}

Il est alors possible d’invoquer une API asynchrone proposé par l’OS, et de reprendre le traitement pourplustard à la réception de l’événement correspondant de l’OS.
Il est également possible de l’invoquer deux fois à la suite, avec des paramètres différents :

def continueAgain()
{
  pourplustard("abc")
  pourplustard("def")
}

Cette transformation du code est pratique, car cela correspond à un enchaînement de callbacks produit par le compilateur.

Pour la programmation réactive, c’est une approche qui permet d’invoquer des appels @suspendable comme s’ils étaient bloquants dans la syntaxe. C’est le plug-in de Scala qui se charge de la transformation du code.

Comme dans l’article précédent, il est trivial de rédiger un scheduler qui va entrelacer les continuations, cela permet de créer un pseudo-multitâches sans avoir à créer de nouveau thread.

Il y a quand même quelques inconvénients.

  • Il n’est pas possible d’invoquer une continuation dans une boucle. Cela génère une récursivité infinie.
for (i <- 1 to 100) {
 println("Hello "+g()) // Impossible !
}
  • La pile d’appel est modifiée. Elle possède du code difficile à interpréter. C’est le cas de tous les générateurs de codes (programmation par aspect, injections par les classloaders, etc.)
  • Les exceptions sont conçues pour un modèle de développement linéaire, elles doivent désormais être traitées par une approche différente

Pour résumer, une continuation est une closure, passée à une procédure pour être exécutée après son traitement (pattern « continue avec »).

Le tableau suivant résume les avantages et inconvénients des deux premières approches.

Générateur Continuation
Usages Dans une boucle Pour continuer après un traitement
Limitations Retourne un Itérateur Local à une fonction Pile / Exception
Boucles
Points forts Généré Généré

Après l’approche Generator, ce modèle constitue la deuxième approche pour gérer des traitements parallèles sans thread. Nous traiterons d’autres approches dans les prochains volets.

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