Javascript VS C++ – Les moteurs d’exécution

Contrairement au programmes C ou C++ qui sont principalement compilés, Javascript est généralement interprété par un moteur d’exécution. Le moteur d’exécution est dans bon nombre de cas une machine virtuelle qui va interpréter, dans le cas où il s’agit d’un interpréteur, un programme informatique. Pour ceux d’entre vous qui ne connaîtraient pas la différence entre un compilateur et un interpréteur, il faut savoir qu’un compilateur transforme un langage source en un langage cible lisible nativement par une machine, le langage machine, tandis qu’un interpréteur va analyser et traduire un programme lisible et exécutable par l’interpréteur, parfois même au cours de sa lecture. Il est donc logique, qu’un programme écrit et compilé en C et C++ soit généralement plus rapide en terme de performance qu’un même programme écrit en Javascript qui serait interprété.

L’interprétation

L’interprétation se fait en plusieurs étapes :

  • Lecture et analyse syntaxique d’une instruction
  • Exécuter l’instruction si elle est correcte, sinon déclencher une erreur
  • Passer à l’instruction suivante

Pour que le programme soit interprété de manière plus « rapide », le code source est généralement d’abord transformé en ce qu’on appelle un arbre syntaxique. Cette étape commune à la compilation et à l’interprétation permet de transformer les instructions, les structures de contrôle et les branchements conditionnels d’un programme sous la forme d’un ensemble de nœuds grâce aux règles de grammaire du langage.

https://en.wikipedia.org/wiki/Abstract_syntax_tree

Cet pré-transformation du programme qui constitue généralement une étape intermédiaire dans le cas de la compilation, permet à l’interpréteur d’exécuter le programme en traversant les branches de l’arbre selon l’état et les instructions en cours.

Javascript est généralement considéré par abus de langage comme un langage interprété, ce qui est faux puisque le langage, qui est au fond qu’un ensemble de règles de grammaire formelle, n’as aucun rapport avec le fait qu’il soit compilé ou interprété. Cela dépend de son implémentation donc des compilateurs ou des interpréteurs qui le traite. Cependant on s’accordera tous à dire que dans la plupart de ses implémentations, Javascript est bel est bien interprété et que par les concepts qui le régit ainsi que par sa nature, on peux voir qu’il a été au départ construit dans ce sens. Bien sûr NodeJS tends à étendre cette vision simpliste.

Premier avantage des interpréteurs, la portabilité. Il suffit simplement de disposer de la machine virtuelle interpréteur sur un système d’exploitation pour pouvoir y exécuter son code, sans avoir a se soucier de compiler le programme pour chacune des plateformes. Second avantage (et inconvénient) et qu’il est possible d’exécuter un programme incomplet dans certains cas. Le programme sera lu au fur et à mesure, ce qui aura pour conséquence que les erreurs éventuelles apparaîtront uniquement au moment de l’exécution de l’instruction concernée.

Par exemple :

console.log('bobo');

aaa

Message lors de l’exécution :

"bobo"

ReferenceError: aaa is not defined

Cet exemple prouve que le programme a entamé son exécution sans même se poser la question de savoir si l’instruction suivante avait un sens. Cela n’est pas toujours vrai, même un programme Javascript peut être vérifié en partie avant sa lecture et ainsi refuser son exécution s’il contient des erreurs, il s’agit de ce qu’on appelle l’analyse statique de programme (Voir partie Javascript est un langage dynamique).

En C ou C++, cette analyse statique est tellement puissante, principalement grâce au fait qu’il s’agit d’un langage statiquement typé, qu’il n’est clairement pas possible de compiler et encore moins d’exécuter un code tel que présenté ci-dessus.

Exemple :

printf("bobo");

aaa

Message lors de la compilation :

main.cpp:59:5: error: use of undeclared identifier 'aaa'

aaa

^

L’interprétation existe aussi dans de nombreuses consoles de programmation. Par exemple la console python qui permet de créer un programme tout en l’exécutant à la manière des lignes de commandes.

