La différence entre la programmation asynchrone, parallèle et concurrente


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.

35 thoughts on “La différence entre la programmation asynchrone, parallèle et concurrente

  • cym13

    Bon sang, si vous saviez comme j’attendais un article comme celui-ci sans le savoir ! J’avais tatouillé de l’erlang et c’est vrai que cette histoire de concurrence m’avais remué.

  • Sam Post author

    Comme je le dis souvent, une grande partie de la difficulté en info c’est qu’un tas de notions sont mal ou pas expliquées. Après on pense que ce qu’on essaye de comprendre est difficile, sans vraiment pouvoir mettre le doigt dessus. Mais c’est simplement qu’il manque des bouts.

    Certaines personnes sont très douées pour remplir les espaces vides sans aide extérieure, mais la plupart d’entre nous ont besoin de ces explications. Le problème c’est que les très bons dev font généralement partie de la première catégorie, et donc ne s’aperçoivent pas que leurs explications, qui sont juste une répétition des informations qui leur ont permis de comprendre, eux, sont lacunaires.

  • Syl

    Salut Sam!

    il n’y a rien aucun effort à faire pour cela.

    Sinon, content de voir le blog repartir!

  • Sam Post author

    Merci les gars, j’ai corrigé toutes les conneries.

    Je sais pas pourquoi, mais écrire algorithme “algorythme” est une erreur qui me colle à la peau.

    Sinon mon pti @syl, tu vas êtres triste, car je remet le blog en pause pour quelques jours, je vais crapahuter dans de vertes contrées et les arbres n’ont pas le wifi.

  • kontre

    Présentation claire et nette. Je connaissais grosso merdo les principes, mais ça permet de tout mettre à plat.
    Le gros souci de ces machins, c’est que c’est assez chaud à débugger et à optimiser. Et du coup difficile à mettre en œuvre.

  • Sam Post author

    Oui, surtout l’asynchrone. Car finalement le parallélisme et la prog concurrente, il suffit de prendre chaque bout individuellement, et ça va, si tu n’as pas fait l’erreur de partager des états (genre une variable globale mise à jour avec deux threads…). Mais la prog asynchrone, ça te fait des stacks traces inbitable. Du coup, tulip, l’interprétation de l’asynchrone en Python, est super pour ça : on a un stack trace parfaitement linéaire.

  • elwan7

    Salut Sam très claire les explications sinon “C’est là que les systèmes synchrones font un peu de magie.” ça devrait pas être le contraire ou j’ai mal compris ?

  • Sam Post author

    Non, non, les systèmes synchrones font bien un peu de magie : ils cachent l’events loop aux yeux du programmeur. Au final, on donne l’impression que ça marche tout seul, mais ce n’est pas le cas, il y a du libevent en C écrit derrière ou un autre truc qui tourne pour rendre l’opération aussi naturelle.

  • N

    Et je me demandais, est-ce que Process peut prendre un objet en “target”, du genre :
    p1 = Process(target=monobjet.mamethode, args=(0, 500))

  • Sam Post author

    Sans probleme. N’importe quel callable. Ça va passer à la moulinette de pickle.

  • N

    J’ai vu (je viens de tester), par contre, on peut pas récupérer la valeur retournée par la fonction? J’arrive pas à trouver dans la doc python.

    D’autre part, imaginons le cas d’un traitement de pleins d’éléments:

    processes = []
    for elem in monarray:
    p = Process(target=mafonction, args=(elem,))
    p.start()
    processes.append(p)

    Et le join, on le fait où ?
    après le p.start() ?
    Ou on repasse sur le tableau des processes pour join chacun des process?

  • PrFirmin

    Ça commence à virer au “Python Sam Exchange” dans les commentaires :o))

  • kontre

    @N : si tu en as un paquet, untilise plutôt un Pool de processus.

    Tant qu’on est dans les questions, je voudrais faire un truc qui m’a l’air tout bête mais que je n’arrive pas à faire marcher correctement. Je dois lire beaucoup de données sur disque et faire un traitement dessus. Le traitement est plutôt rapide par rapport à la lecture des données. Je voulais faire un processus qui lit le fichier et qui envoie les données au second. Seulement je n’ai aucun gain de perfo, je suppose que comme ça passe par pickle je perds tout l’intérêt du truc. Y’a un truc spécial pour faire ça ? Je ne dois pas être le seul à vouloir faire des trucs comme ça en python ?

  • Fornicator

    Bon article, du coup après, il faudra évoquer les files d’attente et les canaux pour que tous ces petits musiciens puissent s’accorder entre eux.

    Enfin on pourra regarder aussi ailleurs, en particulier ce genre de vidéo :

  • keiser1080

    l’article tombe super bien!
    Je suis en plein dedans.
    Pour chaque field issue d’un select dans une db.
    Je doit faire appel à un service rabbit mq rpc faire un mix entre les données de la db et ceux du service rabbit mq,et cree une nouvelle table avec l’ensemble des donnée.
    La reponse du service rabbit mq dure 1S+- * 65000 ça prend +- 13heures.
    Je me suis pris comme celà :
    je boucle sur le select et je balance le resultat dans une queu rabbitmq. Je lance 3 worker qui traite les donnée de la queu et qui interroge le service je stock dans une variable temporaire et ensuite je dois envoyer dans une autre queue mais la sa se casse la gueule la variable vaut none.

    AARGHHHH ça prend la tete!

  • noobInside

    Bonjour,

    D’après vous, quel bon livre (en français svp) pour apprendre les BONNES base de dev? Je “code” de temps en temps en python mais je me gauffre sur des concepts généraux de dev. Donc je perds du temps. Et quand je lis ce très bon article, bah je me dis que j’ai des bonnes lacunes.
    Merci de vos conseils.

  • Stephane

    optenir -> oBtenir
    on l’envoit -> on l’envoiE
    il nous le renvoit -> il nous le renvoiE
    si il n’a pas -> s’il n’a pas
    continu son
    # traval. -> continuE son # travaIl.
    en bloquent pas -> N’en bloquent pas

  • pirateboxge

    Vous êtes des killers pédagogiques !!!
    J’ai pu colmater pleins de brèches grâce à ce tuto.
    Merci.

  • n_arno

    Sauf erreur, dans le dernier script, y’a une boulette:

    process_anti_virus.start()
    process_anti_virus.start()

    Au lieu de :

    process_anti_virus.start()
    process_traitement_mail.start()

    Dans tous les cas, merci pour cet article clair et pratique :D

  • Teocali

    Juste une remarque :

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

    C’est pqs tout a fait vrai si je ne m’abuse : C’est la JVM qui decide quand elle parallelise, et quand elle ne le fait pas. D’apres la doc il semblerait que seul l’API Fork/Join te permette d’etre sur defaire du parralelisme (si disponible).

  • elteut

    Je suis débutant alors je n’ose à peine, surtout un an après … , oh et puis si .. :

    N’y a-t-il pas une inversion, là ?

    process_anti_virus = Process(target=traiter_les_mails)

    process_traitement_mail = Process(target=anti_virus)

  • Gozer

    “Si c’est de l’IO, c’est asynchrone”

    Il ne faudrait pas que le lecteur en déduire une réciproque erronée. Javascript est asynchrone. Les événements , les appels des timers, (et certainement d’autres trucs) sont placés dans des files d’attente et exécutés “si le navigateur a le temps“. C’est asynchrone, et ce n’est pas de l’IO.

  • Sam Post author

    Tout à fait. Il faudrait un jour que je fasse un dossier complet sur ces concepts, parce qu’un article seul amène toujours des simplifications.

  • Oliverpool

    Un peu de déterrage : l’indentation de la boucle while True: de l’anti_virus est erronée (il manque un niveau pour le try)

  • Sek

    Dans la phrase

    C’est là que les systèmes synchrones font un peu de magie.

    . C’est synchrones ou asynchrones?

  • Beafantles

    Sympa l’article, je remonte une petite erreur d’orthographe, située à la toute fin :

    Bref, ces 3 termes, c’est de la sémantiques. (Le “s” est à enlever)

  • Beafantles

    De même, une autre petite erreur avant le second bout de code :

    Dans ce cas ce n’est pas très grâve (Il n’y a pas d’accent circonflexe sur le “a”).

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.