Saltar a contenido

Modelo de ejecución

De un vistazo

Materia: Capa 0 · Cimientos · Tiempo de lectura: ~30 min · Requisitos previos: ninguno.

En una frase: vas a entender qué hace realmente un ordenador cuando ejecuta tu programa —qué es la CPU, cómo es la memoria por dentro, y por qué "stack" y "heap" no son jerga de entrevista sino dos formas físicamente distintas de guardar datos con consecuencias que pagas cada día.

Por qué esto importa

Escribes código todos los días y funciona. Entonces, ¿por qué molestarse en saber qué pasa por debajo?

Porque tarde o temprano aparecen preguntas que no puedes responder desde el lenguaje, solo desde la máquina:

  • ¿Por qué este bucle que recorre una matriz "por columnas" va 6 veces más lento que el mismo bucle "por filas", si hace exactamente las mismas operaciones?
  • ¿Por qué mi proceso PHP se come 2 GB de RAM y no los suelta?
  • ¿Qué significa exactamente Segmentation fault, Stack overflow o Out of memory?
  • ¿Por qué pasar un objeto "por referencia" es barato y copiarlo es caro?
  • ¿Por qué una función recursiva sin fin peta de una forma (stack overflow) y un bucle que acumula en un array peta de otra (out of memory)?

Todas estas tienen la misma raíz: cómo la máquina guarda y ejecuta las cosas. Sin este modelo mental, cada una de esas situaciones es un misterio que resuelves copiando de Stack Overflow. Con él, son consecuencias predecibles. Ese es el salto de "sé usar el lenguaje" a "entiendo qué estoy haciendo".

Intuición primero: el oficinista tonto y el archivador

Imagina una oficina con un solo empleado y un archivador gigante de cajones numerados.

  • El empleado es la CPU. Es rapidísimo pero tonto y sin memoria propia: solo sabe hacer operaciones minúsculas de una en una ("suma estos dos números", "compara estos dos", "ve a leer el cajón 5.012"). No recuerda nada de un instante al siguiente; todo lo que necesita, lo tiene que ir a buscar al archivador.
  • El archivador es la memoria (RAM). Es una fila larguísima de cajones, todos del mismo tamaño (un byte), y cada uno tiene un número: su dirección. El cajón 0, el cajón 1, el cajón 2… hasta miles de millones. En un cajón cabe un número pequeño; para guardar algo más grande, usas varios cajones seguidos.

El empleado tiene además unas pocas casillas en su mesa para tener a mano lo que está usando ahora mismo: son los registros. Son rapidísimos pero hay muy pocos (un puñado). Todo lo demás vive en el archivador.

graph LR
  subgraph CPU["CPU · el oficinista tonto"]
    R["Registros<br/>(unas pocas casillas<br/>en la mesa)"]
    PC["Contador de programa<br/>(qué instrucción toca)"]
  end
  subgraph RAM["Memoria RAM · el archivador"]
    M["Cajones numerados<br/>0, 1, 2, 3, ... miles de millones<br/>(cada uno = 1 byte)"]
  end
  CPU <-->|"leer / escribir<br/>en una dirección"| RAM

Un programa, entonces, no es más que una lista de instrucciones diminutas para este empleado, y ejecutar el programa es el empleado leyéndolas y obedeciéndolas una tras otra. Esa es toda la magia. Todo lo demás son detalles de cómo se organiza esto para que no sea un caos.

Guarda esta imagen. Vamos a colgar el resto de la lección de ella.

El detalle

1. Qué hace la CPU: el ciclo de siempre

La CPU repite, miles de millones de veces por segundo, el mismo ciclo de tres pasos —buscar, decodificar, ejecutar (en inglés fetch–decode–execute):

graph LR
  F["1 · BUSCAR<br/>lee la instrucción<br/>de la dirección que<br/>marca el PC"] --> D["2 · DECODIFICAR<br/>¿qué operación es?<br/>¿sobre qué datos?"]
  D --> E["3 · EJECUTAR<br/>hace la operación<br/>(suma, compara,<br/>lee/escribe memoria)"]
  E --> A["avanza el PC<br/>a la siguiente"]
  A --> F

