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.WaitGroup ou 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.Context para cancelamento.
  • Monitore o número de goroutines com runtime.NumGoroutine() para detectar vazamentos.

Referências

Exercícios

  1. 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()
    }
    
  2. 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)
    }
    
  3. 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.

  4. 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()
    }
    
  5. 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.