Internals Go: boucler les variables de boucle en fermeture


Aujourd'hui, j'ai décidé de traduire pour vous un court article sur l'intérieur de la mise en œuvre des soi-disant fermetures ou fermetures. De plus, vous apprendrez comment Go essaie de déterminer automatiquement s'il faut utiliser un pointeur / lien ou une valeur dans différents cas. Comprendre ces choses évitera les erreurs. Et c'est juste que tous ces intérieurs sont sacrément intéressants, je pense!


Et je voudrais également vous inviter à Golang Conf 2019 , qui se tiendra le 7 octobre à Moscou. Je suis membre du comité de programme de la conférence et mes collègues et moi avons choisi de nombreux rapports tout aussi inconditionnels et très, très intéressants. Ce que j'aime!


Sous la coupe, je passe le mot à l'auteur.



Il y a une page sur le wiki de Go intitulée Erreurs fréquentes . Curieusement, il n'y a qu'un seul exemple: la mauvaise utilisation des variables de boucle avec des goroutines:


for _, val := range values { go func() { fmt.Println(val) }() } 

Ce code affichera la dernière valeur du tableau de valeurs len (valeurs) fois. La correction du code est très simple:


 // assume the type of each value is string for _, val := range values { go func(val string) { fmt.Println(val) }(val) } 

Cet exemple suffit pour comprendre le problème et ne plus jamais se tromper. Mais si vous souhaitez connaître les détails de mise en œuvre, cet article vous donnera une compréhension approfondie du problème et de la solution.


Choses de base: passer par valeur et passer par référence


Dans Go, il existe une différence dans le passage d'objets par valeur et par référence [1]. Commençons par l' exemple 1 [2]:


 func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go foobyval(i) } time.Sleep(100 * time.Millisecond) } 

Personne, très probablement, ne doute que le résultat affichera des valeurs de 0 à 4. Probablement dans une sorte d’ordre aléatoire.


Regardons l' exemple 2 .


 func foobyref(n *int) { fmt.Println(*n) } func main() { for i := 0; i < 5; i++ { go foobyref(&i) } time.Sleep(100 * time.Millisecond) } 

En conséquence, les éléments suivants seront affichés:


5
5
5
5
5


Comprendre pourquoi le résultat est juste cela nous donnera déjà 80% de la compréhension de l'essence du problème. Prenons donc un peu de temps pour trouver les raisons.


Et la réponse est là dans la spécification de la langue Go . La spécification se lit comme suit:


Les variables déclarées dans l'instruction d'initialisation sont réutilisées dans chaque boucle.

Cela signifie que lorsque le programme est en cours d'exécution, il n'y a qu'un seul objet ou morceau de mémoire pour la variable i, et pas un nouveau n'est créé pour chaque cycle. Cet objet prend une nouvelle valeur à chaque itération.


Examinons la différence dans le code machine généré [3] pour la boucle dans les exemples 1 et 2. Commençons par l'exemple 1.


 0x0026 00038 (go-func-byval.go:14) MOVL $8, (SP) 0x002d 00045 (go-func-byval.go:14) LEAQ "".foobyval·f(SB), CX 0x0034 00052 (go-func-byval.go:14) MOVQ CX, 8(SP) 0x0039 00057 (go-func-byval.go:14) MOVQ AX, 16(SP) 0x003e 00062 (go-func-byval.go:14) CALL runtime.newproc(SB) 0x0043 00067 (go-func-byval.go:13) MOVQ "".i+24(SP), AX 0x0048 00072 (go-func-byval.go:13) INCQ AX 0x004b 00075 (go-func-byval.go:13) CMPQ AX, $5 0x004f 00079 (go-func-byval.go:13) JLT 33 

L'instruction Go devient un appel à la fonction runtime.newproc. La mécanique de ce processus est très intéressante, mais laissons cela pour le prochain article. Maintenant, nous nous intéressons davantage à ce qui arrive à la variable i. Il est stocké dans le registre AX, qui est ensuite transmis par valeur à travers la pile à la fonction foobyval [4] comme argument. «Par valeur» dans ce cas ressemble à copier la valeur du registre AX sur la pile. Et changer AX à l'avenir n'affecte pas ce qui est passé à la fonction foobyval.


