Se você está começando a programar em Go, provavelmente já ouviu falar de goroutines. Mas o que são elas e como funcionam? Neste artigo, vamos explorar esse conceito fundamental da linguagem de forma simples e prática.
Vamos começar com um exemplo simples:
package main
import (
"fmt"
)
func goroutine() {
fmt.Println("Ola mundo da goroutine!!")
}
func main() {
fmt.Println("Ola mundo da funcao main!!!!!")
go goroutine()
}
A keyword go
(que dá nome à linguagem) é usada para iniciar uma goroutine para uma determinada função. Isso significa que a função vai rodar de forma CONCORRENTE com a função main. Mas ao rodar esse programa, percebemos algo curioso:
> Ola mundo da funcao main!!!!!
Ei, cadê o output da goroutine? Por que não vemos "Ola mundo da goroutine!!"?
Toda vez que criamos uma goroutine em Go, o runtime do Go precisa chamar o scheduler (basicamente um orquestrador das goroutines). Isso demanda um certo tempo até ele alocar recursos da CPU para a goroutine criada, além de outras tarefas. O problema é que, até tudo isso acontecer, a função main já terminou sua execução!
Para resolver isso, precisamos de um mecanismo de SINCRONIZAÇÃO que diga para a função main ESPERAR que a goroutine que a gente criou termine a execução e, só assim, termine a execução do programa. É aí que entram os waitGroups (grupos de espera, literalmente) em Go.
Pense em como se fosse um contador (é quase literalmente isso):
- Ele começa em 0
- Enquanto ele for maior que 0, a gente diz pra função main esperar pra continuar sua execução e terminar a execução do programa
- Quando volta a 0, o programa pode terminar
Vejamos como implementar:
package main
import (
"fmt"
"sync"
)
func goroutine(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("Ola mundo da goroutine!!")
}
func main() {
var wg sync.WaitGroup
fmt.Println("Ola mundo da funcao main!!!!!")
wg.Add(1)
go goroutine(&wg)
wg.Wait()
}
Ok, vamos entender o que cada operação do waitGroup faz:
wg.Add(1)
adiciona 1 ao contador, para sinalizar que estamos criando uma goroutinewg.Done()
diminui o contador em 1, sinalizando que a goroutine terminouwg.Wait()
pede para a função main esperar que esse contador vá a zero
Quando chamamos wg.Wait()
, estamos dizendo explicitamente para a função main: "Ei, espere até que todas as goroutines terminem!". Só depois que o contador chegar a zero (ou seja, todas as goroutines chamaram wg.Done()
), a função main pode continuar sua execução.
Agora estamos usando o tipo waitGroup da biblioteca sync em Go, que nos provê várias ferramentas de sincronização. Perceba que agora a goroutine requer um parâmetro a mais, que é um PONTEIRO para uma variável do tipo waitGroup para indicar que ela termina execução (usamos a keyword defer para ela sinalizar isso somente no FINAL de sua execução).
Por que passamos por referência usando &
e a goroutine toma como argumento um ponteiro? Ora, se não passássemos por referência, a goroutine criaria uma CÓPIA do waitGroup e não usaria o mesmo da função main, e iniciaríamos o contador do zero! AMBAS as função main e goroutine precisam se basear no MESMO contador, que se encontra num local único da memória, e não em várias instâncias dele. Ao passar por referência, usamos a MESMA referência, o MESMO endereço na memória!
Se não chamarmos wg.Add(1)
antes de criar a goroutine, teremos um problema sério. O contador ficaria negativo ao chamar wg.Done()
(lembre que ele começa em zero), causando um DEADLOCK. O runtime do Go vai nos dar um erro nesse caso.
Agora vamos complicar um pouco - que tal criar várias goroutines ao mesmo tempo?
package main
import (
"fmt"
"sync"
)
func goroutine(num int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("goroutine ", num)
}
func main() {
var wg sync.WaitGroup
fmt.Println("Ola mundo da funcao main!!!!!")
for i := 1; i < 6; i++ {
wg.Add(1)
go goroutine(i, &wg)
}
wg.Wait()
}
Algo interessante acontece quando rodamos esse código várias vezes. A ordem das goroutines muda! Por exemplo:
> Ola mundo da funcao main!!!!!
> goroutine 5
> goroutine 3
> goroutine 2
> goroutine 1
> goroutine 4
Isso acontece porque temos um comportamento NÃO-DETERMINÍSTICO do scheduler do Go. Os recursos de CPU, alocados pelo sistema operacional, estão sempre flutuando, e o scheduler aloca esses recursos de forma diferente para cada goroutine, buscando a máxima eficiência.
E se quisermos que as goroutines executem em sequência? Para isso, vamos usar channels (canais) - uma feature especial do Go que permite passar valores entre goroutines.
package main
import (
"fmt"
"sync"
)
func goroutine(num int, wg *sync.WaitGroup, ch chan bool) {
defer wg.Done()
fmt.Println("esperando permissao pra iniciar ...")
<-ch
fmt.Println("goroutine ", num)
ch <- true
}
func main() {
var wg sync.WaitGroup
ch := make(chan bool, 1)
ch <- true
fmt.Println("Ola mundo da funcao main!!!!!")
for i := 1; i < 6; i++ {
wg.Add(1)
go goroutine(i, &wg, ch)
ch <- true
fmt.Println("esperando a goroutine anterior terminar ...")
<-ch
}
wg.Wait()
}
Vamos analisar cada parte do código com channels:
Na função main, primeiro criamos nosso channel:
ch := make(chan bool, 1)
ch <- true
Repare que criamos um channel BUFFERED usando o argumento 1
em make(chan bool, 1)
. Isso é crucial pois indica que o channel vai armazenar apenas UMA variável do tipo bool por vez. Se não fizéssemos isso, ele armazenaria várias variáveis do tipo bool (como se fosse um array) e não teríamos o comportamento desejado - na verdade, o programa daria erro!
Depois de criar o channel, já passamos o valor true
dentro da própria função main usando ch <- true
. Note que aqui não usamos o sinal =
ou :=
, pois os channels são variáveis que se comportam de forma diferente das demais.
No for loop, temos uma lógica especial:
for i := 1; i < 6; i++ {
wg.Add(1)
go goroutine(i, &wg, ch)
ch <- true
fmt.Println("esperando a goroutine anterior terminar ...")
<-ch
}
Continuamos usando os waitGroups para sincronizar as goroutines com a função main, mas agora os channels garantem a execução em ordem. Para cada goroutine:
- Iniciamos ela com
go goroutine(i, &wg, ch)
- Avisamos que ela tem permissão para executar com
ch <- true
- Esperamos que a goroutine anterior termine execução com
<-ch
(note que mudamos a ordem dos operadores, pois ao invés de passarmos um valor para o channel, estamos recebendo!)
Dentro da própria goroutine, temos uma lógica similar:
func goroutine(num int, wg *sync.WaitGroup, ch chan bool) {
defer wg.Done()
fmt.Println("esperando permissao pra iniciar ...")
<-ch
fmt.Println("goroutine ", num)
ch <- true
}
A goroutine agora:
- Recebe um parâmetro adicional
ch chan bool
- Usa
defer wg.Done()
para sinalizar término ao waitGroup - Espera receber permissão da goroutine anterior com
<-ch
(essa linha BLOQUEIA a execução até receber o valortrue
!) - Executa seu código principal
- Escreve
true
no channel comch <- true
, sinalizando para a função main que pode seguir e criar a próxima goroutine
Com isso, conseguimos o output em sequência:
Ola mundo da funcao main!!!!!
esperando permissao pra iniciar ...
goroutine 1
esperando a goroutine anterior terminar ...
esperando permissao pra iniciar ...
goroutine 2
esperando a goroutine anterior terminar ...
esperando permissao pra iniciar ...
goroutine 3
esperando a goroutine anterior terminar ...
esperando permissao pra iniciar ...
goroutine 4
esperando a goroutine anterior terminar ...
esperando permissao pra iniciar ...
goroutine 5
esperando a goroutine anterior terminar ...
Goroutines são uma feature poderosa do Go que permite concorrência de forma simples e elegante. Com waitGroups e channels, podemos controlar precisamente como nossas goroutines se comportam e interagem entre si.
Lembre-se dos pontos principais:
- Use
go
para criar goroutines - Use waitGroups para sincronizar com a função main
- Passe waitGroups por referência
- Channels ajudam a controlar o fluxo entre goroutines
- O scheduler do Go é não-determinístico por padrão