erlang – Sam & Max http://sametmax.com Du code, du cul Wed, 23 Dec 2020 13:35:02 +0000 en-US hourly 1 https://wordpress.org/?v=4.9.7 32490438 La différence entre la programmation asynchrone, parallèle et concurrente http://sametmax.com/la-difference-entre-la-programmation-asynchrone-parallele-et-concurrente/ http://sametmax.com/la-difference-entre-la-programmation-asynchrone-parallele-et-concurrente/#comments Wed, 09 Oct 2013 22:08:13 +0000 http://sametmax.com/?p=7378 On parle un peu partout de programmation non bloquante ces temps-ci. NoSQL a remis le map/reduce au goût du jour, et PAF, on vous sort le mot clé parallélisation pour vous en vendre une tetrachiée. Les partisants de NodeJS vont crier “asynchrone”, parce que c’est ce que Javascript sait faire de mieux. Et on murmure dans les coins que la robustesse d’Erlang tient dans ses acteurs qui travaillent de manière concurrente dans la VM.

Ok, donc tout ça, ça à l’air de faire la même chose, c’est à dire de faire plusieurs choses en même temps, sans bloquer.

Donc c’est pareil ?

Non. En fait c’est une question de point de vue : non bloquant dans quel contexte ?

Si c’est l’IO, c’est asynchrone

Pour rappel, l’IO (Input/Ouput), c’est toute activité qui implique que des données entrent et sortent de votre programme : saisie utilisateur, print sur un terminal, lecture sur une socket, écriture sur le disque, etc. Une opération I/O a plusieurs caractéristiques :

  • Le temps que prend l’opération n’est pas dépendant du CPU : la vitesse du disque, la latence du réseau, le nombre d’heures de sommeil du sysadmin sont les facteurs qui vont déterminer quand l’opération va prendre fin.
  • Le corollaire, c’est qu’on ne peut pas prédire quand l’opération va prendre fin depuis le programme.
  • Sur les services avec beaucoup d’I/O (serveurs Web, bases de données, crawlers, scripts de déploiement, etc), c’est l’I/O qui généralement prend le plus de temps dans l’exécution du programme. L’optimisation de ces opérations va donc l’accélérer bien plus que de changer votre algo.

La plupart des programmes bloquent quand ils effectuent une opération I/O. Par exemple, si vous faites ceci en Python :

import urllib2

# télécharge et affiche le contenu de la page d'acceuil de sam et max
print(urllib2.urlopen('http://sametmax.com').read())
print("Coucou")

La ligne print("Coucou") ne s’exécutera pas tant que la ligne précédente n’aura pas terminé de s’exécuter. Dans ce cas ce n’est pas très grâve, mais dans ce cas là :

import urllib2


mille_urls = obtenir_liste_de_mille_urls()
contenu = []

# télécharge et sauvegarde dans une liste
# le contenu de chacune des 1000 urls
for url in mille_urls:
    contenu.append(urllib2.urlopen(url).read())

Chaque url est téléchargée une par une, et comme Internet, c’est vachement lent (300 ms X 1000, ça fait 5 minutes, mine de rien), votre programme va prendre un temps fou. Et pour rien en plus, car votre programme va passer la majeure partie du temps à ne rien faire ! En effet, 99% du temps de votre programme est passé à attendre qu’Internet réponde, pendant que votre CPU se touche les noix.

La programmation asynchrone est une réponse à cela : au lieu d’attendre que se finissent les entrées et les sorties, le programme continue de fonctionner.

Une autre problématique se pose alors : comment obtenir le résultat de l’opération d’I/O, puisqu’on ne sait pas quand il va arriver et qu’on attend pas qu’il arrive ?

C’est là que les systèmes asynchrones font un peu de magie. En vérité, une partie du programme attend, mais discrètement, en arrière plan, au niveau de ce qu’on appelle une boucle d’événements (“events loop”), c’est à dire une boucle infinie qui check régulièrement si une opération I/O ne s’est pas terminée.

Cette boucle est invisible pour vous, votre programme continue de tourner. Mais si une opération I/O envoie des données, alors l’events loop va réagir.

