Programmation asynchrone : parallèle ou concurrente ? (Partie 1, Ruby & JavaScript)

Ces deux notions dépendent de contextes différents mais l’idée est la même, nous voulons exécuter plusieurs algorithmes en parallèle. Pour y parvenir, certains langages proposent une ou plusieurs API asynchrones dont le comportement diffère.

Programmation asynchrone

Ce qui caractérise un programme synchrone est l’inactivité du CPU tant qu’une IO (Input/Ouput) n’a pas terminé son activité, on dit qu’il est « bloqué ».
La programmation asynchrone désigne un algorithme dont un fil d’exécution n’attend pas le résultat des instructions précédentes avant d’appeler les suivantes. Ce type de programmation est utile lorsqu’un programme utilise des IO telles que saisie utilisateur, print sur un terminal, lecture sur une socket, écriture sur le disque, etc.

En Ruby, le code est bloquant nativement : les appels sont réalisés de manière synchrone. Voici comment on fait des callbacks en Ruby :

Et voici une démonstration de blocage via deux appels successifs :

Résultat dans irb :

À l’inverse, JavaScript possède nativement des API asynchrones. Voici un exemple de callbacks :

Les appels :

Résultat dans ma console Firefox :

À ce stade, impossible de dire comment JavaScript a résolu ce problème « en interne ». Le concept de programmation asynchrone est là, l’implémentation est-elle concurrente ou parallèle ?

La programmation concurrente

Dans l’exemple précédent, la fonction setTimeout a ajouté la fonction anonyme qui lui est passée en paramètre dans une queue de la boucle d’évènements et a tout de suite rendu la main. Le fil d’exécution reste dans un seul processus : lorsque la fonction anonyme retardée sera déclenchée, le reste du script et elle se partageront un même temps d’exécution. Chaque fil d’exécution sera interrompu un court laps de temps pour laisser le second s’exécuter et réciproquement.

JavaScript apporte un mécanisme dédié à l’asynchronisme concurrent, ce sont les promesses. Le principe est le suivant : on déclare une fonction anonyme dans un objet Promise. Dans cette fonction, on écrit le code que l’on souhaite rendre asynchrone. La déclaration étant faite, il ne reste qu’à appeler l’objet quand ça nous chante. On peut l’appeler en utilisant mon_objet_promise.then(une_autre_fonction_anonyme). Cette autre fonction anonyme déclenchée par then() sera synchrone vis à vis de notre objet Promise, mais la suite de notre programme a déjà poursuivi sa route.

En programmation concurrente, les fils d’exécutions se partagent un unique processus. Le temps que va mettre le code à s’exécuter dans le fil asynchrone vis à vis du fil principal n’est pas prédictible. Puisque chaque fil d’exécution s’interrompt pour laisser la main au suivant, l’espace mémoire (variable, base de données) est modifié atomiquement, valeur après valeur. À un instant T, une variable n’a qu’un seul état possible, même s’il est modifié tour à tour par chaque fil d’exécution et que le développeur ne peut prédire son contenu.

Il n’existe pas d’équivalent dans la bibliothèque standard de Ruby à ma connaissance. Pour faire de l’asynchronisme, il faudra se tourner vers des gems comme EventMachine, Celluloid, reposant au choix soit sur un wrapper vers une autre techno, soit sur une implémentation thread-safe en Ruby.

La programmation parallèle : thread-safe, wait, what ?

En programmation parallèle, un fil d’exécution asynchrone est lancé indépendamment du fil d’exécution principal : c’est un thread. En théorie il fait sa vie, ne consomme pas le temps du fil principal, le l’interrompt pas, ne le bloque pas et n’accède pas à son espace mémoire.

Concernant les ressources partagées (variables, base de données), un problème épineux apparait. À un instant T, deux threads peuvent lire le même état d’une donnée commune et décider d’y inscrire une nouvelle valeur, rigoureusement simultanément. Le problème, c’est que cette ressource ne va pas prendre un état puis l’autre mais être accédée simultanément : son état devient incohérent.

Le concept de thread-safe défini donc comment une ressource peut être partagée par plusieurs threads de manière sûre. Pour illustrer, MySQL utilise des verrous internes lorsqu’un utilisateur modifie une ligne ou une table, il interdira toute autre modification jusqu’à ce que l’opération soit terminée. Les modifications bloquées sont placées dans une file et exécutées les unes après les autres.

Subtilité, utiliser des threads en Ruby 1 ou 2 ne fait pas un programme multi-tâches au sens du système d’exploitation. En effet, l’interpréteur Ruby utilise un Global Interpreter Lock (GIL) qui manipule les threads en les interrompant tour à tour (à moins qu’un thread ne soit bloqué), dans le but de proposer la création de Mutex (verrous). Bien que l’interpréteur Ruby puisse créer de multiples processus sur différents cœurs du CPU, ils ne seront pas exécutés de manière parallèle et ne profitent donc pas d’un gain de performance. Je vous incite chaudement à lire Multithreading in the MRI Ruby Interpreter par Christoph Schiessl, ainsi que cet article traitant de l’évolution qui pourrait se produire avec Ruby 3.

Nous verrons dans un prochain article les différentes possibilités de programmation asynchrone qui s’offrent à nous en Ruby.

Cet article est inspiré de celui du même nom écrit par Sam & Max en Python.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.