El Garbage Collector (GC) de Go es un recolector concurrente de tipo mark-and-sweep basado en el algoritmo de tri-color marking. Para mantener la consistencia de la memoria mientras las goroutines (mutadores) siguen modificando punteros, el runtime necesita momentos de sincronización absoluta conocidos como Stop-the-World (STW). Durante estas fases, el scheduler detiene todas las goroutines de usuario para realizar tareas que no pueden ejecutarse de forma concurrente sin corromper el estado del ciclo de marcado.
Aunque el término “Stop-the-World” suena catastrófico, en Go moderno las pausas STW son extremadamente breves, situándose típicamente en el rango de los microsegundos o pocos milisegundos. Esto es posible porque el diseño de Go delega el trabajo pesado —el escaneo del heap— a la fase concurrente, dejando las fases STW limitadas a tareas de configuración y finalización.
Existen exactamente dos fases STW en un ciclo de GC:
1. Mark Start: Se activa la write barrier (una barrera de escritura que intercepta cambios en punteros para informar al GC) y se escanean las stacks de las goroutines para identificar las raíces (roots).
2. Mark Termination: Se detiene el escaneo de las stacks restante y se finaliza el proceso de marcado para asegurar que no existan objetos “grises” (objetos que aún no han sido procesados pero son alcanzables) que hayan sido creados durante la fase concurrente.
Esta arquitectura es una decisión de diseño deliberada para priorizar la latencia sobre el throughput. Mientras que otros lenguajes con recolectores generacionales (como Java) pueden lograr una eficiencia de CPU mayor al no escanear objetos que “no han muerto” (la generación joven), Go opta por un modelo que minimiza las interrupciones del sistema. Esto evita los picos de latencia en la cola (P99) que son críticos en servicios de baja latencia, aunque signifique que el GC trabaje un poco más en total para escanear objetos que podrían haber sobrevivido.
Si no comprendes este comportamiento y asumes que el GC es totalmente invisible, podrías frustrarte al ver picos de latencia inexplicables. El error no suele estar en el GC, sino en la creación excesiva de objetos que fuerzan ciclos de GC tan frecuentes que el “trabajo útil” de tu aplicación se ve eclipsado por la fase de marcado concurrente, aunque las pausas STW sigan siendo cortas.
Para observar esto, no basta con mirar el código; hay que observar el runtime mediante la variable de entorno GODEBUG=gctrace=1.
package main
import (
"fmt"
"runtime"
"time"
)
// Representa una estructura con punteros que el GC debe rastrear.
type Nodo struct {
valor int
siguiente *Nodo
}
func main() {
// Forzamos la asignación de una gran cantidad de objetos para
// disparar ciclos de GC y observar el comportamiento.
fmt.Println("Iniciando asignaciones masivas. Ejecuta con GODEBUG=gctrace=1")
// Creamos una lista de punteros para aumentar la carga de escaneo de stacks/heap.
data := make([]*Nodo, 0, 1000000)
for i := 0; i < 5; i++ {
fmt.Printf("Ciclo de iteración %d...\n", i)
for j := 0; j < 500000; j++ {
nuevoNodo := &Nodo{
valor: j,
siguiente: nil, // En una lista real, conectaríamos nodos
}
data = append(data, nuevoNodo)
}
// Forzamos un ciclo de GC manual para observar el trace.
// En producción, esto lo decide el runtime basado en el heap goal.
runtime.GC()
// Pequeña pausa para que el usuario pueda observar la salida de gctrace.
time.Sleep(500 * time.Millisecond)
}
fmt.Println("Proceso finalizado.")
}
En el ejemplo anterior, la función main genera una presión de memoria constante al añadir cientos de miles de punteros a un slice. Al llamar a runtime.GC(), obligamos al runtime a iniciar un ciclo completo.
Cuando ejecutes este programa con GODEBUG=gctrace=1 go run main.go, verás líneas de texto en tu terminal. Una línea típica se ve así:
gc 1 @0.012s 2% + 0.5ms + 1.2ms + 0.8ms + 0.03ms 4122 P 6
Fíjate en estos valores:
* 0.5ms y 1.2ms: Representan los tiempos de las fases de escaneo y marcado.
* 0.03ms: Este es el valor crítico. Es la duración del STW. Verás que es órdenes de magnitud menor que el tiempo total del ciclo.
* P 6: Indica que el runtime está utilizando 6 procesadores para la fase concurrente.
La clave aquí es que, aunque el GC está ocupando CPU (el 0.5ms o 1.2ms de trabajo de escaneo), la ejecución de tu programa principal no se detiene por completo; solo se detiene durante ese brevísimo 0.03ms.
El error frecuente
Un error común es confundir el uso de CPU por el GC con el tiempo de pausa STW.
Si tu aplicación tiene picos de latencia en la API, es probable que no se deba a un STW largo (que es muy raro), sino a la contención de CPU. Cuando el GC entra en su fase de marcado concurrente, “roba” ciclos de CPU de los hilos de tu aplicación para poder escanear el heap. Si tu máquina tiene pocos núcleos y el GC está trabajando intensamente, tus peticiones HTTP experimentarán latencia no porque el mundo se haya detenido, sino porque el scheduler está priorizando las tareas del GC para evitar que la memoria se agote.
N° 172