Ca a l’air compliqué, mais en fait, c’est, la plupart du temps, juste une histoire de callback (si la notion vous échappe, je vous renvois à l’article dédié…). Par exemple en Javascript :

var mille_urls = obtenir_liste_de_mille_urls();
var contenu = [];

# notre callback qui va permettre d'ajouter 
# le contenu téléchargé à notre liste
var callback = function(data) { 
      contenu.push(data);
};

# Bon, j'utilise jquery pour simplifier le code...
# On boucle sur les milles URL
$.each(mille_urls, function(index, url) {
  # On télécharge le contenu, MAIS comme
  # $.get est naturellement non blocante,
  # elle ne va pas attendre qu'internet 
  # réponde pour continuer la boucle, et
  # donc on pourra attendre plusieurs réponses
  # en même temps. Pour avoir le résultat de 
  # chaque réponse, on passe un callback qui 
  # va être appelé quand la réponse arrive.
  $.get(url, callback);

});

Comprenez bien la subtilité : à tout moment, il n’y a qu’UN SEUL process javascript qui s’éxécute. Il n’y a pas deux traitements, pas de threads, pas de processus parallèles, rien de tout ça. Simplement, Javascript n’attend pas la réponse de sa requête pour faire la requête suivante, il continu sur sa lancée, et donc peut optimiser les temps d’attente en attendant plusieurs choses en même temps.

Javascript utilise massivement des API asynchrones, c’est au cœur du langage, il n’y a aucun effort à faire pour cela. A l’inverse, Python est synchrone par nature, et il faut vraiment se faire chier pour obtenir un algo asynchrone. Ceci changera avec Python 3.4 qui accueillera tulip dans la stdlib, afin de se moderniser sur ce point. En attendant, si vous voulez faire de l’asynchrone en Python, vous pouvez voir du côté de gevent, monocle ou Tornado. L’alternative est d’utiliser des threads ou des processus séparés, ce qui ne demande rien à installer, mais est un peu verbeux, et est moins performant.

Souvenez-vous que l’I/O, c’est toute entrée et sortie du programme. Un clic sur un bouton, c’est une entrée, mettre à jour un élément du DOM dans le navigateur, c’est une sortie. La programmation asynchrone est donc importante pour la réactivité des programmes.

Si un algorithme peut répartir son travail en plusieurs bouts, c’est parallèle

Par exemple, vous avez 1000 images en haute définition à traiter : il faut les redimensionner, les mettre en noir et blanc et ajouter une ombre sur les bords. Là, la partie de votre programme qui prend le plus de temps, c’est le traitement des images, pas l’I/O, et donc c’est le CPU. Par exemple, en Python :

for image in obtenir_liste_images():
    # I/O
    data = lire_image(image) 

    # gros du travail
    redimensioner(data)
    mettre_en_noir_et_blanc(data)
    ajouter_ombre(data)

    # I/O
    ecrire_image(data, image)

Si vous avez plusieurs ordinateurs, une manière de paralléliser le travail est de mettre 500 images sur l’un, et 500 images sur l’autre, et de lancer le script sur chaque ordi.

Si vous avez plusieurs processeurs dans votre ordi (ce qui est le cas de tous les ordis modernes, et plus seulement les super-calculateurs comme il y a 10 ans), vous pouvez aussi paralléliser le travail sur une seule machine : chaque processeur va s’occuper d’une partie du taf.

Bien entendu, vous pouvez lancer le script 2 fois, mais cela ne marche que sur des travaux simples comme celui là. Et ça suppose que vous connaissez le nombre de CPU que vous voulez faire travailler à l’avance.

Une manière de faire plus propre est d’utiliser des threads ou des processus séparés. En Python, le thread ne servirait à rien, car on se heurterait au GIL, le fameux global interpréteur lock, qui fait qu’une VM n’utilise qu’un processeur, quoi qu’il arrive. Les threads ne sont donc utiles (en Python), que pour l’I/O. Par contre on peut utiliser plusieurs processus :

from multiprocessing import Process

def traiter_les_images(debut, fin):

 for image in obtenir_liste_images()[debut, fin]:
    # I/O
    data = lire_image(image) 

    # gros du travail
    redimensioner(data)
    mettre_en_noir_et_blanc(data)
    ajouter_ombre(data)

    # I/O
    ecrire_image(data, image)