El contador de programa (PC, program counter) es un registro especial que guarda la dirección de la siguiente instrucción. Normalmente avanza sola de una en una; cuando hay un if o un bucle, lo que ocurre por debajo es un salto: se cambia el PC a otra dirección. Un if no es más que "compara dos cosas y, según el resultado, pon el PC aquí o allá".

El detalle que casi nadie interioriza

Tus instrucciones también viven en el archivador, en cajones, igual que los datos. El programa en ejecución es datos en memoria a los que la CPU va apuntando con el PC. Código y datos son, físicamente, lo mismo: bytes en direcciones. Esta idea —la arquitectura de Von Neumann— es de 1945 y es la razón por la que un ordenador es de propósito general en lugar de una máquina de una sola función.

Velocidades, para que el "rapidísimo pero tiene que ir al archivador" tenga números: una operación en un registro le cuesta a la CPU menos de un nanosegundo; ir a buscar algo a la RAM le cuesta ~100 nanosegundos. No parece mucho, pero es como la diferencia entre coger un boli de tu mesa (1 segundo) y bajar a por él a un almacén (2 minutos). Por eso existen las cachés (copias pequeñas y cercanas de trozos de RAM) y por eso el bucle "por columnas" del principio va lento: salta por el archivador rompiendo la caché. Eso lo veremos a fondo en Performance; quédate con que la distancia a los datos importa.

2. Programa vs proceso

Hay que separar dos cosas que se confunden:

  • Un programa es algo muerto: un archivo en disco con la lista de instrucciones (tu .php, un binario .exe). Es la receta.
  • Un proceso es algo vivo: el programa en ejecución, cargado en memoria, con la CPU obedeciéndolo. Es la comida cocinándose.

Del mismo programa puedes tener muchos procesos a la vez (abre tres veces el mismo editor: un programa, tres procesos). Cada proceso es un mundo aparte.

Y aquí está la definición que sostiene toda la lección:

Un proceso es, esencialmente, dos cosas: su espacio de direcciones (toda la memoria que "posee": su trozo de archivador) + su estado de ejecución (qué está haciendo ahora mismo: el PC, los registros).

Todo lo que sigue es entender ese espacio de direcciones: cómo está organizado por dentro.

El truco de la memoria virtual (mención)

Cada proceso cree que tiene el archivador entero para él, con direcciones que empiezan en 0. Es mentira piadosa del sistema operativo: cada proceso ve un archivador virtual privado, y el SO traduce por detrás esas direcciones a la RAM física real, repartiéndola entre todos y aislándolos (por eso un proceso no puede leer la memoria de otro). Esto se llama memoria virtual y tiene su propia lección en Capa 3. Por ahora basta con saber que existe y que por eso cada proceso puede razonar sobre "su" memoria como si estuviera solo.

3. El mapa de memoria de un proceso

El espacio de direcciones de un proceso no es un saco revuelto: está organizado en regiones, cada una con un propósito. De direcciones bajas a altas, el mapa clásico es:

graph TB
  subgraph AS["Espacio de direcciones de un proceso"]
    direction TB
    T["<b>Código (text)</b><br/>tus instrucciones · solo lectura"]
    D["<b>Datos (data / bss)</b><br/>variables globales y constantes"]
    H["<b>Heap</b> ↓ crece hacia abajo<br/>memoria dinámica que pides a mano<br/>(objetos, arrays que crecen)"]
    LIBRE["· · · espacio libre · · ·"]
    S["<b>Stack</b> ↑ crece hacia arriba<br/>variables locales y llamadas a funciones"]
    T --- D --- H --- LIBRE --- S
  end
  • Código (text): tus instrucciones. Solo lectura (para que un bug no reescriba el propio programa).
  • Datos: variables globales y constantes, cosas que existen durante toda la vida del proceso.
  • Heap y Stack: las dos zonas de datos que crecen y menguan mientras el programa corre. Crecen una hacia la otra para aprovechar el hueco libre del medio: el stack baja desde arriba, el heap sube desde abajo. Estas dos son las protagonistas, y la fuente de la mayoría de tus dolores de cabeza.

Vamos a por ellas, que es el corazón de la lección.

