09 out 2026 · infraestrutura, sistemas operacionais, go · 5 min
Swap não é RAM grátis: pausas do GC do Go sob pressão de memória
O caso original
Em setembro, Fernando Simões publicou uma medição incômoda. Um serviço Go rodando num cgroup, com swap habilitado para absorver picos de memória. A pausa mediana do coletor de lixo era de 51 µs. A pior chegou a 40 ms. Desses 40 ms, 39 ms foram gastos em 228 page faults dentro de uma pausa stop-the-world. Em 30 minutos, 312 pausas do mesmo tipo.
A causa é estrutural. O GC do Go guarda metadados fora do heap, numa região que o runtime nunca devolve ao sistema. Essas páginas são lidas a cada ciclo de coleta. O kernel escolhe o que mandar para o swap pela idade, e as páginas de metadados, pouco acessadas em relação ao resto, acabam saindo. Quando o coletor vai lê-las, o processo inteiro está parado esperando o dispositivo responder.
A pergunta
O autor mediu com swap em NVMe. Eu queria medir com swap em zram, que é o que roda nesta VM: páginas comprimidas na própria RAM. Se o page fault é resolvido descomprimindo em memória, o custo por página deveria cair bastante, e a pausa de 40 ms não deveria aparecer. A latência de alocação, essa sim, continua sendo paga.
Como eu testei
- VM KVM com 4 vCPU, 5,8 GB de RAM, Ubuntu 24.04, kernel 6.8. Swap só em zram, algoritmo zstd, 5,8 GB de disco virtual, prioridade 100.
- Alocador do próprio autor, do repositório go-gc-swap-cost, compilado com Go 1.27.1.
- Duas rodadas iguais: 2 picos de 299 MiB sobre uma base de 59 MiB. A rodada apertada rodou com teto de 380 MB de memória e 3 GB de swap; a folgada, com teto de 2 GB e 512 MB de swap. As duas via systemd-run –user –scope.
- O alocador grava um CSV a cada 10 ms: histograma de pausas stop-the-world de /gc/pauses:seconds, faults maiores do processo, contadores globais de swap e a latência de cada etapa de alocação.
Resultados
| métrica | teto de 2 GB, swap de 512 MB | teto de 380 MB, swap de 3 GB |
|---|---|---|
| pausa stop-the-world p99 | 196 µs | 196 µs |
| pausa stop-the-world máxima | 196 µs | 196 µs |
| faults maiores do processo | 0 | 69.026 |
| latência de alocação p50 | 1,2 ms | 1,4 ms |
| latência de alocação p99 | 3,4 ms | 9,7 ms |
| latência de alocação máxima | 11,0 ms | 22,2 ms |
| pswpin no intervalo | 11.198 páginas | 71.676 páginas |
| pswpout no intervalo | 44.813 páginas | 126.199 páginas |
Os valores de pausa são o limite superior do bucket do histograma do Go, não medições contínuas: nenhuma pausa passou de 196 µs. Os contadores de swap são globais da máquina, não do processo.
O que mudou e o que não mudou
A pausa de stop-the-world não apareceu. Nem perto: 196 µs nas duas condições, contra 40 ms no caso do NVMe. O que apareceu foi latência localizada na goroutine que aloca: o p99 subiu de 3,4 ms para 9,7 ms e o pior caso de 11 ms para 22 ms, com 69 mil faults maiores contra zero.
Esse custo é pago só por quem está alocando, não pelo processo inteiro como uma pausa de GC. Mas é o custo que o usuário sente como resposta lenta. Vale lembrar o outro número do artigo original: montar uma mensagem de 511 KiB, que levava de 3 a 5 ms, passou a levar 105 ms com swap em NVMe e 903 ms com volume de rede.
Ressalvas de método
As duas rodadas foram únicas, e esta VM não está ociosa. Antes do teste ela já tinha 3,4 GB de dados no zram, comprimidos em 779 MB (razão de 4,6 para 1), e 17,5 milhões de faults maiores desde o boot, três dias antes. O swap global se movimenta sem mim, então os deltas de pswpin e pswpout entram na tabela como ordem de grandeza, não como medida isolada do meu processo.
O que é limpo na comparação: as duas rodadas usaram o mesmo alocador, o mesmo Go, o mesmo hardware e o mesmo tamanho de carga. Só mudou o teto de memória do cgroup.
Por que a diferença vem do dispositivo
Um page fault maior custa o tempo de buscar a página e trazê-la de volta. Com swap em NVMe, isso significa leitura de disco: fila, submissão de bio, espera. Com zram, significa descompressão em memória. A ordem de grandeza muda, e a pausa segue a ordem de grandeza. A causa está no dispositivo atrás do swap, não no coletor do Go.
O autor também mediu o Green Tea GC, o coletor que entrou no Go 1.26, e não viu impacto relevante na leitura dos metadados. Eu rodei com Go 1.27.1.
O que fazer
- Meça o seu caso. Pausa de GC depende de tamanho de heap, taxa de alocação e dispositivo de swap. A conclusão de outra pessoa não se transfere.
- Isole o serviço em cgroup. Use memory.high antes de memory.max: você recebe aviso antes de o kernel começar a evictar com agressividade.
- Escolha o swap com intenção. zram resolve o caso do disco lento, mas não todos: quando enche, não tem para onde mandar as páginas frias e o kernel perde margem. zswap comprime igual, mas na frente de um swap em disco, com despejo automático das páginas mais antigas.
- Deixe margem. Esta VM tem 5,8 GB e vive com swap em uso. Se o seu serviço precisa de p99 baixo, RAM sobrando é mais barata que ajuste fino de swap.