Goroutines
Esta aula apresenta as goroutines, a unidade fundamental de concorrência leve em Go. Você aprenderá como criar goroutines com a palavra-chave 'go', entenderá como elas se comparam a threads do sistema operacional e explorará seu ciclo de vida, incluindo sincronização básica com WaitGroups.
Goroutines são o coração da concorrência em Go. Elas permitem executar funções de forma concorrente com outras goroutines, inclusive a main, de maneira extremamente leve e eficiente. Diferente de threads tradicionais, que consomem megabytes de pilha, uma goroutine começa com apenas alguns kilobytes, crescendo conforme necessário. Isso possibilita rodar milhares ou até milhões de goroutines simultaneamente sem sobrecarregar o sistema.
Nesta aula, vamos explorar o que são goroutines, como criá-las com a palavra-chave go, compará-las com threads de SO e entender seu ciclo de vida, incluindo como sincronizá-las com sync.WaitGroup.
Concorrência leve
Concorrência é a capacidade de lidar com várias tarefas ao mesmo tempo, mas não necessariamente executá-las em paralelo. Go implementa concorrência através de goroutines, que são gerenciadas pelo runtime da linguagem, não pelo sistema operacional. Isso significa que o escalonador de Go decide quando uma goroutine executa, cooperativamente, permitindo um controle refinado sobre a execução.
A leveza das goroutines vem do fato de que elas possuem uma pilha inicial pequena (cerca de 2 KB) que cresce e encolhe dinamicamente. Além disso, a troca de contexto entre goroutines é muito mais barata que entre threads, pois ocorre no espaço do usuário, sem chamadas de sistema. Isso torna viável criar dezenas de milhares de goroutines em um único programa, algo impensável com threads nativas.
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world")
say("hello")
}
Neste exemplo, a chamada go say("world") inicia uma nova goroutine, enquanto say("hello") executa na goroutine principal. As saídas se intercalam, demonstrando concorrência.
go keyword
A palavra-chave go é usada para iniciar uma goroutine. Ela precede uma chamada de função (ou função anônima) e faz com que essa função execute concorrentemente. A sintaxe é simples: go f() ou go func(){ ... }().
É importante lembrar que a goroutine que invoca go não espera a função terminar; ela continua imediatamente. Se a função principal terminar antes, todas as goroutines são abruptamente encerradas. Por isso, muitas vezes precisamos de mecanismos de sincronização, como sync.WaitGroup.
package main
import (
"fmt"
"sync"
)
func printNumbers(wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 5; i++ {
fmt.Println(i)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go printNumbers(&wg)
wg.Wait()
fmt.Println("Done")
}
Aqui, usamos sync.WaitGroup para aguardar a goroutine terminar. wg.Add(1) incrementa o contador, wg.Done() decrementa e wg.Wait() bloqueia até o contador zerar.
vs threads
Goroutines são frequentemente comparadas a threads do sistema operacional, mas há diferenças cruciais:
- Custo de criação: Threads têm pilhas de 1 MB ou mais, enquanto goroutines começam com ~2 KB, crescendo sob demanda.
- Comunicação: Threads compartilham memória e exigem locks; goroutines se comunicam por canais (channels), promovendo a filosofia "não se comunique por compartilhamento de memória; compartilhe memória se comunicando".
- Escalonamento: Threads são escalonadas pelo kernel (modo kernel); goroutines são escalonadas pelo runtime de Go (modo usuário), com multiplexação sobre threads do SO.
- Número: É possível criar centenas de milhares de goroutines, mas apenas algumas dezenas de milhares de threads.
Em termos de desempenho, a troca de contexto entre goroutines é da ordem de nanosegundos, enquanto entre threads é de microssegundos. Porém, goroutines não são adequadas para tarefas com uso intenso de CPU em paralelo puro; nesse caso, é melhor usar threads (ou GOMAXPROCS).
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
fmt.Println("Número de CPUs:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("Goroutine", id)
}(i)
}
wg.Wait()
}
Esse código mostra como goroutines são multiplexadas em threads. O valor de GOMAXPROCS controla quantos threads do SO podem executar código Go simultaneamente.
Ciclo de vida
Uma goroutine nasce quando a palavra-chave go é usada. Ela vive até que sua função retorne ou até que o programa principal termine (o que pode matá-la prematuramente). Durante sua vida, ela pode ser bloqueada por operações de I/O, canais, mutexes ou chamadas a time.Sleep. O runtime de Go gerencia o escalonamento, movendo goroutines entre estados: executando, pronta e bloqueada.
Para sincronizar o fim de goroutines, usamos sync.WaitGroup ou canais. Outra forma é usar select com canais para esperar múltiplas goroutines. É importante evitar que goroutines vazem (leak), ou seja, que fiquem bloqueadas para sempre, pois isso consome recursos. Um padrão comum é usar um contexto (context.Context) para cancelar goroutines.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("All workers completed")
}
Aqui, cinco goroutines são lançadas e a main espera todas terminarem. Cada goroutine executa sua tarefa e depois chama wg.Done(). O ciclo de vida é claro: criação, execução e término.
Boas práticas
- Sempre sincronize o término das goroutines com
sync.WaitGroupou canais para evitar que a main termine antes. - Evite variáveis compartilhadas sem sincronização; prefira canais para comunicação.
- Use
defer wg.Done()logo no início da função goroutine para garantir que seja chamado mesmo em caso de pânico. - Para goroutines de longa duração, considere usar
context.Contextpara cancelamento. - Monitore o número de goroutines com
runtime.NumGoroutine()para detectar vazamentos.
Referências
- A Tour of Go: Goroutines
- Effective Go: Goroutines
- Go by Example: Goroutines
- Go doc: sync.WaitGroup
- Concurrency in Go (O'Reilly)
Exercícios
Crie um programa que inicie 10 goroutines, cada uma imprimindo seu ID (de 1 a 10). Use sync.WaitGroup para esperar todas terminarem.
✓ Resposta:package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup for i := 1; i <= 10; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Println(id) }(i) } wg.Wait() }Escreva um programa que use goroutines para calcular a soma de números de 1 a 1000, dividindo o trabalho em 4 goroutines. Cada goroutine soma uma faixa de 250 números. Use sync.WaitGroup e uma variável compartilhada (com mutex) ou canal para coletar os resultados.
✓ Resposta:package main import ( "fmt" "sync" ) func sumRange(start, end int, wg *sync.WaitGroup, result *int, mu *sync.Mutex) { defer wg.Done() sum := 0 for i := start; i <= end; i++ { sum += i } mu.Lock() *result += sum mu.Unlock() } func main() { const total = 1000 const numGoroutines = 4 chunk := total / numGoroutines var wg sync.WaitGroup var result int var mu sync.Mutex for i := 0; i < numGoroutines; i++ { start := i*chunk + 1 end := start + chunk - 1 if i == numGoroutines-1 { end = total } wg.Add(1) go sumRange(start, end, &wg, &result, &mu) } wg.Wait() fmt.Println("Soma:", result) }Explique o que acontece se você não usar nenhum mecanismo de sincronização (WaitGroup, canal, etc.) e a função main terminar antes das goroutines. Dê um exemplo que demonstre esse problema.
✓ Resposta:package main import ( "fmt" "time" ) func main() { go func() { time.Sleep(2 * time.Second) fmt.Println("Goroutine executou") }() // main termina imediatamente, sem esperar a goroutine fmt.Println("Main terminou") }Nesse exemplo, a goroutine provavelmente não terá tempo de executar antes do programa encerrar. A saída será apenas "Main terminou", pois o programa termina assim que a função main retorna, matando todas as goroutines.
Modifique o programa do exercício 1 para que cada goroutine receba um nome (string) em vez de um ID, e imprima o nome. Use um slice de strings e passe cada elemento para a goroutine.
✓ Resposta:package main import ( "fmt" "sync" ) func main() { names := []string{"Alice", "Bob", "Charlie", "David", "Eve"} var wg sync.WaitGroup for _, name := range names { wg.Add(1) go func(n string) { defer wg.Done() fmt.Println(n) }(name) } wg.Wait() }Escreva um programa que lance 3 goroutines, cada uma gerando números aleatórios e enviando para um canal. A função main deve ler 5 números de cada goroutine (total 15) e imprimi-los. Use um canal com buffer ou não? Justifique.
✓ Resposta:package main import ( "fmt" "math/rand" "sync" ) func producer(id int, out chan<- int, wg *sync.WaitGroup) { defer wg.Done() for i := 0; i < 5; i++ { out <- rand.Intn(100) } } func main() { ch := make(chan int, 15) // buffer para evitar bloqueios var wg sync.WaitGroup for i := 0; i < 3; i++ { wg.Add(1) go producer(i, ch, &wg) } go func() { wg.Wait() close(ch) }() for val := range ch { fmt.Println(val) } }Usamos um canal com buffer de tamanho 15 para que as goroutines possam enviar sem bloquear até que o buffer encha. Como cada produtor envia 5 números e há 3 produtores, o buffer de 15 é suficiente para armazenar todos os valores antes da leitura. Isso evita que as goroutines fiquem bloqueadas enquanto a main não lê. Alternativamente, poderíamos usar um canal sem buffer, mas então a main precisaria ler concorrentemente para evitar deadlock.