
Hoje decidi traduzir para você um pequeno artigo sobre o interior da implementação dos chamados fechamentos ou fechamentos. Além disso, você aprenderá como o Go tenta determinar automaticamente se deve usar um ponteiro / link ou valor em diferentes casos. Compreender essas coisas evitará erros. E é só que todo esse interior é bem interessante, eu acho!
E também gostaria de convidá-lo para o Golang Conf 2019 , que será realizado em 7 de outubro em Moscou. Sou membro do comitê do programa da conferência, e meus colegas e eu escolhemos muitos relatórios igualmente graves e muito, muito interessantes. O que eu amo!
Abaixo do recado, passo a palavra ao autor.
Há uma página no wiki da Go intitulada Erros Frequentes . Curiosamente, existe apenas um exemplo: uso indevido de variáveis de loop com goroutines:
for _, val := range values { go func() { fmt.Println(val) }() }
Este código produzirá o último valor da matriz de valores len (valores) vezes. A correção do código é muito simples:
Este exemplo é suficiente para entender o problema e nunca mais cometer um erro. Mas se você estiver interessado em conhecer os detalhes da implementação, este artigo fornecerá um entendimento profundo do problema e da solução.
Coisas básicas: passagem por valor e passagem por referência
Em Go, há uma diferença na passagem de objetos por valor e por referência [1]. Vamos começar com o exemplo 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) }
Provavelmente, ninguém tem dúvidas de que o resultado exibirá valores de 0 a 4. Provavelmente em algum tipo de ordem aleatória.
Vejamos o exemplo 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, o seguinte será exibido:
5
5
5
5
5
Entender por que o resultado é justo nos dará 80% da compreensão da essência do problema. Portanto, vamos levar algum tempo para encontrar os motivos.
E a resposta está aí na especificação da linguagem Go . A especificação diz:
Variáveis declaradas na instrução de inicialização são reutilizadas em cada loop.
Isso significa que, quando o programa está sendo executado, há apenas um objeto ou parte da memória para a variável i, e não é criado um novo para cada ciclo. Este objeto assume um novo valor a cada iteração.
Vejamos a diferença no código de máquina gerado [3] para o loop nos exemplos 1 e 2. Vamos começar com o exemplo 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
A instrução Go se torna uma chamada para a função runtime.newproc. A mecânica desse processo é muito interessante, mas vamos deixar isso para o próximo artigo. Agora, estamos mais interessados no que acontece com a variável i. Ele é armazenado no registrador AX, que é passado por valor através da pilha para a função foobyval [4] como argumento. "Por valor", neste caso, parece copiar o valor do registrador AX para a pilha. E alterar o AX no futuro não afeta o que é passado para a função foobyval.
E aqui está o exemplo 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
O código é muito semelhante - com apenas uma diferença, mas muito importante. Agora no AX está o endereço i, e não seu valor. Observe também que o incremento e a comparação do loop são feitos no (AX), não no AX. E então, quando colocamos o AX na pilha, passamos o endereço i para a função. A mudança (AX) também será vista na goroutine.
Sem surpresas. No final, passamos um ponteiro para um número na função foobyref.
Durante a operação, o ciclo termina mais rápido do que qualquer uma das goroutines criadas começa a funcionar. Quando começarem a trabalhar, eles terão um ponteiro para a mesma variável i, e não para uma cópia. E qual é o valor de i neste momento? O valor é 5. O mesmo em que o ciclo parou. E é por isso que todas as goroutines derivam 5.
Métodos com um valor VS métodos com um ponteiro
Comportamento semelhante pode ser observado ao criar goroutines que invocam qualquer método. Isso é indicado pela mesma página da wiki. Veja o exemplo 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 exemplo exibe os elementos da matriz ms. Em ordem aleatória, como esperávamos. Um exemplo muito semelhante 4 usa um método de ponteiro para o 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) }
Tente adivinhar qual será a conclusão: 90, impresso cinco vezes. O motivo é o mesmo do exemplo mais simples 2. Aqui, o problema é menos perceptível devido ao açúcar sintático no Go ao usar métodos de ponteiro. Se nos exemplos, ao mudar do exemplo 1 para o exemplo 2, alteramos i para & i, aqui a chamada parece a mesma! m.Show () nos dois exemplos, e o comportamento é diferente.
Não é uma combinação muito feliz de dois recursos do Go, me parece. Nada no local da chamada indica transmissão por referência. E você precisará examinar a implementação do método Show para ver exatamente como a chamada ocorrerá (e o método, é claro, pode estar em um arquivo ou pacote completamente diferente).
Na maioria dos casos, esse recurso é útil. Escrevemos código mais limpo. Mas aqui, passar por referência leva a efeitos inesperados.
Curto-circuito
Finalmente chegamos ao fechamento. Vejamos o exemplo 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) }
Ele imprimirá o seguinte:
5
5
5
5
5
E isso apesar do fato de que eu é passado por valor para foobyval no fechamento. Semelhante ao exemplo 1. Mas por quê? Vejamos a visualização do loop do assembler:
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
O código é muito semelhante ao Exemplo 2: observe que i é representado por um endereço no registro do AX. Ou seja, passamos i por referência. E isso apesar do fato de que foobyval é chamado. O corpo do loop chama a função usando runtime.newproc, mas de onde vem essa função?
Func1 é criado pelo compilador e é um fechamento. O compilador alocou o código de fechamento como uma função separada e o chama de main. O principal problema dessa alocação é como lidar com variáveis que os fechamentos usam, mas que claramente não são argumentos.
É assim que o corpo de func1 se parece:
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
É interessante aqui que a função tenha um argumento em 24 (SP), que é um ponteiro para int: dê uma olhada na linha MOVQ (AX), AX, que recebe um valor antes de passá-lo para foobyval. De fato, o func1 se parece com isso:
func func1(i *int) { foobyval(*i) } main - : for i := 0; i < 5; i++ { go func1(&i) }
Recebeu o equivalente ao exemplo 2, e isso explica a conclusão. Em linguagem técnica, diríamos que i é uma variável livre dentro de um fechamento e essas variáveis são capturadas por referência em Go.
Mas este é sempre o caso? Surpreendentemente, a resposta é não. Em alguns casos, variáveis livres são capturadas por valor. Aqui está uma variação do nosso exemplo:
for i := 0; i < 5; i++ { ii := i go func() { foobyval(ii) }() }
Este exemplo produzirá 0, 1, 2, 3, 4 em ordem aleatória. Mas por que o comportamento aqui é diferente do Exemplo 5?
Acontece que esse comportamento é um artefato da heurística que o compilador Go usa quando trabalha com fechamentos.
Nós olhamos sob o capô
Se você não estiver familiarizado com a arquitetura do compilador Go, recomendo que você leia meus artigos anteriores sobre este tópico: Parte 1 , Parte 2 .
A árvore de sintaxe específica (em oposição ao abstrato) que é obtida analisando o código é assim:
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 . } }
A função chamada é representada pelo nó FuncLit, uma função constante. Quando essa árvore é convertida em AST (árvore de sintaxe abstrata), o destaque dessa função constante como uma separada é o resultado. Isso acontece no método noder.funcLit, que vive em gc / encerramento.go.
Em seguida, o verificador tipe conclui a transformação e obtemos a seguinte representação para a função no 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
Observe que o valor passado para foobyval é NAME-main.i, ou seja, apontamos explicitamente para a variável da função que encerra o fechamento.
Nesse estágio, o estágio do compilador, chamado capturevars, ou seja, "capturando variáveis", entra em operação. Seu objetivo é decidir como capturar "variáveis fechadas" (ou seja, variáveis livres usadas em fechamentos). Aqui está um comentário da função de compilador correspondente, que também descreve heurísticas:
// capturevars é chamado em uma fase separada após todas as verificações de tipo.
// Decide se deve capturar a variável por valor ou por referência.
// Usamos captura por valor para valores <= 128 bytes que não alteram mais o valor após a captura (essencialmente constantes).
Quando capturevars é chamado no Exemplo 5, ele decide que a variável de loop i deve ser capturada por referência e adiciona o sinalizador addrtaken apropriado a ela. Isso pode ser visto na saída 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 a variável de loop, a heurística de seleção "por valor" não funciona, pois a variável altera seu valor após a chamada (lembre-se da cotação da especificação de que a variável de loop é reutilizada a cada iteração). Portanto, a variável i é capturada por referência.
Nessa variação do nosso exemplo, onde temos ii: = i, ii não é mais usado e, portanto, é capturado pelo valor [5].
Assim, vemos um exemplo impressionante de sobreposição inesperada de dois recursos diferentes de uma linguagem. Em vez de usar uma nova variável a cada iteração do loop, o Go reutiliza a mesma. Isso, por sua vez, leva ao desencadeamento de heurísticas e à escolha da captura por referência, e isso leva a um resultado inesperado. O Go FAQ diz que esse comportamento pode ser um erro de design.
Esse comportamento (não use uma nova variável) provavelmente é um erro ao criar um idioma. Talvez possamos corrigi-lo em versões futuras, mas devido à compatibilidade com versões anteriores, não podemos fazer nada na versão 1 do Go.
Se você está ciente do problema, provavelmente não entrará nesse rake. Mas lembre-se de que variáveis livres sempre podem ser capturadas por referência. Para evitar erros, certifique-se de que apenas variáveis somente leitura sejam capturadas ao usar goroutin. Isso também é importante devido a possíveis problemas nos voos de dados.
[1] Alguns leitores notaram que, estritamente falando, não existe o conceito de "passagem por referência" no Go, porque tudo é passado por valor, incluindo indicadores. Neste artigo, quando você vê "passagem por referência", quero dizer "passagem por endereço" e é explícito em alguns casos (como passar & n para uma função que espera * int) e, em alguns casos, implícito, como nos posteriores partes do artigo.
[2] A seguir, uso o tempo. Durma como uma maneira rápida e suja de aguardar a conclusão de todas as goroutines. Sem isso, o main terminará antes que as goroutines comecem a funcionar. A maneira certa de fazer isso seria usar algo como WaitGroup ou canal concluído.
[3] A representação do assembler para todos os exemplos neste artigo foi obtida usando o comando go tool compile -l -S. O sinalizador -l desativa a função inlining e torna o código do assembler mais legível.
[4] Foobyval não é chamado diretamente, pois a chamada passa. Em vez disso, o endereço é passado como o segundo argumento (16 (SP)) para a função runtime.newproc, e o argumento para foobyval (neste caso, i) aumenta a pilha.
[5] Como exercício, adicione ii = 10 como a última linha do loop for (após chamar go). Qual foi a sua conclusão? Porque