# On crée deux processus, un pour traiter les 500 premières images,
# un pour traiter les images de 500 à 1000
p1 = Process(target=traiter_les_images, args=(0, 500))
p2 = Process(target=traiter_les_images, args=(500, 1000))
# On les démarre, ils se séparent alors du programme pour
# devenir indépendant
p1.start()
p2.start()
# on dit au programme d'attendre la fin des deux processus
# CE programme bloque ici, mais les deux processus, eux,
# ne bloquent pas.
p1.join()
p2.join()

Dans cet exemple, il y a TROIS processus : votre programme Python, et les deux processus qui vont traiter les photos, qui consistent ni plus ni moins en la fonction traiter_les_images() qui a maintenant un process pour elle toute seule.

La plupart des langages ont ce genre de mécanisme pour faire du travail en parallèle. Java utilise les threads par exemple. Javascript utilise les Web Workers.

Nous traitons des données de plus en plus massives (jeux vidéos, encoding divx, retouche d’images, montage de sons…), et maîtriser la parallélisation permet donc d’optimiser les ressources de nos machines modernes afin d’être toujours plus efficace.

Si il y a plusieurs entités indépendantes, c’est concurrent

Si vous avez un serveur et un client, c’est de la programmation concurrente. Si vous avez un module qui s’occupe des I/O utilisateurs, un qui s’occupe de la base de données et un qui surveille le comportement de l’OS, dans des processus séparés, et qui communiquent entre eux, c’est de la programmation concurrente.

La programmation concurrente suppose que chaque acteur de votre système est indépendant et possède son propre état. Idéalement, les acteurs sont capables de communiquer entre eux. Généralement, ils partagent une ressource à laquelle ils doivent accéder, par exemple un fichier de log. Et c’est là qu’il faut faire attention : certaines ressources ne sont pas faites pour êtres utilisées en même temps par plusieurs process. C’est pour ça qu’on parle d’accès concurrent comme d’un gros problème en informatique.

Un exemple de programmation concurrente en Python serait d’avoir un process qui regarde régulièrement si il y a des mails, et les sauvegarde. Si il reçoit un message suspect, il envoie le message à un autre process, un anti-virus, qui en plus de surveiller l’ordi, peut désinfecter le mail. Exemple :

from multiprocessing import Process, Queue

entree_traiteur_de_mail = Queue()
entree_anti_virus = Queue()

def traiter_les_mails():

    # Les processus qui tournent continuellement
    # en arrière plan sont juste boucle infinie
    while True:
        mail = obtenir_mail()
        # Si un mail est suspect, on l'envoie
        # au processus de l'anti-virus, 
        # et on attend qu'il nous le renvoie
        # tout propres.
        # Les deux processus sont indépendant,
        # ils fonctionnent l'un sans l'autre et
        # ne sont pas dans la même VM.
        if mail_est_suspect(mail):
            entree_anti_virus.put(mail)
            mail = entree_traiteur_de_mail.get()
        sauvegarder_mail(mail)


def anti_virus():

    while True:
        # L'anti-virus vérifie périodiquement 
        # s'il n'a pas un mail à nettoyer,
        # mais n'attend que 0.01 seconde, et si
        # rien ne se présente, continue son 
        # travail.
        try:
            # Si il y a un mail à désinfecter,
            # il le nettoie, et le renvoie
            # au processus de traitement de mails.
            mail = entree_anti_virus.get(0.01)
            desinfecter_mail(mail)
            entree_traiteur_de_mail.put(mail)
        except TimeoutError:
            pass
        # L'anti-virus ne fait pas que desinfecter 
        # les mails, il a d'autres tâches à lui
        verifier_virus_sur_system()


# On lance les process. La plupart du temps, il n'y a 
# pas de mail suspect, et donc les deux processus
# n'en bloquent pas. En cas de mail suspect ils bloquent
# le temps d'échanger le mail entre eux.
process_traitement_mail = Process(target=traiter_les_mails)
process_anti_virus = Process(target=anti_virus)
process_anti_virus.start()
process_traitement_mail.start()
process_anti_virus.join()
process_traitement_mail.join()