Finalement, les interpréteurs sont aussi utilisés à des fins d’analyse statique de programme. Ce qui permet de vérifier le bon comportement et la validité d’un programme sans l’exécuter réellement.

La semi-compilation

Pour palier aux nombreux problèmes que pose l’interprétation principalement par apport à l’optimisation de la lecture d’un programme, certaines machines virtuelles effectuent ce qu’on appelle une semi-compilation. Cette semi-compilation permet de transformer le langage source en un langage lisible et optimisé pour ces machines. C’est le cas de nombreux langages que l’on dit, par abus de langage, semi-compilé tel que Java où le code source est transformé en bytecode (code octet) ou les langages appartenant au framework .NET tel que VB, C#, F# où les programmes sont transformés en CIL (Common Intermediate Language) avant d’être finalement transcrit en bytecode.

Schéma simple représentant le processus de compilation en bytecode

Javascript est lui aussi transformé par certains moteur d’exécutions en bytecode qui leurs est dépendant. C’est le cas de SpiderMonkey par exemple qui créé un byte code basé sur la pile. Le moteur Google V8, pour certaines raisons d’optimisations, ne passe pas par cette étape et transforme directement le code Javascript [1] en code machine par le processus de compilation JIT que nous allons voir après.

La compilation JIT

La compilation JIT signifie la compilation Just In Time, compilation à la volée ou encore traduction dynamique. Elle permet à la machine virtuelle de transformer le bytecode en code machine juste avant son exécution. Ce principe permet d’optimiser la lecture du programme, par exemple lors d’appels récurrents à une fonction, celle-ci est compilée en langage machine afin de pouvoir être traitée plus efficacement et plus rapidement. Cette compilation à la volée est possible du fait que le bytecode produit auparavant n’est pas dépendant de l’architecture matérielle. De plus, la transformation du bytecode en code machine est beaucoup moins coûteux qu’une compilation à partir du code source Javascript, ce qui permet d’effectuer la compilation JIT de manière presque transparente.

Schéma simple d’une compilation JIT

Les méthodes de compilation JIT dépendent du moteur d’exécution. On peut en distinguer deux principales qui sont la compilation JIT basée sur les méthodes « per-method basis » et celle basée sur traçage linéaire appelée « Tracing just-in-time compilation ».

Par exemple, le moteur Google V8 utilise la compilation basée sur les méthodes c’est à dire que les fonctions sont compilées une à une lors de leur utilisation, ce qui permet d’éviter la compilation inutile de morceau de code jamais utilisés. De plus ce moteur d’exécution se décompose en deux « compilateurs », le but du premier étant de générer le plus rapidement possible du code machine utilisable mais pas forcement optimisé, tandis que le second repasse et retraite les fonctions à optimiser.

La seconde méthode « trace based compilation » utilisé par TraceMonkey, le compilateur de SpiderMonkey, consiste à rechercher les portions de code où l’exécution est réitéré de nombreuses fois, principalement situé dans les boucles, ce qu’on appelle les points chauds de code, et à les compiler afin de rendre leur exécution plus rapide lors du prochain passage. Pour rendre cela possible, tout le code précédemment exécuté est tracé, d’où le nom, et est stocké dans une structure de donnée appelée un arbre de traces. C’est ensuite l’ensemble du chemin tracé qui est compilé, et non une méthode. Pour ce qui est des chemins non tracés, il reste exécuté par un simple interpréteur. Les limites de ce système porte sur le fait que le nombre de traces est multiplié par les branches de contrôles, et la génération de code à la volée. Pour faire simple, si votre code est bourré de if où d’autres structures de contrôle plus ou moins utiles, il aura du mal à être exécuté convenablement sur les navigateurs utilisant cette méthode. Car chaque trace différente est compilée indépendamment, imaginez donc un code avec des dizaines de conditions imbriquées… La seconde limite se trouve dans le fait qu’un code générique, qui accepte plusieurs type pour un traitement, va donner naissance à une trace par type utilisé, ce qui est un frein pour cette méthode. En effet, la compilation par trace est abandonnée si elle prend trop de temps (si son temps est supérieur à l’interprétation simple).

