Golang

Goroutines: concorrência leve e o modelo de execução do Go Já leu

10 min de leitura

Goroutines: concorrência leve e o modelo de execução do Go
Se existe um recurso que mais distingue Go de outras linguagens modernas, é sua abordagem à concorrência. Goroutines e channels não são uma biblioteca adicionada depois — foram projetados junto com a linguagem, influenci

Se existe um recurso que mais distingue Go de outras linguagens modernas, é sua abordagem à concorrência. Goroutines e channels não são uma biblioteca adicionada depois — foram projetados junto com a linguagem, influenciando sua sintaxe, seu runtime e sua filosofia. Compreender concorrência em Go é compreender Go em sua essência.

Este módulo é o mais denso do curso. Cada artigo constrói sobre o anterior, e os conceitos se interconectam. Vale a pena ler com calma, executar os exemplos e experimentar variações.


O problema que concorrência resolve

Programas modernos precisam fazer múltiplas coisas ao mesmo tempo: responder a requisições HTTP enquanto consultam um banco de dados, processar arquivos enquanto enviam notificações, monitorar serviços enquanto coletam métricas. Sem concorrência, essas tarefas aconteceriam sequencialmente — uma esperando a outra terminar.

Há duas abordagens clássicas para concorrência: threads do sistema operacional e I/O assíncrono com callbacks. Threads são poderosas mas caras — cada thread consome tipicamente 1 a 8 MB de memória de pilha e tem custo significativo de criação e troca de contexto. Callbacks resolvem o problema de memória mas produzem código difícil de ler e debugar — o famoso "callback hell".

Go oferece uma terceira via: goroutines.


O que é uma goroutine

Uma goroutine é uma função executada de forma concorrente com outras goroutines no mesmo processo. Ela é gerenciada pelo runtime do Go, não diretamente pelo sistema operacional.

A diferença prática é radical:

  • Uma thread do SO ocupa entre 1 MB e 8 MB de pilha
  • Uma goroutine começa com apenas 2 KB de pilha, que cresce e encolhe dinamicamente conforme necessário
  • Criar uma goroutine é ordens de magnitude mais barato do que criar uma thread
  • Um programa Go típico pode ter dezenas de milhares de goroutines simultâneas sem problemas

O runtime do Go usa um scheduler M:N — ele mapeia M goroutines em N threads do sistema operacional, onde N é tipicamente igual ao número de núcleos disponíveis. O scheduler é cooperativo e preemptivo: ele alterna goroutines em pontos de I/O, chamadas de sistema e periodicamente em loops longos.


Criando goroutines

A sintaxe é deliberadamente minimalista — a palavra-chave go antes de qualquer chamada de função:

package main

import (
    "fmt"
    "time"
)

func saudar(nome string) {
    fmt.Printf("Olá, %s!\n", nome)
}

func main() {
    go saudar("Ana")    // executa em uma goroutine separada
    go saudar("Bruno")
    go saudar("Carla")

    // Sem isso, main termina antes das goroutines executarem
    time.Sleep(100 * time.Millisecond)
    fmt.Println("main finalizada")
}

O time.Sleep no final é uma muleta — existe para dar tempo às goroutines de executar antes que main termine. Quando main retorna, todas as goroutines são encerradas abruptamente, independentemente de terem terminado ou não. A solução correta para coordenar goroutines será o tema dos próximos artigos.


Goroutines com funções anônimas

O padrão mais comum é combinar go com funções anônimas:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1)
        i := i // captura o valor atual — essencial antes do Go 1.22
        go func() {
            defer wg.Done()
            fmt.Printf("goroutine %d executando\n", i)
        }()
    }

    wg.Wait() // bloqueia até todas as goroutines terminarem
    fmt.Println("todas as goroutines finalizadas")
}

sync.WaitGroup é a solução idiomática para esperar que um grupo de goroutines termine. Será detalhado no Artigo 22 — por ora, entenda que wg.Add(1) incrementa um contador, wg.Done() o decrementa e wg.Wait() bloqueia até o contador chegar a zero.


O modelo de memória do Go

Go segue o princípio "Do not communicate by sharing memory; instead, share memory by communicating" — não comunique por memória compartilhada; compartilhe memória comunicando.

