Le carnet On est
Chapitre 6 · encre charbon

Akka — le modèle d'acteurs

Communication concurrente par messages, sans état partagé. ActorSystem, receive, sender, problème des philosophes. La concurrence sans verrou ni effet de bord.

~ 40 min Source : philosophe.pdf + projets ActorCalcul / ActorPizza 11 flashcards · 7 QCM

📄 Ressources du cours

Pas de slides PDF pour ce chapitre — le support est le TP des philosophes (interblocage avec acteurs Akka) et les projets ActorCalcul / ActorPizza.

📥 Télécharger le TP  ·  🔗 Ouvrir dans un nouvel onglet

🎥 Vidéos recommandées

  • 🇫🇷 Introduction to Akka Actors with Java

    Akka · 09:40 · 50k vues — Johan Andrén from the Akka team at Lightbend shows a short live-coding introduction to using the new actor APIs in Akka 2.6 for Java applications. For more i...

  • 🇫🇷 Using Actors (in Scala with Akka)

    Mark Lewis · 05:59 · 5k vues — This video gives a brief description of a problem that you might consider tackling with actors to help you see how you could think about your own problem sol...

  • 🇬🇧 Introduction to Akka Actors with Java

    Akka · 09:40 · 50k vues — Johan Andrén from the Akka team at Lightbend shows a short live-coding introduction to using the new actor APIs in Akka 2.6 for Java applications. For more i...

1. Le modèle d'acteurs

Idée fondatrice Au lieu de partager de la mémoire entre threads (avec mutex, locks…), on partage uniquement par messages. Chaque acteur :
  • A son propre état privé, jamais partagé
  • Reçoit des messages dans une boîte aux lettres (mailbox)
  • Traite les messages un à un, séquentiellement
  • Peut envoyer des messages à d'autres acteurs
  • Peut créer de nouveaux acteurs enfants
🔑 Pas d'état partagé, donc pas de race condition
Comme deux acteurs ne partagent jamais de mémoire et que chacun traite ses messages séquentiellement, il n'y a pas besoin de mutex. La concurrence émerge de l'asynchronicité des messages, pas de l'accès à des variables partagées.

Akka est le framework Scala/Java qui implémente ce modèle, inspiré directement d'Erlang (Ericsson, années 80).

2. ActorSystem — le conteneur

Un ActorSystem est le conteneur qui gère le cycle de vie de tous les acteurs. C'est le point d'entrée du framework.

import akka.actor._

object Main extends App {
   val system = ActorSystem("MySystem")        // creation du systeme

   // Creation d'acteurs
   val server = system.actorOf(Props[Server], "server")
   val client = system.actorOf(Props[Client], "client")

   // Envoi d'un message
   client ! "start"

   // Arret apres un delai
   Thread.sleep(5000)
   system.terminate()
}
Élements clés
  • system.actorOf(Props[T], "nom") crée un acteur de type T avec un nom unique. Retourne un ActorRef.
  • L'ActorRef est une référence légère sur l'acteur. On l'utilise pour envoyer des messages — pas l'objet lui-même.
  • system.terminate() arrête proprement tous les acteurs.

3. Définir un acteur

Un acteur est une classe qui extends Actor et implémente la méthode receive.

Squelette minimal
class MonActeur extends Actor {
   def receive : Receive = {
      case message => ...
   }
}

Le type Receive est en réalité PartialFunction[Any, Unit]. La méthode receive est un pattern matching sur les messages.

Exemple complet — serveur pizza

import akka.actor.{Actor, ActorSystem, Props}

class PizzaServer extends Actor {
   def receive : Receive = {
      case "pizza" =>
         println("[Serveur] Préparation d'une pizza...")
         sender() ! "Votre pizza est prête !"
      case _ =>
         sender() ! "On ne peut pas vous aider !"
   }
}

Au reçu de "pizza", le serveur affiche un message et répond à l'expéditeur via sender(). Au reçu de tout autre message, il renvoie une excuse polie.

4. Envoyer et recevoir des messages

Opérateur ! — fire-and-forget

destination ! message Envoie le message dans la mailbox de destination et continue immédiatement (asynchrone). On dit tell ou bang. Pas de retour, pas d'attente.
client ! "start"          // envoi simple, fire-and-forget
server ! Factorial(5)    // envoi d'un message structure (case class)

sender() — répondre à l'expéditeur

Dans le corps de receive, sender() renvoie l'ActorRef de l'acteur qui a envoyé le message courant.

case Factorial(n) =>
   val result = factorial(n)
   sender() ! Result(result)   // repondre a celui qui a demande

Définir les messages — case classes / objects