4. El stack: rápido, ordenado y de vida corta

El stack (pila) es la zona donde viven las variables locales y donde se gestiona el quién-llamó-a-quién de las funciones. Su nombre lo dice todo: funciona como una pila de platos. Solo puedes poner un plato encima (push) o quitar el de encima (pop). Último en entrar, primero en salir (LIFO).

Cada vez que llamas a una función, se apila un bloque nuevo llamado stack frame (marco de pila) que contiene:

  • Los argumentos que le pasaste.
  • Sus variables locales.
  • La dirección de retorno: a qué instrucción volver cuando la función termine (para restaurar el PC).

Cuando la función termina (return), su frame se desapila de golpe y esa memoria queda libre para la siguiente llamada. Veámoslo con código:

function total($precio) {
    $iva = $precio * 0.21;      // variable local
    return $precio + $iva;
}

function checkout() {
    $base = 100;                // variable local
    $final = total($base);      // llamamos a total()
    echo $final;
}

checkout();

Momento a momento, el stack:

graph TB
  subgraph P3["3 · dentro de total()"]
    direction TB
    c3["frame checkout: base=100"]
    t3["frame total: precio=100, iva=21<br/>← la CPU está aquí"]
    c3 --- t3
  end
  subgraph P2["2 · dentro de checkout()"]
    c2["frame checkout: base=100<br/>← la CPU está aquí"]
  end
  subgraph P1["1 · arranque"]
    v1["(stack casi vacío)"]
  end
  P1 --> P2 --> P3

Cuando total() hace return, su frame se destruye, $iva y $precio desaparecen automáticamente, y la CPU vuelve a checkout() usando la dirección de retorno guardada. No hay que "limpiar" nada a mano: desapilar es gratis, es literalmente mover el puntero de la cima de la pila hacia abajo.

Esto explica de golpe varias cosas:

  • Por qué el stack es rapidísimo: reservar memoria para una variable local es mover un puntero una posición. Liberarla, igual. No hay que buscar hueco ni negociar nada.
  • Por qué una variable local "no existe" fuera de su función: su frame ya se desapiló. Devolver la dirección de una variable local es un bug clásico (apuntas a un plato que ya quitaste de la pila).
  • Qué es un stack overflow: el stack tiene un tamaño límite (típicamente unos pocos MB). Si encadenas demasiadas llamadas sin retornar —el caso clásico es una recursión infinita— sigues apilando frames hasta que el stack choca con el hueco libre y revienta. No es que se acabe la RAM; es que se acabó el sitio reservado para la pila.
function infinito() {
    return infinito();   // nunca retorna → apila frames sin parar → STACK OVERFLOW
}

5. El heap: flexible, potente y tu responsabilidad

El stack es maravilloso pero tiene dos límites duros: los datos mueren al salir de la función, y su tamaño se tiene que conocer de antemano. ¿Qué haces cuando necesitas datos que viven más allá de una función o cuyo tamaño depende de la ejecución (un array que crece según lo que suba el usuario, un objeto que devuelves y sigue vivo)? Ahí entra el heap (montón).

El heap es una zona grande de memoria de propósito general donde pides trozos explícitamente, del tamaño que quieras y cuando quieras, y esos trozos siguen vivos hasta que alguien los libera —no se atan a ninguna función. Cuando en PHP haces new Cliente() o construyes un array grande, ese objeto vive en el heap; lo que guardas en tu variable local (en el stack) es solo una referencia: la dirección de dónde vive el objeto en el heap.

graph LR
  subgraph Stack
    v["$c (variable local)<br/>= dirección 0x7A20"]
  end
  subgraph Heap
    o["0x7A20:<br/>objeto Cliente<br/>{ nombre, email, ... }"]
  end
  v -->|"apunta a"| o

