Blog

Goroutines e o scheduler do Go

Uma goroutine é uma função que roda junto com o resto do seu programa, e o Go consegue rodar milhares delas ao mesmo tempo. Veja como iniciá-las, esperar por elas, evitar vazamentos e como o scheduler divide poucas threads entre elas.

Uma goroutine é uma função que roda ao mesmo tempo que o resto do seu programa. Você inicia uma escrevendo go na frente de uma chamada de função. Essa parte leva um minuto para aprender. Saber quando suas goroutines terminam, e o que o runtime faz com elas enquanto isso, leva mais tempo.

Este post mostra como iniciar goroutines, como esperar por elas, quanto elas custam e o scheduler que as executa. Ele termina com o bug mais comum de goroutines, o vazamento. Todo programa abaixo rodou no Go 1.26, e a saída foi colada da execução.

go inicia uma goroutine, e main não espera por ela

Colocar go antes de uma chamada de função inicia essa chamada numa goroutine nova e segue em frente na hora, sem esperar a chamada terminar.

package main

import (
	"fmt"
	"time"
)

func report() {
	time.Sleep(time.Minute) // stands in for slow work
	fmt.Println("report finished")
}

func main() {
	go report()
	fmt.Println("main done")
}

Ele imprime:

main done

O report nunca é impresso. main iniciou a goroutine, imprimiu a própria linha e retornou, muito antes de o minuto passar. Quando main retorna, o programa acaba. O Go não espera as outras goroutines e não avisa que algumas ainda estavam rodando. Elas simplesmente param.

O sleep está ali para o resultado ser o mesmo em toda execução. Tire o sleep e escreva go fmt.Println("hello from the goroutine") no lugar, e o resultado vira cara ou coroa. Rodamos essa versão 200 vezes numa máquina. Mais ou menos metade das execuções imprimiu a linha da goroutine, e o resto não. Nada no programa decide qual das duas acontece. Um programa que só funciona quando uma goroutine por acaso é rápida tem um bug.

Às vezes você vai ver um time.Sleep no fim de main para “resolver” isso. Ele esconde o problema numa máquina tranquila e falha numa máquina ocupada. A solução é esperar as goroutines avisarem que terminaram.

Esperando do jeito certo com sync.WaitGroup

Um sync.WaitGroup é um contador de goroutines que ainda estão rodando, e Wait bloqueia até esse contador chegar a zero.

package main

import (
	"fmt"
	"sync"
)

func main() {
	names := []string{"ana", "bo", "chen"}
	greetings := make([]string, len(names))

	var wg sync.WaitGroup
	for i, name := range names {
		wg.Add(1)
		go func() {
			defer wg.Done()
			greetings[i] = "hello, " + name
		}()
	}
	wg.Wait()

	for _, g := range greetings {
		fmt.Println(g)
	}
}

Ele imprime:

hello, ana
hello, bo
hello, chen

Há três chamadas para conhecer:

  • wg.Add(1) soma um ao contador. Chame antes do comando go, na goroutine que vai esperar. Se você chamar dentro da goroutine nova, Wait pode rodar primeiro, ver zero e retornar cedo demais.
  • wg.Done() subtrai um. Colocar essa chamada atrás de defer garante que ela roda mesmo se a função retornar mais cedo.
  • wg.Wait() bloqueia até o contador voltar a zero.

Repare que a saída fica em ordem. As três goroutines podem rodar em qualquer ordem, mas cada uma escreve na sua própria posição, greetings[i]. Duas goroutines nunca mexem no mesmo elemento, e main só lê o slice depois que Wait retorna. A impressão acontece num lugar só, numa ordem fixa. É esse hábito que deixa a saída concorrente previsível.

O Go 1.25 trouxe wg.Go

Desde o Go 1.25, WaitGroup tem um método Go que faz o Add, o comando go e o Done por você:

package main

import (
	"fmt"
	"sync"
)

func main() {
	squares := make([]int, 5)

	var wg sync.WaitGroup
	for i := range squares {
		wg.Go(func() {
			squares[i] = i * i
		})
	}
	wg.Wait()

	fmt.Println(squares)
}

Ele imprime:

[0 1 4 9 16]

wg.Go(f) é o mesmo que wg.Add(1) seguido de uma goroutine que chama f e depois Done. Você não tem como esquecer o Done nem como colocar o Add no lugar errado. A função que você passa não recebe argumentos e não retorna nada, então ela alcança os valores de que precisa por uma closure, como i faz aqui. O resto deste post usa wg.Go. Você ainda vai ver Add e Done na maior parte do código existente, então vale saber ler os dois.

Closures em goroutines e a variável do loop

