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

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

Goroutines em Go: o custo comparado ao de threads, o scheduler M:N e a preempção assíncrona do Go 1.14, GOMAXPROCS, WaitGroup e o wg.Go do Go 1.25, vazamentos de goroutine, o deadlock que o runtime só detecta com tudo parado e o SIGQUIT que imprime as pilhas e encerra o processo.
Golang

• • 17 min de leitura

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 sync.WaitGroup e sync.Mutex: coordenando goroutines — 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 — o ponto de troca cooperativo das versões antigas
  • Qualquer instrução, por sinal do sistema operacional — a preempção assíncrona, desde o 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 O pacote context: propagação de cancelamento e prazos)
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()
}

Esse caso nem chega a ser um pânico: com todas as goroutines bloqueadas, o runtime encerra com fatal error: all goroutines are asleep - deadlock! e mostra todas as goroutines, seus estados e onde cada uma está bloqueada. A detecção só funciona quando todas estão paradas — num servidor, em que outras goroutines seguem vivas, o mesmo impasse trava em silêncio. 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

O sinal SIGQUIT (kill -QUIT, ou Ctrl+\ no terminal) faz o Go imprimir a pilha de todas as goroutines — e encerrar o processo, com código de saída 2. É ótimo para diagnosticar um programa travado, mas não serve para inspecionar um serviço que precisa continuar no ar: para isso, runtime.Stack(buf, true) captura as pilhas de todas as goroutines sem parar nada, e o endpoint /debug/pprof/goroutine?debug=2 do pacote net/http/pprof faz o mesmo por HTTP.

Uma goroutine custa pouco para criar e quase nada para esperar, e é isso que torna natural, em Go, dar a cada requisição, conexão ou tarefa a sua. O custo aparece em outro lugar: goroutine que ninguém espera some com o fim do main, goroutine presa num canal que ninguém lê nunca some, e o runtime só denuncia o impasse quando o programa inteiro parou. Por isso, junto com cada go, vale decidir desde o início quem espera por ela e como ela termina — são as perguntas que canais, WaitGroup e context existem para responder.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma função de busca dispara uma goroutine que consulta um serviço lento e envia o resultado por um canal sem buffer; se a resposta demora, a função desiste e retorna, sem ler o canal. Em produção, o consumo de memória sobe devagar, dia após dia. Explique o que acumula e como confirmar a suspeita com o próprio runtime.

Ver resposta

✓ Resposta: Acumulam goroutines presas. Num canal sem buffer, o envio só termina quando alguém recebe; como a função já desistiu e ninguém mais vai ler, cada goroutine fica bloqueada em ch <- resultado para sempre, segurando sua pilha e tudo o que referencia. É o vazamento que o artigo descreve, e ele não gera erro nenhum: o programa só fica maior. Num teste que chamou mil vezes uma função com esse defeito, runtime.NumGoroutine() passou de 1 para 1001. Em produção, a confirmação vem da mesma métrica exposta ao monitoramento, ou do endpoint /debug/pprof/goroutine, que agrupa as goroutines pela linha em que estão paradas e mostra milhares delas no mesmo envio. A correção mais simples é dar ao canal buffer de 1, make(chan Resultado, 1): o envio passa a não depender de leitor, e a goroutine termina mesmo que ninguém leia. A mais completa é propagar um context e usar select com ctx.Done(), para que o trabalho também seja cancelado. Nos testes, o goleak citado no artigo transforma esse vazamento em falha.

Exercício 2

Uma ferramenta de linha de comando trava num impasse de mutex, e o runtime acusa na hora fatal error: all goroutines are asleep - deadlock!. O mesmo defeito, levado para dentro de um servidor HTTP, faz as requisições pararem de responder sem nenhuma mensagem. Por que o runtime detecta o problema num caso e não no outro?

Ver resposta

✓ Resposta: O detector do runtime só dispara quando todas as goroutines do programa estão bloqueadas sem possibilidade de acordar. Na ferramenta, a goroutine principal travou no segundo Lock e não havia mais nenhuma, então o runtime concluiu que nada poderia destravá-la e encerrou o processo. No servidor, há sempre outras goroutines vivas, como a que aceita conexões, os timers e as de outras requisições, e o runtime não tem como saber que as travadas nunca vão acordar. Num teste com uma goroutine auxiliar dormindo em laço, o mesmo impasse de mutex ficou 300 ms parado sem nenhuma mensagem. Na prática, o detector só ajuda em programas pequenos e em testes; em serviços, quem encontra impasses são os timeouts nas requisições, as métricas de latência e o dump de goroutines pelo /debug/pprof/goroutine?debug=2, que mostra todas elas paradas no mesmo Lock. O go test -race não acha impasses, só acessos concorrentes sem sincronização, mas o sync.Mutex não reentrante do Go faz desse tipo de impasse um padrão fácil de reconhecer no dump.