Esto explica otra tanda de cosas cotidianas:

  • Por qué pasar un objeto es barato y copiarlo es caro: pasar el objeto pasa la referencia (una dirección, 8 bytes). Copiarlo de verdad obliga a reservar otro trozo de heap y duplicar todo el contenido.
  • Por qué el heap es más lento que el stack: pedir un trozo no es mover un puntero; el gestor de memoria tiene que buscar un hueco libre del tamaño adecuado, apuntarlo en sus registros, etc. Barato comparado con ir a disco, caro comparado con el stack.
  • Qué es la fragmentación: si pides y liberas trozos de tamaños distintos durante horas, el heap queda como un aparcamiento con huecos sueltos: tienes memoria libre total de sobra, pero ningún hueco seguido lo bastante grande. Fuente de fugas de rendimiento en procesos de larga vida.

¿Y quién libera el heap?

Aquí los lenguajes se dividen en dos mundos, y es una de las grandes decisiones de diseño de un lenguaje:

  • Gestión manual (C, C++): tú pides (malloc) y tú liberas (free). Máximo control, cero red de seguridad. Si olvidas liberar → fuga de memoria (memory leak): el proceso acumula heap que ya no usa hasta reventar (out of memory). Si liberas dos veces o usas algo ya liberado → corrupción y segfault.
  • Gestión automática (PHP, Java, Go, Python…): un recolector de basura (garbage collector, GC) —o un conteo de referencias, como en PHP— detecta qué trozos de heap ya no tiene apuntados nadie y los libera por ti. Mucho más seguro y cómodo; el precio es que el GC consume CPU y puede meter pausas, y que igualmente puedes tener fugas "lógicas" si sin querer mantienes referencias vivas (un array global que no paras de llenar).

El out of memory de PHP, explicado

Cuando ves Allowed memory size of N bytes exhausted, casi siempre es esto: estás acumulando en el heap (típicamente cargando en memoria un dataset enorme, o un array que crece en un bucle) más rápido de lo que se libera, y chocas contra el límite memory_limit. No es el stack; es el heap llenándose. La cura suele ser no cargarlo todo de golpe (procesar en streaming, por lotes) en vez de subir el límite.

6. Stack vs heap, cara a cara

Stack Heap
Qué guarda Variables locales, llamadas a funciones Objetos, datos dinámicos, cosas que sobreviven a la función
Tiempo de vida Atado al scope (muere al return) Arbitrario (vive hasta que se libera)
Quién lo gestiona Automático (apilar/desapilar) Manual (C) o garbage collector (PHP, Java…)
Velocidad Rapidísimo (mover un puntero) Más lento (buscar hueco)
Tamaño Pequeño y fijo (unos MB) Grande (casi toda la RAM del proceso)
Cómo peta stack overflow (demasiadas llamadas) out of memory (fuga / acumular sin liberar)
Tamaño conocido En tiempo de compilación En tiempo de ejecución

La regla mental: ¿este dato tiene que sobrevivir a la función que lo crea, o su tamaño depende de la ejecución? Si no → stack. Si sí → heap. En lenguajes con GC no eliges explícitamente (el runtime decide, y suele poner los objetos en el heap y los tipos primitivos locales en el stack), pero entender la diferencia te explica el coste de lo que escribes.

Cómo se ve en la práctica

Un caso real que le pasa a todo el mundo: un script PHP que exporta a CSV todos los pedidos de la base de datos.

// ❌ Versión que revienta con muchos pedidos
$pedidos = $repo->findAll();        // trae 2 millones de filas... AL HEAP, de golpe
foreach ($pedidos as $p) {
    fputcsv($salida, $p->toArray());
}
// → "Allowed memory size exhausted": el array $pedidos no cabe en el heap
// ✅ Versión que aguanta cualquier volumen
foreach ($repo->iterate() as $p) {  // trae los pedidos de uno en uno (streaming)
    fputcsv($salida, $p->toArray()); // cada $p vive un instante y se libera
}
// → memoria plana: en el heap solo hay un pedido a la vez

Las dos hacen "lo mismo" a ojos del lenguaje. La diferencia es puramente modelo de ejecución: la primera intenta materializar 2 millones de objetos en el heap simultáneamente; la segunda mantiene uno vivo cada vez. Sin este modelo mental, la solución ("usa un iterador / cursor") es un truco memorizado. Con él, es la consecuencia obvia de cómo funciona el heap.