Uma goroutine iniciada dentro de um loop quase sempre usa a variável do loop, e a parte sobre controle de fluxo mostrou por que isso já foi uma armadilha.

Olhe para squares[i] = i * i acima. A goroutine não roda quando o loop chega nela. Ela roda um pouco depois, talvez quando o loop já terminou. Então qual i ela enxerga?

Desde o Go 1.22, cada iteração do loop ganha o seu próprio i. A goroutine iniciada na terceira volta enxerga o i da terceira volta, e nada o muda depois. É por isso que o programa está correto.

Antes do Go 1.22, o loop inteiro compartilhava um único i. Goroutines que rodavam tarde liam o valor que o loop tivesse alcançado, muitas vezes o último. Código escrito para Go antigo contorna isso com i := i dentro do loop, ou passando o valor como argumento, go func(i int) { ... }(i). No Go 1.22 em diante você não precisa de nenhum dos dois. A regra segue a linha go do go.mod, não a versão do Go que você instalou, então um módulo que ainda diz go 1.21 fica com o comportamento antigo.

Goroutines são baratas

Uma goroutine custa muito menos que uma thread do sistema operacional, então iniciar milhares delas é Go normal.

package main

import (
	"fmt"
	"sync"
)

func main() {
	const n = 10_000
	results := make(chan int, n)

	var wg sync.WaitGroup
	for i := range n {
		wg.Go(func() {
			results <- i + 1
		})
	}
	wg.Wait()
	close(results)

	total := 0
	for r := range results {
		total += r
	}
	fmt.Println("goroutines:", n)
	fmt.Println("total:", total)
}

Ele imprime:

goroutines: 10000
total: 50005000

Dez mil goroutines mandam um número cada para um channel. O channel tem espaço para os dez mil valores, então nenhuma goroutine que envia precisa esperar. Depois de Wait, main fecha o channel e soma tudo o que está nele. O total é 1 + 2 + … + 10.000, que dá 50.005.000, seja qual for a ordem das goroutines.

Por que isso é barato? A parte sobre memória mostrou que a stack de uma goroutine nova começa com alguns kilobytes e só cresce quando precisa. Uma thread do sistema operacional costuma reservar logo de início uma stack fixa bem maior. Criar uma goroutine também é um trabalho que o próprio runtime do Go faz, sem pedir nada ao sistema operacional.

Barato não é de graça. Toda goroutine continua segurando a sua stack, e tudo o que ela referencia, até terminar. Dez mil goroutines de vida curta não são problema. Dez mil que nunca terminam são um vazamento, e isso aparece no fim deste post.

O channel aqui faz um trabalho que você vai conhecer direito na parte sobre channels. Por enquanto, leia results <- i + 1 como “coloque este valor na fila” e for r := range results como “tire valores até a fila ser fechada”.

Concorrência não é paralelismo

Concorrência quer dizer que o seu programa está organizado como várias tarefas que avançam de forma independente. Paralelismo quer dizer que várias tarefas estão executando no mesmo instante, em núcleos de CPU diferentes. Goroutines dão concorrência. Se você também ganha paralelismo depende de quantos núcleos o runtime pode usar.

Esse limite se chama GOMAXPROCS. É o número de threads do sistema operacional que podem rodar código Go no mesmo momento. runtime.NumCPU() informa quantas CPUs lógicas o processo pode usar, e por padrão GOMAXPROCS parte desse número. Desde o Go 1.25, no Linux, o padrão também respeita um limite de CPU de cgroup, o tipo que containers definem. Um programa limitado a duas CPUs recebe um GOMAXPROCS menor que o número de núcleos da máquina. Os dois números dependem de onde o programa roda, então nenhum dos programas aqui os imprime.

Você pode definir GOMAXPROCS por conta própria. Colocar 1 tira o paralelismo por completo, e as goroutines continuam funcionando:

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	runtime.GOMAXPROCS(1)

	var mu sync.Mutex
	total := 0

	var wg sync.WaitGroup
	for i := range 1000 {
		wg.Go(func() {
			mu.Lock()
			total += i
			mu.Unlock()
		})
	}
	wg.Wait()

	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
	fmt.Println("total:", total)
}

Ele imprime:

GOMAXPROCS: 1
total: 499500

runtime.GOMAXPROCS(1) define o limite, e runtime.GOMAXPROCS(0) lê o valor de volta sem mudá-lo. Com limite um, só uma goroutine roda código Go a cada instante. As mil goroutines se revezam, e a resposta continua sendo 0 + 1 + … + 999.

O mutex continua necessário. Revezar não quer dizer que cada goroutine termina a sua soma antes de a próxima começar, e o programa não deve depender dessa configuração de qualquer jeito. A parte sobre sync trata de mutexes e do race detector. Você raramente vai definir GOMAXPROCS em código de verdade. O padrão costuma estar certo.

