Internals Go: variables de bucle de envoltura en cierre


Hoy decidí traducir un breve artículo sobre el interior de la implementación de los llamados cierres o cierres. Además, aprenderá cómo Go intenta determinar automáticamente si usar un puntero / enlace o valor en diferentes casos. Comprender estas cosas evitará errores. ¡Y es que todos estos interiores son muy interesantes, creo!


Y también me gustaría invitarlos a Golang Conf 2019 , que se llevará a cabo el 7 de octubre en Moscú. Soy miembro del comité del programa de la conferencia, y mis colegas y yo hemos elegido muchos informes igualmente interesantes y muy interesantes. Lo que amo


Debajo del corte, le paso la palabra al autor.



Hay una página en la wiki de Go titulada Errores frecuentes . Curiosamente, solo hay un ejemplo: mal uso de variables de bucle con goroutines:


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

Este código generará el último valor de la matriz de valores len (valores) veces. Arreglar el código es muy simple:


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

Este ejemplo es suficiente para comprender el problema y nunca más cometer un error. Pero si está interesado en conocer los detalles de implementación, este artículo le dará una comprensión profunda tanto del problema como de la solución.


Cosas básicas: pasar por valor y pasar por referencia


En Go, hay una diferencia en pasar objetos por valor y por referencia [1]. Comencemos con el ejemplo 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) } 

Probablemente, nadie tiene ninguna duda de que el resultado mostrará valores de 0 a 4. Probablemente en algún tipo de orden aleatorio.


Veamos el ejemplo 2 .


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

Como resultado, se mostrará lo siguiente:


5 5
5 5
5 5
5 5
5 5


Comprender por qué el resultado es exactamente esto nos dará el 80% de la comprensión de la esencia del problema. Por lo tanto, tomemos un tiempo para encontrar las razones.


Y la respuesta está ahí en la especificación del lenguaje Go . La especificación lee:


Las variables declaradas en la declaración de inicialización se reutilizan en cada bucle.

Esto significa que cuando el programa se está ejecutando, solo hay un objeto o pieza de memoria para la variable i, y no se crea uno nuevo para cada ciclo. Este objeto adquiere un nuevo valor en cada iteración.


Veamos la diferencia en el código de máquina generado [3] para el bucle en los ejemplos 1 y 2. Comencemos con el ejemplo 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 

La instrucción Go se convierte en una llamada a la función runtime.newproc. La mecánica de este proceso es muy interesante, pero dejemos esto para el próximo artículo. Ahora estamos más interesados ​​en lo que le sucede a la variable i. Se almacena en el registro AX, que luego se pasa por valor a través de la pila a la función foobyval [4] como argumento. "Por valor" en este caso parece copiar el valor del registro AX en la pila. Y cambiar AX en el futuro no afecta lo que se pasa a la función foobyval.


Y así es como se ve el ejemplo 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 

El código es muy similar, con solo una, pero muy importante, diferencia. Ahora en AX está la dirección i, y no su valor. Tenga en cuenta también que el incremento y la comparación del bucle se realizan en (AX), no en AX. Y luego, cuando ponemos AX en la pila, resulta que pasamos la dirección i a la función. El cambio (AX) también se verá de esta manera en goroutine.


Sin sorpresas Al final, pasamos un puntero a un número en la función foobyref.
Durante la operación, el ciclo termina más rápido de lo que cualquiera de las gorutinas creadas comienza a funcionar. Cuando comiencen a trabajar, tendrán un puntero a la misma variable i, y no a una copia. ¿Y cuál es el valor de i en este momento? El valor es 5. El mismo en el que se detuvo el ciclo. Y es por eso que todas las goroutinas obtienen 5.


Métodos con un valor VS métodos con un puntero


Se puede observar un comportamiento similar al crear gorutinas que invocan cualquier método. Esto se indica en la misma página wiki. Mira el ejemplo 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) } 

Este ejemplo muestra los elementos de la matriz ms. En orden aleatorio, como esperábamos. Un ejemplo 4 muy similar utiliza un método de puntero para el método 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) } 

Intenta adivinar cuál será la conclusión: 90, impresa cinco veces. La razón es la misma que en el ejemplo más simple 2. Aquí el problema es menos notable debido al azúcar sintáctico en Go cuando se utilizan métodos de puntero. Si en los ejemplos, al cambiar del ejemplo 1 al ejemplo 2, cambiamos i a & i, ¡aquí la llamada se ve igual! m.Show () en ambos ejemplos, y el comportamiento es diferente.


No me parece una combinación muy feliz de dos funciones de Go. Nada en el lugar de la llamada indica transmisión por referencia. Y deberá observar la implementación del método Show para ver exactamente cómo se realizará la llamada (y el método, por supuesto, puede estar en un archivo o paquete completamente diferente).


En la mayoría de los casos, esta característica es útil. Escribimos código más limpio. Pero aquí, pasar por referencia conduce a efectos inesperados.


Cortocircuitos


Finalmente llegamos a los cierres. Veamos el ejemplo 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) } 

Él imprimirá lo siguiente:


5 5
5 5
5 5
5 5
5 5


Y esto a pesar del hecho de que se pasa por valor a foobyval en el cierre. Similar al ejemplo 1. ¿Pero por qué? Veamos la vista de bucle del ensamblador:


 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 

El código es muy similar al Ejemplo 2: observe que i está representado por una dirección en el registro AX. Es decir, pasamos i por referencia. Y esto a pesar del hecho de que se llama foobyval. El cuerpo del bucle llama a la función usando runtime.newproc, pero ¿de dónde viene esta función?