Et voici à quoi ressemble l'exemple 2:


 0x0040 00064 (go-func-byref.go:14) LEAQ "".foobyref·f(SB), CX 0x0047 00071 (go-func-byref.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-func-byref.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-func-byref.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-func-byref.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-func-byref.go:13) INCQ (AX) 0x005e 00094 (go-func-byref.go:13) CMPQ (AX), $5 0x0062 00098 (go-func-byref.go:13) JLT 57 

Le code est très similaire - avec une seule différence, mais très importante. Maintenant, dans AX, c'est l'adresse i, et non sa valeur. Notez également que l'incrémentation et la comparaison de la boucle se font sur (AX), pas sur AX. Et puis, lorsque nous mettons AX sur la pile, il se trouve que nous passons l'adresse i à la fonction. Le changement (AX) sera vu de cette façon dans goroutine aussi.


Pas de surprise. En fin de compte, nous passons un pointeur sur un nombre dans la fonction foobyref.
Pendant le fonctionnement, le cycle se termine plus rapidement que n'importe lequel des goroutines créés commence à fonctionner. Quand ils commenceront à travailler, ils auront un pointeur vers la même variable i, et non vers une copie. Et quelle est la valeur de i en ce moment? La valeur est 5. Celle-là même sur laquelle le cycle s'est arrêté. Et c'est pourquoi tous les goroutins en dérivent 5.


Méthodes avec une valeur VS méthodes avec un pointeur


Un comportement similaire peut être observé lors de la création de goroutines qui invoquent toutes les méthodes. Ceci est indiqué par la même page wiki. Regardez l' exemple 3 :


 type MyInt int func (mi MyInt) Show() { fmt.Println(mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) } 

Cet exemple affiche les éléments du tableau ms. Dans un ordre aléatoire, comme nous nous y attendions. Un exemple très similaire 4 utilise une méthode de pointeur pour la méthode Show:


 type MyInt int func (mi *MyInt) Show() { fmt.Println(*mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) } 

Essayez de deviner quelle sera la conclusion: 90, imprimés cinq fois. La raison est la même que dans l'exemple plus simple 2. Ici, le problème est moins visible en raison du sucre syntaxique dans Go lors de l'utilisation des méthodes de pointeur. Si dans les exemples, lors du passage de l'exemple 1 à l'exemple 2, nous avons changé i en & i, ici l'appel se ressemble! m.Show () dans les deux exemples, et le comportement est différent.


Pas une combinaison très heureuse de deux fonctionnalités Go, il me semble. Rien à la place de l'appel n'indique une transmission par référence. Et vous devrez examiner l'implémentation de la méthode Show pour voir exactement comment l'appel se déroulera (et la méthode, bien sûr, peut être dans un fichier ou un package complètement différent).


Dans la plupart des cas, cette fonctionnalité est utile. Nous écrivons un code plus propre. Mais ici, le passage par référence entraîne des effets inattendus.


Court-circuits


Enfin, nous arrivons aux fermetures. Regardons l' exemple 5 :


 func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go func() { foobyval(i) }() } time.Sleep(100 * time.Millisecond) } 

Il imprimera ce qui suit:


5
5
5
5
5


Et cela malgré le fait que i soit passé par valeur à foobyval dans la fermeture. Similaire à l'exemple 1. Mais pourquoi? Regardons la vue de la boucle de l'assembleur:


 0x0040 00064 (go-closure.go:14) LEAQ "".main.func1·f(SB), CX 0x0047 00071 (go-closure.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-closure.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-closure.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-closure.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-closure.go:13) INCQ (AX) 0x005e 00094 (go-closure.go:13) CMPQ (AX), $5 0x0062 00098 (go-closure.go:13) JLT 57 

Le code est très similaire à l'exemple 2: notez que i est représenté par une adresse dans le registre AX. Autrement dit, nous passons i par référence. Et cela malgré le fait que foobyval soit appelé. Le corps de la boucle appelle la fonction à l'aide de runtime.newproc, mais d'où vient cette fonction?


Func1 est créé par le compilateur, et c'est une fermeture. Le compilateur a alloué le code de fermeture en tant que fonction distincte et l'appelle depuis main. Le principal problème de cette allocation est de savoir comment traiter les variables utilisées par les fermetures, mais qui ne sont clairement pas des arguments.