Les deux méthodes peuvent cependant être complémentaires en passant la main de l’une à l’autre selon les cas. C’est ce que prévoit Mozilla firefox avec le projet JägerMonkey : créer un compilateur qui s’occupe de la compilation hors des traces et basé sur les méthodes.

Les moteurs d’exécution

Pour Javascript, de très nombreux moteurs d’exécution existent et sont intégrés dans différents environnements. Les plus connus à l’heure actuelle sont SpiderMonkey que l’on retrouve au sein de Mozilla Firefox, la Runtime Google V8 au sein de Google Chrome, Chakra à partir d’Internet Explorer 9 [2] et JavascriptCore appelé Nitro par Apple au sein de Safari. Les performances différent selon la tâche à accomplir, mais pour ne pas en faire une liste exhaustive, je vous laisserais la liberté de les tester vous même.

Avec C et C++, il existe aussi quelques projets d’interpréteurs comme UnderC [3], Ch ou encore CINT [4] utilisé au sein du projet ROOT du Cern (L’Organisation européenne pour la recherche nucléaire où le fameux LHC, large hadron collider, trouve sa place entre la Suisse et la France) tandis que Javascript dispose lui aussi de quelques projets de « compilateurs ». Par exemple « Rhino Javascript compiler » permet de transformer un code source Javascript en code source Java et de le « compiler » [5].

Le ramasse-miette

Le ramasse-miette, connu aussi sous le nom anglophone de « garbage collection », est un système indépendant qui va gérer les allocations mémoire et plus précisément la libération d’espace mémoire. Son but est donc de déterminer quels sont les blocs mémoire à libérer en fonction de divers critères. Par apport à C ou C++ où il faut explicitement allouer puis libérer la mémoire, Javascript alloue la mémoire par la déclaration d’une variable puis la libère implicitement grâce au ramasse-miette intégré à l’environnement du moteur d’exécution.

Allocation en Javascript :

var n = 10; //Allocation d'un number

var s = "Hello"; //Allocation d'une chaîne de caractères

var obj = {
    a:"Hello",
    b:"World"
}; //Allocation d'un objet

var arr = [0,1,2]; //Allocation d'un tableau

var f = function() {
    console.log('Ma fonction');
}; //Allocation d'une fonction

En Javascript, la mémoire qui stocke les variables, les fonctions et les objets est alloué lors de la déclaration.

Allocation en C :

int *i = malloc(sizeof(int)); //Allocation bloc mémoire de taille int

*i = 10; //Affectation de la valeur

printf("%i", *i); //Ecrit la valeur

free(i); //Libère le bloc mémoire pointée

i = NULL; //Force le pointeur à NULL pour éviter les comportements indéterminés

En C, l’allocation vers un bloc mémoire doit être défini explicitement via la méthode malloc. Une fois que la variable ne nous est plus nécessaire, on peux libérer la mémoire en le définissant explicitement avec la fonction free. On remarque que le pointeur est mis à NULL après la libération du bloc mémoire afin d’éviter les « dangling pointer », c’est à dire le même pointeur mais qui ne pointe plus sur le même contenu, ce qui pourrait provoquer lors de sa manipulation ou de l’accès à son contenu des crashs ou même donner des sorties erronées [6].

Allocation en C++ :

A *a = new A(); //Allocation d'un objet A

a->i = 10; //Affectation de son champ i

a->n = 20; //Affectation de son champ n

printf("%i %i", a->i , a->n); //Ecrit les valeurs de l'objet a

delete a; //Libère la mémoire alloué pour l'objet a

En C++, l’allocation mémoire et faite lors de l’instanciation d’une classe.

