Artículos en profundidad sobre la tecnología que da forma al futuro.

Goose: sistemas sin heap y lo que revela sobre Rust

Goose es un lenguaje de sistemas sin heap, sin GC y sin anotaciones de lifetime. Qué revela su diseño sobre Rust y las regiones.

Ilustración abstracta de un árbol de pequeños nodos conectados, dibujados como puntos brillantes unidos por arcos cortos, todos flotando dentro de una lámina plana y translúcida
Un árbol construido con enlaces auto-relativos significa los mismos bytes en cualquier dirección: la persistencia se convierte en memcpy.

Aquí va una afirmación que molestará al menos a dos comunidades de lenguajes: el borrow checker fue un desvío. No un error (el modelo de ownership de Rust resolvió problemas reales e impulsó a toda la industria), sino un desvío frente a una idea más antigua y sencilla que ahora estamos redescubriendo. Esa idea son las regiones: memoria reservada en grandes arenas tipo pila, que se libera de golpe cuando termina un ámbito. El lenguaje Goose, un nuevo lenguaje de sistemas que está dando que hablar esta semana, es la apuesta moderna más agresiva por esa idea que he visto, y su diseño plantea una pregunta incómoda: cuánto de lo que soportamos en C++ y Rust es realmente imprescindible y cuánto es solo el heap hablando.

Goose no tiene asignador de heap. Sin recolector de basura, sin conteo de referencias, sin destructores, sin anotaciones de lifetime, sin unsafe. Cada valor dinámico vive en línea en una de unas cuantas pilas de datos que el compilador asigna de forma estática. Crecer es mover un puntero. Liberar una estructura anidada de un millón de elementos es una sola escritura en la cima de la pila. El autor afirma que sus benchmarks van más rápido que C++ idiomático (3,3x) y que Rust seguro (1,12x) usando menos memoria. La reacción pública de John Carmack señaló justo la parte que más me interesa: las referencias relativas que convierten una estructura de datos en su propio formato de archivo. Tanto si Goose llega a usarse en software de producción como si no, es un experimento mental serio sobre cómo es un lenguaje de sistemas cuando eliminas el heap y no pides disculpas por ello.

El modelo de memoria es todo el lenguaje

La mayoría de las características de un lenguaje son negociables. La sintaxis, los sistemas de módulos, incluso el manejo de errores: puedes imaginar dos lenguajes razonables que tomen decisiones opuestas. Los modelos de memoria no son así. Eliges uno y todo lo que viene después queda forzado: tus estructuras de datos, tu historia de concurrencia, tu FFI, tus mensajes de error. Goose lo entiende y se inclina por completo en esa dirección.

La regla es fácil de enunciar: un programa tiene la pila de llamadas nativa, datos estáticos y N pilas de datos, donde el compilador calcula N. Como máximo hay un valor redimensionable vivo por pila, y siempre está en la cima. Esa única invariante da muchísimo. Como el valor que crece nunca se mueve (no hay nada encima que deba reubicarse), las referencias a un array en crecimiento siguen siendo válidas para siempre. En C++, un std::vector<T> que reasigna memoria invalida todos los punteros e iteradores que apuntan a él, por eso generaciones de programadores de C++ se han quemado al mantener una referencia a través de un push_back. Rust seguro evita el problema haciéndolo inexpresable: tendrías que usar índices u32 y pagarlo con comprobaciones de límites y ergonomía. Goose, sencillamente, te deja conservar la referencia. Las reglas de tipos y lifetimes se verifican en tiempo de compilación, y el compilador infiere de qué está "enraizada" cada referencia y rechaza las que podrían sobrevivir a sus propietarios, pero sin anotaciones, sin reglas de aliasing y sin exclusividad.

struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later

Si has trabajado con el borrow checker de Rust, esto parece un truco de magia. ¿Adónde fueron los lifetimes? La respuesta honesta: a una restricción. El sistema de ownership de Rust puede demostrar seguridad para patrones de compartición arbitrarios: grafos, ciclos, estructuras mutables compartidas entre ámbitos. Goose demuestra seguridad para un conjunto mucho más estrecho de patrones y sencillamente rechaza el resto. Ese intercambio (expresividad por simplicidad) es el más antiguo del diseño de lenguajes, y es el hilo del que voy a seguir tirando aquí. Si leíste nuestro artículo sobre qué significa realmente la soundness en el borrow checker de Rust, Goose es la otra cara de esa moneda: soundness lograda no con un análisis más potente, sino con un lenguaje más pequeño.

Todo en línea: la disposición de datos como característica de primera clase

