wandres.dev
ARQUITECTO · Gestión de memoria avanzada

La pila como recurso

Cuánto mide realmente la pila de un hilo, qué cuesta una variable local grande, por qué alloca y los arrays de longitud variable son un vector de ataque, y cómo diseñar funciones dentro de un presupuesto de marco.

⏱ 17 min

La pila parece infinita porque casi nunca la agotas, y gratis porque reservar en ella cuesta restar a un registro. Ninguna de las dos cosas es cierta. Es un bloque de tamaño fijo decidido antes de que tu código empiece a correr, compartido con todo lo que llames, y su agotamiento no produce un error recuperable sino una señal que llega cuando ya no queda pila para manejarla. Tratarla como un recurso administrado, con presupuesto y medición, es lo que separa un programa que aguanta de uno que se cae con la entrada del cliente número tres mil.

🎯 Al terminar esta lección sabrás
  • Determinar el tamaño real de la pila del hilo principal y de los hilos creados, y cómo fijarlo.
  • Medir el consumo de marco de cada función y ponerle un límite verificado por el compilador.
  • Explicar por qué alloca y los arrays de longitud variable convierten una entrada externa en un salto del puntero de pila.
  • Diseñar rutinas con presupuesto acotado usando búfer pequeño con reserva de respaldo.

El tamaño real de una pila

No existe “la pila del proceso”: existe una pila por hilo, y sus tamaños se deciden en momentos distintos. La del hilo principal la fija el núcleo al ejecutar el programa a partir del límite RLIMIT_STACK, que en Linux suele valer ocho mebibytes y es consultable con ulimit -s. Los hilos creados con pthread_create reciben una región reservada con mmap cuyo tamaño sale del atributo correspondiente, con un valor por omisión que en glibc hereda el mismo límite. macOS es más severo: ocho mebibytes para el hilo principal pero solo quinientos doce kibibytes para los secundarios. En Windows, el tamaño de reserva por defecto lo graba el enlazador en la cabecera del ejecutable y ronda un mebibyte.

ulimit -s                       # KiB de pila del hilo principal
cat /proc/self/limits           # limite efectivo del proceso en curso
pthread_attr_t at;
pthread_attr_init(&at);
pthread_attr_setstacksize(&at, 256 * 1024);   // presupuesto explicito por hilo
pthread_attr_setguardsize(&at, 4096);         // pagina centinela al final
pthread_create(&hilo, &at, trabajo, nullptr);

Esa aparente trivialidad tiene consecuencias de arquitectura. Un servidor con diez mil hilos y ocho mebibytes por hilo reserva ochenta gibibytes de espacio de direcciones; sobrevive solo porque las páginas se comprometen al tocarse, pero en sistemas de treinta y dos bits el espacio virtual simplemente no da, y ahí nació buena parte de la presión hacia modelos de concurrencia con pilas pequeñas. Bajar el tamaño a doscientos cincuenta y seis kibibytes multiplica por treinta y dos el número de hilos posibles y divide por treinta y dos la profundidad de recursión tolerable. Es un compromiso explícito, y merece decidirse a propósito.

Al final de cada pila el sistema coloca una página centinela sin permisos. Desbordar suavemente la toca y produce SIGSEGV, indistinguible a simple vista de desreferenciar un puntero nulo, con el agravante de que el manejador de señales necesitaría pila para ejecutarse y ya no queda. Detectar un desbordamiento de forma limpia exige una pila alternativa para señales.

static char pila_senal[SIGSTKSZ];
stack_t ss = { .ss_sp = pila_senal, .ss_size = sizeof pila_senal };
sigaltstack(&ss, nullptr);
// y registrar el manejador de SIGSEGV con SA_ONSTACK

El coste de una variable local

Una local grande no cuesta tiempo de asignación —el prólogo resta al puntero de pila una constante, independientemente de si son ocho bytes o cien mil—, pero consume presupuesto para todo lo que la función llame por debajo. El error clásico es un búfer de sesenta y cuatro kibibytes en una función que se invoca desde una recursión, o en una hoja de un árbol de llamadas profundo.

Lo bueno es que el consumo es medible en compilación, función por función.

