Akka.Net est un portage fidèle de la librairie Akka disponible en Java/Scala. Cette librairie est basée sur le modèle Acteur et a pour objectif de faciliter la conception d’applications concurrentes et/ou distribuées. Dans cet article, nous allons voir comment Akka.Net aide à écrire des programmes multithreadés, scalables et élastiques. Nous verrons plus particulièrement comment Akka.Net peut :
- Faire du multithreading sans se soucier de la synchronisation,
- Communiquer de façon transparente avec une machine distante,
- Répartir une charge de calculs sur plusieurs processus / nœuds,
- Rajouter et enlever dynamiquement des nœuds de calcul.
Vue d’ensemble
Akka.Net est composé de plusieurs « assemblies ». L’assembly principale « Akka » regroupe les fonctionnalités de base. Les autres « assemblies » ajoutent des fonctionnalités supplémentaires :
- Akka (« scaling up« ) est la brique de base pour faire des applications asynchrones, concurrentes et événementielles.
- Akka.Remote (« scaling out« ) permet de réaliser des applications distribuées et peer-to-peer. Cette brique est indispensable pour utiliser Akka.Cluster.
- Akka.Cluster (« elasticity« ) permet d’ajouter et d’enlever dynamiquement des nœuds à un Cluster.
Multithreading (Akka)
Un acteur peut être vu comme une personne autonome qui communique par message uniquement avec les autres personnes. Chaque acteur se voit attribuer une messagerie. Un acteur traite entièrement un message avant de passer au suivant. Les ressources internes d’un acteur sont inaccessibles depuis l’extérieur. Ainsi, à la condition que les messages soient immutables, le code exécuté à l’intérieur d’un acteur est parfaitement « thread safe », sans synchronisation supplémentaire. On peut alors éxécuter plusieurs acteurs en parallèle pour utiliser au maximum les multi-cœurs du CPU. Cependant, le programme reste vulnérable aux situations de compétition, s’il est par exemple basé sur l’ordre de réception des messages.
Un acteur est une classe qui hérite d’une classe de base du Framework. Le plus commun est d’hériter de ReceiveActor. Ici, l’acteur MyCustomActor écrit le contenu de l’objet MyMessage dans la console lorsqu’il le reçoit.
01.
public
class
MyCustomActor : ReceiveActor
02.
{
03.
public
MyCustomActor()
04.
{
05.
Receive<MyCustomActor>(m =>
06.
{
07.
Console.WriteLine($
"{m.Content}"
);
08.
Thread.Sleep(10000);
// supposons un calcul qui prends un certain temps
09.
});
10.
}
11.
}
Ce message est une classe dont la propriété Content est « readonly« , c’est à dire immutable.
01.
public
class
MyMessage
02.
{
03.
public
string
Content {
get
; }
//propriété readonly property dans C# 6.0
04.
05.
public
MyMessage(
string
content)
06.
{
07.
Content = content;
08.
}
09.
}
Un moyen simple d’adapter l’utilisation du CPU en fonction des capacités de sa machine est d’utiliser l’implémentation du « RoundRobin » fourni. Pour cela, on va utiliser un routeur : un acteur spécifique qui aura pour but de rediriger les messages qui lui sont envoyés vers les réels acteurs selon une stratégie définie (ici le RoundRobin).
Notons que la communication avec un acteur est toujours asynchrone et donc non bloquante pour l’appelant.
01.
static
void
Main(
string
[] args)
02.
{
03.
using
(var mySystem = ActorSystem.Create(
"mySystem"
))
// un ActorSystem permet de créer des acteurs
04.
{
05.
var props = Props.Create<MyCustomActor>()
06.
.WithRouter(
new
RoundRobinPool(Environment.ProcessorCount));
07.
08.
var customActors = mySystem.ActorOf(props,
"customActors"
);
09.
10.
while
(
true
) customActors.Tell(
new
MyMessage(Console.ReadLine()));
// cette opération n'est pas blocante
11.
}
12.
}
Pour uniquement du multithreading, on pourrait se passer d’Akka.net en utilisant simplement la TPL et en ne partageant pas de ressources entre les threads, par exemple. Akka.Net va devenir intéressant en rajoutant Akka.Remote et surtout Akka.Cluster.
Scalabilité horizontale (Akka.Remote)
Le composant Akka.Remote permet d’utiliser les capacités de plusieurs machines. Pour cela, le Framework applique le principe localisation transparente (« location transparency« ). Le concept est simple (la réalité est cependant plus complexe) : passer d’une application mono-processus à une application distribuée en modifiant uniquement la configuration. Dans la configuration, on renseigne l’adresse, le port et le protocole de transport utilisés pour communiquer avec un ou plusieurs acteurs distants. De base, Akka.Remote utilise le protocole de transport tcp à travers la librairie Helios. A noter que les objets ont besoin d’être sérialisés avant d’être transportés dans le réseau. Cette étape est aussi faite de façon transparente.
Dans notre exemple, on définit trois composants :
- L’application « Client » qui envoie des messages « MyMessage » aux acteurs MyCustomActor distants.
- L’application « Calculator » qui exécute les acteurs MyCustomActor tout en traitant en parallèle un à un les messages de « Client » selon une stratégie de RoundRobin.
- L’assembly « Shared » qui est une référence externe de deux applications et qui contient le code de l’acteur MyCustomActor et le message MyMessage.
« Client » a le même code que notre première méthode Main (on peut néanmoins remplacer Environment.ProcessorCount par autre chose). En revanche, la configuration attribue maintenant une adresse au routeur « customActors » et une adresse pour lui-même.
01.
<
configSections
>
02.
<
section
name
=
"akka"
type="Akka.Configuration.Hocon.AkkaConfigurationSection, Akka/>
03.
<
configSections
>
04.
<
akka
>
05.
<
hocon
>
06.
<!--[CDATA[
07.
akka {
08.
actor {
09.
provider = "Akka.Remote.RemoteActorRefProvider, Akka.Remote"
10.
deployment {
11.
/customActors {
12.
remote = "akka.tcp://mySystem@localhost:8090" // donne l’adresse et port de "Calculator"
13.
}
14.
}
15.
}
16.
remote {
17.
helios.tcp {
18.
port = 0 // attribution automatique du port
19.
hostname = localhost
20.
}
21.
}
22.
}
23.
]]-->
24.
<
hocon
>
25.
<
akka
>
« Calculator » n’a quasiment aucun code. C’est parce que « Client » va déployer à distance (« remote deployment ») les acteurs MyCustomActor dont le code est dans l’assembly Shared.
01.
class
Program
02.
{
03.
static
void
Main(
string
[] args)
04.
{
05.
using
(var system = ActorSystem.Create(
"mySystem"
))
06.
{
07.
Console.ReadKey();
08.
}
09.
}
10.
}
Dans la configuration de « Calculator », on spécifie simplement son adresse.
01.
akka {
02.
actor {
03.
provider = "Akka.Remote.RemoteActorRefProvider, Akka.Remote"
04.
}
05.
remote {
06.
helios.tcp {
07.
port = 8090 // le port de "Calculator"
08.
hostname = localhost
09.
}
10.
}
11.
}
Akka.Remote permet de créer des applications scalables mais ne permet pas de rajouter des nœuds dynamiquement. De plus, on crée des points de faiblesses si une machine tombe en panne. Akka.Cluster permet de remédier à ces problèmes.
Elasticité (Akka.Cluster)
Akka.Cluster permet de créer un groupe de nœuds appelé « cluster« » (un nœud étant un processus dans une machine). Ce cluster est élastique et résistant à la panne d’un nœud. Le cluster se transforme au gré des ajouts et des retraits des nœuds. Ces transformations sont possibles grâce à l’échange de messages spécifiques appelés « gossip« . Par exemple, lorsqu’un nœud se rajoute au cluster, un certain nombre de ces messages vont s’échanger afin que ce nœud puisse communiquer avec les autres (et vis-versa).
Afin de faciliter la communication entre différents nœuds du cluster, on peut définir des rôles. Ces rôles vont permettre d’orienter les messages envoyés par certains nœuds. Dans notre exemple, on va utiliser quatre composants (dont trois issus de l’exemple précédent) :
- L’application « Client » avec le rôle « client-role » qui envoie des messages MyMessage à plusieurs nœuds dans le cluster en suivant une stratégie de RoundRobin
- L’application « Calculator » avec le rôle « calculator-role » qui exécute les acteurs MyCustomActor en parallèle. Cette application est instancié sur plusieurs fois.
- L’assembly « Shared » qui est une référence dans « Client » et « Calculator » et qui contient le code de l’acteur MyCustomActor et le message MyMessage.
- L’application « Seeder » avec le rôle « seeder-role » qui est notre nœud seed (« seed-node« ) (et qui devrait normalement être redondée pour plus de résilience). C’est à dire que ce nœud va être contacté en premier par chaque nœud.
« Client » va être légèrement modifié car la stratégie de routage RoundRobin est définie dans la configuration.
1.
var props = Props.Create<MyCustomActor>()
2.
.WithRouter(FromConfig.Instance);
Dans la config de « Client », on définit la stratégie de routage propre au routeur « customActors ». Ce routeur déploie des instances de MyCustomActor sur des nœuds ayant le rôle « calculator-role ». Il déploie 2 instances par nœud avec un maximum de 8 instances au total dans le cluster (soit un maximum de 4 nœuds utilisés).
01.
akka {
02.
actor {
03.
provider = "Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"
04.
deployment {
05.
/customActors {
06.
router = round-robin-pool
07.
cluster {
08.
enabled = on
09.
max-total-nr-of-instances = 8 // nombre d'instances MyCustomActor dans le cluster
10.
max-nr-of-instances-per-node = 2 // nombre d'instances MyCustomActor dans un nœud
11.
allow-local-routees = off
12.
use-role = calculator-role // rôle avec lequel ce déploiement s'applique
13.
}
14.
}
15.
}
16.
}
17.
18.
remote {
19.
helios.tcp { // on spécifie ici l'adresse, le port et le protocole de transport utilisé par ce noeud
20.
transport-class = "Akka.Remote.Transport.Helios.HeliosTcpTransport, Akka.Remote"
21.
applied-adapters = []
22.
transport-protocol = tcp
23.
hostname = "localhost"
24.
port = 0
25.
}
26.
}
27.
28.
cluster {
29.
seed-nodes = ["akka.tcp://mySystem@localhost:4053"] // on spécifie ici l'adresse du noeud seed
30.
roles = [client-role] // on spécifie ici le rôle du noeud
31.
}
32.
}
« Calculator » aura le même code que dans l’exemple précédent mais une configuration différente.
01.
akka {
02.
actor {
03.
provider = "Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"
04.
}
05.
06.
remote {
07.
helios.tcp { // on spécifie ici l'adresse, le port et le protocole de transport utilisé par ce noeud
08.
transport-class = "Akka.Remote.Transport.Helios.HeliosTcpTransport, Akka.Remote"
09.
applied-adapters = []
10.
transport-protocol = tcp
11.
hostname = "localhost"
12.
port = 0 // attribution automatique du port
13.
}
14.
}
15.
16.
cluster {
17.
seed-nodes = ["akka.tcp://mySystem@localhost:4053"] // on spécifie ici l'adresse du noeud seed
18.
roles = [calculator-role]
19.
}
20.
}
« Seeder » aura le même code que « Calculator » mais avec une configuration différente.
01.
akka {
02.
actor {
03.
provider = "Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"
04.
}
05.
06.
remote {
07.
helios.tcp {
08.
transport-class = "Akka.Remote.Transport.Helios.HeliosTcpTransport, Akka.Remote"
09.
applied-adapters = []
10.
transport-protocol = tcp
11.
hostname = localhost
12.
port = 4053 // on spécificie ici un port particulier
13.
}
14.
}
15.
16.
cluster {
17.
seed-nodes = ["akka.tcp://mySystem@localhost:4053"]
18.
roles = [seeder-role]
19.
}
20.
}
On peut maintenant répartir dynamiquement une charge de calculs sur plusieurs applications « Calculator », chaque application étant elle-même multithreadée.
De nombreuses autres fonctionnalités
Dans cet article, nous sommes allés droit au but afin de répondre à un problème récurrent de l’informatique moderne : concevoir des applications scalables et élastiques. Cependant, de nombreuses fonctionnalités du Framework n’ont pas été abordées :
- l’API liée aux acteurs : la librairie de base comporte de nombreuses fonctionnalités pour manipuler les acteurs comme la possibilité de créer des interactions complexes entre plusieurs acteurs ou de modifier dynamiquement le comportement d’un acteur.
- La supervision : Akka permet de spécifier la stratégie de gestion d’erreurs dans une hiérarchie d’acteurs. Par exemple, s’il faut stopper ou redémarrer un ou plusieurs acteurs enfants lorsqu’un de ces acteurs a lancé une exception.
- La persistance : Akka.Persistence permet de sauvegarder l’état interne d’un acteur, ce qui permet de restaurer son état après un crash par exemple.
- Tests : Akka.TestKit permet de tester unitairement son application Akka.Net concurrente et asynchrone. MultiNode TestKit permet d’exécuter des tests coordonnés sur plusieurs processus.
- Les Streams : Akka.Streams permet d’échanger des messages sous formes de flux de données afin d’éviter le transport de gros messages. Le composant applique aussi le principe de « back-pressure » qui consiste à ralentir un producteur lorsque les consommateurs ne peuvent pas suivre.