Cet article est publié sous licence CC BY-NC-SA
Comme expliqué dans le précédent article sur MacRuby, Ruby a été pensé à l’origine pour fonctionner sur des machines mono-processeur. L’interpréteur officiel Ruby (MRI) utilise des threads émulés (green threads) pour simuler le multi-tâches. Malheureusement cette façon de procéder ne permet pas de tirer partie des processeurs multi-coeurs. Avec les green threads, les tâches ne sont pas réellement exécutées en parallèle puisqu’elles le sont toutes sur le même processeur.
MacRuby a pris le parti de faire sauter le verrou global qui a jusque lors régissait le fonctionnement de l’interpréteur Ruby et d’utiliser des threads natifs. Récemment, avec l’arrivée de Mac OS X 10.6, MacRuby a pu pousser cette tendance encore plus loin en ajoutant le support de GCD. Grand Central Dispatch est une technologie mise au point par Apple pour faciliter la vie des développeurs. GCD va leur permettre d’utiliser en toute facilité la puissance des processeurs multi-coeurs. Utiliser GCD permet de masquer au développeur toutes les subtilités et les détails de la gestion des threads, plus besoin pour lui de créer, gérer et détruire ses threads POSIX. Le gros avantage de cette technologie est qu’il devient trivial de mettre en place un morceau de code qui utilisera toute la puissance de la machine pour des traitements lourds.
Disponible en premier lieu uniquement en Objective-C, l’équipe de MacRuby a récemment sortie une nouvelle version de l’interpréteur capable de tirer partie de cette technologie. On peut donc maintenant, depuis notre langage préféré, utiliser de vrais threads et ce en plus de manière très simple.
Les files d’attente (Queues) sont des structures de données qui permettent d’exécuter des tâches de manière concurrente ou séquentielle. Une file d’attente séquentielle exécutera les tâches dans l’ordre reçu, l’une après l’autre, alors qu’une file d’attente concurrentielle peut exécuter plusieurs tâches à la fois.
Dans MacRuby, les files d’attente sont gérer par la classe Dispatch::Queue
. GCD maintient, pour le développeur, un ensemble de threads POSIX et s’occupe de dispatcher les tâches. GCD va également se charger de distribuer les tâches aux processeurs disponible sans que le développeur n’ai à s’en soucier.
GCD offre 3 types de files d’attente :
Créer et gérer une file d’attente est très simple via GCD :
# Création d’une file d’attente en série queue = Dispatch::Queue.new(‘org.synbioz.gcd’)
# Dispatcher un traitement de manière synchrone queue.sync do puts ‘Travail synchrone.’ sleep 5.0 puts ‘Travail synchrone terminé !’ end
# Dispatcher un traitement de manière asynchrone queue.async do puts ‘Travaille asynchrone.’ sleep 5.0 puts ‘Travail asynchrone terminé !’ end
Le code exécuté dans le bloc #sync
sera bloquant pour la file d’attente alors que le bloc #async
sera exécuté en arrière plan sans bloquer la file.
Les files d’attente ont de nombreux avantages par rapport aux threads POSIX ou aux threads Ruby. Les threads sont très gourmands en terme de mémoire et s’avèrent lourds à créer et à détruire. Les filles d’attente sont elles au contraire légères, il est tout à fait possible de créer des milliers de files d’attente en même temps ce qui serait impossible ou particulièrement lent avec des threads POSIX. Un autre avantage notoire de GCD est que son implémentation tire parti du système de répartition des charges géré par le système.
S’assurer que les méthodes et les données ne sont utilisées que par un et un seul thread à la fois est un problème récurrent. Ruby, de base, ne prévoit aucun outil pour gérer la synchronisation, il nous fait passer par les classes Mutex
et Monitor
. GCD amène avec lui une façon bien plus élégante de procéder, de plus haut niveau que les Mutex
et bien plus simple à utiliser que Monitor
.
class Calculation def initialize @queue = Dispatch::Queue.new(‘org.synbioz.gcd.synchro’) end
def calculate
@queue.sync do
# Ici une section critique de code qui ne doit être accédé que par un thread à la fois
calculation
end
end end
Il est à noter que les files d’attente série fonctionne sur le principe du FIFO ce qui nous assure qu’un seul thread exécutera notre bloc de code à un instant T.
Lorsqu’on travaille avec des files d’attente, on a parfois besoin de savoir que toutes les tâches de cette dernière ont été exécutées. GCD fournit la classe Group
pour gérer ce cas. La classe Group
permet de synchroniser une file d'attente en toute simplicité. En passant un group en paramètre à
#async ou à
#sync, vous enregistrez la tâche dans la file comme appartenant à un groupe de tâches. Une fois des tâches enregistrées dans un groupe, vous pouvez soit attendre que toutes les tâches du groupe soient finies grâce à la méthode
wait que vous appellerez sur votre groupe ou pouvez également enregistrer un bloc de code, via la méthode
notify`, qui devra être exécuté lorsque le groupe aura fini d’exécuter ses tâches.
L’une des principales utilités des exécutions parallèles est de faire des traitements lourds en calculs en arrière plan. On peut alors utiliser les files d’attente GCD pour exécuter les tâches en coordination avec les groupes pour synchroniser l’exécution des tâches. Voici comment on pourrait implémenter ce système :
include Dispatch class Computation def initialize(&block) # Chaque thread a sa propre file d’attente FIFO dans laquelle sera dispatché # l’exécution du code passé à la variable &block. Thread.current[:computations] ||= Queue.new(“org.synbioz.gcd.computations-#{Thread.current.object_id}”) @group = Group.new # On dispatche de façon asynchrone le traitement à la file d’attente du thread. Thread.current[:computations].async(@group) { @value = block.call } end
def value
# On attent que les calculs soient finis
@group.wait
# On retourne la valeur calculée
@value
end end
Et un appel à ce système qui fera nos calculs en arrière plan :
result = Computation.new do puts ‘Long travail de calcul en arrière plan.’ sleep 10 rand end
result.value
Il est également très simple de mettre en place du code avec exécution en parallèle grâce aux groupes et aux files d’attente concurrentes fournies par GCD :
# Nous utiliseront ce tableau dans la file puisque l’interpréteur # n’a pas de Global Interpreter Lock et ne peut donc pas nous # assurer un accès thread-safe au tableau result = []
# Utilisation d'un groupe pour synchroniser l'exécution du bloc
group = Dispatch::Group.new
result_queue = Dispatch::Queue.new('org.synbioz.concurrent-queue.#{result.object_id}')
0.upto(1000) do
# Envoie la tâche à la file d'attente concurrente par défaut
Dispatch::Queue.concurrent.async(group) do
res = complicated_computation
result_queue.async(group) { result[idx] = res }
end
end
# On attend que tous les blocs aient été exécutés
group.wait # On retourne le résultat final
result
Si vous avez besoin d’optimiser des morceaux de votre code pour que ceux-ci s’exécute plus vite, utiliser la parallèlisation sera souvent la solution. Il y a bien d’autres choses encore que vous pouvez faire avec GCD, par exemple Dispatch::Semaphore est un outil puissant permettant aux threads de communiquer entre eux, de gérer et partager les ressources. Nous avons également Dispatch::Source qui fournit un moyen élégant de faire de la programmation basée sur des événements.
Si le sujet vous intéresse, je vous conseille vivement de continuer à vous documenter et d’expérimenter via MacRuby, vous verrez qu’il devient rapidement très simple de gérer des processus en parallèle et donc d’améliorer la réactivité de son application.
L’équipe Synbioz.
Libres d’être ensemble.
Nos conseils et ressources pour vos développements produit.