Atlasingeniería

Algoritmos y programaciónRecursiónTema 2

Qué guarda la pila de llamadas

Cada llamada pausada ocupa un marco con sus datos y el punto al que debe volver. Ver esa pila explica la recursión, los stack traces y por qué un programa correcto puede morir con una entrada grande.

Para este tema conviene tener claro:Pensar en recursivo

Cuando una función llama a otra, la primera no desaparece: queda pausada a mitad de una línea, esperando un resultado. El programa tiene que recordar sus variables y el punto exacto donde retomar. Esa memoria ordenada de trabajo pendiente es la pila de llamadas, y entenderla explica tres cosas que parecen distintas: cómo funciona la recursión, cómo se lee un stack trace y por qué un programa correcto puede morir con una entrada grande.

Qué hay en un marco

Cada invocación crea un marco (stack frame). Conceptualmente guarda tres cosas: los parámetros, las variables locales y la dirección de retorno, que es el lugar del código que llamó y donde hay que seguir cuando esta función termine.

El marco nuevo se apila arriba. Cuando la función termina, su marco sale primero. Es una estructura LIFO: el último en entrar es el primero en salir.

Este es el programa que vamos a seguir durante todo el post:

def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)

print(factorial(4))

Así se ve la pila en el momento de mayor profundidad, cuando factorial(1) está por devolver:

factorial(1)n = 1ejecuta línea 3factorial(2)n = 2espera en línea 4factorial(3)n = 3espera en línea 4factorial(4)n = 4espera en línea 4<module>espera en línea 6CIMAentra y salepor acáBASE
La pila en su punto más alto. Sólo el marco de arriba ejecuta: los de factorial están pausados en la línea 4, esperando el resultado del marco que tienen encima, y el módulo espera en la línea 6.

Antes de seguir, predecí

factorial(4) y factorial(3) están los dos en la pila. ¿Comparten la variable n?

La pila, llamada por llamada

Seguir la pila paso a paso es la forma más segura de predecir qué hace un programa recursivo. Antes de ejecutarlo, una pregunta:

Antes de seguir, predecí

Justo cuando factorial(1) devuelve 1, ¿qué código se ejecuta a continuación?

Ahora comprobalo. Avanzá de a un paso con las flechas: a la izquierda se marca la línea que se ejecuta y en gris las líneas donde quedaron pausados los marcos de abajo; a la derecha, cada marco con su n. Cambiá n para ver cómo crece la pila.

Escena 1 — La pila crece y se vacía

paso a paso

Cargando la escena…

En la ida la pila crece un marco por llamada; en la vuelta cada marco retoma con su propia n, multiplica y sale.

Profundidad y espacio

Lo que ocupa memoria no es la cantidad total de llamadas, sino cuántos marcos conviven al mismo tiempo. Esa cantidad máxima se llama profundidad de recursión:

pilamarco×profundidad\text{pila} \approx \text{marco} \times \text{profundidad}

La diferencia entre “llamadas totales” y “profundidad” es la que más se confunde. Mirá Fibonacci en su versión ingenua:

def fibonacci(n):
    return n if n <= 1 else fibonacci(n - 1) + fibonacci(n - 2)

fibonacci(5) hace 15 llamadas en total, y ese número crece como O(2n)O(2^n). Pero la segunda rama recién empieza cuando la primera ya terminó y sacó sus marcos de la pila.

Antes de seguir, predecí

Contando sólo los marcos de fibonacci, ¿cuántos hay como máximo en la pila al calcular fibonacci(5)?
AlgoritmoLlamadas totalesProfundidad máxima
factorial(n)nO(n)
Búsqueda binaria recursivaO(log n)O(log n)
fibonacci(n) ingenuoO(2ⁿ)O(n)
Recorrido de árbol balanceadonO(log n)
Recorrido de árbol degeneradonO(n)
El tiempo mira todas las llamadas; el espacio de pila, sólo el camino más largo.

Cuando no entra otro marco

La pila tiene un tamaño fijo. Si la recursión no termina, o termina pero demasiado profunda, llega un momento en que no entra otro marco y el programa aborta: es el desbordamiento de pila (stack overflow). Python se ataja antes con su propio límite de profundidad y lanza RecursionError.

