Début avril, l'article "JavaScript: la pile d'appels et la magie de sa taille" a été publié sur Habré - son auteur est parvenu à la conclusion que chaque frame de pile occupe (72 + 8 * nombre de_variables_locales) octets: "Il s'avère que nous ont tout compté correctement et nous pouvons affirmer que la taille d'un ExecutionStack vide dans Chrome est de 72 octets et la taille de la pile d'appels est d'un peu moins d'un mégaoctet. Bon travail! "
Pour l'amorçage - modifions légèrement le code utilisé AxemaFr pour les expériences:
{let i = 0;
const func = () => {
i += 1.00000000000001;
func();
};
try {
func();
} catch (e) {
console.log(i);
}}
Au lieu de 1, maintenant à chaque étape, nous ajoutons un peu plus, et par conséquent, au lieu de 13951, nous obtenons 12556.000000000002 - comme si une variable locale était ajoutée à la fonction!
Répétons les questions posées par Senior Frontend Developer AxemaFr: «Pourquoi en est-il ainsi? Qu'est ce qui a changé? Comment comprendre, en regardant la fonction, combien de fois elle peut être exécutée de manière récursive?! "
Ustensiles de cuisine
Sur la ligne de commande Chrome, vous pouvez transmettre des arguments au moteur JS; en particulier, vous pouvez modifier la taille de la pile de 984 Ko à n'importe quelle autre clé
--js-flags=--stack-size=
.
Pour déterminer la quantité de pile requise par chaque fonction, la clé
--print-bytecode
déjà mentionnée nous aidera . Il n'a pas été mentionné que la sortie de débogage est envoyée à stdout, ce que Chrome sous Windows n'a stupidement pas, car il est compilé en tant qu'application GUI. C'est facile à réparer: faites une copie de chrome.exe, et dans votre éditeur hexadécimal préféré, corrigez l'octet
0xD4
de la valeur
0x02
à
0x03
(pour ceux qui ne sont pas amis avec l'éditeur hexadécimal, cet octet aidera à corriger le script Python). Mais si vous lisez cet article en ce moment dans Chrome et exécutez simplement le fichier corrigé - disons que vous l'avez nommé cui_chrome.exe - une nouvelle fenêtre s'ouvrira dans une instance de navigateur existante et l'argument
--js-flags
sera ignoré. Pour démarrer une nouvelle instance de Chrome, vous devez lui en transmettre une nouvelle
--user-data-dir
:
cui_chrome.exe --no-sandbox --js-flags="--print-bytecode --print-bytecode-filter=func" --user-data-dir=\Windows\Temp
Sans,
--print-bytecode-filter
vous vous noyer dans des décharges de code bytec de kilomètres de fonctions intégrées à Chrome.
Après avoir lancé le navigateur, ouvrez la console développeur et entrez le code utilisé AxemaFr:
{let i = 0;
const func = () => {
i++;
func();
};
func()}
Avant d'appuyer sur Entrée, un vidage apparaîtra dans la fenêtre de la console derrière Chrome:
[bytecode généré pour la fonction: func (0x44db08635355 <SharedFunctionInfo func>)]
Nombre de paramètres 1
Register count 1
Frame size 8
36 S> 000044DB086355EE @ 0 : 1a 02 LdaCurrentContextSlot [2]
000044DB086355F0 @ 2 : ac 00 ThrowReferenceErrorIfHole [0]
000044DB086355F2 @ 4 : 4d 00 Inc [0]
000044DB086355F4 @ 6 : 26 fa Star r0
000044DB086355F6 @ 8 : 1a 02 LdaCurrentContextSlot [2]
37 E> 000044DB086355F8 @ 10 : ac 00 ThrowReferenceErrorIfHole [0]
000044DB086355FA @ 12 : 25 fa Ldar r0
000044DB086355FC @ 14 : 1d 02 StaCurrentContextSlot [2]
44 S> 000044DB086355FE @ 16 : 1b 03 LdaImmutableCurrentContextSlot [3]
000044DB08635600 @ 18 : ac 01 ThrowReferenceErrorIfHole [1]
000044DB08635602 @ 20 : 26 fa Star r0
44 E> 000044DB08635604 @ 22: 5d fa 01 CallUndefinedReceiver0 r0, [1]
000044DB08635607 @ 25: 0d LdaUndefined
52 S> 000044DB08635608 @ 26: ab Retour
Piscine constante (taille = 2)
Table des gestionnaires (taille = 0)
Table de position source (taille = 12)
Comment le vidage changera-t-il si la ligne est
i++;
remplacée par
i += 1.00000000000001;
?
[bytecode généré pour la fonction: func (0x44db0892d495 <SharedFunctionInfo func>)]
Nombre de paramètres 1
Enregistrez le nombre 2
Taille du cadre 16
36 S> 000044DB0892D742 @ 0: 1a 02 LdaCurrentContextSlot [2]
000044DB0892D744 @ 2: ac 00 ThrowReferenceErrorIfHole [0]
000044DB0892D746 @ 4:26 fa étoile r0
000044DB0892D748 @ 6: 12 01 LdaConstant [1]
000044DB0892D74A @ 8 : 35 fa 00 Add r0, [0]
000044DB0892D74D @ 11 : 26 f9 Star r1
000044DB0892D74F @ 13 : 1a 02 LdaCurrentContextSlot [2]
37 E> 000044DB0892D751 @ 15 : ac 00 ThrowReferenceErrorIfHole [0]
000044DB0892D753 @ 17 : 25 f9 Ldar r1
000044DB0892D755 @ 19 : 1d 02 StaCurrentContextSlot [2]
60 S> 000044DB0892D757 @ 21 : 1b 03 LdaImmutableCurrentContextSlot [3]
000044DB0892D759 @ 23 : ac 02 ThrowReferenceErrorIfHole [2]
000044DB0892D75B @ 25 : 26 fa Star r0
60 E> 000044DB0892D75D @ 27 : 5d fa 01 CallUndefinedReceiver0 r0, [1]
000044DB0892D760 @ 30 : 0d LdaUndefined
68 S> 000044DB0892D761 @ 31: ab Retour
Piscine constante (taille = 3)
Table des gestionnaires (taille = 0)
Table de position source (taille = 12)
Voyons maintenant ce qui a changé et pourquoi.
Explorer des exemples
Tous les opcodes V8 sont décrits dans github.com/v8/v8/blob/master/src/interpreter/interpreter-generator.cc
Le premier vidage est décodé comme ceci:
LdaCurrentContextSlot [2]; a: = contexte [2]
ThrowReferenceErrorIfHole [0]; si (a === indéfini)
; throw ("ReferenceError:% s n'est pas défini", const [0])
Inc [0]; a ++
Star r0; r0: = a
LdaCurrentContextSlot [2]; a: = contexte [2]
ThrowReferenceErrorIfHole [0]; si (a === indéfini)
; throw ("ReferenceError:% s n'est pas défini", const [0])
Ldar r0; a: = r0
StaCurrentContextSlot [2] ; context[2] := a
LdaImmutableCurrentContextSlot [3] ; a := context[3]
ThrowReferenceErrorIfHole [1] ; if (a === undefined)
; throw("ReferenceError: %s is not defined", const[1])
Star r0 ; r0 := a
CallUndefinedReceiver0 r0, [1] ; r0()
LdaUndefined ; a := undefined
Return
Le dernier argument opcode
Inc
et
CallUndefinedReceiver0
définit le slot de retour, dans lequel l'optimiseur collecte des statistiques sur les types utilisés. Cela n'affecte pas la sémantique du bytecode, donc aujourd'hui nous ne sommes pas du tout intéressés.
Sous le vidage, il y a un post-scriptum: "Constant pool (size = 2)" - et en effet nous voyons que le bytecode utilise deux lignes -
"i"
et
"func"
- pour la substitution dans le message d'exception lorsque les symboles avec de tels noms ne sont pas définis. Il y a un post-scriptum au-dessus du vidage: "Frame size 8" - conformément au fait que la fonction utilise un interpréteur register (
r0
).
Le cadre de pile de notre fonction se compose de:
- argument unique
this
; - adresses de retour;
- le nombre d'arguments passés (
arguments.length
); - références à un pool constant avec des chaînes utilisées;
- liens vers le contexte avec des variables locales;
- trois autres pointeurs nécessaires au moteur; et enfin
- espace pour un registre.
Total 9 * 8 = 72 octets, en tant que signataire AxemaFret compris.
Sur les sept termes listés, en théorie, trois peuvent changer: le nombre d'arguments, la présence d'un pool constant et le nombre de registres. Qu'avons-nous obtenu dans la variante avec 1.00000000000001?
LdaCurrentContextSlot [2]; a: = contexte [2]
ThrowReferenceErrorIfHole [0]; si (a === indéfini)
; throw ("ReferenceError:% s n'est pas défini", const [0])
Star r0; r0: = a
LdaConstant [1]; a: = const [1]
Ajouter r0, [0]; a + = r0
Star r1; r1: = a
; ... plus loin comme avant
Premièrement, la constante ajoutée a pris la troisième place dans le pool de constantes; deuxièmement, un autre registre était nécessaire pour le charger, de sorte que la trame de pile de la fonction a augmenté de 8 octets.
Si vous n'utilisez pas de symboles nommés dans la fonction, vous pouvez vous passer du pool de constantes. Sur github.com/v8/v8/blob/master/src/execution/frame-constants.h#L289 décrit le format de trame de pile V8 et déclare que lorsque le pool de constantes n'est pas utilisé, la taille de trame de pile est réduite d'un pointeur . Comment pouvez-vous en être sûr? À première vue, il semble qu'une fonction qui n'utilise pas de symboles nommés ne peut pas être récursive; mais jetez un œil:
{let i = 0;
function func() {
this()();
};
const helper = () => (i++, func.bind(helper));
try {
helper()();
} catch (e) {
console.log(i);
}}
[generated bytecode for function: func (0x44db0878e575 <SharedFunctionInfo func>)]
Parameter count 1
Register count 1
Frame size 8
37 S> 000044DB0878E8DA @ 0 : 5e 02 02 00 CallUndefinedReceiver1 <this>, <this>, [0]
000044DB0878E8DE @ 4 : 26 fa Star r0
43 E> 000044DB0878E8E0 @ 6 : 5d fa 02 CallUndefinedReceiver0 r0, [2]
000044DB0878E8E3 @ 9 : 0d LdaUndefined
47 S> 000044DB0878E8E4 @ 10 : ab Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 8)
L'objectif - "Piscine constante (taille = 0)" - a été atteint; mais le débordement de pile, comme précédemment, se produit via les appels 13951. Cela signifie que même lorsque le pool de constantes n'est pas utilisé, le cadre de pile de la fonction contient toujours un pointeur vers celui-ci.
Est-il possible d'obtenir une taille de cadre de pile plus petite que la taille calculée AxemaFrvaleur minimum? - oui, si aucun registre n'est utilisé à l'intérieur de la fonction:
{function func() {
this();
};
let chain = ()=>null;
for(let i=0; i<15050; i++)
chain = func.bind(chain);
chain()}
[bytecode généré pour la fonction: func (0x44db08c34059 <SharedFunctionInfo func>)]
Nombre de paramètres 1
Nombre de registres 0
Taille du cadre 0
25 S> 000044DB08C34322 @ 0: 5d 02 00 CallUndefinedReceiver0 <ceci>, [0]
000044DB08C34325 @ 3: 0d LdaUndefined
29 S> 000044DB08C34326 @ 4: ab Retour
Piscine constante (taille = 0)
Table des gestionnaires (taille = 0)
Table de position source (taille = 6)
(Dans ce cas, une chaîne d'appels à partir de 15051 conduit déjà à "RangeError: taille maximale de la pile des appels dépassée".)
Ainsi, la conclusion du signataire AxemaFrque "la taille d'un ExecutionStack vide dans Chrome est de 72 octets" a été réfuté avec succès.
Affiner les prédictions
Nous pouvons affirmer que la taille minimale de la trame de la pile pour une fonction JS dans Chrome est de 64 octets. Pour cela, vous devez ajouter 8 octets pour chaque paramètre formel déclaré, 8 octets supplémentaires pour chaque paramètre réel en excès du nombre de paramètres déclarés et 8 octets supplémentaires pour chaque registre utilisé. Un registre est alloué pour chaque variable locale, pour charger des constantes, pour accéder à des variables depuis un contexte externe, pour passer des paramètres réels lors d'appels, etc. Il n'est guère possible de déterminer le nombre exact de registres utilisés à partir du code source dans JS. Il est à noter que l'interpréteur JS prend en charge un nombre illimité de registres - ils ne sont pas liés aux registres du processeur sur lequel l'interpréteur est exécuté.
Maintenant, il est clair pourquoi:
- (
func = (x) => { i++; func(); };
) , ; - (
func = () => { i++; func(1); };
) , — :[generated bytecode for function: func (0x44db08e12da1 <SharedFunctionInfo func>)] Parameter count 1 Register count 2 Frame size 16 34 S> 000044DB08E12FE2 @ 0 : 1a 02 LdaCurrentContextSlot [2] 000044DB08E12FE4 @ 2 : ac 00 ThrowReferenceErrorIfHole [0] 000044DB08E12FE6 @ 4 : 4d 00 Inc [0] 000044DB08E12FE8 @ 6 : 26 fa Star r0 000044DB08E12FEA @ 8 : 1a 02 LdaCurrentContextSlot [2] 35 E> 000044DB08E12FEC @ 10 : ac 00 ThrowReferenceErrorIfHole [0] 000044DB08E12FEE @ 12 : 25 fa Ldar r0 000044DB08E12FF0 @ 14 : 1d 02 StaCurrentContextSlot [2] 39 S> 000044DB08E12FF2 @ 16 : 1b 03 LdaImmutableCurrentContextSlot [3] 000044DB08E12FF4 @ 18 : ac 01 ThrowReferenceErrorIfHole [1] 000044DB08E12FF6 @ 20 : 26 fa Star r0 000044DB08E12FF8 @ 22 : 0c 01 LdaSmi [1] 000044DB08E12FFA @ 24 : 26 f9 Star r1 39 E> 000044DB08E12FFC @ 26 : 5e fa f9 01 CallUndefinedReceiver1 r0, r1, [1] 000044DB08E13000 @ 30 : 0d LdaUndefined 48 S> 000044DB08E13001 @ 31 : ab Return Constant pool (size = 2) Handler Table (size = 0) Source Position Table (size = 12) - 1.00000000000001 —
r1
, .