La segunda gran idea es que no existe un único tipo de array. Goose tiene una familia de arrays que solo difieren en lo que ocurre con el tamaño: fijo en tiempo de compilación, congelado tras la construcción, crecible dentro de una capacidad en línea, solo crecible, crecible y reducible. Todos tienen la forma [metadatos][elementos...]: contiguos, compactos, nunca un puntero a un bloque de elementos. Las cadenas son simplemente arrays de u8, así que una estructura puede contener partes de tamaño variable en línea, en el orden de declaración. Un registro con un campo de cadena de longitud variable y una lista de subregistros de longitud variable es un solo bloque de bytes sin ninguna indirección.

Las cifras que se declaran llaman la atención: un registro de pedido que ocupa 29 bytes y cero asignaciones en Goose, frente a 160 bytes y una asignación con std::string + std::vector<Item> en C++, o 153 bytes y cuatro asignaciones en Rust. Ahora bien, en la discusión salió casi de inmediato una corrección justa: el ejemplo del tutorial de Goose sobre "un vector de cadenas" ignora la optimización de cadenas cortas (SSO), que todas las STL principales implementan. Las cadenas cortas en C++ no asignan memoria. Eso no tumba el argumento (SSO ayuda con cadenas de menos de unos 15–22 bytes y no hace nada en el caso general de datos anidados de tamaño variable), pero recuerda que las tablas de benchmarks en la página principal de un lenguaje son marketing, aunque el lenguaje sea bueno. Las pérdidas también aparecen, y eso es mérito del autor, más de lo que publican la mayoría de proyectos.

Hay un coste real, y Goose no lo oculta: un array de elementos de tamaño variable es secuencial. Puedes iterarlo, pero no indexarlo: el elemento k está donde caigan sus bytes. Si necesitas acceso aleatorio, mantienes un segundo array de referencias como índice. Es el tipo de compromiso que la gente del diseño orientado a datos lleva décadas haciendo a mano (básicamente así funcionan los formatos columnares y los diseños tipo FlatBuffers), pero aquí es el valor por defecto, y optar por salirse exige trabajo extra. Que eso sea una característica o una camisa de fuerza depende por completo de tus patrones de acceso.

Referencias relativas: tu estructura de datos es su formato de archivo

Mi idea favorita de Goose es la referencia relativa, y es en la que también se fijó Carmack. Una referencia normal es una dirección de máquina: ocho bytes, absolutos, sin sentido fuera del proceso. Una referencia relativa guarda el mismo enlace como un desplazamiento de 1, 2 o 4 bytes, medido desde el propio campo o desde la base de un pool con nombre. Las estructuras construidas con enlaces auto-relativos son independientes de la posición: significan lo mismo dondequiera que estén en memoria. Eso significa que guardar una estructura de datos es un write(), y cargarla es un read() más una pasada de verificación.

Esa última cláusula importa más de lo que parece. La versión ingenua de este truco (mapear en memoria un archivo lleno de punteros) es un desastre clásico de seguridad. La versión de Goose valida: from_bytes comprueba el encuadre, cada etiqueta, cada longitud y cada enlace antes de que los bytes se conviertan en un valor, así que un archivo corrupto o hostil devuelve false, no un puntero salvaje. Obtienes la velocidad de las estructuras mapeadas con mmap y algo parecido a la seguridad de un parser.