gcc -std=c23 -O2 -fstack-usage prog.c     # genera prog.su con bytes por funcion
gcc -std=c23 -O2 -Wframe-larger-than=4096 prog.c
gcc -std=c23 -O2 -Wstack-usage=16384 prog.c

El fichero con extensión .su lista cada función, su consumo de marco y si ese consumo es estático, dinámico o acotado. Convertir -Wframe-larger-than en error dentro del sistema de compilación es una de las políticas más rentables que existen: cuesta un minuto y captura para siempre la clase entera de fallos por local desmedida.

Hay dos factores que distorsionan la lectura ingenua. El primero es el inlining: al integrar una función en su llamante, los marcos se fusionan y el consumo aparece atribuido a otro sitio, así que medir con -O0 da números que no tienen nada que ver con los de producción. El segundo es que el compilador puede solapar en el mismo espacio locales de ámbitos disjuntos, de modo que dos búferes grandes en ramas distintas de un if a menudo cuestan uno solo. Ninguna de las dos cosas está garantizada por el lenguaje.

📐

Marco estático

Tamaño conocido en compilación. Auditable con -fstack-usage y limitable con avisos.

🌀

Marco dinámico

Depende de datos en ejecución. alloca y arrays de longitud variable. No auditable.

🧵

Presupuesto por hilo

Profundidad máxima igual a tamaño de pila dividido por marco medio. Es aritmética, no suerte.

🚧

Página centinela

Detecta el desbordamiento suave. No detecta el salto por encima de ella.

alloca y los arrays de longitud variable

alloca reserva en el marco actual moviendo el puntero de pila en ejecución. No pertenece a ISO C, no está en POSIX, y su lista de defectos es lo bastante larga como para que su propia página de manual desaconseje usarla.

No puede fallar de forma detectable: si no hay espacio, no devuelve nulo, corrompe. Su memoria vive hasta que retorna la función, no hasta el final del bloque, de modo que un alloca dentro de un bucle acumula reservas en cada iteración hasta agotar la pila sin que nada lo señale. Interactúa mal con el inlining, porque integrar en un bucle una función que la usa transforma un consumo acotado en uno ilimitado. Y hace imposible el análisis estático de consumo de pila, que es justo la herramienta con la que se defiende uno.

void mal(size_t n) {
    char *p = alloca(n);       // n viene de fuera: el usuario mueve tu puntero de pila
    ...
}

void tambien_mal(size_t n) {
    char buf[n];               // array de longitud variable: el mismo problema con sintaxis limpia
    ...
}

Los arrays de longitud variable comparten la patología con mejor sintaxis y algo más de disciplina de ámbito. Se incorporaron en C99 como obligatorios, pasaron a opcionales en C11 —detectables con la macro __STDC_NO_VLA__—, y C23 mantiene opcional el almacenamiento automático de tamaño variable aunque exige soportar los tipos de dimensión variable como parámetros, que sirven para documentar dimensiones sin reservar nada.

El ataque concreto se llama stack clash y merece entenderse. La página centinela detecta que la pila crece un poco de más, pero si una sola reserva la salta entera —porque el tamaño solicitado supera el tamaño de la página—, el puntero de pila aterriza en otra región válida del mapa de memoria sin haber tocado la centinela nunca. A partir de ahí, escribir en la supuesta pila corrompe el montón o una biblioteca cargada. La defensa del compilador consiste en emitir sondas página a página al bajar el puntero.

gcc -std=c23 -O2 -fstack-clash-protection -Wvla -Walloca prog.c

Que el núcleo de Linux erradicara los arrays de longitud variable de su código a partir de 2018 no fue una preferencia estética: eran a la vez un riesgo de seguridad, un obstáculo para el análisis de consumo y, medidos, más lentos que un búfer fijo por el código de gestión que el compilador debe generar.

flowchart TD
A[Tamano conocido en compilacion] --> B[Local fija en el marco]
C[Tamano en ejecucion] --> D{Cota superior conocida}
D -->|Si| E[Buffer fijo del tamano de la cota]
D -->|No| F{Cabe en un buffer pequeno}
F -->|Si| G[Buffer pequeno en la pila]
F -->|No| H[Reserva en el monton con liberacion explicita]
style H fill:#a6e3a1,color:#11111b