Na prática, isso significa que a forma preferida de passar dados entre goroutines é através de channels — não por variáveis compartilhadas com locks. Isso não significa que variáveis compartilhadas com sincronização são proibidas — são legítimas e às vezes mais eficientes — mas a filosofia do Go favorece o modelo de channels.


Concorrência vs paralelismo

Uma distinção fundamental que frequentemente gera confusão:

Concorrência é sobre estrutura — um programa que pode lidar com múltiplas tarefas ao mesmo tempo, alternando entre elas conforme necessário. Mesmo em um único núcleo, um programa concorrente pode interleavar goroutines.

Paralelismo é sobre execução simultânea — múltiplas tarefas executando literalmente ao mesmo tempo em múltiplos núcleos.

Go oferece ambos. A variável GOMAXPROCS controla quantas threads do sistema operacional o runtime usa simultaneamente:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    // Por padrão, usa todos os núcleos disponíveis
    fmt.Println("Núcleos:", runtime.NumCPU())
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

    // Forçar execução em apenas 1 thread (sem paralelismo real)
    runtime.GOMAXPROCS(1)

    // Ceder voluntariamente o processador para outras goroutines
    runtime.Gosched()
}

Desde o Go 1.5, GOMAXPROCS é igual ao número de CPUs por padrão. Alterar esse valor raramente é necessário em código de aplicação.


Um exemplo real: requisições HTTP concorrentes

O poder das goroutines se manifesta em cenários práticos. Comparando execução sequencial e concorrente de múltiplas requisições HTTP:

package main

import (
    "fmt"
    "net/http"
    "sync"
    "time"
)

func verificarURL(url string, wg *sync.WaitGroup, resultados chan<- string) {
    defer wg.Done()

    inicio := time.Now()
    resp, err := http.Get(url)
    duracao := time.Since(inicio)

    if err != nil {
        resultados <- fmt.Sprintf("ERRO  %-35s %v", url, err)
        return
    }
    defer resp.Body.Close()

    resultados <- fmt.Sprintf("%-5d %-35s %v", resp.StatusCode, url, duracao)
}

func main() {
    urls := []string{
        "https://go.dev",
        "https://pkg.go.dev",
        "https://github.com",
        "https://google.com",
        "https://cloudflare.com",
    }

    var wg sync.WaitGroup
    resultados := make(chan string, len(urls))

    inicio := time.Now()

    for _, url := range urls {
        wg.Add(1)
        go verificarURL(url, &wg, resultados)
    }

    // Goroutine separada para fechar o channel após todas terminarem
    go func() {
        wg.Wait()
        close(resultados)
    }()

    for resultado := range resultados {
        fmt.Println(resultado)
    }

    fmt.Printf("\nTotal: %v\n", time.Since(inicio))
}

Executando sequencialmente, 5 requisições que demoram 300 ms cada totalizariam 1,5 segundo. Executando concorrentemente, todas acontecem em paralelo e o total se aproxima do tempo da requisição mais lenta — cerca de 300 ms.


Goroutines e o scheduler: pontos de troca

O scheduler do Go troca goroutines em pontos específicos. Entender esses pontos ajuda a escrever código mais previsível:

  • Operações de I/O — leitura/escrita em arquivos, rede, pipes
  • Chamadas de sistema
  • Operações em channels — envio e recebimento
  • time.Sleep
  • runtime.Gosched() — cessão voluntária
  • Alocações de memória em certas condições
  • Chamadas de função em loops (preempção assíncrona desde Go 1.14)

Antes do Go 1.14, um loop CPU-bound sem pontos de troca podia monopolizar uma thread e impedir outras goroutines de executar. A preempção assíncrona resolveu esse problema — o scheduler pode interromper goroutines mesmo em loops puros.


Goroutines vazando: um problema real

Uma goroutine leak acontece quando uma goroutine é criada mas nunca termina — ficando presa em um channel bloqueado, em um loop infinito ou esperando por um recurso que nunca chegará. Com o tempo, goroutines acumuladas consomem memória e degradam a performance.