Pattern standard Définir un sealed trait Message + plusieurs case class ou case object pour les variantes. Le receive devient un pattern matching exhaustif.
sealed trait Request
case class Factorial(n : Int) extends Request
case class SumFirst(n : Int) extends Request

case class Result(value : BigInt)

L'usage de sealed + case classes apporte les bénéfices du CM3 : exhaustivité vérifiée, pattern matching propre, == sémantique, pas de new.

5. Retrouver un acteur — actorSelection

Quand un acteur veut parler à un autre dont il n'a pas la référence directe, il utilise un chemin :

val server = context.actorSelection("/user/serverName")
server ! "pizza"
ActorRef vs ActorSelection
  • ActorRef = référence directe, garantit que l'acteur existe à la création
  • ActorSelection = sélection par chemin, peut pointer sur 0, 1 ou plusieurs acteurs ; résolu à chaque envoi
Préférer ActorRef quand possible (plus rapide et plus sûr).

Hiérarchie des chemins

  • /user/ : racine des acteurs créés par l'utilisateur
  • /user/server : un acteur créé avec system.actorOf(Props[…], "server")
  • /user/server/child : un acteur créé par server avec context.actorOf(…, "child")

6. Cycle de vie d'un acteur

Chaque acteur a des hooks qu'on peut surcharger pour initialiser ou nettoyer :

HookQuandUsage typique
preStart()Avant le premier messageInitialisation, envoi du premier message
postStop()Après l'arrêtCleanup, libération de ressources
preRestart(reason, msg)Avant un redémarrageSauvegarder l'état avant reset
postRestart(reason)Après le redémarrageRestaurer l'état
class Client(serverPath : String) extends Actor {
   val server = context.actorSelection(serverPath)

   override def preStart() : Unit = {
      server ! Factorial(5)        // envoi automatique au demarrage
      server ! SumFirst(10)
   }

   def receive : Receive = {
      case Result(value) =>
         println(s"${self.path.name} a reçu : $value")
   }
}

7. Pattern ask — attendre une réponse

Parfois on veut une réponse, pas juste envoyer-et-oublier. Le pattern ask renvoie un Future qui se résout quand l'acteur répond.

import akka.pattern.ask
import akka.util.Timeout
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext.Implicits.global

implicit val timeout : Timeout = 5.seconds

val future = server ? Factorial(10)   // ? renvoie Future[Any]
future onComplete {
   case scala.util.Success(Result(v)) => println(v)
   case scala.util.Failure(e)          => println(s"erreur : $e")
}
⚠ Le pattern ask casse l'asynchronisme pur Il introduit un Future et un timeout. À utiliser seulement quand on a vraiment besoin de chaîner une réponse — préférer tell (!) dans tous les autres cas.

8. Exemple complet — ActorCalcul

Le projet du cours combine tout ce qu'on a vu : sealed trait de messages, serveur qui répond, deux clients qui interagissent en parallèle.

import akka.actor._
import scala.concurrent.duration._
import akka.util.Timeout
import akka.pattern.ask
import scala.concurrent.ExecutionContext.Implicits.global

// ===== Messages =====
sealed trait Request
case class Factorial(n : Int) extends Request
case class SumFirst(n : Int) extends Request
case class Result(value : BigInt)

// ===== Serveur =====
class Server extends Actor {
   def factorial(n : Int) : BigInt =
      if (n <= 1) 1 else n * factorial(n - 1)

   def sumFirst(n : Int) : Int = (1 to n).sum

   def receive : Receive = {
      case Factorial(n) =>
         println(s"Serveur: calcul factorielle de $n")
         sender() ! Result(factorial(n))

      case SumFirst(n) =>
         println(s"Serveur: somme des $n premiers")
         sender() ! Result(sumFirst(n))
   }
}

// ===== Client =====
class Client(serverPath : String) extends Actor {
   val server = context.actorSelection(serverPath)
   implicit val timeout : Timeout = 5.seconds

   override def preStart() : Unit = {
      server ! Factorial(5)
      server ! SumFirst(10)
   }

   def receive : Receive = {
      case Result(value) =>
         println(s"${self.path.name} a reçu : $value")
   }
}

// ===== Main =====
object Main extends App {
   val system = ActorSystem("MySystem")
   val server = system.actorOf(Props[Server], "server")
   val client1 = system.actorOf(Props(new Client("/user/server")), "client1")
   val client2 = system.actorOf(Props(new Client("/user/server")), "client2")
   Thread.sleep(5000)
   system.terminate()
}

Sortie attendue (ordre variable) :

Serveur: calcul factorielle de 5
Serveur: somme des 10 premiers
client1 a reçu : 120
client1 a reçu : 55
Serveur: calcul factorielle de 5
Serveur: somme des 10 premiers
client2 a reçu : 120
client2 a reçu : 55