El planteamiento de Carmack era exacto: "'Parsear' datos de archivos y de red suele ser 100 veces más lento de lo necesario". Cualquiera que haya perfilado un camino caliente de deserialización lo sabe. La respuesta de la industria hasta ahora han sido los formatos zero-copy dirigidos por esquema (Cap'n Proto, FlatBuffers), que funcionan pero añaden un compilador de esquemas y una biblioteca de runtime a tu proyecto. La respuesta de Goose es que el diseño en memoria y el diseño en el cable son lo mismo por construcción, porque el lenguaje nunca tuvo punteros a direcciones absolutas. El ejemplo del árbol binario de búsqueda es casi descarado: nodos de 12 bytes, enlaces incluidos, insertar es un push y una escritura, y todo el árbol persiste simplemente escribiendo sus bytes. Los aficionados a Lisp reconocerán el movimiento filosófico (colapsar una distinción que todos daban por fundamental), que forma parte de por qué Lisp sigue importando como referencia de diseño seis décadas después.

Ya hemos estado aquí: regiones, arenas y ML Kit

La respuesta históricamente informada a Goose llegó puntual en los comentarios: "Recuerdo que los de ML fueron por este camino. Creo que lo llamaban regiones". Correcto. La gestión de memoria basada en regiones se remonta al trabajo de ML Kit en los años noventa: la inferencia de regiones de Tofte y Talpin permitía a un compilador de ML sustituir el recolector de basura por regiones tipo pila determinadas estáticamente, reservando y liberando regiones enteras sin contabilidad por objeto. El veredicto histórico honesto es que se consideró en cierto modo un callejón sin salida: la inferencia de regiones funcionaba, pero los programas reales mostraron un crecimiento de memoria patológico suficiente (regiones que mantenían datos muertos vivos muchísimo tiempo) como para que la comunidad investigadora se alejara en gran medida. Los recolectores de basura conservaron la corona.

Pero la idea nunca murió de verdad; simplemente cambió de trabajo. La asignación por arenas es práctica estándar en motores de juegos, compiladores y servidores de alto rendimiento. Los pools de memoria de Apache, las arenas por petición en servidores web, el patrón de "reiniciar el búfer temporal en cada frame" en los bucles de juego: son regiones con otro nombre, gestionadas a mano porque el lenguaje no te ayuda. Lo que Goose hace de forma distinta a ML Kit merece decirse con precisión: en lugar de inferir regiones para un lenguaje funcional de propósito general (difícil e impredecible), expone el modelo de pila directamente y hace que la intención del programador forme parte del tipo. T[>..] dice "esto crece, las referencias siguen siendo válidas". El trabajo del compilador es comprobar una disciplina declarada, no descubrirla. Es un problema mucho más fácil, y es la misma razón por la que Rust triunfó donde los esquemas anteriores de lifetimes automáticos tropezaron: los programadores toleran un contrato que pueden ver.

Hay una lección más amplia sobre cómo los lenguajes de producción absorben la investigación. La inferencia de regiones como sustitución totalmente automática del GC: fracasó. Las regiones como patrón manual de sistemas: omnipresentes. El borrow checking: un éxito enorme que a su vez toma mucho prestado de las ideas de regiones y de los tipos lineales. Los procesos de Erlang sin compartición: reivindicados, y los hilos de Goose son literalmente eso: workers compilados como programas separados con su propia memoria, valores que cruzan colas tipadas como un memcpy, y carreras de datos no representables. Nadie acierta el cuadro completo a la primera. Las ideas circulan durante décadas antes de llegar a una forma que funcione.

Dónde se rompe el modelo

Dije que empezaría con una afirmación contraria y luego admitiría dónde se rompe. Aquí va la admisión. El borrow checker no es un desvío si los patrones de compartición de tu programa son realmente arbitrarios, y una enorme clase de programas tiene exactamente esa forma. La pregunta más afilada de la discusión sobre Goose fue también la más sencilla: ¿cómo construyes una tabla hash que asocie claves a cadenas, cuando el programa inserta y borra cadenas aleatorias en momentos aleatorios? El borrado es el problema clave. Una pila que solo crece no puede recuperar un hueco en medio. Las soluciones propuestas (una pila por clase de tamaño, compactación periódica, un pool con lista libre donde "liberar" un slot solo significa que puede reutilizarse) funcionan, y el diseño del pool es realmente ingenioso (un slot obsoleto lee un valor distinto del tipo correcto en lugar de corromper la memoria). Pero cada una implica que el programador reimplemente a mano una parte de un gestor de memoria, para esa estructura de datos concreta.

Similarmente: el uso de memoria. Sin GC, una pila que crece retiene todo lo que llegó a retener hasta que termina el ámbito. Los programas de larga ejecución con tiempos de vida irregulares (la pregunta sobre documentos JSON que surgió en el hilo, las cachés, el estado de sesión) encajan en el patrón disciplinado por ámbitos o no encajan, y cuando no encajan, ninguna elegancia te salva. El modo de fallo de ML Kit (datos muertos fijados en regiones vivas) no desaparece en Goose; se vuelve visible y manual, lo que quizá sea mejor, pero sigue ahí. Un comentarista planteó la postura correcta: Goose es un lenguaje para un propósito restringido, y eso está bien. Las cargas orientadas a datos, los compiladores, los sistemas de entidades de juegos, todo lo que tiene forma de pipeline (leer, transformar, emitir, reiniciar) encaja de maravilla. Un grafo de objetos mutable y de larga vida, no.

Y una advertencia más sobre las cifras principales. Las suites de benchmarks autoalojadas son un género con modos de fallo conocidos: estás comparando tu mejor Goose con el C++ "idiomático" de otro, y los idiomas son donde empiezan las peleas. El fallo con SSO demuestra que el código de comparación no estaba ajustado de forma adversarial. La cifra de 1,12x sobre Rust seguro es más interesante que la de 3,3x, precisamente porque es pequeña: indica que el modelo de memoria gana en disposición y en eliminación de asignaciones, no por arte de magia, y una versión de Rust bien ajustada que use arenas e índices cerraría buena parte de la distancia. Y ese es el punto: sí puedes escribir Rust basado en arenas, pero el lenguaje lo hace incómodo, y la incomodidad es lo que determina qué código se escribe de verdad.

Dos trayectorias de vuelo dibujadas como estelas de luz en un cielo oscuro, una larga y sinuosa, otra más corta y directa, convergiendo en un punto
Rutas distintas hacia la misma seguridad: Rust restringe la compartición, Goose restringe el propio modelo de memoria.

Lo que los lenguajes de producción robarán realmente a Goose

Casi nadie escribirá Goose en producción en los próximos años: los lenguajes de una sola persona que compilan a través de un único backend en C no llegan rápido, y el autor lo dice así en cuanto a su estado. Esa no es la pregunta interesante. La pregunta interesante es qué ideas escapan hacia los lenguajes que ya usamos, porque así es como siempre funciona esto. Mis predicciones, por orden de confianza:

  1. Estructuras independientes de la posición con carga verificada. La serialización zero-copy con una pasada de seguridad es demasiado buena para quedarse en un nicho. Espera más versiones integradas en el lenguaje de lo que FlatBuffers hace de forma torpe, sobre todo a medida que los servicios intensivos en datos sigan topando con los costes de parseo.
  2. Arrays como familia, no como un tipo. "¿Qué le pasa al tamaño?" es una pregunta organizativa mejor que "¿pila o heap?". Fijo, congelado, con capacidad acotada, solo crecible: estas distinciones existen en cualquier base de código como convenciones informales. Convertirlas en tipos es barato y detecta errores reales.
  3. Valores de función en tiempo de compilación. Goose trata los valores de función como entidades de tiempo de compilación, así que xs.filter() { it > 0 } se compila al bucle que parece. C++ con plantillas y la monomorfización de Rust ya hacen esto; la diferencia es que Goose lo convierte en la única forma, eliminando por completo la trampa de los punteros a función en tiempo de ejecución.
  4. Retornos multi-frame verificados estáticamente. return err from load retorna desde una función N marcos más arriba, comprobado en tiempo de compilación, sin unwinder, sin pirámide de Result, sin ? en cada llamada. Los sistemas de efectos rondan esta misma idea desde la otra dirección. Uno de ellos llegará a un lenguaje mainstream en esta década.

El modelo de hilos sin compartición también merece mención, sobre todo porque es el menos novedoso y el más reivindicado: son los procesos de Erlang, son los isolates de Dart, es el modelo de actores sin la ideología. Que siga reinventándose indica que el consenso de la industria sobre el estado mutable compartido está resuelto en teoría e ignorado en la práctica. Goose, al menos, hace imposible ignorarlo.

El argumento de fondo: simplicidad mediante restricción

Mirado en conjunto, el diseño de Goose es un solo movimiento repetido: reducir el conjunto de programas representables hasta que demostrar la seguridad sea barato. Sin heap no hay punteros colgantes al heap. Las pilas que solo crecen implican que las referencias no pueden invalidarse. Los hilos con workers separados implican que las carreras no pueden existir. La construcción sin copias implica que no hay temporales accidentales. Cada restricción elimina una categoría entera de errores y una categoría entera de maquinaria del lenguaje (las reglas de aliasing del borrow checker, el destructor, el unwinder) que existía para gestionarlos.

Esto es el argumento de Rob Pike de que "menos es exponencialmente más" sin guantes, y aterriza en la misma tensión en la que cayó el Go de Pike: las restricciones se sienten como liberación hasta que tu programa es justo el que la restricción excluye, y entonces se sienten como un muro. Go excluyó los genéricos durante una década y sobrevivió; también excluyó muchos programas correctos. Goose excluye muchísimo más. La razón para prestarle atención de todos modos es que las viejas reglas de Pike sobre la simplicidad han envejecido bien precisamente porque los presupuestos de complejidad son reales: cada garantía que un lenguaje ofrece automáticamente es una garantía que cada programador que lo usa no tiene que mantener en la cabeza.

Entonces, ¿fue el borrow checker un desvío? Para la programación de sistemas de propósito general, no: la expresividad de Rust hace falta de verdad para los casos de compartición arbitraria, y su éxito dio a la industria un vocabulario para hablar de ownership. Pero ¿y para la gran y silenciosa mayoría del código de sistemas que tiene forma de pipeline, de arena o de petición? Goose es la prueba de que quizá podríamos haber tenido seguridad de memoria, velocidad y binarios pequeños con una fracción de la ceremonia, si hubiéramos estado dispuestos a decir "no" a más programas. El diseño de lenguajes es el arte de elegir qué programas prohibir. Goose prohíbe unos distintos a los que estamos acostumbrados, y el resultado es el boceto de lenguaje de sistemas más interesante que he leído en años. No espero enviarlo a producción. Sí espero seguir robándole ideas.

La apuesta de Goose es que un lenguaje puede prohibir más programas que Rust y seguir siendo útil: los benchmarks sugieren que vale la pena tomarlo en serio, con sus pérdidas incluidas.