Concorrência em Go: goroutines, channels e padrões de sincronização

Capítulo 14

Tempo estimado de leitura: 11 minutos

+ Exercício

O que é concorrência em Go (e o que ela não garante)

Concorrência é a capacidade de estruturar um programa para lidar com múltiplas tarefas ao mesmo tempo (ou intercaladas), sem assumir uma ordem fixa de execução. Em Go, isso é feito principalmente com goroutines e channels.

Pontos essenciais para evitar bugs:

  • Goroutines não garantem ordem: a linha que você escreveu primeiro não necessariamente executa primeiro.
  • Não assuma timing: usar time.Sleep para “esperar” goroutines é frágil.
  • Concorrência não é paralelismo: paralelismo depende de múltiplos núcleos e do runtime; concorrência é como você organiza o trabalho.

Agendamento (scheduler) e por que a ordem muda

O runtime de Go possui um agendador que distribui goroutines entre threads do sistema operacional. Ele pode pausar e retomar goroutines em pontos diferentes, então o interleaving muda de execução para execução. Por isso, sincronização deve ser feita com primitivas apropriadas (channels, WaitGroup, Mutex), e não por suposição.

Criando goroutines na prática

Uma goroutine é iniciada com a palavra-chave go antes de uma chamada de função. Ela executa concorrentemente com a goroutine principal.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	wg.Add(2)
	go func() {
		defer wg.Done()
		fmt.Println("tarefa A")
	}()

	go func() {
		defer wg.Done()
		fmt.Println("tarefa B")
	}()

	wg.Wait()
	fmt.Println("fim")
}

Passo a passo:

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

  • Crie um sync.WaitGroup para esperar tarefas.
  • Chame wg.Add(n) antes de iniciar as goroutines.
  • Em cada goroutine, use defer wg.Done().
  • No final, wg.Wait() bloqueia até todas terminarem.

Armadilha comum: captura de variável em loop

Ao iniciar goroutines dentro de um for, cuidado com a variável do loop. Se você capturar a variável diretamente, todas as goroutines podem ver o mesmo valor (o valor final do loop), dependendo do timing.

// ERRADO (pode imprimir valores repetidos/inesperados)
for i := 0; i < 5; i++ {
	go func() {
		fmt.Println(i)
	}()
}

Corrija passando o valor como parâmetro:

// CERTO
for i := 0; i < 5; i++ {
	i := i
	go func(v int) {
		fmt.Println(v)
	}(i)
}

Channels: comunicação e sincronização

Channels permitem que goroutines se comuniquem com segurança, transferindo valores entre elas. Um channel também pode ser usado como mecanismo de sincronização (quem envia pode bloquear até alguém receber, e vice-versa).

Channel não bufferizado (unbuffered)

Um channel não bufferizado sincroniza remetente e destinatário: o envio bloqueia até haver um recebimento.

package main

import "fmt"

func main() {
	ch := make(chan string) // unbuffered

	go func() {
		ch <- "olá" // bloqueia até alguém receber
	}()

	msg := <-ch
	fmt.Println(msg)
}

Passo a passo:

  • Crie o channel com make(chan T).
  • Envie com ch <- valor.
  • Receba com valor := <-ch.

Channel bufferizado (buffered)

Um channel bufferizado tem uma fila interna com capacidade. Envios só bloqueiam quando o buffer está cheio; recebimentos bloqueiam quando está vazio.

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // bloquearia (buffer cheio)

fmt.Println(<-ch) // 1

Use buffer quando fizer sentido desacoplar produtor e consumidor, mas evite “chutar” tamanhos sem medir: buffer não substitui um desenho correto de fluxo.

Fechamento de channel e range

Fechar um channel sinaliza que não haverá mais envios. Receber de um channel fechado retorna o valor zero do tipo e um booleano indicando se ainda havia valores.

v, ok := <-ch
if !ok {
	// channel fechado e drenado
}

O padrão idiomático para consumir até o fim é for range:

for v := range ch {
	fmt.Println(v)
}

Regra importante: em geral, quem envia é quem deve fechar o channel. Fechar do lado do consumidor costuma causar pânico (envio em channel fechado) se ainda houver produtores ativos.

Sincronização com WaitGroup e proteção com Mutex

WaitGroup para esperar um conjunto de goroutines

sync.WaitGroup é ideal quando você precisa aguardar um conjunto de tarefas terminar, sem necessariamente trocar dados por channel.

var wg sync.WaitGroup
wg.Add(1)

go func() {
	defer wg.Done()
	// trabalho
}()

wg.Wait()

Mutex para proteger estado compartilhado

Se múltiplas goroutines acessam e modificam a mesma variável (por exemplo, um mapa), você precisa proteger esse acesso. Em Go, mapas não são seguros para escrita concorrente.

package main