La programmation concurrente est donc une question d’architecture : vous êtes en concurrence ou non si vous décidez de répartir votre code entre plusieurs acteurs indépendant ou non. Les acteurs peuvent avoir des tâches distinctes, et ne pas se bloquer, mais communiquer sur les tâches communes. L’avantage de la programmation concurrente, c’est sa robustesse : si un process plante, le reste de votre programme continue de fonctionner. C’est pour cette raison qu’Erlang, un langage connu pour créer des systèmes increvables, base toute sa philosophie là dessus : un programme Erlang est composé de milliers d’acteurs communiquant entre eux par messages.

Hey, mais, attends là !

Ton exemple de programmation parallèle, c’est aussi une exécution concurrente. Et puis si on fait pleins de processus, pour faire la même tâche d’I/O, ils ne se bloquent pas entre eux, donc c’est non bloquant sur l’I/O, c’est asynchrone !

Allez-vous me dire, fort intelligement. Car nous avons des lecteurs intelligents.

Hé oui, effectivement, ce sont des notions qui se chevauchent. Comme je vous l’ai dit, c’est une question de point de vue. Si on se place du point de vue de l’algo, on peut paralléliser le traitement, ou non. Et il y a plusieurs manières de paralléliser. Si on se place du point de vue de l’I/O, on peut bloquer ou non, et alors on est dans de l’asynchrone. Si on se place du point de vue des acteurs, on peut en avoir plusieurs indépendants ou non, alors on est en concurrence.

En fait, même plusieurs acteurs qui communiquent entre eux sont considérés comme étant chacun en train de faire de l’I/O, avec les autres…

Bref, ces 3 termes, c’est de la sémantiques. Au final, ce qui importe, c’est que vous compreniez les enjeux qu’il y a derrière pour écrire un programme qui fasse son boulot comme il faut, et finisse en temps et en heure.

]]>
http://sametmax.com/la-difference-entre-la-programmation-asynchrone-parallele-et-concurrente/feed/ 35 7378
Première approche du langage Erlang (vu par un Pythoniste) http://sametmax.com/premiere-approche-du-langage-erlang-vu-par-un-pythoniste/ http://sametmax.com/premiere-approche-du-langage-erlang-vu-par-un-pythoniste/#comments Mon, 23 Apr 2012 22:32:15 +0000 http://sametmax.com/?p=430 Un jour, Max a eu envie de créer un logiciel de chat dédié au monde du porno. Quand il a ce genre d’idée à la con qui va prendre 10 ans de développement, il fait ce qu’aucun développeur expérimenté ne ferait : il se lance directement dans un truc très compliqué qu’il ne comprends pas, il le fait marcher au hasard en 10 jours, et après il se fout de la gueule des gens qui lui ont dit que ça ne marcherait jamais. Moi le premier.

Sam, si c’est idiot et que ça marche. Ce n’est pas idiot.

La capacité à développer au radar mérite un article à lui tout seul, mais en l’occurence cet article est là pour parler d’Erlang, le langage qui fait tourner eJabberd, le serveur de chat que Max avait installé.

Ce serveur a été bidouillé, transconfiguré, des plugins ont été rajoutés à chaud, enlevés, recompilés, avec erreur, sans erreur. Peu importe, le serveur n’a jamais cessé de fonctionner malgré tous les mauvais traitements qu’on lui a fait subir.

Devant cette étonnante robustesse, nous avons regardé le code source : un ensemble de symboles absolument incomprehensibles qu’une recherche sur le net nous a permis d’identifier sous le nom d’Erlang, pour Ericsson Language.

Ce langage de programmation a été en effet crée par la firme de téléphonie il y a 20 ans afin de gérer ses infrastructures avec le minimum de défaillances. Et de notre expérience, ça marche plutôt bien, mais il faut également pouvoir se taper la syntaxe qui tient plus des maths que de la prog.

Exemple avec la fonction factorielle en Python(1):

def fact(n):
    n = abs(n) or 1
    for i in xrange(n - 1, 1, -1):
        n = i * n
    return n