Func1 es creado por el compilador, y es un cierre. El compilador ha asignado el código de cierre como una función separada y lo llama desde main. El principal problema con esta asignación es cómo manejar las variables que usan los cierres, pero que claramente no son argumentos.


Así es como se ve el cuerpo 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 

Es interesante aquí que la función tiene un argumento de 24 (SP), que es un puntero a int: eche un vistazo a la línea MOVQ (AX), AX, que toma un valor antes de pasarlo a foobyval. De hecho, func1 se parece a esto:


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

Recibió el equivalente del ejemplo 2, y esto explica la conclusión. En lenguaje técnico, diríamos que i es una variable libre dentro de un cierre y dichas variables se capturan por referencia en Go.


¿Pero es este siempre el caso? Sorprendentemente, la respuesta es no. En algunos casos, las variables libres se capturan por valor. Aquí hay una variación de nuestro ejemplo:


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

Este ejemplo generará 0, 1, 2, 3, 4 en orden aleatorio. Pero, ¿por qué el comportamiento aquí es diferente del Ejemplo 5?


Resulta que este comportamiento es un artefacto de la heurística que utiliza el compilador Go cuando trabaja con cierres.


Miramos debajo del capó


Si no está familiarizado con la arquitectura del compilador Go, le recomiendo que lea mis primeros artículos sobre este tema: Parte 1 , Parte 2 .


El árbol de sintaxis específico (en oposición al abstracto) que se obtiene al analizar el código tiene este aspecto:


 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 función llamada está representada por el nodo FuncLit, una función constante. Cuando este árbol se convierte en AST (árbol de sintaxis abstracta), el resultado será resaltar esta función constante como una función separada. Esto sucede en el método noder.funcLit, que vive en gc / closet.go.


Luego, el comprobador de tipos completa la transformación y obtenemos la siguiente representación para la función en el 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 

Tenga en cuenta que el valor pasado a foobyval es NAME-main.i, es decir, señalamos explícitamente la variable desde la función que envuelve el cierre.


En esta etapa, entra en funcionamiento la etapa del compilador, llamada capturevars, es decir, "capturando variables". Su propósito es decidir cómo capturar las "variables cerradas" (es decir, las variables libres utilizadas en los cierres). Aquí hay un comentario de la función del compilador correspondiente, que también describe la heurística:


// capturevars se llama en una fase separada después de todas las comprobaciones de tipo.
// Decide si capturar la variable por valor o por referencia.
// Utilizamos captura por valor para valores <= 128 bytes que ya no cambian de valor después de la captura (esencialmente constantes).


Cuando se llama a capturevars en el Ejemplo 5, decide que la variable de bucle i debe capturarse por referencia, y le agrega el indicador addrtaken apropiado. Esto se puede ver en la salida 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 

Para la variable de bucle, la heurística de selección "por valor" no funciona, ya que la variable cambia su valor después de la llamada (recuerde la cita de la especificación de que la variable de bucle se reutiliza en cada iteración). Por lo tanto, la variable i se captura por referencia.
En esa variación de nuestro ejemplo, donde tenemos ii: = i, ii ya no se usa y, por lo tanto, se captura por valor [5].


Por lo tanto, vemos un ejemplo sorprendente de superposición de dos características diferentes de un idioma de una manera inesperada. En lugar de usar una nueva variable en cada iteración del ciclo, Go reutiliza la misma. Esto, a su vez, conduce a la activación de la heurística y la elección de la captura por referencia, y esto conduce a un resultado inesperado. Las preguntas frecuentes de Go indican que este comportamiento puede ser un error de diseño.


Este comportamiento (no use una nueva variable) es probablemente un error al diseñar un lenguaje. Quizás lo arreglemos en futuras versiones, pero debido a la compatibilidad con versiones anteriores, no podemos hacer nada en Go versión 1.

Si eres consciente del problema, lo más probable es que no pises este rastrillo. Pero tenga en cuenta que las variables libres siempre se pueden capturar por referencia. Para evitar errores, asegúrese de que solo se capturen las variables de solo lectura cuando se usa goroutin. Esto también es importante debido a posibles problemas con los vuelos de datos.




[1] Algunos lectores han notado que, estrictamente hablando, no hay un concepto de "pasar por referencia" en Go, porque todo se pasa por valor, incluidos los punteros. En este artículo, cuando ve "pasar por referencia", me refiero a "pasar por dirección" y es explícito en algunos casos (como pasar & n a una función que espera * int), y en algunos casos implícito, como en los posteriores partes del artículo.


[2] De aquí en adelante, uso el tiempo. Dormir como una forma rápida y sucia de esperar a que se completen todas las gorutinas. Sin esto, main terminará antes de que las gorutinas comiencen a funcionar. La forma correcta de hacer esto sería usar algo como WaitGroup o done channel.


[3] La representación del ensamblador para todos los ejemplos en este artículo se obtuvo usando el comando go tool compile -l -S. El indicador -l deshabilita la función en línea y hace que el código del ensamblador sea más legible.


[4] Foobyval no se llama directamente, ya que la llamada pasa por ir. En cambio, la dirección se pasa como el segundo argumento (16 (SP)) a la función runtime.newproc, y el argumento a foobyval (i en este caso) sube la pila.


[5] Como ejercicio, agregue ii = 10 como la última línea del bucle for (después de llamar ir). ¿Cuál fue tu conclusión? Por qué

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


All Articles