Voici à quoi ressemble le corps de func1:


 0x0000 00000 (go-closure.go:14) MOVQ (TLS), CX 0x0009 00009 (go-closure.go:14) CMPQ SP, 16(CX) 0x000d 00013 (go-closure.go:14) JLS 56 0x000f 00015 (go-closure.go:14) SUBQ $16, SP 0x0013 00019 (go-closure.go:14) MOVQ BP, 8(SP) 0x0018 00024 (go-closure.go:14) LEAQ 8(SP), BP 0x001d 00029 (go-closure.go:15) MOVQ "".&i+24(SP), AX 0x0022 00034 (go-closure.go:15) MOVQ (AX), AX 0x0025 00037 (go-closure.go:15) MOVQ AX, (SP) 0x0029 00041 (go-closure.go:15) CALL "".foobyval(SB) 0x002e 00046 (go-closure.go:16) MOVQ 8(SP), BP 0x0033 00051 (go-closure.go:16) ADDQ $16, SP 0x0037 00055 (go-closure.go:16) RET 

Il est intéressant ici que la fonction ait un argument en 24 (SP), qui est un pointeur vers int: jetez un œil à la ligne MOVQ (AX), AX, qui prend une valeur avant de la passer à foobyval. En fait, func1 ressemble à ceci:


 func func1(i *int) { foobyval(*i) }    main   - : for i := 0; i < 5; i++ { go func1(&i) } 

Reçu l'équivalent de l'exemple 2, ce qui explique la conclusion. En langage technique, nous dirions que i est une variable libre à l'intérieur d'une fermeture et ces variables sont capturées par référence dans Go.


Mais est-ce toujours le cas? Étonnamment, la réponse est non. Dans certains cas, les variables libres sont capturées par valeur. Voici une variante de notre exemple:


 for i := 0; i < 5; i++ { ii := i go func() { foobyval(ii) }() } 

Cet exemple affichera 0, 1, 2, 3, 4 dans un ordre aléatoire. Mais pourquoi le comportement ici est-il différent de l'exemple 5?


Il s'avère que ce comportement est un artefact de l'heuristique que le compilateur Go utilise lorsqu'il fonctionne avec des fermetures.


On regarde sous le capot


Si vous n'êtes pas familier avec l'architecture du compilateur Go, je vous recommande de lire mes premiers articles sur ce sujet: Partie 1 , Partie 2 .


L'arbre de syntaxe spécifique (par opposition à abstrait) obtenu en analysant le code ressemble à ceci:


 0: *syntax.CallStmt { . Tok: go . Call: *syntax.CallExpr { . . Fun: *syntax.FuncLit { . . . Type: *syntax.FuncType { . . . . ParamList: nil . . . . ResultList: nil . . . } . . . Body: *syntax.BlockStmt { . . . . List: []syntax.Stmt (1 entries) { . . . . . 0: *syntax.ExprStmt { . . . . . . X: *syntax.CallExpr { . . . . . . . Fun: foobyval @ go-closure.go:15:4 . . . . . . . ArgList: []syntax.Expr (1 entries) { . . . . . . . . 0: i @ go-closure.go:15:13 . . . . . . . } . . . . . . . HasDots: false . . . . . . } . . . . . } . . . . } . . . . Rbrace: syntax.Pos {} . . . } . . } . . ArgList: nil . . HasDots: false . } } 

La fonction appelée est représentée par le nœud FuncLit, une fonction constante. Lorsque cet arbre est converti en AST (arbre de syntaxe abstraite), la mise en évidence de cette fonction constante comme une fonction distincte sera le résultat. Cela se produit dans la méthode noder.funcLit, qui réside dans gc / fermeture.go.


Ensuite, le vérificateur de pointes termine la transformation et nous obtenons la représentation suivante pour la fonction dans l'AST:


 main.func1: . DCLFUNC l(14) tc(1) FUNC-func() . DCLFUNC-body . . CALLFUNC l(15) tc(1) . . . NAME-main.foobyval a(true) l(8) x(0) class(PFUNC) tc(1) used FUNC-func(int) . . CALLFUNC-list . . . NAME-main.il(15) x(0) class(PAUTOHEAP) tc(1) used int 