Exercício 3

Um colega lê um texto antigo segundo o qual um laço for {} sem chamadas de função, numa goroutine, com GOMAXPROCS(1), trava o programa inteiro, porque o scheduler nunca consegue tirar a vez dele. Ele testa no Go 1.27 e o main continua rodando. O texto estava errado?

Ver resposta

✓ Resposta: Estava certo para a época em que foi escrito. Até o Go 1.13, o scheduler só trocava de goroutine em pontos cooperativos, como chamadas de função, operações de canal, I/O e alocações. Um laço sem nenhum desses pontos, rodando na única thread disponível, nunca devolvia a vez, e o resto do programa parava. O Go 1.14 introduziu a preempção assíncrona: o runtime envia um sinal à thread que está há tempo demais com a mesma goroutine e a interrompe em qualquer instrução segura, sem depender de pontos cooperativos. Num teste com runtime.GOMAXPROCS(1), uma goroutine em for {} e um time.Sleep de 10 ms no main, o main voltou e imprimiu normalmente. A lição vai além desse caso: textos sobre o runtime do Go envelhecem rápido, e afirmações sobre scheduler, coletor de lixo ou tamanho de pilha precisam ser conferidas contra a versão em uso. O laço vazio continua sendo uma má ideia, porque queima 100% de um núcleo sem fazer nada; só deixou de travar os outros.

Exercício 4

Para processar 100 arquivos em paralelo, alguém escreve um laço que dispara go func() { wg.Add(1); defer wg.Done(); processar(arq) }() e, depois do laço, wg.Wait(). Às vezes o programa termina com arquivos sem processar. Onde está o erro, e o que o Go 1.25 trouxe para evitar exatamente isso?

Ver resposta

✓ Resposta: O wg.Add(1) está dentro da goroutine, e nada garante que ela rode antes de o laço acabar e o main chegar ao wg.Wait(). Se o Wait encontrar o contador em zero, porque as goroutines ainda nem começaram, ele retorna na hora e o programa segue, ou termina, com o trabalho pela metade. É uma condição de corrida clássica, e a documentação do WaitGroup é explícita: o Add precisa acontecer antes do go, na goroutine que vai esperar. A forma correta é a do artigo, com wg.Add(1) no laço e defer wg.Done() dentro da goroutine. Desde o Go 1.25, sync.WaitGroup tem o método Go: wg.Go(func() { processar(arq) }) faz o Add, dispara a goroutine e chama o Done no fim, e com isso elimina essa classe inteira de erro. Com 100 arquivos, vale ainda limitar quantos rodam ao mesmo tempo, com um canal usado como semáforo ou com o pacote golang.org/x/sync/errgroup e seu SetLimit.

Exercício 5

Um serviço em produção está lento, e alguém sugere mandar kill -QUIT no processo "para ver as goroutines sem derrubar nada", como diz um tutorial. O que acontece de fato, e quais são as formas de obter o mesmo diagnóstico com o serviço no ar?

Ver resposta

✓ Resposta: O processo cai. Ao receber SIGQUIT, o runtime do Go imprime a pilha de todas as goroutines no erro padrão e encerra o programa com código de saída 2. Num teste com um programa que imprimia uma linha a cada 300 ms, o kill -QUIT produziu o dump, com a linha SIGQUIT: quit e as goroutines listadas, e o processo morreu. O tutorial confundiu o comportamento do Go com o da JVM, em que esse sinal imprime as threads e a aplicação continua rodando. Para diagnosticar com o serviço no ar, a ferramenta padrão é o net/http/pprof: importado em branco num servidor interno, ele expõe /debug/pprof/goroutine?debug=2, com as pilhas completas, e /debug/pprof/goroutine, que agrupa goroutines iguais, além de perfis de CPU e memória. Dentro do próprio código, runtime.Stack(buf, true) captura as pilhas de todas as goroutines sem interromper nada, e pode ser ligado a um comando administrativo. O SIGQUIT continua útil, mas para o caso oposto: um processo já travado, que vai ser reiniciado de qualquer jeito.

Comentários

Mais em Golang

Arrays e Slices: a espinha dorsal das coleções em Go
Arrays e Slices: a espinha dorsal das coleções em Go

Arrays e slices em Go: tamanho no tipo, len e cap, fatiamento que compartilha…

Estudando e se aprofundando em Go
Estudando e se aprofundando em Go

Por que aprender Go hoje: a linguagem de Docker e Kubernetes, o que ela…

Leitura e escrita de arquivos com os pacotes os e bufio
Leitura e escrita de arquivos com os pacotes os e bufio

Arquivos em Go: ReadFile e WriteFile, Open, Create e OpenFile, bufio.Scanner e…