// PROBLEMA: goroutine vaza se ninguém lê do channel
func vazamento() {
    ch := make(chan int)
    go func() {
        ch <- 42 // bloqueia para sempre se ninguém receber
    }()
    // função retorna sem ler do channel — goroutine fica presa
}

// SOLUÇÃO: usar context para cancelamento (detalhado no Artigo 23)
func semVazamento(ctx context.Context) {
    ch := make(chan int, 1) // channel com buffer
    go func() {
        select {
        case ch <- 42:
        case <-ctx.Done(): // goroutine termina se o contexto for cancelado
        }
    }()
}

Para detectar goroutine leaks em testes, o pacote goleak da Uber é amplamente usado:

go get go.uber.org/goleak
func TestSemVazamento(t *testing.T) {
    defer goleak.VerifyNone(t)
    // ... código do teste
}

Inspecionando goroutines em runtime

O pacote runtime permite inspecionar goroutines em execução:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func trabalho(id int) {
    time.Sleep(1 * time.Second)
}

func main() {
    fmt.Printf("Goroutines antes: %d\n", runtime.NumGoroutine())

    for i := 0; i < 10; i++ {
        go trabalho(i)
    }

    fmt.Printf("Goroutines durante: %d\n", runtime.NumGoroutine())

    time.Sleep(2 * time.Second)
    fmt.Printf("Goroutines após: %d\n", runtime.NumGoroutine())
}

Em produção, expor métricas de goroutines é uma prática recomendada — um número crescente e irrestrito de goroutines é um sinal claro de vazamento.


Stack traces de goroutines

Quando um programa Go entra em pânico, o runtime imprime o stack trace de todas as goroutines ativas — não apenas a que causou o problema. Isso é extremamente útil para diagnóstico:

package main

import "sync"

func deadlock() {
    var mu sync.Mutex
    mu.Lock()
    mu.Lock() // deadlock — trava esperando por si mesmo
}

func main() {
    deadlock()
}

A saída de pânico mostrará todas as goroutines, seus estados e onde cada uma está bloqueada. Para obter o stack trace de todas as goroutines a qualquer momento sem pânico, use runtime/debug:

import "runtime/debug"

debug.PrintStack() // imprime stack da goroutine atual

Ou envie o sinal SIGQUIT ao processo — Go imprime o stack de todas as goroutines e continua executando.


Resumo do que foi coberto

Este artigo apresentou goroutines em profundidade: o que são, como diferem de threads tradicionais, o modelo de scheduler M:N, a sintaxe de criação com go, a distinção entre concorrência e paralelismo, GOMAXPROCS, um exemplo prático de requisições concorrentes, os pontos de troca do scheduler, goroutine leaks e como evitá-las, e inspeção de goroutines em runtime. O próximo artigo apresenta channels — o mecanismo de comunicação entre goroutines.


Referências e leituras complementares

  • Go Blog: Concurrency is not Parallelism — Rob Pike sobre a distinção fundamental. https://go.dev/blog/waza-talk

  • Go Blog: Go Concurrency Patterns — Padrões clássicos de concorrência em Go. https://go.dev/blog/pipelines

  • Documentação do pacote runtime — Referência de NumGoroutine, GOMAXPROCS e Gosched. https://pkg.go.dev/runtime

  • Go by Example: Goroutines — Exemplos práticos comentados. https://gobyexample.com/goroutines

  • goleak — Uber's goroutine leak detector — Ferramenta para detectar vazamentos em testes. https://github.com/uber-go/goleak

  • The Go Memory Model — Especificação formal de como goroutines enxergam memória compartilhada. https://go.dev/ref/mem

Comentários

Mais em Golang

Pacotes: visibilidade, nomenclatura e organização de projeto
Pacotes: visibilidade, nomenclatura e organização de projeto

Em Go, todo arquivo fonte pertence a um pacote. Não existe código solto fora...

Channels: comunicação entre goroutines
Channels: comunicação entre goroutines

No artigo anterior, goroutines foram apresentadas como unidades de execução c...

Artigo 48 — APIs RESTful completas: projeto do zero ao deploy
Artigo 48 — APIs RESTful completas: projeto do zero ao deploy

Artigo 48 — APIs RESTful completas: projeto do zero ao deploy Do conceito ao...