Preempção em Go: como o runtime interrompe goroutines
Como o Go interrompe goroutines que monopolizam o processador: a checagem no prólogo, a preempção por sinal desde o Go 1.14 e o papel do coletor de lixo.
avançadoPublicada em · 25 min de leitura
Pense numa agência bancária com um único caixa. Um cliente chega, abre um cofrinho e começa a contar moedas em voz alta, sem olhar para a fila e sem a menor pressa de sair do balcão. A regra da agência diz “um atendimento por vez”, mas não diz quando o atendimento acaba. Os outros clientes esperam a tarde inteira, e ninguém tem coragem de pedir para ele levantar.
Em Go, o caixa é um processador lógico (o P), o cliente é uma e a agência é o runtime. é a capacidade de tirar o cliente do balcão mesmo quando ele ainda não terminou de contar. Esta aula explica como o Go aprendeu a fazer isso, por que demorou a aprender e o que muda quando o programa roda com um único P.
Antes de começar, três termos. G é a goroutine, o trabalho que você dispara com go f(). M é a thread do sistema operacional, quem executa o código de fato. P é o , a permissão que um M precisa ter para rodar código Go. O número de Ps é o GOMAXPROCS, que diz quantas goroutines podem rodar ao mesmo tempo. O modelo completo, com filas e roubo de trabalho, está em o scheduler do Go. Esta aula assume que você já criou goroutines, como em Goroutine.
Mapa da aula
| Parte | Seções | O que você vai aprender |
|---|---|---|
| O problema | 1 a 3 | O que é preempção, por que o Go precisou dela e quais programas caem na armadilha |
| 4 e 5 | A checagem no de cada função e o motivo de ela não bastar | |
| 6 a 8 | O , o sinal SIGURG, o e a parada do mundo do coletor de lixo | |
| Observando na prática | 9 a 12 | Três programas rodados com e sem preempção, e a leitura do assembly |
| Erros e prática | 13 a 15 | O que costuma dar errado, exercícios com resposta de referência e glossário |
1. O que é preempção, para quem nunca ouviu falar
Preempção é interromper uma tarefa em execução para entregar o processador a outra, sem que a tarefa interrompida tenha pedido isso. O sistema operacional faz isso o tempo todo. Um relógio dispara interrupções, o kernel decide qual thread continua, e a thread que estava rodando não precisa concordar. Quando o escalonador tira a tarefa do processador dessa forma, dizemos que ele é preemptivo.
O contrário é o escalonamento cooperativo. Nele, cada tarefa devolve o processador quando termina ou quando chega a um ponto em que ela decide ceder. Esse modelo funciona bem enquanto todo mundo se comporta. O problema é que, num programa com milhares de goroutines, ninguém garante que todas se comportem. Basta uma que nunca chegue ao ponto de ceder para as outras ficarem de fora.
A preempção do Go aplica o mesmo princípio às goroutines, e quem a executa é o runtime, não o kernel. Quem usa Go quase nunca percebe essa camada, porque ela age por baixo. Ela só aparece quando falha, e a falha costuma ser um programa que simplesmente não responde.
2. Por que o Go precisou dela
Imagine um programa com um único P, ou seja, GOMAXPROCS=1. A goroutine de fundo entra num laço que nunca termina. O a colocou no P, e o P não tem para onde trocar. A goroutine principal pede para dormir 50 ms e, quando o tempo passa, precisa do P para voltar a rodar. O P está ocupado com um laço que não sai.
Sem preempção, esse é o resultado: a goroutine principal acorda quando o laço quiser, ou nunca. O próprio time do Go descreveu o problema assim. A nota de versão do Go 1.2 diz, em tradução livre, que “em versões anteriores, uma goroutine em laço infinito podia deixar as outras de fora na mesma thread, um problema sério quando GOMAXPROCS valia um”. Mais tarde, a nota do Go 1.14 trocou a solução parcial por uma completa: “goroutines agora são preemptíveis de forma assíncrona. Com isso, laços sem chamadas de função deixam de poder travar o scheduler ou atrasar de forma significativa o coletor de lixo”.
A seção 9 mostra esse programa rodando nas duas situações, com a saída real.
3. Exemplos de laços que monopolizariam o processador
O padrão aparece em programas bem diferentes. Três exemplos cobrem a maior parte dos casos.
O primeiro é uma soma de verificação (checksum) sobre um bloco de memória de centenas de megabytes. O laço interno percorre os bytes, soma e avança. Não há chamada de função dentro dele, porque o compilador emite só instruções aritméticas e de memória. Enquanto esse laço roda, o P está ocupado.
O segundo é um teste de primalidade por divisão sucessiva, aplicado a um número grande. Outra vez, o trabalho é um laço de divisões, sem nada que devolva o controle.
O terceiro é menos óbvio. Imagine uma função auxiliar pequena, chamada dentro de um laço de busca. Se o compilador a embutir no corpo do laço (o que se chama inlining), a chamada desaparece do código de máquina, e o laço fica sem nenhum ponto em que o runtime possa intervir. Esse caso fica registrado nas notas de não confirmado, porque não o testei nesta aula.
Nos três casos, o que importa é a mesma coisa: o código passa muito tempo sem devolver o controle ao runtime. A preempção existe para que esse tempo tenha um limite.
Pergunta de entrevistaPor que o Go precisou de preempção, e o que acontecia sem ela?
Sem preempção, uma goroutine em um laço apertado que nunca chama função segura o P para sempre. O exemplo da aula usa GOMAXPROCS=1: a goroutine de fundo entra num laço infinito, a principal pede para dormir 50 ms e, quando acorda, precisa do P, que está ocupado. Ela só volta quando o laço quiser, ou nunca. Isso prejudica a latência das outras goroutines e atrapalha até o coletor de lixo, que esperava as goroutines chegarem a um ponto de parada. Por isso o runtime ganhou formas de interromper goroutines. O trade-off é a complexidade, porque a interrupção só pode acontecer em pontos seguros, onde o runtime sabe descrever o estado da goroutine.
4. A checagem no prólogo: a preempção cooperativa
Antes da versão 1.14, o runtime contava com um mecanismo mais simples, que ainda existe e continua ativo. Toda função compilada começa com um trecho chamado prólogo, que roda antes do corpo da função. Entre outras coisas, o prólogo compara o ponteiro de pilha (SP) com um limite guardado na própria goroutine, o campo stackguard0.
Essa comparação existe para crescer a pilha. Se a função vai precisar de mais espaço do que o disponível, o runtime aumenta a pilha antes de prosseguir. O runtime percebeu que o mesmo teste servia para outra coisa. Para pedir preempção, ele troca o stackguard0 por um valor especial, stackPreempt, que faz a próxima chamada de função falhar a comparação. A falha desvia a execução para o runtime, que verifica o motivo, percebe que não faltava espaço e sim um pedido de preempção, e então tira a goroutine do processador.
A parte que interessa para a leitura do assembly é o trecho que faz a comparação. Compilei a função comPrologo, da seção 11, para arm64 (a arquitetura deste Mac) e encontrei estas instruções no começo dela:
MOVD 16(g), R16
CMP R16, RSP
BLS 44
A primeira carrega um campo da goroutine, a segunda compara esse valor com o ponteiro de pilha e a terceira desvia para o caminho de crescimento de pilha se o limite foi ultrapassado. O comentário do código-fonte, em runtime/preempt.go, descreve a mesma ideia: os pontos síncronos são implementados sobrecarregando o teste de limite de pilha nos prólogos das funções (tradução livre).
A figura abaixo resume o caminho. Repare que o laço sem chamadas, no canto superior direito, não chega ao prólogo de nenhuma função. Não há comparação, não há desvio e, portanto, não há como o runtime entrar.
Na figura, o fluxo desce do laço até o teste do prólogo e se divide em dois. Só o ramo que termina no morestack leva ao escalonador. O laço da direita fica isolado, sem nenhuma seta chegando a ele, e é isso que a seção 5 discute. Os retângulos do runtime aparecem em outra cor, e a caixa com alerta é o laço que nunca chega ao teste.
O prólogo é um ponto de checagem síncrono: a goroutine chega a ele por conta própria, na hora em que chama uma função, e o estado dela já está organizado para ser suspenso. Esse é o motivo de a preempção cooperativa ser barata. O custo, porém, está na palavra “chamar”. Um laço que não chama nada não tem onde parar.
5. Por que a checagem no prólogo não bastava
A preempção cooperativa resolve a maioria dos casos, porque a maior parte do código Go chama funções com frequência. Ela falha em duas situações, e as duas são exatamente as que a seção 3 apresentou.
A primeira é o laço sem chamadas. Se o corpo do laço não chama nenhuma função, nenhum prólogo é executado e nenhuma checagem acontece, por mais tempo que o laço dure. A segunda é a : uma função que não chama nenhuma outra e cabe num quadro de pilha curto não precisa de checagem de pilha, e o compilador a marca como nosplit. A seção 11 mostra essa diferença no código gerado.
Há também um motivo de projeto. O time do Go tentou inserir pontos de preempção dentro dos próprios laços. A proposta de Austin Clements, atualizada em janeiro de 2019, registra que esses protótipos produziram lentidão inaceitável em laços apertados (tradução livre), justamente os laços em que a lentidão mais dói. Por isso a proposta pede preempção não cooperativa, feita por sinal do sistema operacional, que só age quando o runtime a pede.
6. A preempção assíncrona por sinal
Desde o Go 1.14, o runtime também pode interromper uma goroutine sem esperar que ela chame uma função. Quem decide que a interrupção é necessária é o sysmon, uma thread de monitoramento do próprio runtime. O comentário no código diz que ele “sempre roda sem P”, ou seja, ele não disputa o processador com as goroutines que está vigiando.
A cada volta, o sysmon verifica cada P que está rodando. Se o P continua com a mesma goroutine há 10 ms, a função retake chama preemptone. Essa função faz três coisas. Marca o pedido de preempção na goroutine, colocando gp.preempt em verdadeiro e trocando o stackguard0 por stackPreempt, de modo que a checagem síncrona também funcione. Marca o próprio P. E, se a preempção assíncrona estiver habilitada, envia à thread M um sinal, que no Unix é o SIGURG.
O tempo de 10 ms é uma constante do código, forcePreemptNS, e aparece com o mesmo valor no código do Go 1.14 e no do Go 1.27.2. Ela define o quantum que uma goroutine recebe antes de ser interrompida.
A figura abaixo mostra a sequência entre os quatro participantes. O sysmon marca o pedido e envia o sinal, a thread M recebe o sinal, e um teste decide se a interrupção pode acontecer agora.
O sinal chega à thread M, não à goroutine. A thread M estava executando código Go no momento, e o handler do sinal recebe o contexto dela, com o contador de programa (PC) e o ponteiro de pilha exatamente como estavam quando o sinal chegou. O handler então faz a pergunta mais importante de toda a aula: essa posição do código é um ponto seguro?
7. O que é um ponto seguro
Um ponto seguro é uma instrução em que o runtime pode suspender a goroutine sem corromper nada. A diferença para a checagem no prólogo é que, no sinal, o runtime pode ser interrompido em qualquer instrução do programa, não só no começo de uma função. Por isso ele precisa conferir antes de agir.
A função isAsyncSafePoint, em runtime/preempt.go, confere três condições. A primeira é que seja seguro suspender a goroutine e varrer a pilha e os registradores de forma conservadora, sem que um ponteiro escondido se perca. A segunda é que a pilha tenha espaço para receber a chamada injetada. A terceira é que seja seguro interagir com o runtime naquele ponto, por exemplo porque nenhum lock do runtime está preso. Posições dentro do próprio runtime, ou no meio de uma sequência atômica como a barreira de escrita do coletor, não atendem a essas condições.
Quando a posição é segura, o handler ajusta o contador de programa para que a thread pareça ter chamado a função asyncPreempt. Essa função salva todos os registradores, inclusive os estendidos, e entra no scheduler. Quando a goroutine volta a rodar, o estado dela está íntegro, e ela não percebe que foi interrompida.
Quando a posição não é segura, o handler não faz nada. O pedido continua marcado na goroutine e na P, e a próxima checagem, síncrona ou por sinal, tenta de novo. A proposta de 2019 descreve essa escolha como desistir e tentar mais tarde (tradução livre), e reconhece que ela pode atrasar muito num laço em que pontos seguros são raros. Esse é o motivo de a preempção assíncrona ser um complemento, e não um substituto, da checagem no prólogo.
Pergunta de entrevistaQual a diferença entre preempção cooperativa e assíncrona no Go?
A cooperativa depende de a goroutine passar pela checagem no prólogo das funções. Se ela roda um laço apertado sem chamar funções, essa checagem nunca acontece. Desde o Go 1.14 existe também a preempção assíncrona. O sysmon, uma thread de monitoramento que roda sem P, percebe que o mesmo P está com a mesma goroutine há 10 ms, marca o pedido de preempção e envia um sinal para a thread. O handler do sinal pergunta se aquele ponto do código é um ponto seguro. Se for, o runtime injeta asyncPreempt e o scheduler troca de goroutine. Se não for, a interrupção espera. Assim laços sem chamadas de função deixam de monopolizar o processador.
8. Por que o coletor de lixo esperava
O coletor de lixo tem um momento em que precisa de um mundo parado. Para marcar objetos vivos sem que o programa os altere ao mesmo tempo, o runtime precisa que todas as goroutines estejam em pontos conhecidos, e isso se chama parada do mundo (, de stop-the-world). A função gcStart, em runtime/mgc.go, chama stopTheWorldWithSema para iniciar um ciclo do coletor. Dentro dessa função, o runtime chama preemptall, que pede preempção em todos os Ps que estão rodando goroutines. A parada só termina quando cada um deles para.
A espera é o problema. Um P que roda um laço sem chamadas, sem preempção assíncrona, nunca chega a um ponto de checagem, e a parada não termina. A nota do Go 1.14 descreve o efeito como atrasar de forma significativa o coletor de lixo (tradução livre). A documentação do pacote runtime para a opção asyncpreemptoff é mais direta: ela torna alguns laços não preemptíveis por longos períodos, o que pode atrasar o GC e o escalonamento das goroutines (tradução livre).
A figura abaixo mostra a situação exata do experimento da seção 10: dois Ps, um deles preso num laço e o outro ocioso.
O P1 atende o pedido imediatamente, porque não tem nada a interromper. O P0 não atende, e o runtime.GC fica esperando. Note que o segundo processador livre não ajuda. A parada do mundo precisa de todos os Ps, não de um só.
9. Observando: laço apertado com GOMAXPROCS=1
O primeiro programa reproduz o caso da seção 2. A goroutine de fundo roda um laço vazio, e a goroutine principal dorme 50 ms e imprime o tempo que levou para acordar.
package main
import (
"fmt"
"time"
)
func main() {
inicio := time.Now()
go func() {
for {
// Laço apertado: nenhuma chamada de função dentro do laço.
}
}()
time.Sleep(50 * time.Millisecond)
fmt.Printf("main acordou depois de %v\n", time.Since(inicio).Round(time.Millisecond))
}
Rodei o programa com GOMAXPROCS=1, nas duas configurações. Sem variável de ambiente, a preempção assíncrona está ligada. Com GODEBUG=asyncpreemptoff=1, ela está desligada. Como SIGALRM não encerra um programa Go, usei um watchdog externo que mata o processo com SIGKILL após 5 segundos.
== preempção padrão, GOMAXPROCS=1 (5 execuções) ==
main acordou depois de 51ms
main acordou depois de 56ms
main acordou depois de 57ms
main acordou depois de 68ms
main acordou depois de 208ms
== GODEBUG=asyncpreemptoff=1, GOMAXPROCS=1 ==
exit=137 TRAVOU (SIGKILL do watchdog após 5 s)
Com a preempção ligada, a goroutine principal acorda perto dos 50 ms pedidos. A diferença entre 50 e o valor medido é, presumivelmente, o tempo até o sysmon perceber o P preso e pedir a interrupção. Não medi essa etapa separadamente. Com a preempção desligada, o programa não imprime nada e não termina: o watchdog o matou em todas as tentativas.
Os números acima não são constantes. As medições foram feitas com o load average perto de 140 no mesmo Mac, e nas primeiras tentativas da mesma sessão o atraso ficou entre 51 e 93 ms. A faixa serve de ordem de grandeza, sem precisão de latência.
10. Observando: runtime.GC com laço apertado
O segundo programa põe o coletor de lixo no lugar da goroutine principal. A goroutine de fundo continua em laço, e a principal chama runtime.GC() e mede quanto tempo a chamada levou.
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
go func() {
for {
// Laço apertado: nenhuma chamada de função dentro do laço.
}
}()
time.Sleep(10 * time.Millisecond)
inicio := time.Now()
runtime.GC()
fmt.Printf("runtime.GC() terminou depois de %v\n", time.Since(inicio).Round(time.Microsecond))
}
Este caso usa GOMAXPROCS=2, para mostrar que um segundo P livre não resolve o problema.
== preempção padrão, GOMAXPROCS=2 (5 execuções) ==
runtime.GC() terminou depois de 2.329ms
runtime.GC() terminou depois de 3.932ms
runtime.GC() terminou depois de 36.962ms
runtime.GC() terminou depois de 10.402ms
runtime.GC() terminou depois de 14.117ms
== GODEBUG=asyncpreemptoff=1, GOMAXPROCS=2 ==
exit=137 TRAVOU (SIGKILL do watchdog após 5 s)
Com a preempção assíncrona, a parada do mundo levou de 2 ms a 37 ms nas cinco execuções desta sessão. Duas execuções anteriores, feitas antes dessa série, tinham levado 802 µs e 957 µs, o que mostra a variação da máquina carregada. Com a preempção desligada, runtime.GC() não retorna. O que não aparece na saída é o que aconteceu dentro: o main estava parado dentro da chamada, esperando a parada do mundo que o laço nunca atendia.
11. Observando: função folha e função com prólogo
O terceiro programa separa as duas formas de ponto de checagem. Ele roda, em um laço, uma de duas funções: uma folha pequena, que não chama outra, e uma função com prólogo, que chama outra. A escolha vem da linha de comando.
package main
import (
"fmt"
"os"
"time"
)
var sink int
//go:noinline
func folha(i int) int {
return i * 2
}
//go:noinline
func comPrologo(i int) int {
return folha(i) + 1
}
func main() {
var f func(int) int
switch os.Args[1] {
case "folha":
f = folha
case "prologo":
f = comPrologo
}
inicio := time.Now()
go func() {
for i := 0; ; i++ {
sink = f(i)
}
}()
time.Sleep(50 * time.Millisecond)
fmt.Printf("main acordou depois de %v\n", time.Since(inicio).Round(time.Millisecond))
}
Antes de rodar, conferi o assembly gerado para arm64 com go build -gcflags=-S. A função folha aparece no cabeçalho como LEAF|NOFRAME, e o corpo dela é só uma operação aritmética seguida do retorno, sem nenhuma comparação de pilha. A função comPrologo começa com as três instruções mostradas na seção 4. Esse é o fato que explica os resultados abaixo.
== folha, GODEBUG=asyncpreemptoff=1, GOMAXPROCS=1 ==
exit=137 TRAVOU (SIGKILL do watchdog após 5 s)
== prologo, GODEBUG=asyncpreemptoff=1, GOMAXPROCS=1 (3 execuções) ==
main acordou depois de 66ms
main acordou depois de 61ms
main acordou depois de 72ms
== folha, preempção padrão, GOMAXPROCS=1 (3 execuções) ==
main acordou depois de 64ms
main acordou depois de 54ms
main acordou depois de 53ms
Sem a preempção assíncrona, o laço que chama a função folha trava, porque nenhuma chamada passa por um ponto de checagem. O laço que chama a função com prólogo acorda normalmente, porque cada chamada passa pela comparação de pilha. Com a preempção padrão, a função folha também volta a funcionar, porque o sinal não depende de prólogo. Os tempos das duas últimas séries ficam na mesma faixa, e a diferença entre elas está dentro da variação medida.
12. Bordas: plataformas, sinais e opções de depuração
A nota do Go 1.14 informa que a preempção assíncrona é suportada em todas as plataformas, exceto windows/arm, darwin/arm, js/wasm e plan9. A exclusão de darwin/arm se refere à arquitetura de 32 bits. Este Mac roda darwin/arm64, que a nota não exclui. Essa leitura é minha, e não testei Windows nem Linux nesta aula.
Como a preempção usa sinais, os programas em Unix recebem mais sinais do que antes. A mesma nota diz que chamadas de sistema lentas podem falhar com EINTR, e que programas que usam syscall ou golang.org/x/sys/unix precisam tratar esse erro. A nota não diz como a biblioteca padrão trata esse caso, e não verifiquei isso nesta aula.
A variável GODEBUG=asyncpreemptoff=1 desliga o sinal, e a documentação do pacote runtime diz que ela é útil para depurar problemas do coletor de lixo, porque também desliga a varredura conservadora da pilha usada nas goroutines preemptadas de forma assíncrona. O nome da opção indica o uso, que é diagnóstico. Não há razão para mantê-la em produção.
13. Erros comuns e boas práticas
Quase todo erro com preempção nasce de uma das quatro crenças abaixo. Cada uma é razoável à primeira vista, e cada uma falha por um motivo específico.
O primeiro erro é achar que a preempção garante latência. Ela impede que uma goroutine monopolize o processador para sempre, mas dá a cada goroutine uma fatia de cerca de 10 ms, e o tempo até a interrupção depende da carga da máquina, como a seção 9 mostrou. Um serviço que precisa responder em 1 ms não pode contar com ela.
O segundo erro é confiar que todo laço com chamada de função é seguro. A chamada só conta se a função tem prólogo. Uma função folha pequena não tem, e uma função embutida pelo compilador some do código. A seção 11 mostrou o primeiro caso, e o segundo ficou registrado como não testado.
O terceiro erro é deixar asyncpreemptoff=1 em produção porque “funcionou no teste”. A opção existe para depurar o coletor de lixo, e ela recria exatamente o travamento desta aula.
O quarto erro é usar a preempção como mecanismo de cancelamento. Ela devolve o processador, mas não encerra o trabalho. Para parar um laço longo, a boa prática é olhar um sinal de cancelamento, como o ctx.Done() de um contexto, em intervalos regulares. A seção 14 traz um exemplo.
Há também uma prática que evita o problema de raiz. Em laços longos, divida o trabalho em lotes e verifique o contexto ao fim de cada lote. Isso dá ao programa pontos de decisão previsíveis, em vez de depender do sinal. Quando houver dúvida sobre um laço que demora, meça com o perfilador. A nota sobre PProf reúne o uso do perfilador.
14. Exercícios práticos
Exercício 1: prever o laço com fmt.Println
O programa abaixo tem um laço que imprime a cada um milhão de iterações. Rode-o com GODEBUG=asyncpreemptoff=1 e GOMAXPROCS=1. O laço trava?
package main
import (
"fmt"
"time"
)
func main() {
go func() {
for i := 0; ; i++ {
if i%1000000 == 0 {
fmt.Println("worker rodando", i)
}
}
}()
time.Sleep(50 * time.Millisecond)
fmt.Println("main acordou")
}
Resposta de referência: não trava. Eu rodei e a saída mostra o worker imprimindo até 27000000 e, depois, main acordou. A chamada a fmt.Println passa por funções com prólogo, então o laço tem pontos síncronos, mesmo com a preempção assíncrona desligada. A lição é a da seção 11: a presença de uma chamada muda a resposta, e não o tamanho do laço.
Exercício 2: medir a variação do acordar
Rode o programa da seção 9 dez vezes com GOMAXPROCS=1 e anote o menor e o maior tempo. Explique por que o resultado é um intervalo e não um número fixo.
Resposta de referência: o tempo mínimo fica perto dos 50 ms pedidos, e o máximo depende da carga da máquina e do momento em que o sysmon percebe o P preso. Em princípio, uma máquina ociosa daria um intervalo mais estreito, mas essa comparação não foi medida. Um número único, sem a condição da máquina, não diz nada sobre o runtime.
Exercício 3: explicar o GC travado
Usando a figura da seção 8, explique por que runtime.GC() trava com asyncpreemptoff=1 e GOMAXPROCS=2, mas termina com a preempção padrão. A resposta deve citar a parada do mundo e o papel de cada P.
Resposta de referência: a chamada precisa parar todos os Ps antes de marcar os objetos. O P1 está ocioso e para na hora. O P0 roda um laço sem chamadas, e sem preempção assíncrona não existe ponto em que ele possa parar. Com a preempção padrão, o sinal força a parada no P0, e a marcação começa.
Exercício 4: parar um laço com contexto
O programa abaixo roda um laço até que um contexto expire. Ele não depende da preempção para terminar.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
inicio := time.Now()
contagem := 0
for {
select {
case <-ctx.Done():
fmt.Printf("parou por cancelamento depois de %v\n", time.Since(inicio).Round(time.Millisecond))
fmt.Println("contagem > 0:", contagem > 0)
return
default:
contagem++
}
}
}
Saída real, com GOMAXPROCS=1:
parou por cancelamento depois de 37ms
contagem > 0: true
Resposta de referência: o laço para porque ctx.Done() fica pronto, e não porque o runtime o interrompeu. Essa é a forma correta de encerrar trabalho longo. A preempção só garante que outras goroutines recebam tempo enquanto este laço roda.
15. Glossário
- Goroutine (G): unidade de execução leve do Go, criada com
go. Detalhes em Goroutine. - Thread do SO (M): thread do sistema operacional que executa código Go. Detalhes em Thread.
- Processador lógico (P): permissão para rodar código Go, com a fila local de goroutines prontas. Seu número é o
GOMAXPROCS. - Scheduler: componente do runtime que decide qual goroutine roda em qual M e P. Detalhes em o scheduler do Go.
- sysmon: thread de monitoramento do runtime que roda sem P e vigia as goroutines em execução.
- Preempção: interrupção de uma tarefa em execução para dar o processador a outra.
- Preempção cooperativa: preempção que acontece quando a goroutine chega a um ponto de checagem, aqui o prólogo de uma função.
- Preempção assíncrona: preempção por sinal do sistema operacional, possível em qualquer ponto seguro do código.
- Ponto seguro: instrução em que o runtime pode suspender a goroutine sem corromper o estado da pilha ou dos registradores.
- Prólogo: trecho que roda no começo de toda função compilada, incluindo a comparação de pilha.
- stackguard0: limite de pilha guardado na goroutine. O runtime o troca por
stackPreemptpara pedir preempção. - Função folha pequena: função que não chama outras e, por isso, pode não ter prólogo de checagem.
- SIGURG: sinal usado pelo runtime para pedir preempção assíncrona em Unix.
- STW (stop-the-world): parada de todas as goroutines, necessária em certos momentos do coletor de lixo.
- EINTR: erro que uma chamada de sistema devolve quando é interrompida por um sinal.
O que não foi confirmado
- O atraso exato da preempção não foi medido em condições controladas. As cinco medições da seção 9 ficaram entre 51 e 208 ms com load average perto de 140, e a periodicidade do sysmon não foi medida.
- Os tempos da seção 10 também vêm de uma máquina carregada. Não medi as pausas do STW separadamente do tempo total de
runtime.GC(). - Os experimentos rodaram só com a toolchain go1.27.2 deste Mac. O comportamento nas versões 1.14 a 1.26 não foi executado. A leitura do código de 1.14 veio da tag do repositório, e confirmei nela
forcePreemptNS,sigPreempte o uso deretakepelo sysmon. Não conferi, na tag de 1.14, o caminho completo do handler de sinal que a versão 1.27.2 usa emdoSigPreempt. - A afirmação de que uma função embutida pelo compilador deixa de ser ponto de checagem vem da lógica do compilador. Não testei com um caso real.
- Não testei Windows nem Linux. A leitura de que darwin/arm64 está coberto pela nota de 1.14 é minha, derivada da exclusão ser para darwin/arm.
- Não testei outras funções da biblioteca padrão além de
fmt.Printlnno exercício 1. A afirmação de que chamadas à biblioteca padrão são pontos de checagem vale para as que têm prólogo, e não verifiquei todas. - Não abri a documentação pública do Go 1.27. A referência de versão nesta aula é o código local, e a nota do Go 1.14 é a fonte pública mais recente que consultei sobre o mecanismo.
Fontes
Consultadas em 2026-11-03. As citações em português são traduções livres dos textos originais em inglês.
- Go 1.14 Release Notes, seção de runtime: https://go.dev/doc/go1.14
- Go 1.2 Release Notes, seção “Pre-emption in the scheduler”: https://go.dev/doc/go1.2
- Austin Clements, “Proposal: Non-cooperative goroutine preemption”, atualizada em 2019-01-18: https://go.googlesource.com/proposal/+/master/design/24543-non-cooperative-preemption.md
- Código-fonte do Go 1.14 (tag go1.14), arquivos runtime/proc.go, runtime/signal_unix.go, runtime/runtime1.go e runtime/stack.go: https://go.googlesource.com/go/+/refs/tags/go1.14/src/runtime/
- Código-fonte da toolchain instalada, go1.27.2, em /opt/homebrew/Cellar/go/1.27.2/libexec/src/runtime: arquivos preempt.go, proc.go, signal_unix.go, stack.go, mgc.go e extern.go
- Documentação do pacote runtime da toolchain local, obtida com
go doc runtimeego doc runtime.Gosched, com os textos de asyncpreemptoff e de Gosched
Relacionados
- Goroutine
- O scheduler do Go: G, M e P
- Thread
- Garbage Collector
- Stack Split Check
- Stack
- PProf
- Go