Como o scheduler roda goroutines

O runtime do Go roda muitas goroutines sobre um número pequeno de threads do sistema operacional, e a peça que decide qual goroutine roda onde é o scheduler.

Explicado como se você tivesse dez anos

Imagine a cozinha de um restaurante com muitos cozinheiros e só alguns fogões. Os cozinheiros são as goroutines. Os fogões são as threads que fazem o trabalho de fato. Pode haver cinquenta cozinheiros e quatro fogões.

Um chef, o scheduler, decide qual cozinheiro fica em qual fogão. Cada cozinheiro trabalha num prato por um tempo, e depois outro cozinheiro tem a vez.

Às vezes um cozinheiro precisa esperar o forno. Ele não fica parado na frente do fogão sem fazer nada. Ele sai da frente, e o chef manda outra pessoa para aquele fogão. Quando o forno apita, o cozinheiro que esperava volta para a fila de um fogão. Pode nem ser o fogão onde ele começou.

É por isso que uma cozinha com quatro fogões consegue manter cinquenta cozinheiros ocupados.

A versão precisa

O scheduler do runtime usa três tipos de objeto, normalmente chamados de G, M e P:

  • G é uma goroutine: a stack dela e o ponto em que ela está.
  • M é uma machine, ou seja, uma thread do sistema operacional. Só um M consegue de fato executar código.
  • P é um processor: a permissão para rodar código Go, mais uma fila local de goroutines prontas para rodar. Existem exatamente GOMAXPROCS P’s.

Para rodar código Go, um M precisa segurar um P. O M pega uma G da fila de execução desse P e a executa. Isso se chama escalonamento M:N: muitas goroutines dividem um número menor de threads do sistema operacional, e quem decide qual goroutine roda em qual thread é o runtime do Go, não o sistema operacional.

M + P rodando fila local M0 P0 M1 P1 espera G1 parada em <-ch G2 G3 G4 G5 G1 G1 roda em P0 (thread M0), G2 em P1 (thread M1). O resto espera nas filas. G1 chega a <-ch sem nada a receber. Fica parada e P0 fica livre. M0 não fica ocioso: P0 pega G3 da sua fila e a executa. G2 envia em ch. G1 fica pronta de novo e entra na fila de P1. G2 termina. P1 roda G1, agora na thread M1 e não na M0.

Simplificado: dois P’s, cada um ligado a uma thread, cada um com uma fila de execução local. Quando G1 bloqueia num channel, ela fica estacionada e abre mão do seu P, então a thread roda a próxima goroutine em vez de esperar. Quando G1 fica pronta para rodar, ela volta para uma fila de execução, que pode ser de outro P. O scheduler de verdade também tem uma fila de execução global, e P’s ociosos roubam trabalho dos ocupados.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. P0 está ligado à thread M0 e roda G1. P1 está ligado à thread M1 e roda G2. G3 e G4 esperam na fila de P0, e G5 na de P1.
  2. G1 tenta receber de um channel que está vazio. O scheduler estaciona G1 num estado de espera. Ela não está mais usando uma thread nem um P.
  3. M0 não espera por G1. P0 pega a próxima goroutine, G3, da sua fila e a executa.
  4. G2 envia um valor nesse channel. Isso deixa G1 pronta para rodar, e G1 entra na fila de P1, porque foi em P1 que o envio aconteceu.
  5. G2 termina, e P1 roda G1. G1 começou na thread M0 e agora está rodando na M1.

Uma goroutine não fica presa a uma thread. Ela roda onde quer que um P a pegue.

Mais três coisas que o scheduler resolve:

  • System calls bloqueantes. Alguns trabalhos, como ler um arquivo, bloqueiam a thread inteira dentro do sistema operacional. Quando isso acontece, o runtime tira o P do M bloqueado e o entrega a outra thread, para as outras goroutines continuarem rodando. A documentação do pacote runtime diz isso com todas as letras: threads bloqueadas em system calls não contam para o limite de GOMAXPROCS. Leituras de rede são diferentes. O runtime espera os sockets com um network poller, então uma goroutine esperando a rede fica estacionada como G1, sem segurar uma thread.
  • Preempção. Uma goroutine não pode ficar com um P para sempre. Desde o Go 1.14, o runtime consegue interromper uma goroutine na maioria das plataformas, mesmo num loop apertado sem chamadas de função, então uma goroutine ocupada não consegue deixar as outras sem vez.
  • Work stealing. Quando a fila de um P está vazia, ele olha a fila de execução global e depois pega goroutines de outros P’s. A animação deixa isso de fora para ficar legível.

