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.WaitGroup para 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

Exercícios

  1. Crie um programa que inicie 10 goroutines, cada uma imprimindo seu número (de 1 a 10). Use sync.WaitGroup para 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()
    }
    
  2. 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)
    }
    
  3. 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, como time.Sleep(time.Second) ou use um canal para sincronizar.
  4. 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
    }
    
  5. 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)
    }