Programmation concurrente

Dans l'intégralité des programmes Neon que vous avez écrits depuis le début, vous n'avez écrit qu'une suite d'instructions  qui s'exécutaient les unes après les autres. Mais l'interpréteur Neon permet de faire des choses bien plus puissantes que juste exécuter des lignes de code à la suite.


I - Exécuter des fonctions en parallèle

Habituellement, quand on lance une fonction, celle-ci s'exécute entièrement, puis la ligne d'après, etc. Par exemple, dans ce programme :

function f(x) do

    for (i, 0, 5) do

        print(x)

    end

end


f(1)

f(2)

on aura un affichage ressemblant à ceci :

1

1

1

1

1

2

2

2

2

2

Voyons une méthode pour exécuter ces deux fonctions en même temps. A partir de maintenant, nous parlerons de processus. Un processus est un programme dans lequel toutes les instructions s'exécutent les unes après les autres. Ainsi, d'habitude, nous n'utilisons qu'un seul processus : le processus principal. Mais nous pouvons aussi avoir plusieurs processus qui s'exécutent en même temps. Pour créer un nouveau processus, il faut d'abord avoir une fonction créée à l'aide de function. C'est cette fonction qui sera exécutée dans un nouveau processus. Pour la lancer en parallèle, il suffit d'écrire :

parallel f(arguments)

Pour illustrer ce propos, le programme de tout à l'heure, mais avec la fonction f lancée en parallèle :

function f(x) do

    for (i, 0, 5) do

        print(x)

    end

end


parallel f(1)

parallel f(2)

II - Gérer le retour de fonctions exécutées en parallèle

Depuis le début, les fonctions que nous avons lancées en parallèle ne renvoyaient rien. Mais on peut parfaitement faire des processus qui renvoient une valeur.

Mais comment ça marche ?

En réalité, quand vous lancez une fonction avec le mot-clé parallel, ce mot-clé vous renvoie une valeur de type Promise (une promesse).

Cette promesse est une sorte d'identifiant pour le processus qui vous l'a envoyée. Tant que le processus n'a pas terminé, cette promesse aura la valeur (et le type) d'une promesse, comme le montre la capture d'écran ci-dessus. Mais une fois que le processus qui vous a envoyé la promesse termine, alors la promesse (et toutes les copies que vous en aurez faites) se transformeront en la valeur de retour du processus. Exemple avec ce programme :

function f(x) do

    for (i, 0, x) do

        x += x

    end

    return (x)

end


p = parallel f(10)


print(p)


l = [p, p, p, p, p]


print(l)


while (type(p) == "Promise") do

    pass

end


# le processus est maintenant fini


print(p)


print(l)

Décortiquons-le un peu.

Tout d'abord, je définis une fonction qui calcule un nombre en fonction de x, son paramètre.

Ensuite, j'appelle cette fonctions avec x=10, et je récupère le retour de parallel dans la variable p.

J'affiche p, puis je crée une liste contenant 5 fois p et je l'affiche.

Enfin, j'attends que le processus p se termine (j'attends que la promesse se transforme), puis j'affiche p et la liste l.

Ce qui se passe est très simple : Avant la fin du processus, on ne manipule que des promesses, donc on n'affiche que des promesses, puis une fois le processus fini, toutes les promesses se sont transformées en la valeur renvoyée par le processus. Voici d'ailleurs la sortie de ce programme :

III - L'attente passive

Dans le programme du dessus, pour attendre que le processus finisse, j'utilise une boucle while qui ne fait rien. Ce comportement peut être dérangeant puisque cela consomme de la puissance du processeur pour pas grand chose. De plus lorsque l'on programme avec des processus, il peut être utile d'avoir des processus qui attendent des événements, sans pour autant pomper toute la puissance du processeur. Par exemple, si je veux faire un processus qui attend que quelqu'un appuie sur une touche, je n'ai pas envie que ça ralentisse mes autres processus qui eux, calculent des choses utiles.

C'est pour cela que Neon permet l'attente passive au sein d'un processus. Cette attente passive est réalisée à l'aide du mot-clé await(condition). Tant que la condition n'est pas vérifiée, le processus est mis en pause, et uniquement les autres processus sont exécutés. L'interpréteur va seulement aller vérifier la condition de temps en temps. Une fois la condition vérifiée, le processus mis en pause repart comme si de rien n'était.

Ainsi, grâce au mot-clé await, on peut écrire une fonction qui attend la fin d'un processus, mais plus efficacement :

function join(p) do

    await(type(p) != "Promise)

end

Tant que p a le type promise, le processus qui a appelé join est mis en pause, et il repart après.


IV - Blocs d'instructions atomiques

Quand on écrit un programme utilisant plusieurs processus, on est parfois amené à également utiliser des variables globales que chaque processus peut lire et modifier. Mais parfois, cela peut causer des problèmes. Regardons le programme ci-dessous :

function f() do

    if (type(nombre) == "Number") then

        return (nombre + 1)

    end

end

function g() do

    for (i, 0, 10) do

        nombre = "coucou"

    end

end

nombre = 15

parallel f()

parallel g()

Dans ce cas, au moment où on va exécuter f, il peut se passer deux choses. Ou alors, après avoir testé la valeur de la variable nombre, cette valeur n'aura pas changée, auquel cas tout va bien.

Mais il peut se passer une autre chose. Entre le moment où f teste la valeur de nombre et renvoie nombre+1, il se peut que la fonction g modifie cette valeur, auquel cas, "coucou"+1 n'a pas de sens, et le programme aura une erreur.

Il existe donc un mécanisme qui permet de garantir qu'un ensemble d'instructions est exécuté d'une traite, sans qu'aucun autre processus n'ait le droit de faire quoi que ce soit.

Ce mécanisme, c'est le bloc d'instructions atomiques.

La syntaxe se présente comme ceci :

atomic

    instructions1

    instruction2

    ...

end

Pour conclure, on pourrait écrire tout le corps de la fonction f dans un bloc atomic, et on serait certain que la fonction g ne peut pas modifier la variable nombre entre temps.

V - Comment ça marche ?

Vous le savez, Neon a pour vocation de pouvoir être utilisé partout, sur n'importe quelle machine. Or, vous le savez aussi, beaucoup de machines n'ont pas la possibilité d'exécuter matériellement plusieurs programmes à la fois. C'est pour cela qu'en réalité, les différents processus que l'on peut créer ne s'exécutent pas réellement en même temps. L'interpréteur Neon réalise ce qu'on appelle de l'entrelacement.

Il a une liste de processus, et il exécute un peu d'instructions de chaque processus avant de passer au processus suivant. Cette manière de faire est compliquée à gérer, mais rend l'interpréteur souverain de tout ce qu'il fait. Ainsi, l'interpréteur peut choisir combien d'instructions il veut exécuter sur chaque processus avant de passer au suivant.

Et à travers une fonction, vous pouvez choisir le nombre d'instructions exécutées sur chaque processus avant de passer au suivant. Cette fonction s'appelle setAtomicTime, et vous permet de spécifier un nombre d'instructions atomiques.