Les objets pour lesquels il n’existe plus de références vont pouvoir être libérés tout de suite tandis que déterminer le moment où les autres objets peuvent être libérés peu être une tâche beaucoup plus fastidieuse, notamment pour un langage dynamiquement typé comme Javascript. Savoir si un bloc mémoire est encore utile ou non est un problème indécidable, c’est à dire qu’il ne peut pas être totalement résolu par un algorithme. Cependant, certains algorithmes d’approximation qui ont fait leurs preuves sont utilisés par les ramasses-miettes. Les deux algorithmes les plus connus sont le comptage de références et l’algorithme « marquer et balayer ».

Le comptage par référence, consiste tout simplement à compter le nombre de référence pointant vers un objet. Lorsque ce nombre tombe à zéro, cela signifie qu’il n’existe plus de références pointant vers l’objet, la mémoire qu’il lui a été alloué peut ainsi être libérée.

Cet algorithme pose malheureusement problème lors des références cycliques, c’est à dire lorsqu’un objet A référence un objet B qui lui même référence A.

function f() {
    var A = {};
    var B = {};
    A.content = B;
    B.content = A;
};

f();

L’objet A contient l’objet B et l’objet B contient l’objet A. Même si l’objet A ou B ne sont plus utilisés leur nombre de références sera toujours supérieur à zéro, ici en l’occurrence 1, ce qui entraîne une fuite de mémoire.

Cet algorithme a d’ailleurs été utilisé dans Internet Explorer 6 & 7 pour la gestion de la mémoire du DOM ce qui pouvait donner lieu à des fuites de mémoire lors de référence cyclique entre les objets de DOM et Javascript.

L’algorithme marquer et balayer ou encore appelé algorithme traversant est un peu plus malin car il consiste à parcourir le graphe des objets depuis l’objet racine et à vérifier si un objet est atteignable. Si un objet n’est plus atteignable, la mémoire allouée par celui-ci sera libéré par le ramasse-miette.

Dans l’algorithme marquer et balayer chaque objet va avoir une « couleur ». Par exemple, la couleur blanche signifie que l’objet n’as pas encore été traité et la couleur noire signifie que l’objet traité est atteignable.

Grossièrement, l’algorithme se déroule en suivant ces étapes :

  1. L’algorithme va parcourir le graphe et marquer chaque objet qu’il traverse (donc les objets atteignables) avec la couleur noire.
  2. Une fois la marquage terminé l’algorithme libère tout les objets qui sont encore de couleur blanche car cela signifie qu’il n’ont pas été atteints.
  3. Finalement les objets marqués en noirs sont tous coloré à nouveau avec la couleur blanche et l’algorithme peut recommencer.

[Mark-Sweep]

Il existe des variantes de cet algorithme avec trois ou quatre couleurs, mais nous ne rentrerons pas en détail. En tout cas, c’est cet algorithme amélioré par diverses méthodes que l’on retrouve majoritairement dans l’ensemble des moteurs d’exécution récents.

Bien que le ramasse-miette permette d’alléger le travail du développeur et d’éviter certains bugs dus à un mauvais management de la mémoire, il n’est pas sans revers. Il va avoir un impact sur les performances globales et consommer des ressources supplémentaires. De plus, la libération de la mémoire est imprévisible, ce qui pose problème dans les environnements temps réels, sur les systèmes de gestion de transaction et ceux impliquant des traitements concurrents.

J’espère que cet article, non exhaustif, vous aura permis de plonger un peu plus loin sur les processus mis en œuvre pour assurer la bonne exécution d’un programme écrit Javascript et dans beaucoup d’autres langage interprété ou semi-compilés.

Sources

[1] Compilateur Google V8 : http://jayconrod.com/posts/51/a-tour-of-v8-full-compiler

[2] http://en.wikipedia.org/wiki/JavaScript_engine

[3] http://home.mweb.co.za/sd/sdonovan/underc.html

[4] Projet ROOT du Cern : https://root.cern.ch/drupal/content/cint

[5] Rhino compiler : https://developer.mozilla.org/en-US/docs/Mozilla/Projects/Rhino/JavaScript_Compiler

[6] Dangling pointeurs : http://www.yourdictionary.com/dangling-reference#computer


Laisser un commentaire