Onde a analogia falha: numa cozinha há um só tipo de coisa no fogão, o cozinheiro. O Go tem ali duas ideias separadas: a thread (M) e a permissão para rodar código Go (P). Uma thread presa numa system call é como um fogão com um cozinheiro que não consegue sair do lugar, e o runtime resolve isso trazendo outro fogão, uma thread nova, e entregando o P a ela. Além disso, um chef pensa em cada decisão. O scheduler do Go faz as mesmas escolhas simples muito rápido, e não sabe qual goroutine é mais importante.

Vazamentos de goroutines

Um vazamento de goroutine é uma goroutine que nunca termina, normalmente porque está esperando num channel que ninguém vai usar de novo.

package main

import (
	"fmt"
	"runtime"
)

func firstResult() int {
	ch := make(chan int)
	go func() {
		ch <- 42 // nobody will ever receive this
	}()
	return -1 // gave up without reading ch
}

func main() {
	fmt.Println("before:", runtime.NumGoroutine())
	for range 3 {
		firstResult()
	}
	fmt.Println("after:", runtime.NumGoroutine())
}

Ele imprime:

before: 1
after: 4

runtime.NumGoroutine() informa quantas goroutines existem. Antes do loop existe uma, o próprio main. Cada chamada a firstResult inicia uma goroutine que tenta enviar num channel sem buffer. Um envio num channel sem buffer espera até alguém receber. Mas firstResult retorna sem receber, e nenhum outro código tem ch. Então cada goroutine espera para sempre. Três chamadas, três goroutines presas e uma contagem de 4.

Nada quebra. O Go só acusa deadlock quando todas as goroutines estão presas, e aqui main continua rodando. O garbage collector também não ajuda. Ele não libera uma goroutine bloqueada, mesmo quando nada mais alcança o channel dela. Num servidor que chama firstResult uma vez por requisição, a contagem sobe a cada requisição até a memória acabar.

A contagem é exata aqui porque uma goroutine é contada desde o momento em que go a cria, e essas três nunca terminam. Contar goroutines também é como você encontra um vazamento em código de verdade. Acompanhe runtime.NumGoroutine() ao longo do tempo, ou olhe o profile de goroutines do net/http/pprof. Se o número continua subindo enquanto a carga fica estável, alguma coisa está vazando. Uma lista longa de goroutines presas na mesma linha mostra onde.

O Go 1.26 também traz um profile experimental chamado goroutineleak. Ele só existe em programas compilados com GOEXPERIMENT=goroutineleakprofile, e usa o garbage collector para achar goroutines bloqueadas em algo que nenhuma goroutine em execução alcança. Testamos com este programa, e ele é um bom lembrete do que “concorrente” quer dizer: numa execução, ele acusou duas goroutines vazadas, não três. O mais provável é que a terceira já tivesse sido criada mas ainda não tivesse chegado ao envio quando o profile foi tirado. Um detector de vazamento só enxerga goroutines que já estão presas.

Para esse bug há duas correções comuns:

  • Dar ao channel espaço para aquele único valor, make(chan int, 1). O envio termina na hora, e a goroutine sai mesmo que ninguém leia.
  • Dar à goroutine um jeito de ouvir “pare”. É para isso que servem select e context, nas partes sobre channels e sobre context.

A regra para levar com você: antes de iniciar uma goroutine, saiba como ela vai terminar.

O que vem a seguir

Este post usou channels e um mutex sem explicá-los. A parte sobre channels e select mostra como goroutines passam valores umas para as outras, incluindo channels sem buffer, fechamento e select. A parte sobre sync trata de mutexes, do race detector e do pacote atomic, de que você precisa assim que goroutines compartilham memória.

O que lembrar

  • go f() inicia f numa goroutine nova e não espera. Quando main retorna, o programa acaba, e as goroutines que estavam rodando param.
  • Espere com um sync.WaitGroup. Desde o Go 1.25, wg.Go(func() { ... }) faz o Add e o Done por você.
  • Goroutines são baratas, então milhares delas é normal. Junte os resultados por um channel ou por um slice protegido, e imprima de um lugar só.
  • Concorrência é como o programa está estruturado. Paralelismo é rodar no mesmo instante, e GOMAXPROCS limita isso.
  • O scheduler roda goroutines (G) em threads (M) por meio de processors (P). Uma goroutine que bloqueia fica estacionada, e a thread dela segue para outro trabalho.
  • Desde o Go 1.22, cada iteração do loop ganha a sua própria variável, então uma goroutine iniciada num loop enxerga o valor da sua iteração.
  • Uma goroutine bloqueada para sempre num channel é um vazamento. Saiba como cada goroutine que você inicia vai terminar.

Iniciar uma goroutine é uma palavra. Saber quando ela termina é trabalho seu.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.