Goroutines
Esta aula apresenta as goroutines em Go, explicando o que são, como criá-las com a palavra-chave 'go', suas diferenças em relação a threads do sistema operacional e seu ciclo de vida. O conteúdo inclui exemplos práticos, boas práticas e exercícios com respostas.
Goroutines são a base da concorrência em Go. Elas permitem executar funções de forma concorrente com outras funções, de maneira leve e eficiente. Diferente de threads tradicionais, as goroutines são gerenciadas pelo runtime do Go, que as multiplexa em um número menor de threads do sistema operacional. Isso permite que programas Go lidem com milhares ou até milhões de goroutines simultaneamente sem sobrecarregar o sistema.
Nesta aula, vamos explorar o conceito de concorrência leve, aprender a usar a palavra-chave go para iniciar goroutines, comparar goroutines com threads e entender seu ciclo de vida, incluindo como e quando elas terminam.
Concorrência leve
Concorrência leve significa que o custo de criar e gerenciar uma goroutine é muito baixo. Enquanto uma thread do sistema operacional pode consumir megabytes de pilha e exigir chamadas de sistema para criação e troca de contexto, uma goroutine começa com uma pilha de apenas alguns kilobytes (inicialmente 2 KB no Go 1.4+, mas pode crescer conforme necessário). O runtime do Go gerencia o agendamento das goroutines de forma cooperativa, distribuindo-as entre um número fixo de threads do SO (geralmente igual ao número de CPUs lógicas).
Isso significa que você pode criar milhares de goroutines sem problemas de desempenho. Por exemplo, um servidor web que atende milhares de conexões simultâneas pode usar uma goroutine por conexão, sem se preocupar com o overhead de threads. Além disso, a troca de contexto entre goroutines é muito mais rápida, pois ocorre dentro do mesmo espaço de endereço e sem intervenção do kernel.
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")
}
go keyword
A palavra-chave go é usada para iniciar uma goroutine. Ela é colocada antes de uma chamada de função (ou método) e faz com que essa função seja executada concorrentemente com o restante do programa. A função pode ser anônima ou nomeada, e pode receber argumentos normalmente. O fluxo do programa continua imediatamente após a chamada go, sem esperar que a goroutine termine.
É importante notar que a goroutine executa a função em um novo contexto de execução, mas compartilha o mesmo espaço de endereçamento. Portanto, o acesso a variáveis compartilhadas deve ser sincronizado (usando canais ou outros mecanismos). O exemplo a seguir mostra o uso de uma função anônima em uma goroutine:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("Executando em goroutine")
}()
time.Sleep(time.Second)
}
vs threads
Goroutines são frequentemente comparadas a threads do sistema operacional, mas existem diferenças fundamentais:
- Criação: Threads são criadas pelo kernel, com custo alto (alocação de pilha, chamadas de sistema). Goroutines são criadas pelo runtime do Go, com custo baixo (alocação de pilha pequena, sem chamadas de sistema).
- Tamanho da pilha: Pilha de thread é fixa e grande (tipicamente 1 MB ou mais). Pilha de goroutine é pequena e dinâmica (começa com 2 KB e cresce conforme necessário).
- Agendamento: Threads são agendadas pelo kernel (preemptivo). Goroutines são agendadas pelo runtime do Go (cooperativo, mas com preempção em pontos de segurança).
- Troca de contexto: Troca entre threads envolve o kernel (custo de milhares de ciclos). Troca entre goroutines é feita em user-space (custando dezenas a centenas de ciclos).
- Escalabilidade: É viável ter milhares de threads, mas cada uma consome recursos significativos. Goroutines permitem milhões de concorrências simultâneas.
Na prática, você pode pensar em goroutines como threads virtuais gerenciadas pelo runtime, que são muito mais leves e eficientes para tarefas concorrentes.
Ciclo de vida
Uma goroutine começa sua vida quando é criada com a palavra-chave go. Ela executa a função associada até que a função retorne ou a goroutine seja terminada explicitamente (por exemplo, via runtime.Goexit()). Quando a função principal (main) retorna, todas as goroutines são abruptamente terminadas, independentemente de terem concluído ou não. Por isso, é comum usar canais ou sync.WaitGroup para sincronizar o término das goroutines com a função main.
O ciclo de vida de uma goroutine pode ser resumido em: criação (com go), execução concorrente, e término (quando a função retorna ou o programa principal termina). Goroutines também podem ser bloqueadas em operações de I/O, canais ou primitivas de sincronização. O runtime gerencia o agendamento para que goroutines bloqueadas não consumam CPU.
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d iniciando\n", id)
// Simula trabalho
for i := 0; i < 1000000; i++ {
}
fmt.Printf("Worker %d concluído\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("Todos os workers concluídos")
}
Boas práticas
- Sempre sincronize o acesso a dados compartilhados usando canais ou mutexes.
- Use
sync.WaitGrouppara esperar que um conjunto de goroutines termine. - Evite goroutines que nunca terminam (vazamento de goroutines). Use contextos com cancelamento para interrompê-las.
- Não assuma ordem de execução entre goroutines; elas são concorrentes.
- Para tarefas de longa duração, considere usar um pool de workers com canais.
Referências
- A Tour of Go: Goroutines
- Effective Go: Goroutines
- Go Package runtime - Goroutines
- Concurrency is not Parallelism (Rob Pike)
- Go by Example: Goroutines
Exercícios
Crie um programa que inicie 10 goroutines, cada uma imprimindo seu número (de 1 a 10). Use
sync.WaitGrouppara esperar todas terminarem antes de o programa principal finalizar.✓ Resposta:package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup for i := 1; i <= 10; i++ { wg.Add(1) go func(n int) { defer wg.Done() fmt.Println(n) }(i) } wg.Wait() }Escreva um programa que use uma goroutine para calcular a soma de 1 a 100 e envie o resultado para um canal. A função main deve receber o resultado e imprimi-lo.
✓ Resposta:package main import "fmt" func sum(from, to int, ch chan int) { s := 0 for i := from; i <= to; i++ { s += i } ch <- s } func main() { ch := make(chan int) go sum(1, 100, ch) result := <-ch fmt.Println("Soma:", result) }Explique por que o programa abaixo pode não imprimir "Olá" e como corrigi-lo:
package main import "fmt" func main() { go fmt.Println("Olá") }✓ Resposta:O programa termina antes que a goroutine tenha chance de executar, pois a função main retorna imediatamente após iniciar a goroutine. Para corrigir, adicione uma espera, comotime.Sleep(time.Second)ou use um canal para sincronizar.Crie um programa que demonstre a diferença entre execução sequencial e concorrente usando goroutines. Execute uma função que imprime números de 1 a 5 com um atraso de 200ms. Primeiro chame a função sequencialmente duas vezes, depois use goroutines.
✓ Resposta:package main import ( "fmt" "time" ) func printNumbers(id int) { for i := 1; i <= 5; i++ { fmt.Printf("Goroutine %d: %d\n", id, i) time.Sleep(200 * time.Millisecond) } } func main() { // Sequencial printNumbers(1) printNumbers(2) // Concorrente go printNumbers(3) go printNumbers(4) time.Sleep(2 * time.Second) // espera as goroutines terminarem }Escreva um programa que lance 5 goroutines, cada uma gerando um número aleatório entre 1 e 100, e envie esses números para um canal. A função main deve ler 5 números do canal e imprimir a soma deles.
✓ Resposta:package main import ( "fmt" "math/rand" "sync" ) func generateRandom(ch chan int, wg *sync.WaitGroup) { defer wg.Done() ch <- rand.Intn(100) + 1 } func main() { ch := make(chan int, 5) var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go generateRandom(ch, &wg) } wg.Wait() close(ch) sum := 0 for n := range ch { sum += n } fmt.Println("Soma:", sum) }