Voltar ao início

Go Internals - Scheduler

13 min de leitura
Cover Image for Go Internals - Scheduler
Lucas LemosLucas Lemos

Introdução

Em Go Internals - Essenciais mapeamos as camadas do runtime e apontamos o Scheduler como o primeiro mergulho: como goroutines multiplexam threads do SO, o que significam G, M e P, e por que programas Go concorrentes se comportam como se comportam sob carga.

Este artigo é esse mergulho. Seguimos em Go 1.23, lemos o código real do scheduler em src/runtime/ e ligamos concorrência no nível da linguagem (go, channels, sync) às invariantes que o runtime impõe. Layout de memória, stacks e o allocator vêm em Memória; aqui o foco é quem roda o quê, em qual thread e quando o controle troca.

O modelo G/M/P (goroutine / máquina / processador)

O Go moderno usa um scheduler com work stealing e três abstrações centrais (veja runtime2.go e comentários em proc.go):

  • G (goroutine) — goroutine: stack do usuário, registradores, status, vínculo com M (máquina) enquanto roda.
  • M (máquina) — machine: thread do SO que executa código Go (ou do runtime) quando ligada a um P (processador).
  • P (processador) — processor: contexto de execução necessário para rodar código Go; guarda uma run queue local e estado do scheduler.
flowchart TB
  subgraph logical["Paralelismo lógico (GOMAXPROCS)"]
    P0["P₀ runq local"]
    P1["P₁ runq local"]
    P2["P₂ runq local"]
  end
  subgraph threads["Threads do SO"]
    M0["M₀"]
    M1["M₁"]
    M2["M₂"]
    M3["M₃ syscall bloqueante"]
  end
  subgraph goroutines["Goroutines"]
    G1["G"]
    G2["G"]
    G3["G"]
    G4["G em espera"]
  end
  P0 --> M0
  P1 --> M1
  P2 --> M2
  P0 --> G1
  P0 --> G2
  P1 --> G3
  G4 -.->|"parked em channel/netpoll"| M3

Regra que pega muita gente de surpresa: código Go do usuário só roda quando um M (máquina) segura um P (processador). Se um M (máquina) bloqueia em syscall sem P (processador), pode manter o G (goroutine) bloqueado estacionado enquanto outro M (máquina) é criado ou acordado para continuar trabalho executável — dentro dos limites que o runtime gerencia.

GOMAXPROCS (padrão: número de CPUs lógicas) é a quantidade de Ps (processadores). Esse é o teto de goroutines executando bytecode Go ao mesmo tempo. Ter mais goroutines que GOMAXPROCS é normal; elas se revezam.

import "runtime"

func main() {
  fmt.Println(runtime.GOMAXPROCS(0)) // valor atual
}

Ajustar GOMAXPROCS não limita a contagem de goroutines; limita execução paralela de código Go em Ps (processadores) distintos.

Goroutines não são threads do SO

Uma goroutine é um contexto de execução leve, gerenciado pelo runtime. Você inicia com go f(). Uma thread do SO (M (máquina) no jargão do runtime) é o que o kernel agenda; criar uma é caro em comparação (reserva de stack na ordem de megabytes, bookkeeping no kernel).

A aposta do Go é muitas goroutines, poucas threads:

Goroutine (G (goroutine)) Thread do SO (M (máquina))
Criada por go / runtime runtime via clone / APIs de thread da plataforma
Custo típico Stack pequena (cresce sob demanda), metadados nas estruturas do runtime Agendamento do kernel, mapeamento de stack maior
Quantidade em produção Milhares a milhões é normal Na prática perto de GOMAXPROCS mais algumas para I/O bloqueante e sysmon
Quem agenda Scheduler Go (runtime) Kernel do SO
func main() {
  for i := 0; i < 100_000; i++ {
    go func() {}()
  }
  time.Sleep(time.Second)
}

Criar 100.000 goroutines é trivial em Go; criar 100.000 threads não é. O runtime amortiza o uso de threads estacionando goroutines quando bloqueiam e reutilizando o mesmo M (máquina) para outros G (goroutine) executáveis.

Isso não significa que goroutine é de graça. Cada G (goroutine) tem stack, ocupa filas do scheduler e pode segurar locks. O trabalho do scheduler é manter os M (máquina) ocupados sem deixar trabalho executável acumular sem limite enquanto outros morrem de fome.

Estados da goroutine (simplificado)

A máquina de estados completa em runtime2.go tem mais subestados; para ler o scheduler, este modelo mental basta:

Estado (conceito) Significado
Runnable Pronta para rodar; na run queue ou prestes a ser roubada
Running Executando em um M (máquina) com P (processador)
Waiting Bloqueada: channel, lock, sleep, rede, handoff de syscall, etc.
Dead Terminou; recursos liberados em coordenação com o GC

As transições acontecem em funções como gopark, goready e no loop central schedule em proc.go. Quando você faz ch <- v e bloqueia porque o buffer está cheio, seu G (goroutine) sai de _Grunning e outro G (goroutine) naquele P (processador) roda.

Bootstrap: de runtime.main ao seu main

Essenciais comentou que main.main não é a entrada real. Na subida, o linker roda a init do runtime; runtime.main (em proc.go) monta scheduler, memória, GC e só então inicia o main do seu pacote em uma nova goroutine.

Sequência aproximada:

sequenceDiagram
  participant OS
  participant RT as runtime
  participant G0 as goroutine main
  participant User as main.main
  OS->>RT: início do processo
  RT->>RT: schedinit, mallocinit, ...
  RT->>G0: go main.main em goroutine
  RT->>RT: loop de schedule
  G0->>User: seu código

Quando você roda:

go worker()

o compilador vira isso em chamada ao runtime que aloca ou reutiliza um G (goroutine), copia argumentos e enfileira — não cria um M (máquina) novo por go. Os Ps (processadores) e Ms (máquinas) existentes pegam trabalho das run queues.

Vale uma busca em $GOROOT/src/runtime/proc.go: func main() no pacote de runtime.main, e newproc / newproc1 para ver como o go entra no scheduler.

Run queues e work stealing

Gs (goroutines) executáveis esperam em run queues:

  1. Fila local por P (processador) — ring lock-free, caminho quente para goroutines criadas naquele P (processador), muitas vezes LIFO por localidade.
  2. Fila global — protegida por lock; overflow e caminhos de fairness passam por ela.

Quando um M (máquina) termina uma fatia de tempo ou um G (goroutine) bloqueia, schedule() escolhe o próximo G (goroutine). A ordem preferencial é, em linhas gerais: fila local → global → work stealing da fila local de outro P (processador) → netpoller / hooks de idle.

Work stealing mantém os Ps (processadores) ocupados: se Pᵢ tem fila local vazia, tenta levar cerca da metade dos Gs (goroutines) executáveis da fila de Pⱼ (detalhes de implementação variam entre versões; a invariante é balancear carga sem um lock global em todo schedule).

flowchart LR
  subgraph Pbusy["Pⱼ ocupado"]
    Qj["runq local: G G G G G"]
  end
  subgraph Pidle["Pᵢ ocioso"]
    Qi["runq local: vazia"]
  end
  Qi -->|"rouba ~metade"| Qj

Por que roubar ajuda: goroutines criadas em um P (processador) tendem a ficar ali por um instante (produtor/consumidor no mesmo cache de CPU). Com carga desigual, o roubo espalha trabalho sem cada schedule() bater numa estrutura global única.

Nota de fairness: filas locais puramente LIFO são ótimas para cache, mas podem causar starvation em padrões patológicos. O runtime periodicamente puxa da fila global e usa preempção (abaixo) para Gs (goroutines) de longa duração não monopolizarem um P (processador).

O loop de scheduling em uma figura

Em alto nível, cada M (máquina) ligado a um P (processador) gira num loop:

flowchart TD
  A["G rodando no P"] --> B{"bloqueia ou preempted?"}
  B -->|sim| C["park G, enfileira motivo da espera"]
  B -->|não| D["ainda rodando"]
  C --> E["schedule()"]
  E --> F{"runq local?"}
  F -->|sim| G["roda próximo G"]
  F -->|não| H{"global / steal / netpoll?"}
  H --> G
  G --> A