import (
	"sync"
)

func main() {
	counts := map[string]int{}
	var mu sync.Mutex
	var wg sync.WaitGroup

	words := []string{"go", "go", "chan", "go"}
	wg.Add(len(words))

	for _, w := range words {
		w := w
		go func() {
			defer wg.Done()
			mu.Lock()
			counts[w]++
			mu.Unlock()
		}()
	}

	wg.Wait()
	_ = counts
}

Prefira channels para coordenar fluxo e Mutex para proteger estado compartilhado. Muitas vezes, um desenho com channels evita a necessidade de compartilhar estado.

Padrões de concorrência

Worker pool (pool de trabalhadores)

Um worker pool limita a quantidade de goroutines trabalhando ao mesmo tempo. Isso é útil para evitar abrir milhares de goroutines e saturar CPU, disco ou rede.

Ideia: um channel de jobs alimenta N workers; um channel de resultados coleta saídas.

package main

import (
	"fmt"
	"sync"
)

type Job struct {
	ID int
}

type Result struct {
	JobID int
	Out   string
}

func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		results <- Result{JobID: j.ID, Out: fmt.Sprintf("worker %d processou job %d", id, j.ID)}
	}
}

func main() {
	jobs := make(chan Job)
	results := make(chan Result)

	var wg sync.WaitGroup
	workers := 3
	wg.Add(workers)
	for i := 1; i <= workers; i++ {
		go worker(i, jobs, results, &wg)
	}

	go func() {
		for j := 1; j <= 10; j++ {
			jobs <- Job{ID: j}
		}
		close(jobs)
	}()

	go func() {
		wg.Wait()
		close(results)
	}()

	for r := range results {
		fmt.Println(r.Out)
	}
}

Passo a passo:

  • Crie jobs e results.
  • Inicie N workers lendo de jobs até o channel fechar.
  • Feche jobs quando terminar de produzir.
  • Feche results quando todos os workers terminarem (via WaitGroup).
  • Consuma results com range.

Fan-out / Fan-in

Fan-out: distribuir trabalho para várias goroutines. Fan-in: juntar resultados em um único fluxo.

Um jeito comum de fazer fan-in é ter um channel de resultados compartilhado e fechar esse channel quando todos os produtores terminarem (novamente com WaitGroup).

func fanIn[T any](chs ...<-chan T) <-chan T {
	out := make(chan T)
	var wg sync.WaitGroup
	wg.Add(len(chs))

	for _, ch := range chs {
		ch := ch
		go func() {
			defer wg.Done()
			for v := range ch {
				out <- v
			}
		}()
	}

	go func() {
		wg.Wait()
		close(out)
	}()

	return out
}

select para timeouts e cancelamento

select permite esperar por múltiplas operações de channel. Ele é a base para timeouts, cancelamento e multiplexação de eventos.

Timeout com time.After

select {
case v := <-ch:
	_ = v
case <-time.After(500 * time.Millisecond):
	// timeout
}

Cancelamento com context.Context

Para cancelamento cooperativo, use context. A goroutine deve checar ctx.Done() e encerrar quando cancelado.

func doWork(ctx context.Context, in <-chan int, out chan<- int) {
	for {
		select {
		case <-ctx.Done():
			return
		case v, ok := <-in:
			if !ok {
				return
			}
			out <- v * 2
		}
	}
}

Dica: prefira context.WithCancel ou context.WithTimeout para controlar o ciclo de vida de pipelines e pools.

Exercício completo: processar múltiplos arquivos em paralelo com segurança

Objetivo: dado um conjunto de caminhos de arquivos, processá-los em paralelo com um limite de workers, calcular estatísticas (linhas e bytes) e retornar um relatório. O exercício deve ser seguro (sem data races), lidar com erros e suportar cancelamento/timeout.

Especificação

  • Entrada: lista de caminhos ([]string).
  • Saída: mapa map[string]FileStat com estatísticas por arquivo.
  • Concorrência: usar worker pool com N workers.
  • Sincronização: usar channels para jobs/resultados e WaitGroup para fechamento correto.
  • Segurança: agregar resultados em uma única goroutine (evita Mutex) ou usar Mutex se preferir atualizar o mapa de várias goroutines.
  • Cancelamento: se ocorrer erro, cancelar o contexto para parar o restante.

Implementação (passo a passo)

1) Defina tipos para job, resultado e estatísticas.

type FileJob struct {
	Path string
}

type FileStat struct {
	Bytes int64
	Lines int64
}

type FileResult struct {
	Path string
	Stat FileStat
	Err  error
}

2) Crie uma função que processa um arquivo. Ela deve ser determinística e não compartilhar estado global.

