Stack y Heap
Tanto el stack como el heap son partes de la memoria RAM que están disponibles para que el programa los use en tiempo de ejecución, pero están estructurados de diferentes maneras.
Conceptos generales
Diferencias de rendimiento:
- Agregar datos al stack es más rápido que agregarlos al heap, ya que el asignador (memory allocator) nunca debe buscar un lugar para almacenar los datos: siempre los ubica en la parte superior de la pila
- Asignar datos en el heap es más costoso que asignar en el stack, ya que el asignador (memory allocator) primero debe buscar un espacio lo suficientemente grande para guardar los datos y luego realizar la contabilidad para preparar la siguiente asignación
- Acceder a los datos en el heap suele ser más lento que acceder a los datos en el stack, ya que se debe seguir un puntero para llegar a ellos
- Los datos con un tamaño desconocido en tiempo de compilación o un tamaño que puede cambiar se debe almacenar en el heap
Localidad de memoria: Los procesadores actuales son más rápidos cuando hacen menos saltos en la memoria. Por ejemplo: un mesero que toma pedidos en un restaurante es más eficiente si toma todos los pedidos de una mesa antes de pasar a la siguiente. Tomar un pedido de la mesa A, luego uno de la mesa B, después uno de la A y luego uno de la B sería un proceso mucho más lento. De manera similar, un procesador funciona mejor cuando trabaja con datos cercanos entre sí (como los del stack) en lugar de datos más alejados (como los del heap).
Llamadas a funciones: Cuando el código llama a una función, los valores pasados a la función (incluyendo punteros a datos en el heap) y las variables locales de la función se apilan en el stack. Al finalizar la función esos valores se extraen del stack.
fn main() {
// Variables en el stack de main()
let numero = 42; // i32 - stack
let texto = String::from("Hola"); // String - puntero en stack, datos en heap
println!("Antes de llamar la funcion procesar_datos()");
// Se llama a la función pasando valores
let resultado = procesar_datos(numero, &texto);
println!("Después de llamar la funcion procesar_datos()");
println!("Resultado: {}", resultado);
// Al finalizar main(), todas sus variables se extraen del stack
}
// Cuando se llama esta función:
// - 'num' (i32) se apila en el stack
// - 'mensaje' (&str - puntero) se apila en el stack
fn procesar_datos(num: i32, mensaje: &str) -> String {
// Variables locales de la función - también van al stack
let multiplicador = 2;
let resultado_calculo = num * multiplicador;
// Creamos un nuevo String (datos van al heap, puntero al stack)
let respuesta = format!("{}: {} x {} = {}",
mensaje,
num,
multiplicador,
resultado_calculo);
println!("Dentro de procesar_datos()");
println!(" num: {} (stack)", num);
println!(" multiplicador: {} (stack)", multiplicador);
println!(" resultado_calculo: {} (stack)", resultado_calculo);
// Al retornar:
// - Se devuelve 'respuesta' (ownership se transfiere)
// - Todas las variables locales se extraen del stack
// - Los parámetros también se extraen del stack
respuesta
}
Puntos clave del ejemplo
- Los valores simples como
i32van completamente al stack - Los
Stringtienen su puntero en el stack pero los datos reales en el heap - Las referencias (
&str) son solo punteros que van al stack - Cada llamada a función crea un nuevo "frame" en el stack
- Al terminar cada función, su frame se elimina automáticamente
- Los valores se pueden "boxear" (asignar al heap) usando
Box<T>
Stack
- Región en RAM, pero con soporte directo de la CPU mediante un registro especial (stack pointer) y un conjunto de instrucciones
- El stack almacena los valores en el orden en que los obtiene y los elimina en el orden opuesto
- Último en entrar, primero en salir (LIFO - Last In, First Out)
- Ejemplo: Una pila de platos: cuando se agregan más platos, estos se colocan en la parte superior de la pila, y cuando se necesita un plato, se toma uno de la parte superior. Agregar o quitar platos de abajo o del medio no funcionaría tan bien
- Agregar datos es conocido como pushing onto the stack y remover datos es conocido como popping off the stack
- Requisito de tamaño: Todos los datos almacenados en el stack deben tener un tamaño conocido y fijo en tiempo de compilación
- Organización: Altamente organizado y predecible
- Gestión automática: No requiere allocating explícito, la gestión es automática: cuando se llama una función se reserva espacio, cuando termina se libera automáticamente
- Gestión simple: La gestión de memoria del stack es trivial: la máquina simplemente incrementa o decrementa un solo valor llamado "stack pointer"
- Por thread: Cada thread tiene su propio stack
- Regla por defecto: Todos los valores en Rust se asignan al stack por defecto
Heap
- Región en RAM, pero sin soporte directo del hardware: lo maneja el sistema operativo y las librerías del lenguaje
- El heap está menos organizado que el stack
- Proceso de asignación: Cuando se almacena un dato en el heap, se solicita una cierta cantidad de espacio. El memory allocator (asignador de memoria) encuentra un lugar vacío en el heap que sea lo suficientemente grande, lo marca como "en uso" y devuelve un pointer (puntero) que es la dirección de esa ubicación
- Este proceso se llama allocating on the heap o simplemente allocating (asignación). Agregar valores al stack no se considera allocating (asignación).
- Flexibilidad de tamaño: Los datos con un tamaño desconocido en tiempo de compilación o un tamaño que puede cambiar se almacenan en el heap
- Acceso indirecto: Debido a que el puntero al heap es de un tamaño fijo conocido, se puede almacenar el puntero en el stack, pero cuando se necesitan los datos reales, se debe seguir al puntero
- Ejemplo: Al llegar a un restaurante se menciona el número de personas en el grupo. El mesero encuentra una mesa vacía donde caben todos los del grupo y los guía allí. Si alguien del grupo llega tarde puede preguntar donde te has sentado para encontrarte
- Gestión compleja: La gestión de memoria del heap es compleja: la memoria se libera en puntos arbitrarios y cada bloque puede ser de tamaño arbitrario. Puede tener "huecos" entre bloques asignados.
- La gestión es manual (malloc/free en C, new/delete en C++, o automática con garbage collection)
- Tipos comunes en Rust:
String,Vec<T>,Box<T>,HashMap, etc., almacenan sus datos en el heap - Asignación explícita: En Rust, se usa explícitamente tipos como
Box<T>para asignar memoria en el heap
Relación con ownership
Monitorear qué partes del código usan qué datos en el heap, minimizar la cantidad de datos duplicados y limpiar los datos no utilizados para no quedarse sin espacio son problemas que el ownership aborda. Una vez que se comprenda el ownership no será necesario pensar mucho en el stack o heap, pero saber que el propósito principal del ownership es administrar los datos del heap puede ayudar a explicar por qué funciona como lo hace.