L'ordre exact dépend de l'ordonnanceur — c'est le propre du modèle d'acteurs. Chaque acteur traite ses messages séquentiellement, mais les acteurs entre eux sont concurrents.

9. Le problème des philosophes (Dijkstra)

Énoncé classique Cinq philosophes sont assis autour d'une table ronde. Entre chaque paire se trouve une fourchette. Un philosophe alterne entre deux états :
  • 🧠 Penser
  • 🍝 Manger
Pour manger, un philosophe doit posséder les deux fourchettes (gauche et droite). C'est l'illustration canonique des problèmes de synchronisation et d'accès aux ressources partagées.
⚠ Interblocage (deadlock) Si tous les philosophes prennent leur fourchette gauche en même temps, aucun ne peut prendre la droite ⇒ tout le système est bloqué.
⚠ Famine (starvation) Certains philosophes mangent souvent, d'autres jamais — répartition inéquitable des ressources.

Solution 1 — Superviseur centralisé

Un acteur Superviseur contrôle l'accès aux fourchettes.

Principe :

  • Un philosophe demande l'autorisation au superviseur avant de manger
  • Le superviseur vérifie si les deux fourchettes sont disponibles
  • Si oui ⇒ il accorde l'autorisation
  • Sinon ⇒ le philosophe attend
  • Après avoir mangé, le philosophe informe le superviseur qu'il libère les fourchettes

Variables d'état du superviseur : tableau[Int] indiquant pour chaque fourchette si elle est libre (true) ou prise (false).

Pourquoi pas d'interblocage Le superviseur ne donne jamais une seule fourchette à un philosophe. Soit il accorde les deux d'un coup, soit aucune. Aucun philosophe ne peut donc se retrouver avec une seule fourchette en attendant l'autre.

Solution 2 — Distribuée (sans superviseur)

Pas de processus central. Chaque fourchette est un acteur. Un philosophe communique avec ses deux voisines.

Stratégie :

  • Un philosophe prend d'abord la fourchette de numéro le plus petit
  • Puis la fourchette de numéro le plus grand
  • Une fourchette n'est utilisée que par un seul philosophe à la fois
🔑 Pourquoi cet ordre prévient l'interblocage
Tous les philosophes prennent les fourchettes dans le même ordre (croissant des numéros). Donc même si plusieurs essaient en même temps, il existe toujours au moins un qui aura les deux : celui qui réussit à prendre la fourchette de plus grand numéro avant les autres.

Réviser le chapitre

🃏 Flashcards

Idée centrale du modèle d'acteurs ?
Pas de mémoire partagée. Communication uniquement par messages. Chaque acteur a son état privé et traite ses messages séquentiellement.
Pourquoi pas besoin de mutex ?
Aucun état partagé entre acteurs. Chaque acteur traite ses messages un à un, en interne il est sequentiel. Pas de race condition.
Comment créer un acteur ?
val ref = system.actorOf(Props[MaClasse], "nom"). Props décrit comment créer l'acteur, actorOf renvoie un ActorRef.
Que fait a ! msg ?
Envoie msg dans la mailbox de a et continue immédiatement. Asynchrone, fire-and-forget. Pas de retour.
À quoi sert sender() ?
À l'intérieur de receive, renvoie l'ActorRef de l'expéditeur du message courant. Permet de répondre.
Différence ActorRef vs ActorSelection ?
ActorRef = référence directe. ActorSelection = chemin résolu à chaque envoi, peut pointer sur 0, 1 ou N acteurs. Préférer ActorRef.
Type d'une méthode receive ?
ReceivePartialFunction[Any, Unit]. C'est un pattern matching sur tout type Any qui ne renvoie rien (effet de bord = envoyer des messages).
Pourquoi utiliser sealed trait + case classes pour les messages ?
Exhaustivité vérifiée par le compilateur, pattern matching propre, == sémantique. Sécurise et clarifie le contrat de communication.
À quoi sert preStart() ?
Hook appelé avant le premier message. Sert à initialiser l'état ou envoyer un premier message au démarrage de l'acteur.
Que fait l'opérateur ? (ask) ?
Envoie un message et retourne un Future[Any] qui se résoudra avec la réponse. Nécessite un implicit Timeout. Casse l'asynchronisme pur, à utiliser avec parcimonie.
Quel est le problème de Dijkstra ?
Les philosophes. 5 philosophes / 5 fourchettes. Chacun a besoin des 2 fourchettes voisines pour manger. Illustre deadlock (tous bloqués) et starvation (certains jamais).

✎ Quiz éclair

1.Quel mot-clé étend un acteur Akka ?
  • Thread
  • Actor
  • Process
  • Runnable