func computeFileStat(path string) (FileStat, error) {
	f, err := os.Open(path)
	if err != nil {
		return FileStat{}, err
	}
	defer f.Close()

	var st FileStat
	buf := make([]byte, 32*1024)
	for {
		n, rerr := f.Read(buf)
		if n > 0 {
			st.Bytes += int64(n)
			for _, b := range buf[:n] {
				if b == '\n' {
					st.Lines++
				}
			}
		}
		if rerr == io.EOF {
			break
		}
		if rerr != nil {
			return FileStat{}, rerr
		}
	}
	return st, nil
}

3) Implemente o worker: ele recebe jobs, processa e envia resultados. Ele deve respeitar cancelamento via context.

func fileWorker(ctx context.Context, jobs <-chan FileJob, results chan<- FileResult, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			return
		case j, ok := <-jobs:
			if !ok {
				return
			}
			st, err := computeFileStat(j.Path)
			select {
			case <-ctx.Done():
				return
			case results <- FileResult{Path: j.Path, Stat: st, Err: err}:
			}
		}
	}
}

4) Orquestre o pool, feche channels corretamente e agregue resultados com segurança em uma única goroutine consumidora.

func ProcessFiles(ctx context.Context, paths []string, workers int) (map[string]FileStat, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	jobs := make(chan FileJob)
	results := make(chan FileResult)

	var wg sync.WaitGroup
	wg.Add(workers)
	for i := 0; i < workers; i++ {
		go fileWorker(ctx, jobs, results, &wg)
	}

	go func() {
		defer close(jobs)
		for _, p := range paths {
			select {
			case <-ctx.Done():
				return
			case jobs <- FileJob{Path: p}:
			}
		}
	}()

	go func() {
		wg.Wait()
		close(results)
	}()

	stats := make(map[string]FileStat, len(paths))
	for r := range results {
		if r.Err != nil {
			cancel()
			return nil, fmt.Errorf("processando %s: %w", r.Path, r.Err)
		}
		stats[r.Path] = r.Stat
	}

	return stats, nil
}

O que este desenho garante:

  • Não há escrita concorrente no mapa: apenas a goroutine agregadora (a que faz range results) escreve em stats.
  • jobs é fechado pelo produtor (goroutine que envia os jobs).
  • results é fechado somente após todos os workers terminarem (wg.Wait()).
  • Em caso de erro, cancel() sinaliza para workers e produtor pararem o quanto antes.

Variação: agregando com Mutex (quando fizer sentido)

Se você quiser que cada worker atualize diretamente o mapa compartilhado, use sync.Mutex. Isso pode ser útil quando você não quer um channel de resultados, mas exige cuidado para não misturar controle de fluxo com estado compartilhado.

func ProcessFilesWithMutex(ctx context.Context, paths []string, workers int) (map[string]FileStat, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	jobs := make(chan string)
	stats := make(map[string]FileStat, len(paths))
	var mu sync.Mutex

	var firstErr error
	var errMu sync.Mutex

	var wg sync.WaitGroup
	wg.Add(workers)
	for i := 0; i < workers; i++ {
		go func() {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					return
				case p, ok := <-jobs:
					if !ok {
						return
					}
					st, err := computeFileStat(p)
					if err != nil {
						errMu.Lock()
						if firstErr == nil {
							firstErr = fmt.Errorf("processando %s: %w", p, err)
							cancel()
						}
						errMu.Unlock()
						return
					}
					mu.Lock()
					stats[p] = st
					mu.Unlock()
				}
			}
		}()
	}

	go func() {
		defer close(jobs)
		for _, p := range paths {
			select {
			case <-ctx.Done():
				return
			case jobs <- p:
			}
		}
	}()

	wg.Wait()
	if firstErr != nil {
		return nil, firstErr
	}
	return stats, nil
}

Checklist de depuração (para evitar suposições)

  • Existe algum time.Sleep usado como sincronização? Substitua por WaitGroup ou channels.
  • Algum mapa está sendo escrito por mais de uma goroutine? Use agregador único ou Mutex.
  • Quem fecha cada channel? Garanta que o produtor fecha e que não há envios após o fechamento.
  • Se há cancelamento, as goroutines checam ctx.Done() em pontos de bloqueio?
  • Se há fan-in, o channel de saída é fechado somente quando todos os produtores terminam?

Agora responda o exercício sobre o conteúdo:

Ao criar várias goroutines dentro de um loop, qual abordagem evita que todas imprimam o mesmo valor por causa da captura da variável do loop?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Em loops, a variável pode ser reutilizada e as goroutines podem observar o valor final. Ao criar uma cópia por iteração e/ou passar o valor como parâmetro, cada goroutine recebe um valor estável e independente.

Próximo capitúlo

Contexto e cancelamento em Go: context.Context em operações concorrentes

Arrow Right Icon
Capa do Ebook gratuito Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos
78%

Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos

Novo curso

18 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.