search

Goroutine Scheduling: como Goroutines são gerenciadas pelo runtime do Go

Goroutine Scheduling: como Goroutines são gerenciadas pelo runtime do Go

Por que entender o scheduling de Goroutine importa

Em Go, concorrência é um recurso central: você cria uma Goroutine e o runtime se encarrega de executá-la “em paralelo quando possível” e “de forma concorrente sempre”. Esse detalhe — concorrência versus paralelismo — depende de como o scheduler do runtime distribui trabalho entre threads do sistema operacional e núcleos de CPU disponíveis.

Compreender o scheduling é útil para:

  • Performance: identificar gargalos (ex.: excesso de Goroutines bloqueadas, contenção, falta de paralelismo).
  • Previsibilidade: entender por que uma Goroutine “demora” apesar de haver CPU ociosa (ou o contrário).
  • Diagnóstico: interpretar perfis (pprof), traces (go tool trace) e dumps de Goroutines.
  • Confiabilidade: evitar padrões que levam a starvation, deadlocks e filas locais desequilibradas.

Este texto explica, de forma factual e passo a passo quando necessário, como o runtime gerencia uma Goroutine e quais mecanismos influenciam quando ela roda.


Conceitos essenciais: concorrência, paralelismo e o papel do runtime

  • Concorrência: várias tarefas progridem “ao mesmo tempo” do ponto de vista do programa, alternando execução.
  • Paralelismo: várias tarefas executam literalmente ao mesmo tempo em múltiplos núcleos/CPUs.

Em Go, você cria milhares (ou milhões, dependendo do caso) de Goroutines; o runtime multiplexa essas Goroutines em um conjunto menor de threads do SO. Isso reduz overhead comparado a “uma thread por tarefa”, mas exige um scheduler eficiente.


O modelo GMP: G, M e P

O scheduler do Go é frequentemente explicado pelo modelo GMP, composto por três entidades:

G — Goroutine

Representa a unidade de execução lógica: pilha, registradores salvos, estado (runnable, running, waiting, etc.) e metadados (como ponteiros para filas e informações de rastreamento).

Ponto importante: uma Goroutine não “é” uma thread; ela é executada por uma thread quando o scheduler a escolhe.

M — Machine (thread do sistema)

É uma thread do sistema operacional (pthread em Unix-like, thread Win32 no Windows) que efetivamente executa código Go (ou, em alguns momentos, código em C/OS).

Uma M pode executar Go apenas quando estiver associada a um P (abaixo). Se uma thread entra em uma syscall bloqueante e não pode prosseguir, o runtime pode criar/usar outra M para continuar executando Goroutines.

P — Processor (capacidade de execução Go)

O P é um “token” lógico que representa a capacidade de executar código Go. Ele mantém estruturas como:

  • run queue local (fila local de Goroutines prontas)
  • caches e metadados úteis ao runtime

O número de Ps é definido por GOMAXPROCS. Em termos práticos:

  • Se GOMAXPROCS = N, o runtime tenta permitir até N Goroutines executando simultaneamente (paralelismo máximo), desde que haja CPU e trabalho.
  • Pode haver mais Ms do que Ps (por exemplo, para lidar com syscalls bloqueantes), mas apenas Ms com um P “na mão” executam Go ao mesmo tempo.

Ciclo de vida e estados de uma Goroutine

Uma Goroutine transita por estados como:

  • runnable: pronta para executar, aguardando ser escalada.
  • running: atualmente executando em uma M (com P).
  • waiting: bloqueada (ex.: esperando I/O, timer, mutex, channel, syscall, GC safepoint).
  • dead: finalizada.

O scheduler move Goroutines entre filas e estados conforme eventos acontecem: criação, bloqueio, desbloqueio, preempção, finalização.


Como o runtime decide “quem roda agora”

Go runtime

1) Filas locais e fila global

Cada P possui uma fila local de Goroutines prontas. Além disso, existe uma fila global (global run queue), usada como mecanismo de balanceamento e para casos em que Goroutines são tornadas prontas por eventos “globais”.

Em geral, o scheduler prefere:

  1. Executar Goroutines da fila local do P
  2. Se vazia, tentar buscar da fila global
  3. Se ainda vazia, tentar roubar trabalho (work stealing) de outro P

Essa preferência é importante porque filas locais reduzem contenção e aumentam eficiência em máquinas multicore.

2) Work stealing (roubo de trabalho)

Quando um P fica sem Goroutines prontas, ele tenta “roubar” parte do trabalho de outro P, tipicamente pegando uma fração da fila local do outro. Esse mecanismo ajuda a evitar que um P fique ocioso enquanto outro está sobrecarregado.

Efeito observável: em programas muito concorrentes, a execução pode “migrar” Goroutines entre Ps para equilibrar carga.

3) Netpoller e desbloqueio por I/O

Go usa um mecanismo de I/O não bloqueante e um componente conhecido como netpoller (em plataformas suportadas) para sockets e certos tipos de I/O. Quando um evento de rede ocorre, Goroutines esperando por aquele I/O podem ser marcadas como runnable e colocadas em uma fila apropriada.

Em termos práticos: uma Goroutine que estava waiting por rede pode voltar a competir por CPU sem que seja necessário manter uma thread bloqueada por conexão.


Bloqueios: o que acontece quando uma Goroutine não pode progredir

Bloqueio em operações Go (channels, mutexes, timers)