Você não chama schedule() no código de usuário; o runtime insere isso em pontos de sincronização e quando o M (máquina) precisa achar trabalho novo.

Bloqueio sem desperdiçar threads

Nem toda espera é igual.

Syscalls

Quando um G (goroutine) entra numa syscall bloqueante (ex.: alguns read em fd bloqueante, I/O de arquivo sem integração com o poller), o runtime pode desacoplar M (máquina) de P (processador): o P (processador) é liberado para outro M (máquina) rodar Gs (goroutines) executáveis enquanto o M (máquina) da syscall espera no kernel. Na volta da syscall, o G (goroutine) precisa readquirir um P (processador) para rodar código Go de novo.

Por isso "goroutine barata" não significa "syscall barata": você ainda pode prender um M (máquina) no kernel; o runtime só evita prender o P (processador) junto.

sync.Mutex e locks do runtime

Bloquear num sync.Mutex estaciona o G (goroutine) nas estruturas de espera do runtime; o P (processador) roda outros Gs (goroutines). É bloqueio consciente do scheduler — sem thread extra do SO por goroutine esperando.

I/O de rede e o netpoller

Espera de rede é outra história. O runtime integra com epoll (Linux), kqueue (BSD/macOS), IOCP (Windows), etc. Sockets registrados no poller podem estacionar Gs (goroutines) sem bloquear um M (máquina) num read: quando chegam dados, o G (goroutine) volta a ser executável.

flowchart TB
  subgraph net["netpoller"]
    EP["epoll / kqueue / ..."]
  end
  Gread["G: conn.Read"] -->|"non-blocking + park"| EP
  EP -->|"fd pronto"| Gready["G runnable → runq"]

Um servidor com dezenas de milhares de conexões ociosas é sobretudo Gs (goroutines) em espera, não dezenas de milhares de threads bloqueadas no kernel. Concorrência (próximo artigo da série para channels) se apoia nos mesmos primitivos de park.

Preempção: por que for {} já foi problema

O Go antigo era cooperativo em safe points: chamadas de função, ops de channel, back-edges de loop no código gerado, etc. Um loop apertado sem chamadas podia monopolizar um P (processador) para sempre.

Go 1.14+ trouxe preempção assíncrona no Unix: o runtime pode parar um G (goroutine) em execução via sinais (SIGURG no Linux), inspecionar limites de stack e reagendar. Isso torna loops apertados e código numérico longo bem menos propensos a matar de fome outras goroutines — com custo (tratamento de sinal, coordenação de safepoint).

Camadas de preempção hoje (pilha conceitual):

Mecanismo Quando entra
Safe points cooperativos Sempre: checks baratos em calls, loops
Preempção assíncrona por sinal G (goroutine) rodando no P (processador) sem atingir safe points rápido o bastante
sysmon Thread em background: retomar P (processador) de M (máquina) preso em syscall, preemptar runners longos, acordar netpoller, ajudantes de GC

sysmon roda sem um G (goroutine) completo no sentido usual; é parte do motivo pelo qual o runtime impõe políticas globais mesmo quando goroutines de usuário nunca cedem.

Para Go 1.23, trate preempção como na maior parte automática, mas não mágica: cgo, certos locks do runtime e cantos com //go:nosplit ainda importam em debug avançado — citamos só onde esclarecem o scheduler.

Paralelismo vs concorrência no scheduler

  • Concorrência — várias goroutines progredindo ao longo do tempo (intercaladas).
  • Paralelismo — literalmente ao mesmo instante em várias CPUs.

Com GOMAXPROCS=1, você pode ter concorrência enorme e paralelismo zero para código Go. Com GOMAXPROCS=8 numa máquina de 8 cores, até oito Gs (goroutines) rodam bytecode Go em paralelo se houver trabalho executável suficiente.

func burn() {
  for i := 0; i < 1_000_000_000; i++ {
    _ = i * i
  }
}

func main() {
  runtime.GOMAXPROCS(1)
  go burn()
  go burn()
  time.Sleep(2 * time.Second) // ambas compartilham um P; preempção intercala
}

