La pila de llamadas por dentro
En resumen: La pila de llamadas guarda un marco por cada llamada que no ha terminado; cuando la recursión es demasiado profunda se agota y el proceso muere con StackOverflowException.
Problema
El catálogo que tumba la página
El menú de una tienda en línea es un árbol: categorías, subcategorías dentro de ellas, unos cuantos productos en cada una. Junto al nombre dice cuántos productos hay, subcategorías incluidas: «Electrónica (10)». Un método recursivo los cuenta, como en la lección «Recursión»:
static int CountProducts(Category category){ int total = category.Products; foreach (Category child in category.Children) { total += CountProducts(child); } return total;}
var phones = new Category("Teléfonos", 5);var laptops = new Category("Portátiles", 3);var electronics = new Category("Electrónica", 2).Add(phones, laptops);var kitchen = new Category("Cocina", 6);var home = new Category("Hogar", 4).Add(kitchen);var shop = new Category("Tienda", 0).Add(electronics, home);Console.WriteLine("Productos: " + CountProducts(shop));Productos: 20Funciona durante meses. Entonces llegan dos importaciones nuevas de proveedores. La primera pone cada categoría nueva dentro de la anterior: la cadena tiene 100 000 niveles de profundidad. La segunda, por error, pone «Hogar» dentro de su propia subcategoría «Cocina». En los dos casos la página falla con Stack overflow., y el proceso del servidor muere.
El código es el mismo, y es correcto. ¿Qué se agotó?
La idea
Lo que cuesta una llamada
Cada llamada recibe su propia memoria en la pila. Se llama marco (stack frame) y guarda tres cosas:
- los parámetros: aquí
category, la categoría que cuenta esta llamada; - las variables locales:
totalychild, hasta dónde ha llegado el bucle; - el punto de retorno: qué línea del que llama continúa cuando esta llamada termina.
El marco vive hasta que la llamada vuelve. Entonces se tira y su lugar se reutiliza. El número de marcos vivos a la vez es la profundidad de la recursión. No es el número de llamadas: el recorrido llama a CountProducts 6 veces, pero hay como máximo 3 marcos a la vez.
En la lección de recursión ya viste la pila de marcos del factorial. Cada llamada tiene su propio n y espera la respuesta de la que está encima:
static int Factorial(int n){ if (n == 1) { return 1; } return n * Factorial(n - 1);}
Console.WriteLine(Factorial(3)); // 6Paso a paso, en palabras
- Main llama a Factorial(3).
- Memoria nueva para Factorial con n = 3.
- 3 no es 1, así que el método llama a Factorial(2) y espera la respuesta.
- Lo mismo otra vez: Factorial(2) llama a Factorial(1). Cada llamada tiene su propio n.
- n es 1: este es el caso base. Devuelve 1 sin volver a llamar.
- La respuesta baja: 2 * 1 = 2.
- Y otra vez: 3 * 2 = 6.
- Factorial(3) devuelve 6 y Main lo imprime.
Míralo
Los marcos del recorrido
Ahora lo mismo con el árbol de la tienda. Mira las ranuras de cada marco y la línea en la que espera el marco de abajo:
static int CountProducts(Category category){ int total = category.Products; foreach (Category child in category.Children) { total += CountProducts(child); } return total;}
Console.WriteLine("Productos: " + CountProducts(shop));Paso a paso, en palabras
- Main llama a CountProducts(shop). Por ahora en la pila solo está el marco de Main.
- Un marco nuevo: el parámetro category = Tienda, la local total = 0 y el punto de retorno, a dónde volver cuando termine.
- El bucle llega a child = Electrónica y se llama a sí mismo. El marco de Tienda se detiene en la línea 6 y espera; el marco nuevo recuerda esa línea.
- Teléfonos no tiene subcategorías: el bucle no se ejecuta. Es el caso base. Hay 3 marcos del recorrido vivos a la vez: la profundidad es 3.
- return 5: el marco se tira. Electrónica continúa desde la línea 6: total = 2 + 5 = 7.
- Portátiles ocupa el mismo lugar en la pila y devuelve 3. total = 7 + 3 = 10.
- Electrónica devuelve 10. El marco de Tienda despierta donde se detuvo: total = 10.
- El siguiente hijo: Hogar, luego Cocina. Otra vez 3 marcos, en los mismos lugares.
- Cocina devuelve 6, Hogar devuelve 4 + 6 = 10, y Tienda llega a 10 + 10 = 20.
- 20 productos. 6 llamadas, pero nunca más de 3 marcos del recorrido a la vez: la pila crece con la profundidad del árbol, no con el número de categorías.
Cuando «Teléfonos» vuelve, su marco desaparece, y «Portátiles» ocupa el mismo lugar. La memoria del recorrido crece con el camino más largo del árbol, no con el número de categorías. Para una cadena de 100 000 categorías el camino más largo es 100 000.
Pruébalo
¿Cuántos marcos a la vez?
Aquí CountProducts cuenta cuántos de sus marcos están vivos (depth) y recuerda el valor mayor (deepest). El árbol es el mismo: Tienda, Electrónica con Teléfonos y Portátiles, Hogar con Cocina.
static int depth = 0;static int deepest = 0;
static int CountProducts(Category category){ depth++; deepest = Math.Max(deepest, depth); int total = category.Products; foreach (Category child in category.Children) { total += CountProducts(child); } depth--; return total;}
int total = CountProducts(shop);Console.WriteLine($"{total} productos, como máximo {deepest} marcos a la vez");depth sube uno al entrar y baja uno justo antes de volver. El punto más profundo está en Teléfonos, Portátiles y Cocina: Tienda, el padre y la propia categoría, 3 marcos. Seis es el número de llamadas, no de marcos a la vez.
La idea
Por qué se agota la pila
La pila es un bloque de memoria aparte para cada hilo, y no crece sola: en Java, C# y Node es del orden de 1 MB, y Python cuenta marcos y se detiene en 1000. Un marco es pequeño, de decenas a cien bytes, pero la cadena de la importación necesita 100 000 marcos a la vez. chain(n) construye una cadena así: n categorías, cada una dentro de la anterior, un producto cada una.
var small = Chain(900);Console.WriteLine($"Profundidad 900: {CountProducts(small)} productos");var deep = Chain(100_000);var worker = new Thread(() =>{ try { Console.WriteLine($"Profundidad 100000: {CountProducts(deep)} productos"); } catch (InsufficientExecutionStackException e) { Console.WriteLine("Profundidad 100000: " + e.GetType().Name); }}, 1024 * 1024);worker.Start();worker.Join();Profundidad 900: 900 productosProfundidad 100000: InsufficientExecutionStackExceptionEn C# un desbordamiento real no se puede capturar: el proceso imprime Stack overflow., una lista de marcos, y se detiene, junto con todo el servidor. Ningún catch lo puede salvar. Por eso el programa de arriba no lo provoca de verdad. La primera línea de CountProducts es una protección: RuntimeHelpers.EnsureSufficientExecutionStack() lanza InsufficientExecutionStackException cuando la pila está casi llena, y esa excepción sí se puede capturar. El recorrido se ejecuta en un hilo con una pila de 1 MB (el segundo argumento de new Thread), para que se comporte igual en cualquier máquina: en Linux el hilo principal tiene 8 MB y cabrían más niveles.
static int CountProducts(Category category){ RuntimeHelpers.EnsureSufficientExecutionStack(); int total = category.Products; foreach (Category child in category.Children) { total += CountProducts(child); } return total;}La memoria que necesita el recorrido es O(profundidad), como en la lección «Complejidad en espacio». En un árbol equilibrado la profundidad es pequeña. En una cadena es n.
Míralo
El error cae por cada marco
El desbordamiento ocurre en el marco de arriba del todo, 100 000 niveles más arriba. La protección lanza InsufficientExecutionStackException, y cae como cualquier excepción: un marco sin catch se tira y la excepción sigue bajando. Así cayó la excepción al retirar dinero por los marcos en la lección «Lanzar excepciones y crear las tuyas»:
public void Withdraw(int amount){ if (amount > Balance) { throw new ArgumentException("No alcanza el dinero"); } Balance -= amount;}static void PayRent(Account account){ account.Withdraw(800); Console.WriteLine("El alquiler está pagado.");}
try{ PayRent(new Account(500));}catch (ArgumentException e){ Console.WriteLine("Rechazado: " + e.Message);}Paso a paso, en palabras
- Main llama a PayRent para una cuenta con 500 €.
- PayRent llama a Withdraw(800): un marco nuevo encima.
- 800 > 500: el método lanza una excepción.
- El saldo no cambia: el marco se descarta sin terminar sus líneas.
- PayRent no tiene catch: «El alquiler está pagado.» no se imprime, el marco se descarta.
- catch en Main atrapa la excepción y el programa sigue desde aquí.
Cuando el error llega a catch, los 100 000 marcos ya no están y la pila vuelve a estar vacía. Por eso el programa de arriba sigue y escribe. Un StackOverflowException real no cae así: el proceso se detiene en el acto.
La idea
El ciclo: el caso base nunca llega
¿Dónde está el caso base de CountProducts? No hay ningún if. El caso base es una categoría sin subcategorías: el bucle no se ejecuta ni una vez, y el método devuelve sus propios productos.
La segunda importación puso «Hogar» dentro de su propia «Cocina». Ahora el camino Tienda → Hogar → Cocina → Hogar → Cocina → … no tiene fin. Toda categoría en él tiene una subcategoría, así que el caso base nunca llega:
kitchen.Add(home);try{ Console.WriteLine("Productos: " + CountProducts(shop));}catch (InsufficientExecutionStackException e){ Console.WriteLine("Con el ciclo: " + e.GetType().Name);}Con el ciclo: InsufficientExecutionStackExceptionEsto es un caso base que nunca se alcanza. Está en el código, pero la entrada nunca lleva a él. El árbol viene de fuera (una importación, una API, una base de datos), así que el recorrido tiene que sobrevivir tanto a un árbol profundo como a uno circular. Cómo, está en la siguiente lección, «De la recursión al bucle».
La idea
Dos casos base
A veces hay más de un motivo para parar. Find busca una categoría por nombre y tiene dos casos base:
- encontrada: el nombre coincide, y el método devuelve la categoría sin seguir bajando;
- más abajo no hay nada: ninguna subcategoría la contiene, y el método devuelve
null.
static Category? Find(Category category, string name){ if (category.Name == name) { return category; } foreach (Category child in category.Children) { Category? found = Find(child, name); if (found != null) { return found; } } return null;}
foreach (string name in new[] { "Cocina", "Jardín" }){ Category? found = Find(shop, name); if (found == null) { Console.WriteLine(name + ": no existe esa categoría"); } else { Console.WriteLine($"{name}: {found.Products} productos"); }}Cocina: 6 productosJardín: no existe esa categoríaEn cuanto un hijo encuentra la categoría, la respuesta sube directamente por todos los marcos, sin recorrer a los demás hijos.
Pruébalo
Completa
¿Qué devuelve Find cuando la categoría no está en ninguno de los subárboles?
static Category? Find(Category category, string name)
{
if (category.Name == name)
{
return category;
}
foreach (Category child in category.Children)
{
Category? found = Find(child, name);
if (found != null)
{
return found;
}
}
return ;
}Este es el segundo caso base. Si ningún hijo encuentra el nombre, la respuesta es «ninguna»: null. Quien llama lo comprueba y pasa al siguiente hijo.
Pruébalo
Encuentra el error
La tienda vende a plazos. Months(150, 50) devuelve 3 meses. Months(130, 50) también debería devolver 3 (50 €, 50 € y 30 €), pero mata el proceso con Stack overflow.. ¿Qué línea está mal?
Elige la línea con el error.
El caso base es «la deuda es exactamente 0». Con 130 € la deuda va 80, 30, −20, −70 y nunca es 0: el caso base existe, pero la entrada salta sobre él. La corrección es debt <= 0: el caso base también atrapa el salto. Comparar con un número exacto es un caso base seguro solo cuando todo paso pasa seguro por él.
Ejercicio
El camino de migas
Resuélvelo en tu editor.
El camino hasta la categoría. Sobre los productos la tienda muestra el camino hasta la categoría: «Tienda > Hogar > Cocina». Escribe un método recursivo Path(category, name) que devuelva la lista de nombres desde la raíz hasta la categoría que buscas, o null si no está. Los dos casos base son como en Find. Lee el nombre e imprime el camino.
CocinaTienda > Hogar > CocinaCon la entrada Jardín la salida es No existe esa categoría.
Más tareas
- La categoría más profunda. Escribe un
Depth(category)recursivo: una categoría sin hijos tiene profundidad 1; si no, es 1 + el hijo más profundo. Para el árbol de la lección es 3, para la cadena de la importación es 100 000. - Los plazos. Corrige
Monthsy ejecútalo con 130 y 50, y luego con 0 y 50. ¿Cuántos marcos abre cada llamada?
Más precisamente
Qué tan grande es la pila
La pila no es infinita, y no es igual en todas partes. Los números de abajo son valores por defecto, y se pueden cambiar.
Más precisamente, para curiosos
El tamaño. En .NET el hilo principal tiene unos 1 MB en Windows, y en Linux lo que da el sistema, normalmente 8 MB. new Thread(work, maxStackSize) le da a un hilo nuevo su propio tamaño. EnsureSufficientExecutionStack mira cuánto queda en la pila del hilo actual, así que la protección funciona con cualquier tamaño.
El tamaño de un marco. Cuántos marcos caben depende del marco: más parámetros y variables locales son más bytes. El compilador JIT puede hacer un marco más pequeño, así que el número de llamadas antes del desbordamiento no es un número exacto y cambia entre ejecuciones.
El árbol y la memoria de objetos. Las propias categorías no están en la pila. Son objetos en la memoria de objetos (el heap), y un marco guarda solo una referencia a uno de ellos. Por eso 100 000 categorías caben en la memoria sin problema, mientras que 100 000 marcos a la vez no.
Volver. El punto de retorno es la dirección de la instrucción donde continúa quien llama. En el procesador es literalmente una dirección de memoria, escrita en el marco. La escena la muestra como un número de línea.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Lambdas y delegados · Hilos · Big-O: cómo crece el trabajo · Complejidad en espacio
- Continúa en: De la recursión al bucle · Exponenciación rápida · Árbol binario de búsqueda · DFS: islas y ciclos
- Ejercicios sobre el tema: Recursión