Quando uma Goroutine bloqueia em uma operação gerenciada pelo runtime (por exemplo, esperar em um channel ou adquirir um mutex), o runtime:

  • coloca a Goroutine em estado waiting
  • libera a execução para que outra Goroutine rode naquele P/M
  • agenda a Goroutine novamente quando a condição for satisfeita (desbloqueio)

Como isso é feito “no nível do runtime”, o custo tende a ser menor do que bloquear uma thread do SO.

Syscalls bloqueantes (I/O tradicional, chamadas ao SO)

Se uma Goroutine faz uma syscall que bloqueia a thread (por exemplo, uma chamada que o runtime não consegue integrar ao netpoller), a M pode ficar presa.

Para manter GOMAXPROCS efetivo, o runtime pode:

  • desanexar o P daquela M bloqueada
  • anexar o P a outra M disponível (ou criar uma nova M)
  • continuar executando outras Goroutines

Isso evita que um P fique “perdido” apenas porque uma thread está bloqueada no kernel.


Preempção: evitando que uma Goroutine monopolize CPU

Historicamente, o Go dependia mais de pontos cooperativos (a Goroutine “cedia” em certos momentos). Com evoluções do runtime, existe preempção assíncrona em cenários onde uma Goroutine executa por muito tempo sem bloquear.

Na prática, a preempção serve para:

  • reduzir latência (por exemplo, para o GC ou para outras Goroutines prontas)
  • evitar que loops intensivos em CPU causem starvation

Mesmo assim, o comportamento não é “time-slicing” idêntico ao do SO para threads comuns; o Go equilibra preempção, pontos seguros (safepoints) e custos de troca de contexto.


Garbage Collector (GC) e scheduling: interferência controlada

Go barbage collector

O GC do Go é concorrente: ele roda junto com o programa, mas precisa de coordenação com Goroutines para marcar e varrer memória com segurança.

Alguns impactos no scheduling:

  • Em fases específicas, o runtime pode exigir safepoints, onde Goroutines precisam cooperar para garantir consistência.
  • O GC pode usar recursos de CPU, competindo com Goroutines de aplicação.
  • O runtime tenta manter pausas curtas e distribuir trabalho, mas cargas de alocação intensa podem aumentar overhead.

Se o seu programa cria muitas Goroutines que alocam intensamente, é comum observar maior atividade do GC e alterações na distribuição de CPU.


Passo a passo mental: do go f() até a execução

Quando você executa:

go f()

um roteiro simplificado é:

  1. O runtime cria uma nova Goroutine (G) com pilha inicial pequena e metadados.
  2. Essa Goroutine é marcada como runnable.
  3. Ela é colocada na run queue local do P atual (na maioria dos casos) ou na fila global, dependendo da situação.
  4. Uma M com um P seleciona a próxima Goroutine runnable (geralmente da fila local).
  5. A Goroutine passa para running e começa a executar f().
  6. Se bloquear (channel, mutex, I/O, syscall), volta para waiting, permitindo que outra rode.
  7. Ao desbloquear, volta a runnable e reentra nas filas.
  8. Ao terminar, fica dead e seus recursos são reciclados conforme o runtime e o GC.

Esse modelo explica por que criar Goroutines é barato, mas não “gratuito”: ainda existe custo de agendamento, pilha, sincronização e, em cargas extremas, contenção.


Observabilidade prática: como verificar o que o scheduler está fazendo

Para investigar scheduling de Goroutine no mundo real, ferramentas típicas incluem:

  • pprof: perfis de CPU e bloqueio ajudam a identificar contenção e hotspots.
  • Execution trace: go test -trace ou instrumentação com runtime/trace, visualizado via go tool trace, mostra eventos de scheduler, estados de Goroutines, wakeups e latências.
  • Dump de Goroutines: runtime.Stack() ou sinais/diagnósticos em produção (depende do setup) revelam Goroutines presas e onde estão bloqueando.

Sinais comuns de problema de scheduling/contenção:

  • muitas Goroutines em waiting por mutex/channel
  • poucas Goroutines running apesar de GOMAXPROCS alto (por bloqueio externo)
  • run queues desequilibradas (visível em trace)
  • alta latência em wakeups de timers/I/O

Armadilhas frequentes ligadas a Goroutine Scheduling

  1. Criar Goroutine sem controle: explosão de Goroutines pode aumentar overhead de agendamento e memória (pilhas + metadados).
  2. Bloqueio em syscall não integrada: pode aumentar Ms e causar pressão no sistema.
  3. Contenção intensa em mutex: muitas Goroutines prontas mas disputando o mesmo lock geram baixa eficiência.
  4. Trabalho CPU-bound sem pontos de preempção suficientes: loops muito apertados podem degradar latência (apesar da preempção moderna, padrões extremos ainda podem afetar responsividade).
  5. Afinidade e cache: migração de Goroutines entre Ps pode afetar localidade de cache; o runtime tenta equilibrar, mas cargas específicas podem sofrer.

Gerenciando Goroutine

O runtime do Go gerencia Goroutine usando um scheduler eficiente baseado no modelo GMP: Goroutines (G) são multiplexadas em threads (M), com capacidade de execução controlada por Ps (P) e GOMAXPROCS. Filas locais, fila global e work stealing equilibram carga; bloqueios em operações Go são tratados de forma cooperativa, enquanto syscalls bloqueantes exigem estratégias para não “perder” paralelismo. Preempção e coordenação com o GC completam o quadro, afetando latência e throughput.

Entender esses mecanismos ajuda a interpretar sintomas reais (contenção, baixa utilização de CPU, latência) e a escolher padrões de concorrência mais previsíveis e observáveis ao construir sistemas em Go.

Compartilhar este artigo: