Cuidados ao usar Goroutines em GO
- #GoLang
Introducão
Há algum tempo, eu me deparei com um bug envolvendo concorrência em Golang. Depois de fazer algumas pesquisas buscando resolver esse problema, decidi escrever este artigo detalhando o que aprendi.
Concorrência é a capacidade de um programa de executar várias tarefas independentes simultaneamente. Em Go, essa capacidade é implementada por meio das goroutines. Goroutines são iniciadas usando a palavra-chave “go” antes de uma chamada de função. Quando a função é chamada dessa forma, uma nova goroutine é iniciada para executar essa função em paralelo com a goroutine principal.
Embora as goroutines em Golang sejam muito poderosas e eficientes em termos de desempenho, elas podem apresentar alguns perigos se não forem usadas corretamente. Nesse artigo estão três principais perigos a serem considerados:
Race conditions
Uma race condition ou data race ocorre quando duas ou mais goroutines tentam acessar e modificar a mesma variável ou recurso compartilhado ao mesmo tempo. Isso pode levar a resultados imprevisíveis, como dados corrompidos, erros de segurança e instabilidade do sistema.
package main
var contador int
func main() {
go d()
go d()
}
func d(){
contador++
}
O problema de data race no código acima ocorre porque duas goroutines (criadas pelas chamadas das funções d
com a palavra-chave go
dentro da função main
) estão acessando a variável global contador
simultaneamente e sem sincronização explícita.
Como a operação de incremento não é atômica, pode haver uma race condition (data race) entre as duas goroutines, o que pode levar a resultados inconsistentes e imprevisíveis.
Nota: Uma operação atômica é uma operação que é executada em um único passo indivisível, sem possibilidade de interrupção ou interferência de outras operações.
A operação contador++
pode ser executada de forma intercalada, de modo que uma goroutine leia o valor atual de contador
antes que a outra goroutine tenha a chance de atualizá-lo, resultando em um valor final menor do que o esperado.
Para evitar esse problema, pode-se usar um mecanismo de sincronização, como um mutex, para garantir que apenas uma goroutine por vez acesse a variável contador
.
package main
import "sync"
var contador int
var mu sync.Mutex
func main() {
go d()
go d()
}
func d(){
mu.Lock()
contador++
mu.Unlock()
}
O mutex (ou “mutual exclusion”) é um mecanismo de sincronização usado para evitar race conditions (data races) em programas concorrentes.
No código fornecido, a variável mu
é uma instância da estrutura sync.Mutex
, que representa um mutex em Go. A chamada à função mu.Lock()
bloqueia o mutex e impede que outras goroutines acessem o mesmo recurso compartilhado (neste caso, a variável contador
).
Em seguida, a goroutine incrementa o valor de contador
e, finalmente, chama mu.Unlock()
para desbloquear o mutex e permitir que outras goroutines possam acessar o recurso novamente.
Dessa forma, a utilização do mutex garante que apenas uma goroutine por vez execute a seção crítica do código (neste caso, a operação de incremento da variável contador
), evitando assim possíveis conflitos de acesso concorrente e garantindo a consistência dos dados compartilhados.
Concorrência não determinística
Como as goroutines são executadas concorrentemente e em paralelo, o comportamento do programa pode se tornar não determinístico se não for cuidadosamente projetado e controlado. Isso pode levar a resultados inesperados, bugs difíceis de reproduzir e dificuldades de depuração.
func ReceiverDataFromHandler(ctx *context.Context) {
// valisação dos dados
go SaveInMySql(ctx)
// retorno da funcão independente do retorno de SaveinMySql()
}
func SaveInMySql(ctx *context.Context) {
//elimina o contexto da memória
}
Neste exemplo de código, uma goroutine foi implementada para evitar que o usuário precise esperar o término do processamento da função SaveinMySql(ctx)
para receber uma resposta do servidor. Se a função principal encerrar primeiro, não haverá problemas.
No entanto, se a função SaveinMySql(ctx)
encerrar primero ela irá eliminar o contexto antes da conclusão da função principal, isso pode levar a um comportamento imprevisível ou até mesmo a um pânico em todo o sistema. O resultado do programa pode variar dependendo de qual função terminar primeiro, o que pode gerar um bug difícil de ser depurado ou reproduzido.
Deadlocks
Os deadlocks ocorrem quando duas ou mais goroutines ficam bloqueadas, esperando uma pela outra para liberar um recurso ou sinalizar uma conclusão. Isso pode levar a travamentos do sistema, tempos de resposta lentos e erros de falha de recurso.
package main
import "log"
func main() {
ch := make(chan int)
// goroutine 1
go func() {
x := <-ch // Aguardando resposta do canal
log.Println("1 ", x)
ch <- x + 1
log.Println("fim goroutine 1")
}()
// goroutine 2
go func() {
x := <-ch // Aguardando resposta do canal
log.Println("2 ", x)
ch <- x + 1
log.Println("fim goroutine 2")
}()
// Aguardamos a resposta do canal, mas nenhuma goroutine está enviando
<-ch // deadlock aqui
}
O código acima cria dois canais e duas goroutines anônimas. Cada goroutine tenta ler um valor do canal ch
, incrementá-lo em 1 e, em seguida, enviá-lo de volta para o canal.
No entanto, após criar as goroutines, o programa fica bloqueado na linha <-ch
, aguardando um valor ser enviado para o canal. Como as duas goroutines anônimas estão bloqueadas tentando ler do canal, o programa entra em deadlock e não prossegue.
Esse código ilustra um exemplo comum de deadlock em programação concorrente, em que duas ou mais goroutines estão aguardando por um evento que somente outra goroutine poderá fornecer, causando uma situação em que nenhum dos processos consegue prosseguir.
Para evitar o deadlock, seria necessário garantir que alguma goroutine envie um valor para o canal antes que a goroutine principal comece a aguardar uma resposta ou então a própria goroutine principal poderia enviar um valor para o canal antes da mesma começar a aguardar um valor do canal.
package main
import "log"
func main() {
ch := make(chan int)
// goroutine 1
go func() {
x := <-ch // Aguardando resposta do canal
log.Println("goroutine 1 ", x)
ch <- x + 1
}()
// goroutine 2
go func() {
x := <-ch // Aguardando resposta do canal
log.Println("goroutine 2 ", x)
ch <- x + 1
}()
// Enviamos uma mensagem para o canal
ch <- 22
// Aguardamos a resposta do canal, mas nenhuma goroutine está enviando
<-ch // deadlock aqui
}
Conclusão
Para evitar esses perigos, é importante entender bem como as goroutines funcionam e seguir as melhores práticas de programação em Golang, como evitar o compartilhamento de estado entre as goroutines sempre que possível, usar primitivas de sincronização seguras, limitar o número de goroutines criadas e monitorar de perto o desempenho do sistema.