La causa más común no es una entrada gigante, sino un caso base que nunca se alcanza:

def factorial(n):
    if n == 0:
        return 1
    return n * factorial(n - 1)

factorial(-1)

El otro caso es la recursión correcta pero profunda: recorrer recursivamente una lista enlazada de cien mil nodos necesita cien mil marcos, y CPython corta en mil por defecto. Si la profundidad depende de datos que no controlás, conviene una versión iterativa.

Leer un stack trace

Un stack trace es una foto de la pila en el momento del error. Esto es lo que imprime Python con el factorial(-1) de arriba, simplificado:

Traceback (most recent call last):
  File "factorial.py", line 6, in <module>
    factorial(-1)
  File "factorial.py", line 4, in factorial
    return n * factorial(n - 1)
  File "factorial.py", line 4, in factorial
    return n * factorial(n - 1)
  File "factorial.py", line 4, in factorial
    return n * factorial(n - 1)
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

Most recent call last: Python lo imprime con la llamada más reciente abajo. Arriba está <module>, la base de la pila; cada bloque siguiente es un marco más arriba, y justo antes del mensaje de error está el marco donde se rompió. Java y Node lo imprimen al revés, con el más reciente primero. En un error normal, buscá el último marco que apunte a tu código, no a una librería.

La pila en sistemas reales

Cada lenguaje decide cuánta pila hay y qué pasa al pasarse. Los números son órdenes de magnitud, porque dependen de la plataforma y de cuánto ocupa cada marco:

EntornoLímite típicoQué pasa al pasarseCómo se ajusta
Python (CPython)1.000 niveles por defectoRecursionErrorsys.setrecursionlimit, con riesgo de que el intérprete se caiga
Node.js (V8)Unos 10.000 marcos simplesRangeError: Maximum call stack size exceededFlag --stack-size, poco recomendable
Java (JVM)Según la pila del hilo, del orden de 1 MBStackOverflowError-Xss, o el tamaño al crear un Thread
GoLa pila de cada goroutine crece sola, hasta 1 GB en 64 bitsfatal error: stack overflowdebug.SetMaxStack
C / C++La pila del hilo, suele ser 8 MB en LinuxSegmentation fault, sin mensajeulimit -s o atributos del hilo
Más a fondo · nivel seniorRecursión de cola: por qué no conviene contar con ella

Una llamada está en posición de cola cuando no queda trabajo pendiente después de ella: return f(x) sí, return n * f(x) no. En ese caso un compilador puede reutilizar el marco actual en lugar de apilar uno nuevo, y la recursión usa espacio constante.

El problema es que esa garantía depende del entorno:

  • Python: CPython no la hace, por decisión de diseño, para conservar stack traces completos.
  • JavaScript: el estándar ES2015 la exige, pero en la práctica sólo la implementa Safari. V8, y por lo tanto Node y Chrome, no.
  • Scala y Kotlin: @tailrec y tailrec convierten la función en un ciclo al compilar, y fallan la compilación si la llamada no está realmente en posición de cola. Ahí sí es una garantía.
  • Scheme: la exige la especificación del lenguaje.

Consecuencia práctica: si el código tiene que correr en varios runtimes, escribir “de cola” no protege de nada. Un ciclo explícito sí.

Un detalle más para sistemas con muchos hilos: cada hilo tiene su propia pila. Mil hilos de plataforma en la JVM reservan del orden de mil megas de memoria virtual sólo en pilas. Es una de las razones detrás de las goroutines de Go y los hilos virtuales de Java, cuyas pilas empiezan chicas y crecen a demanda.

Cierre

Autoevaluación

¿Lo entendiste?

¿Qué guarda un marco de pila?
La versión ingenua de fibonacci(n) hace O(2ⁿ) llamadas. ¿Cuánto espacio de pila usa?
Un traceback muestra la misma función en la misma línea repetida cientos de veces. ¿Qué es lo más probable?
Escribiste factorial en Python con recursión de cola. ¿Evita el RecursionError con n = 100.000?