Diseñar dentro del presupuesto

El patrón que sustituye a alloca sin perder su ventaja se conoce como optimización de búfer pequeño: una local de tamaño fijo cubre el caso común sin tocar el montón, y solo el caso raro paga una reserva dinámica.

bool procesar(const char *entrada, size_t n) {
    char  fijo[256];
    char *buf = fijo;
    if (n > sizeof fijo) {
        buf = malloc(n);
        if (buf == nullptr) return false;   // el fallo SI es detectable
    }
    bool ok = trabajar(buf, entrada, n);
    if (buf != fijo) free(buf);
    return ok;
}

Este código tiene tres virtudes que alloca no puede ofrecer: el consumo de pila es constante y auditable, el fallo por falta de memoria es un valor de retorno en lugar de una corrupción, y no hay forma de que una entrada externa determine cuánto baja el puntero de pila. El precio es una rama y un free condicional, que es exactamente el tipo de precio que conviene pagar.

Para el resto, el criterio es aritmético. Si conoces el tamaño de pila y el consumo por marco, conoces la profundidad máxima; si no puedes acotar la profundidad —recursión sobre datos que vienen de fuera, como un analizador de JSON anidado—, la recursión no es una técnica admisible y toca convertirla en un bucle con pila explícita en el montón, donde agotarse es un error que puedes devolver. Y donde la profundidad sea inevitable, existe la opción de vigilar activamente: comparar la dirección de una local con un límite calculado al arrancar el hilo cuesta dos instrucciones y convierte el desbordamiento en un error tratable.

La pila es un asignador que decidiste no administrar

Piensa qué es realmente la pila y verás que llevas todo el curso usando un asignador sin llamarlo así. Reserva en tiempo constante, libera en tiempo constante, no fragmenta jamás, tiene localidad perfecta porque siempre trabajas en las mismas líneas de caché calientes, y no necesita metadatos. Es, medido por rendimiento puro, el mejor asignador que tendrás nunca. Lo que lo hace posible es exactamente la restricción que estudiaste en las arenas: un orden de vida estrictamente anidado. La pila es una arena con marcas de posición automáticas, donde el prólogo toma la marca y el epílogo la restaura, y el compilador lo escribe por ti. Que sea invisible es una victoria del diseño del lenguaje, pero también la razón de que se administre mal: lo que no se nombra no se mide, y lo que no se mide se agota. De ahí sale la reformulación que cambia la práctica: el tamaño de pila es un presupuesto que alguien fijó, y si no fuiste tú, fue un valor por omisión que no sabía nada de tu programa. Cuando lo asumes, decisiones que parecían de estilo se vuelven de ingeniería. Una recursión sobre datos externos no es elegante ni inelegante: es una petición de memoria sin cota superior expuesta a un atacante. Un búfer local grande no es derroche: es una hipoteca sobre la profundidad de todo lo que esa función llame. Y alloca no es una herramienta prohibida por superstición, sino una que traslada al usuario el control de un recurso que no tiene forma de fallar limpiamente. La regla general que queda es más ancha que C: todo recurso finito o tiene un dueño que lo presupuesta y lo mide, o tiene un día en que se acaba en el peor momento posible.

⚔️ Ponle números a tu pila
  1. Averigua el tamaño real de la pila de tu hilo principal y el de un hilo creado con pthread_create, e imprímelos comparando direcciones de locales al entrar y en profundidad.
  2. Compila un módulo tuyo con -fstack-usage y -O2, ordena el fichero .su por consumo y explica las tres funciones más caras; repite con -O0 y justifica la diferencia.
  3. Añade -Wframe-larger-than=4096 y -Wvla a tu compilación y conviértelos en error; arregla lo que salte.
  4. Escribe una función con alloca dentro de un bucle y observa el crecimiento del consumo de pila; reescríbela con el patrón de búfer pequeño con respaldo y compara el consumo medido.
  5. Instala un manejador de SIGSEGV sobre una pila alternativa con sigaltstack y comprueba que puedes distinguir un desbordamiento de pila de una desreferencia nula.