Le même chose en Erlang:

fact(N) -> fact(N,1).
fact(0,Acc) -> Acc;
fact(N,Acc) when N > 0 -> fact(N-1,N*Acc).

Notez que c’est une implémentation à récursion terminale, et comme Erlang n’accumule pas les stack frames en cas de TCO, elle est équivalente en terme de performance à la version itérative de Python. On pourrait faire plus simple, mais ça consommerait plus de ressources. Et si vous n’avez rien pigé à ce paragraphe, ne vous inquiétez pas, je ne l’aurais pas compris encore ce matin.

Comme on pense remettre le couvert avec un projet de chat plutôt maousse, et que le stateful temps réel est le point fort du langage, je me suis dis que j’allais me bouffer un petit tuto. Je vous fais le tour du proprio.

Il n’y a pas de boucle

Pas de for. Pas de while. Pas de foreach. Nada. Tout se fait par récursion, et si vous n’avez pas fait beaucoup de programmation fonctionnelle dans votre vie, c’est un vrai chamboulement dans la manière de penser un programme.

Heureusement, comme Python, Erlang vient avec un tas d’outils qui permettent d’effectuer des traitements itératifs sans se noyer(noyer(noyer())):

  • listes en intention;
  • unpacking;
  • fonctions anonymes;
  • any(), all(), takewhile(), dropwhile(), zip(), etc. Tout ce qu’on trouve dans Python itertools et bien plus.

Les variables sont psychorigides

Comme en Python, les variables sont types dynamiquement (pas besoin de déclarer le type) et fortement (pas de transtypage implicite). Python possède également le concept de variables non mutables, par exemple, on ne peut modifier un entier. Mais Erlang va plus loin, et interdit la modification d’une variable dans le même scope :

8> A = 1. 1
9> A = 2. ** exception error: no match of right hand side value 2

En clair, quand vous faites un assignement en Erlang, il est là pour rester. C’est un choix à la fois technique et philosophique, qui permet au langage se prémunir encore plus des effets de bord, et d’encourage l’usage des messages.

Comme à la poste

Car Erlang fonctionne ainsi : vous avez une machine virtuelle qui fait tourner du bytecode, comme en Python. Mais contrairement à Python, on peut diviser le travail en des centaines d’actors, un concept qui est un mélange entre un worker et un objet.

Comme un objet car il encapsule son propre état mais peut le partager avec d’autres, contrairement à un worker, et qu’il tourne avec la même machine virtuelle que tous les autres. Mais comme un worker car chaque actor est un process séparé, qui vit, plante, et travaille dans son coin. En total concurrence avec les autres acteurs.

Par exemple si vous avez un jeu video en ligne, chaque joueur peut être un acteur dans le programme.

Pas de problème de GIL, de thread, de synchronize, ou quoi que ce soit. Chacun chez soi, et quand on veut partager une information ou donner un ordre, on envoit un message, comme une lettre, qui peut arriver ou non, être traité ou non, et obtenir une réponse… ou non.

On ne part pas du principe que tout marche, on essaye juste de faire marcher le plus de choses possible, le mieux possible. En effet Erlang possède un mécanisme de gestion des erreurs perfectionné, et faire crasher un process fait partie intégrale du workflow.

C’est assez dur à appréhender au début. Comme quand on vient de Java (LBYL) et qu’on apprend qu’en Python, on utilise try/catch pour la gestion de flux (EAFP). Encore une fois, Erlang va plus loin, et utilise le crash comme gestion de flux. En cas de plantage, le process est simplement collecté, et relancé. Cette opération est très légère en Erlang, tout comme le passage de messages ou la concurrence entre les actors.

Cela rend non seulement Erlang résitant aux pannes, mais cela simplifie aussi grandement la gestion des erreurs. On ne se demande plus si les choses vont marcher, on se demande uniquement ce qu’on va faire quand ça marche.