Notez que la valeur transmise à foobyval est NAME-main.i, c'est-à-dire que nous pointons explicitement vers la variable de la fonction qui encapsule la fermeture.


À ce stade, l'étape du compilateur, appelée capturevars, c'est-à-dire «capture variable», entre en service. Son but est de décider comment capturer les "variables fermées" (c'est-à-dire les variables libres utilisées dans les fermetures). Voici un commentaire de la fonction de compilation correspondante, qui décrit également l'heuristique:


// capturevars est appelé dans une phase distincte après toutes les vérifications de type.
// Il décide de capturer la variable par valeur ou par référence.
// Nous utilisons la capture par valeur pour des valeurs <= 128 octets qui ne changent plus de valeur après la capture (essentiellement des constantes).


Lorsque capturevars est appelé dans l'exemple 5, il décide que la variable de boucle i doit être capturée par référence et lui ajoute le drapeau addrtaken approprié. Cela peut être vu dans la sortie AST:


 FOR l(13) tc(1) . LT l(13) tc(1) bool . . NAME-main.ia(true) g(1) l(13) x(0) class(PAUTOHEAP) esc(h) tc(1) addrtaken assigned used int 

Pour la variable de boucle, l'heuristique de sélection «par valeur» ne fonctionne pas, car la variable change de valeur après l'appel (rappelez-vous la citation de la spécification selon laquelle la variable de boucle est réutilisée à chaque itération). Par conséquent, la variable i est capturée par référence.
Dans cette variation de notre exemple, où nous avons ii: = i, ii n'est plus utilisé et est donc capturé par la valeur [5].


Ainsi, nous voyons un exemple étonnant de chevauchement de deux caractéristiques différentes d'une langue d'une manière inattendue. Au lieu d'utiliser une nouvelle variable à chaque itération de la boucle, Go réutilise la même. Ceci, à son tour, conduit au déclenchement d'heuristiques et au choix de capture par référence, et cela conduit à un résultat inattendu. La FAQ Go indique que ce comportement peut être une erreur de conception.


Ce comportement (n'utilisez pas de nouvelle variable) est probablement une erreur lors de la conception d'un langage. Peut-être que nous le corrigerons dans les futures versions, mais en raison de la compatibilité descendante, nous ne pouvons rien faire dans Go version 1.

Si vous êtes conscient du problème, vous ne marcherez probablement pas sur ce râteau. Mais gardez à l'esprit que les variables libres peuvent toujours être capturées par référence. Pour éviter les erreurs, assurez-vous que seules les variables en lecture seule sont capturées lors de l'utilisation de goroutin. Ceci est également important en raison de problèmes potentiels avec les vols de données.




[1] Certains lecteurs ont remarqué qu'à proprement parler, il n'y a pas de concept de «passage par référence» dans Go, car tout est passé par valeur, y compris les pointeurs. Dans cet article, lorsque vous voyez «passer par référence», je veux dire «passer par adresse» et il est explicite dans certains cas (comme le passage de & n à une fonction qui attend * int), et dans certains cas implicite, comme dans les versions ultérieures parties de l'article.


[2] Ci-après, j'utilise le temps. Le sommeil comme un moyen rapide et sale d'attendre que tous les goroutines se terminent. Sans cela, le principal se terminera avant que les goroutines ne commencent à fonctionner. La bonne façon de procéder serait d'utiliser quelque chose comme WaitGroup ou done channel.


[3] La représentation de l'assembleur pour tous les exemples de cet article a été obtenue à l'aide de la commande go tool compile -l -S. L'indicateur -l désactive la fonction inline et rend le code assembleur plus lisible.


[4] Foobyval n'est pas appelé directement, car l'appel passe par go. Au lieu de cela, l'adresse est passée en tant que deuxième argument (16 (SP)) à la fonction runtime.newproc, et l'argument à foobyval (i dans ce cas) remonte la pile.


[5] Comme exercice, ajoutez ii = 10 comme dernière ligne de la boucle for (après avoir appelé go). Quelle a été votre conclusion? Pourquoi?

Source: https://habr.com/ru/post/fr468863/


All Articles