Pools CPU-bound devem alinhar contagem de goroutines e GOMAXPROCS com o hardware; servidores I/O-bound costumam ter muito mais Gs (goroutines) que cores porque a maioria está parked no poller ou em channels.

Compilador e go: o que de fato entra no schedule

O compilador transforma:

go func(x int) { fmt.Println(x) }(42)

em chamada a runtime.newproc com descritor de função e argumentos copiados. O G (goroutine) novo começa na entrada da função com seu próprio segmento de stack (pequeno no início, crescido pelo allocator de stack — artigo Memória).

Captura em closure significa que a struct passada a newproc pode incluir variáveis alocadas no heap se escaparem; o custo de scheduling é separado do de alocação, mas go em loop apertado ainda pressiona allocator e run queues.

Observando o scheduler

GODEBUG

Flags úteis (lista completa na doc do runtime para 1.23):

GODEBUG=schedtrace=1000 ./myapp   # linha do scheduler a cada 1000 ms
GODEBUG=scheddetail=1,schedtrace=1000 ./myapp

schedtrace imprime gomaxprocs, Ps (processadores) ociosos, contagem de threads e tamanhos de run queue — bom sanity check sob carga.

Execution trace

import (
  "os"
  "runtime/trace"
)

func main() {
  f, _ := os.Create("trace.out")
  trace.Start(f)
  defer trace.Stop()
  // workload
}
go tool trace trace.out

Mostra vida das goroutines, eventos de P (processador), G (goroutine) e M (máquina), motivos de bloqueio — a melhor visão interativa das decisões de scheduling.

Delve e pprof

dlv debug ./myapp
# goroutines, goroutine <id>, stack
go test -cpuprofile=cpu.prof -bench .
go tool pprof -http=:8080 cpu.prof

Perfis de CPU mostram onde o tempo foi on-CPU; combine com trace quando suspeitar de scheduling ou contenção de lock em vez de código quente do usuário.

Padrões que brigam com o scheduler

Padrão O que acontece
go sem limite Run queue cresce, pressão no GC, thrashing eventual — sem paralelismo mágico além de GOMAXPROCS
GOMAXPROCS >> cores Troca de contexto extra; raramente ajuda trabalho CPU-bound
GOMAXPROCS=1 em app CPU-bound multi-core Gargalo artificial
Seções críticas enormes Menos oportunidades de preempção dentro da seção; atrasa outros Gs (goroutines) naquele P (processador)
Tempestade de cgo ou syscall bloqueante Muitos Ms (máquinas), churn de handoff de P (processador); pode cair throughput
Assumir ordem do go Scheduler não garante ordem de conclusão

Quando tunar GOMAXPROCS: em geral deixe o padrão. Containers com limite de CPU (cgroups) são exceção comum — o runtime do Go 1.23 pode respeitar cotas de cgroup quando configurado; confira com runtime.GOMAXPROCS(0) dentro do container.

Checklist do modelo mental

Antes de abrir Memória, você deve conseguir responder:

  1. Qual a diferença entre goroutine, thread e P (processador)?
  2. Onde goroutines executáveis esperam e para que serve work stealing?
  3. Por que I/O de rede bloqueante escala diferente de I/O de arquivo bloqueante em muitos cenários?
  4. O que GOMAXPROCS limita de fato?
  5. Qual ferramenta você usaria para provar que uma goroutine está runnable mas não está rodando?

Com isso claro, crescimento de stack e escape analysis encaixam numa base sólida — stacks são por G (goroutine), e o scheduler é quem roda o código que as usa.

Conclusão

O scheduler do Go multiplexa uma população grande de Gs (goroutines) num pool pequeno de Ms (máquinas), usando Ps (processadores) como unidade de execução paralela de código Go. Run queues locais mais work stealing espalham carga; netpoller e lógica de park evitam transformar cada espera em thread; preempção e sysmon mantêm o sistema justo sob carga de CPU ou de syscalls.

O próximo da série é Memória — como stacks de goroutine crescem e encolhem, quando valores escapam para o heap e como isso se liga ao allocator e ao GC que Essenciais já desenhou.