Voir la section du chapitre pour la justification détaillée.
2.Que fait a ! msg ?
  • Attend la réponse de a
  • Force la suppression de a
  • Envoie msg en asynchrone, sans attendre
  • Vérifie si a est mort
Voir la section du chapitre pour la justification détaillée.
3.Dans un receive, comment répondre à l'expéditeur ?
  • sender() ! reponse
  • this ! reponse
  • context.reply(reponse)
  • parent() ! reponse
Voir la section du chapitre pour la justification détaillée.
4.Pourquoi utilise-t-on des case classes pour les messages ?
  • C'est la seule façon en Scala
  • Pour économiser la mémoire
  • Pour le pattern matching et l'égalité par contenu
  • Pour activer le multithreading
Voir la section du chapitre pour la justification détaillée.
5.Que renvoie system.actorOf(Props[MyActor], "name") ?
  • Une instance MyActor
  • Un ActorRef
  • Un Future[MyActor]
  • Une String
Voir la section du chapitre pour la justification détaillée.
6.Qu'est-ce qui distingue le problème des philosophes ?
  • Une question d'ordonnancement de threads
  • Un test d'I/O
  • Une question de récursivité
  • Un problème classique de synchronisation et accès aux ressources partagées
Voir la section du chapitre pour la justification détaillée.
7.Dans la solution distribuée des philosophes, pourquoi prendre les fourchettes dans l'ordre croissant ?
  • Pour éviter le deadlock — au moins un philosophe pourra prendre les deux
  • Parce que c'est plus rapide
  • Pour respecter la table ronde
  • Pour économiser de la mémoire
Voir la section du chapitre pour la justification détaillée.

Score : 0 / 7 ·

❓ Q/R

Pourquoi un acteur traite-t-il ses messages séquentiellement ?
Parce que c'est la garantie qui élimine le besoin de synchronisation interne. Si un acteur traitait plusieurs messages en parallèle, il faudrait protéger son état avec des verrous — on perdrait l'avantage du modèle. Tout l'intérêt d'Akka repose sur cette propriété : à l'intérieur d'un acteur, on programme comme en monothread.

Note : la concurrence apparaît entre acteurs, pas à l'intérieur. Plusieurs acteurs sur des cœurs différents peuvent fonctionner en parallèle.
Quand utiliser tell (!) vs ask (?) ?
  • tell (!) : par défaut. Envoyer un message sans attendre de réponse synchrone. Si une réponse est nécessaire, l'acteur appelant la recevra dans son receive via sender().
  • ask (?) : quand on a besoin de chaîner une réponse dans un Future (ex : en dehors d'un acteur, dans du code "classique" Scala). Coût : un wrapper Future, un timeout obligatoire, plus de complexité.
Préférer tell chaque fois que possible. C'est l'idiomatique Akka.
Comment éviter un deadlock dans le problème des philosophes ?
Plusieurs stratégies, vues dans le cours :
  1. Superviseur centralisé : un acteur arbitre l'accès aux fourchettes. Il ne donne jamais une seule fourchette à un philosophe — soit les deux d'un coup, soit aucune.
  2. Ordre total sur les fourchettes : chaque philosophe prend toujours d'abord la fourchette de plus petit numéro. Ça brise la symétrie du cycle d'attente, garantissant qu'il y a toujours au moins un philosophe qui peut manger.
Une troisième stratégie classique (non dans le cours mais commune) : limiter à 4 le nombre de philosophes qui peuvent tenter de manger en même temps.
L'envoi d'un message peut-il échouer silencieusement ?
Oui, à plusieurs niveaux :
  • Si l'acteur destinataire n'existe plus (terminé), le message va dans la « dead letters queue » — perdu silencieusement.
  • Si receive ne matche pas le message reçu (pas de case _), le message est ignoré silencieusement.
  • Si la mailbox déborde (rare avec la config par défaut), le message peut être abandonné.
Pour ces raisons, il est conseillé de toujours définir un case _ => qui log au moins l'erreur, ou de monitor les acteurs critiques.
Quels avantages d'Akka par rapport à une approche thread/lock classique ?
  • Pas de mutex à gérer — l'erreur de programmation la plus courante en concurrence disparaît.
  • Scalabilité horizontale : on peut déployer les acteurs sur plusieurs machines avec Akka Cluster, sans changer le code.
  • Modèle uniforme : local ou distribué, c'est la même API (transparence de localisation).
  • Robustesse via la supervision : un acteur enfant qui crashe est redémarré par son parent.
  • Léger : un acteur consomme ~300 octets, on peut en avoir des millions sur une seule machine.
Inconvénients : modèle mental différent à apprendre, debugging plus complexe (asynchrone), performances brutes inférieures à du multithread bien tuné pour des cas très spécifiques.