Lo que sacrificas / errores comunes

  • Confundir "referencia" con "el objeto": en PHP, $b = $a con objetos copia la referencia, no el objeto; los dos apuntan al mismo heap. Cambiar por $b cambia lo que ve $a. Fuente inagotable de bugs. (clone es lo que copia de verdad.)
  • Creer que stack overflow y out of memory son lo mismo: son zonas distintas que se agotan por causas distintas. Diagnosticar bien empieza por saber cuál petó.
  • Pensar que el GC te exime de pensar en memoria: te quita el free manual, no las fugas lógicas. Un static $cache = [] que no paras de llenar es una fuga en toda regla, con GC y todo.
  • Sobre-optimizar por intuición: "el stack es más rápido, meto todo ahí" no es una decisión que tomes en PHP/Java, y en C te lleva a bugs peores. El objetivo aquí es entender el coste, no micro-optimizar a ciegas.

Resumen

  1. La CPU es un ejecutor tonto y veloz que repite buscar–decodificar–ejecutar; la memoria es una fila de bytes numerados (direcciones). Código y datos son ambos bytes en direcciones.
  2. Un proceso = su espacio de direcciones (su memoria) + su estado de ejecución (PC + registros). Del mismo programa puede haber muchos procesos aislados.
  3. La memoria del proceso está en regiones: código, datos, heap y stack.
  4. El stack es rápido y automático, guarda variables locales y llamadas; los datos mueren al salir de la función. Se agota con demasiadas llamadas (stack overflow).
  5. El heap es flexible, guarda lo que sobrevive a la función o crece dinámicamente; lo liberas tú (C) o un GC (PHP/Java). Se agota acumulando sin liberar (out of memory).

Ejercicios socráticos

No busques la respuesta fuera. Razónala con el modelo de esta lección. Si te atascas, la pista está en el texto.

  1. Una función recursiva que se llama a sí misma 10 millones de veces sin condición de parada, ¿peta por stack overflow o por out of memory? ¿Y un bucle while(true) que en cada vuelta hace $datos[] = new Objeto()? Explica por qué cada uno peta de una zona distinta.
  2. En PHP haces $a = new Carrito(); $b = $a; $b->añadir($item);. Después, ¿$a tiene el item? Dibuja qué hay en el stack y qué hay en el heap para justificarlo.
  3. ¿Por qué crees que el stack tiene un tamaño fijo y pequeño (unos MB) mientras que el heap puede ocupar casi toda la RAM? Piensa en cómo se gestiona cada uno: ¿qué haría difícil que el stack fuera enorme y dinámico?
  4. Devolver desde una función la dirección de una variable local es un bug clásico en C. Con lo que sabes del stack, ¿qué ha pasado con esa dirección para cuando quien llamó intenta usarla?
  5. Tu compañero "arregla" un out of memory subiendo memory_limit de 256M a 4G. El script vuelve a fallar con más datos un mes después. ¿Por qué era un parche y no un arreglo? ¿Qué atacarías tú?

Repaso espaciado

Pasa estas a Anki (repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.

  • [ ] ¿Qué dos cosas componen, en esencia, un proceso? ¿Por qué dos procesos del mismo programa no comparten memoria?
  • [ ] ¿Por qué reservar en el stack es más rápido que reservar en el heap? (Responde en términos de qué operación física hace cada uno.)
  • [ ] Distingue stack overflow de out of memory: qué zona se agota en cada uno y qué patrón de código provoca cada uno.
  • [ ] ¿Qué guarda un stack frame y qué le pasa cuando la función hace return?
  • [ ] En un lenguaje con GC, ¿por qué sigues pudiendo tener fugas de memoria? Da un ejemplo.

Para seguir tirando del hilo

  • Computer Systems: A Programmer's Perspective (Bryant & O'Hallaron), cap. 1 y 9 — la referencia para este modelo, con rigor.
  • Operating Systems: Three Easy Pieces (Arpaci-Dusseau) — gratis online; la parte de Virtualization profundiza en el espacio de direcciones y la memoria virtual.
  • Experimento: escribe la función recursiva infinita en PHP y en C y observa cómo peta cada una. Luego el bucle que llena un array. Ver el error con tus ojos asienta esto más que releerlo.