Sam & Max » parallèle http://sametmax.com Du code, du cul Sat, 07 Nov 2015 10:56:13 +0000 en-US hourly 1 http://wordpress.org/?v=4.1 La différence entre la programmation asynchrone, parallèle et concurrente 27 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 synchrones 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/ 27