· 24 min de lectura · Por Grover Cordova
Recolector de basura vs. Rust: pausas, picos y latencia
Cómo funciona un recolector de basura, por qué puede causar picos de latencia y qué cambia con Rust. Con diagramas, un simulador y sus límites.
Todo programa debe devolver la memoria que ya no usa. La pregunta no es si cuesta, sino quién paga, cuánto y en qué momento. Esa respuesta define cómo se comporta tu sistema bajo carga.
Esta nota no busca declarar un ganador. Para muchísimos sistemas, un recolector moderno es la decisión correcta. Pero si tu negocio depende de responder siempre a tiempo, conviene entender dónde nacen los picos.
Tres formas de devolver la memoria
- Manual, como en C: tú decides cuándo liberar. Es rápido y predecible, pero un error produce estos fallos.
- Recolector de basura, como en Java, Go o C#: un proceso busca lo que ya no se usa y lo recupera más tarde. Es seguro y cómodo.
- Propiedad, como en Rust: el compilador decide dónde liberar, y lo escribe en el programa. El Libro de Rust lo resume así: al salir de alcance, el valor se libera.
Cómo trabaja un recolector de basura
La idea central es la alcanzabilidad: si ningún camino lleva a un objeto, nadie podrá volver a usarlo.
Los recolectores actuales son mucho más sofisticados que este esquema. Trabajan en paralelo a tu programa, dividen los objetos por edad y compactan la memoria. Python combina otra técnica, el conteo de referencias, con un recolector para los ciclos.
El costo no es solo la pausa
Se suele hablar de las pausas, pero son solo uno de tres costos:
- Memoria. El recolector necesita espacio para trabajar. En Go, duplicar
GOGCduplica la memoria adicional y reduce a la mitad el costo de CPU: es un intercambio explícito. Con el valor habitual de 100, el montón puede crecer hasta cerca del doble de los datos vivos. - CPU. Marcar objetos es trabajo real. La misma guía de Go indica que el recolector usa el 25 % de los recursos de CPU durante la fase de marcado.
- Variación. El trabajo llega en momentos que tu código no eligió, y compite con las peticiones en curso.
Por qué aparecen los picos
Go hace casi todo su trabajo de forma concurrente, y sus pausas completas son breves. Aun así, su guía enumera varias fuentes de latencia: las pausas breves entre fases, los retrasos de planificación por el 25 % de CPU, las rutinas que deben ayudar al recolector cuando se asigna mucha memoria y el costo extra de escribir punteros durante el marcado.
El tiempo de un ciclo crece con los datos vivos y con la cantidad de punteros por recorrer. Por eso las cachés grandes en memoria son el caso difícil: cuanto más útil es la caché, más trabajo le das al recolector.
Qué le pasa al flujo del sistema
Aquí está lo que más se subestima: una pausa no solo retrasa a quien estaba siendo atendido. Las peticiones siguen llegando, se acumulan y hay que atenderlas después, con la capacidad que sobra.
Cuanto más cargado está el sistema, menos capacidad libre tiene para recuperarse. La misma pausa duele mucho más al 90 % que al 50 %.
La regla es sencilla. Con una carga u, la cola tarda en vaciarse la duración de la pausa multiplicada por u / (1 − u). Una pausa de 50 ms toma 50 ms más en vaciarse al 50 % de carga, y 450 ms al 90 %. Un sistema real es más complejo, pero la tendencia se mantiene.
El promedio esconde el problema
Si mides solo el promedio, nunca verás esto. Por eso se usan percentiles: p99 es el tiempo por debajo del cual está el 99 % de las peticiones. Dicho de otro modo, 1 de cada 100 tarda más.
Un p99 alto no es un detalle estadístico. En un servicio con un millón de peticiones al día, son diez mil respuestas lentas diarias, y suelen concentrarse en los momentos de mayor carga, cuando más importan.
Con muchos servicios, el pico deja de ser raro
Una acción de un usuario suele tocar varios servicios, y su respuesta es tan lenta como el más lento. Jeff Dean y Luiz Barroso lo describieron en The Tail at Scale:
Lo que en un servicio parecía raro, en un sistema de cien servicios le pasa a la mayoría de las peticiones.
Un caso real: Discord
El caso más citado es el de Discord. Su servicio Read States, escrito en Go, guardaba decenas de millones de registros en una caché y mostraba picos periódicos de latencia. Discord los atribuyó al recolector: debía recorrer toda la caché, y Go fuerza una recolección al menos cada dos minutos. Reducir la caché aliviaba los picos, pero empeoraba el p99, porque más consultas iban a la base de datos.
Al reescribirlo en Rust, los picos desaparecieron. Con optimizaciones, superó a la versión anterior en latencia, CPU y memoria. Según un artículo de AWS, la versión en Rust fue más de 10 veces más rápida en general y redujo 100 veces las peores latencias.
Conviene leerlo con cuidado:
- Se trata de un servicio concreto, con un patrón muy favorable a Rust.
- Usaba Go 1.9, y el artículo es de 2020. Go ha seguido mejorando.
- Los propios autores aclaran que no recomiendan reescribirlo todo en Rust.
Otro caso: Apple, de Java a Swift
El problema no es exclusivo de Go, y la solución no siempre es Rust. En 2025, Apple contó que migró de Java a Swift el servicio que revisa contraseñas filtradas. Tras ajustar el recolector G1, seguía viendo pausas prolongadas bajo carga. Con Swift reportó cerca de un 40 % más de capacidad, memoria de cientos de megabytes por instancia, frente a decenas de gigabytes en Java, y latencias menores a 1 ms en el 99,9 % de las peticiones.
Es un caso publicado por la empresa creadora de Swift, pero la lección es general: en servicios muy exigentes, una gestión de memoria determinista puede cambiar el resultado, sea con Rust o con otro lenguaje.
Cómo libera Rust
En Rust no hay un proceso aparte. El punto de liberación queda fijado al compilar:
fn main() {
{
let reporte = vec![0u8; 1024];
println!("{} bytes", reporte.len());
} // `reporte` se libera aquí, en este punto exacto
println!("La memoria ya se devolvió");
}
Por eso el costo es predecible: ocurre siempre en el mismo lugar del código, y lo paga la operación que creó el problema, no una petición ajena. Lo explicamos en la guía de ownership.
Lo medimos nosotros
Quisimos comprobarlo. En nuestro microservicio de ejemplo, con 5 millones de registros en memoria y un solo núcleo, el p99 de Go subió a más de 80 ms. Con 4 núcleos bajó a 2,6 ms. Node.js, también con recolector, se mantuvo cerca de 2 ms con un núcleo. La conclusión es que no todos los recolectores se comportan igual, y que los recursos disponibles cambian el resultado.
Rust también puede tener picos
Sin recolector no hay magia. Estas son las fuentes habituales de picos en Rust:
- Liberar estructuras enormes. Soltar un mapa de un millón de entradas toma tiempo, y ocurre justo en esa línea.
- Bloquear el hilo en código asíncrono. Una operación lenta dentro de una tarea detiene a las demás que comparten ese hilo.
- Contención en candados. Un
Mutexmuy disputado produce colas, en cualquier lenguaje. - Reservar memoria en la ruta crítica. El asignador tampoco es gratis.
- El mundo exterior. La red, el disco y la base de datos no saben en qué lenguaje escribes.
La diferencia es que estos costos están a la vista, en tu código, y puedes moverlos de lugar:
use std::collections::HashMap;
use std::thread;
fn main() {
let cache: HashMap<u64, String> =
(0..1_000_000).map(|i| (i, i.to_string())).collect();
// Liberar un millón de entradas toma tiempo.
// Se entrega a otro hilo para no frenar la petición en curso.
thread::spawn(move || drop(cache));
println!("La petición sigue su curso");
}
Los recolectores modernos son muy buenos
Sería deshonesto comparar Rust con los recolectores de hace quince años. ZGC, de Java, hace su trabajo costoso de forma concurrente, sin detener los hilos de la aplicación más de un milisegundo, con pausas independientes del tamaño del montón y para montones de hasta 16 TB.
Eso cambia la discusión, pero no la elimina. Las pausas casi desaparecen, aunque el trabajo sigue existiendo: la misma documentación de ZGC advierte que demasiados hilos del recolector le quitan CPU a la aplicación, y recomienda que el sistema nunca supere el 70 % de uso de CPU.
Comparación directa
| Aspecto | Recolector moderno | Rust |
|---|---|---|
| Quién libera | Un proceso en tiempo de ejecución | El compilador lo fija en el código |
| Cuándo | Cuando el recolector lo decide | Al salir de alcance |
| Pausas | Desde breves hasta menos de 1 ms, según el recolector | Sin pausas de recolección |
| CPU | Trabajo adicional y continuo | Sin ese trabajo; el costo es la liberación puntual |
| Memoria | Necesita holgura | Cerca de los datos vivos |
| Latencia de cola | Depende de la carga y de la configuración | Más predecible, sin estar garantizada |
| Productividad inicial | Alta | Menor, con curva de aprendizaje real |
| Contratación | Mercado amplio | Mercado más pequeño |
| Ajuste | Parámetros del recolector | Diseño del código |
Cuándo el recolector es la mejor opción
Si tu duda concreta es entre dos lenguajes, mira Rust vs. Go. Quédate con tu recolector si:
- Tu sistema pasa la mayor parte del tiempo esperando a la base de datos o a la red.
- Tus objetivos de latencia se miden en cientos de milisegundos.
- Tu equipo domina el lenguaje, y el costo de infraestructura no es un problema.
- No has medido el problema. Sin datos, una migración es una apuesta.
Rust empieza a tener sentido cuando el p99 o el p99.9 es un requisito del negocio, cuando manejas muchos datos en memoria o cuando el costo de CPU y memoria se nota en la factura. También reduce la huella de carbono, en la medida en que reduce servidores.
Qué medir antes de decidir
- Los percentiles, no el promedio. p50, p95, p99 y p99.9, por servicio y bajo carga real.
- El tiempo de recolección. Actívalo con
GODEBUG=gctrace=1en Go o con-Xlog:gcen Java. - Si los picos coinciden. Compara los picos de latencia con los ciclos del recolector. Si no coinciden, el problema está en otra parte.
- La configuración. Prueba primero a ajustar el recolector y a reducir asignaciones. Suele ser mucho más barato que migrar.
- Un solo componente. Si decides cambiar, elige el servicio más crítico y compara. Las cinco preguntas antes de migrar ayudan a ordenar esa decisión.
Fuentes
- A Guide to the Go Garbage CollectorThe Go Authors
- runtime/proc.go: forcegcperiodThe Go Authors
- Why Discord is switching from Go to RustDiscord, 2020
- Swift at Apple: Migrating the Password Monitoring service from JavaSwift.org, 2025
- The Z Garbage CollectorOpenJDK
- Sustainability with RustAWS Open Source Blog, 2022
- The Tail at ScaleJeffrey Dean y Luiz André Barroso, Google, 2013
- What Is Ownership?The Rust Programming Language
- gc: Garbage Collector interfacePython Documentation