Les aspects zarbs

  • Le pattern matching. C’est une forme de filtre qui utilise l’unpacking. Difficile à résumer en deux mots, d’autant qu’il s’utilise un peu partout. Il va me falloir du temps à me forcer à raisonner avec.
  • Le return implicite. Si vous avez programmé en coffee script, vous vous sentirez comme à la maison. En plus la syntaxe des fonctions est similaires.
  • Les atoms. Le concept le plus proche serait un mix entre les symbols en Ruby et les enums en C. Rajoutez à cela qu’il n’y a pas de valeur null comme None.
  • Les opérateurs de comparaisons et les conditions sont franchement tordues. Et il y a la notion de “guards“, une forme de check qui remplace la vérification de type par une vérification de condition, mais qui n’accepte qu’un subset limité d’appel de fonctions dans le corps de l’instruction. Après quelques recherche, la majorité des gens se limitent à utiliser l’instruction “case” au maximum, qui reste ce qu’il y a de plus clair.
  • or, and, orelse, andelse. La première lettre des variables obligatoirement en majuscule. Pas de clause “else”. Et des instructions qui terminent non par “;”, mais par “.”. “%” comme début de commentaire. C’est louche.

Par exemple, en Python(2) :

def help_me(animal, _says="fgdadfgna"):
    if animal == "cat":
        _says = "meow"
    if animal == "beef":
        _says = "mooo"
    if animal in ("dog", "tree"):
        _says = "bark"
    return (animal, "says " + _says + "!")

En Erlang :

help_me(Animal) ->
    Talk = if Animal == cat -> "meow";
              Animal == beef -> "mooo"; % ...dur de faire la différence entre ";"
              Animal == dog -> "bark";
              Animal == tree -> "bark";
              true -> "fgdadfgna"
    end, % ... et "," ...
    {Animal, "says " ++ Talk ++ "!"}. % ... et "."

En conclusion

La syntaxe alambiquée, la gestion des strings misérables, l’obligation de compiler et l’absence de certains outils de confort comme les paramètres par défaut des fonctions rendent Erlang a priori peu attratif pour un codeur Python.

Pourtant les deux langages ne sont pas si différents. On retrouve les listes, les tuples et les modules. On sent qu’Erlang est plus proche de sa logique métier, et que Python est plus abstrait, et plus généraliste. Mais un développeur à l’aise avec l’un saura retrouver ses billes avec l’autre, d’autant qu’un shell interactif est présent dans chacun des cas.

Cependant se pencher sur Erlang est vraiment inspirant. On y découvre une approche nouvelle de problèmes qu’on connaissait déjà, et des paradigmes différents qui bénéficieront forcément aux prochaines sessions de dev sous M150. Un peu comme se lancer dans Lisp, seulement avec l’impression que ça peut servir à quelque chose.

Mais le plus important reste que Erlang et Python sont vraiment deux langages complémentaires. Python est un maître dans l’analyse de données, le trairement de chaînes et le scripting tout en étant une grosse bille pour les process concurrents. Erlang n’aime pas les calculs rapides ou les jeux de chiffres fournis, par contre quand il faut gérer des milliers de petites tâches en même temps tout en se prenant des baffes dans le gueule, c’est un champion.

Bref, c’est un outil intéressant à garder sous la main. Je ne sais pas à quel point cela va être praticable de développer un gros projet avec, mais si un gros opérateur télécom nordique l’utilise depuis deux décénies, je suis assez optimiste. Et puis c’est ça ou Node.js, alors quitte à se taper un langage de merde pour traiter massivement de la concurrence, autant choisir une techno qui a déjà un serveur de jabber bien rodé.


(1) En vérité, si le module Python math n’avait pas déjà factorial(), j’écrirais plutôt la fonction ainsi :

def fact(n, _mul=lambda x, y: x * y):
    return reduce(_mul, xrange(abs(n), 1, -1), 1)

Mais je suppose qu’un expert Erlang aurait également une version de la mort qui tue.

(2) Ok, c’est moche. On ferait plutôt ça:

SAYS = {"cat": "meow", "beef": "moo", "dog": "bark", "tree": "bark"}
def help_me(animal, default="fgdadfgna", says=SAYS):
    return (animal, "says %s!" % says.get(animal, default))

Et en Erlang surement un “case”.

]]>
http://sametmax.com/premiere-approche-du-langage-erlang-vu-par-un-pythoniste/feed/ 5 430