La gestión de memoria es un problema fundamental en la computación, que a menudo se resuelve con compromisos entre rendimiento, seguridad y complejidad. Los lenguajes de sistemas tradicionales como C/C++ ofrecen control de bajo nivel y alto rendimiento, pero a expensas de la seguridad de memoria, lo que lleva a vulnerabilidades comunes como buffer overflows y use-after-free. Lenguajes modernos como Rust abordan la seguridad de memoria a través de un sistema de ownership y borrowing, pero introducen complejidad sintáctica con anotaciones de por vida y reglas de aliasing.

Goose propone una solución alternativa: un modelo de memoria sin heap, donde todos los valores dinámicos residen en data stacks gestionados por el compilador. Este enfoque elimina la necesidad de recolectores de basura, conteo de referencias o destructores, simplificando la gestión de memoria a operaciones de bump-pointer y reseteo de stack. Al garantizar la seguridad de memoria en tiempo de compilación sin anotaciones explícitas, Goose busca ofrecer el rendimiento de C++ con la seguridad de Rust, pero con una menor sobrecarga cognitiva para el desarrollador. La relevancia actual de este enfoque radica en la creciente demanda de sistemas de alto rendimiento y alta fiabilidad en dominios como infraestructura de nube, sistemas embebidos y aplicaciones de baja latencia, donde los compromisos de memoria son críticos.

Arquitectura del Sistema

La arquitectura de Goose se centra en un modelo de memoria radicalmente simplificado. Un programa Goose utiliza la pila de llamadas nativa, datos estáticos y N 'data stacks' (pilas de datos), cuyo número es determinado estáticamente por el compilador. Cada data stack es una reserva de espacio de direcciones grande con un 'bump pointer'. No existe otro tipo de memoria dinámica. A lo sumo, un valor redimensionable está activo por stack y siempre se encuentra en la parte superior, lo que garantiza que el crecimiento nunca mueva datos y no requiera verificaciones de capacidad. La validez de las referencias se prueba en tiempo de compilación, y el runtime solo gestiona los bump pointers.

Las estructuras de datos son intrínsecamente contiguas y 'inline'. Por ejemplo, un array de strings o un registro con campos de tamaño variable se almacenan como un bloque contiguo de memoria, sin punteros internos. Esto mejora la localidad de caché y reduce la sobrecarga de memoria. Los 'enums' (tipos de datos algebraicos) pueden almacenarse en modo fijo (ocupando espacio para el variante más grande) o en modo variable (ocupando solo el espacio de su variante actual), optimizando el uso de memoria. Las referencias son de dos tipos: referencias de máquina (punteros de 8 bytes) y referencias relativas, que almacenan un offset de 1, 2 o 4 bytes. Estas últimas permiten estructuras de datos 'position independent', facilitando la serialización directa a disco y la carga con verificación de integridad.

El compilador de Goose garantiza la construcción 'in-place' de valores, lo que significa que un valor se construye directamente en su destino final, sin copias intermedias. Las funciones que devuelven arrays 'grow-only' por valor escriben sus elementos directamente en la variable del llamador. El manejo de errores se realiza a través de un mecanismo de 'return E from f' que permite retornar un error desde una función anidada a un llamador específico en la pila de llamadas, verificado estáticamente. La concurrencia se basa en un modelo 'share-nothing', donde cada 'thread_fn' se compila como un programa separado con su propia memoria. Los valores se comunican entre hilos a través de 'typed queues' mediante operaciones de 'memcpy', eliminando data races, locks y atomics. El compilador genera código C plano, lo que permite la interoperabilidad con bibliotecas C existentes a través de 'extern fn' y la compilación con cualquier compilador C.

Flujo de Gestión de Memoria en Goose

  1. 1 Declaración de Variable El compilador asigna la variable a un data stack o a memoria estática.
  2. 2 Crecimiento de Array Un 'bump pointer' avanza en el data stack; no hay reubicación ni verificación...
  3. 3 Creación de Referencia El compilador verifica estáticamente que la referencia no sobreviva a su prop...
  4. 4 Fin de Scope La memoria se libera con una única operación de 'store' al tope del stack.

Flujo de Comunicación entre Hilos

  1. 1 thread_fn Un hilo se compila como un programa separado con su propia memoria.
  2. 2 qput(Value) Un valor plano (sin referencias) se copia a una cola tipada.
  3. 3 qget<Type>() El hilo receptor bloquea hasta que un valor del tipo esperado está disponible.
  4. 4 memcpy El valor se copia directamente desde la cola a la memoria del hilo receptor.
CapaTecnologíaJustificación
compute Goose Compiler Compilador de lenguaje de sistemas que genera código C, gestiona la asignación de memoria en data stacks y realiza análisis de por vida estático. vs LLVM (para Rust, C++), GCC (para C, C++)
storage Data Stacks Mecanismo de asignación de memoria principal, gestionado por bump pointers sin heap global. Permite estructuras de datos contiguas y sin reubicación. vs Heap (malloc/free, new/delete), Garbage Collector (Java, Go), Reference Counting (Swift, C++ shared_ptr)
networking Typed Queues (inter-thread) Mecanismo de comunicación entre hilos 'share-nothing' que utiliza memcpy para transferir datos planos. vs Shared Memory + Locks/Atomics, Message Passing (Erlang, Go channels), RPC frameworks
orchestration C Compiler (GCC, Clang, MSVC, TinyCC) Backend de compilación final para el código C generado por Goose, permitiendo la ejecución en diversas plataformas. -O2 (optimización), -lm (math library), -pthread (threading)

Trade-offs

Ganancias
  • ▲ Rendimiento (velocidad)
  • ▲ Uso de memoria
  • ▲ Seguridad de memoria
  • ▲ Simplicidad del modelo de memoria
  • ▲ Localidad de caché
Costes
  • ▲ Flexibilidad en la gestión de memoria (no hay heap global)
  • △ Arrays de elementos de tamaño variable no indexables directamente
  • △ No hay closures que escapen, punteros a funciones, ni dispatch dinámico más allá de ADT tags
  • △ Compilación de programa completo (sin compilación incremental)
for round in 3 {
  var scratch: u8[>..] = []; // grow-only: growth is a pointer bump
  for i in 100000 { scratch.push((i % 256) as! u8); }
  print("round ", round, ": ", scratch.len, " bytes");
} // the free: one store to the stack top

struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 }); // a reference to element 0
for i: i32 in 2..1000001 { items.push(Item { i, 0.0 }); }
first.weight = 99.5; // still valid, a million pushes later
Demuestra la asignación de un array 'grow-only' en un data stack y cómo las referencias permanecen válidas a pesar del crecimiento.
enum Shape { Circle { r: f64 }, Rect { w: f64, h: f64 }, Dot }
var packed: Shape..[>..] = []; // 9, 17 and 1 bytes, not 17 each
packed.push(Shape.Circle { r: 1.0 });
packed.push(Shape.Rect { w: 2.0, h: 3.0 });
packed.push(Shape.Dot);
for s in packed {
  match s { Rect &r => { r.w += 1.0; }, _ => {} } // edits the payload in place, inside the array
}
Muestra un enum en modo variable, donde cada variante ocupa solo el espacio necesario, y cómo se pueden tomar referencias a los payloads dentro del array.
struct Node { key: i32, left: Node&<u32>?, right: Node&<u32>? } // 12 bytes, links included
fn insert(pool: Node[>..]&, key: i32) {
  if pool.len == 0 { pool.push(Node { key: key }); return; }
  var cur .= pool[0]; // .= binds a reference; = would copy the node
  loop {
    if key == cur.key { return; }
    let next = if key < cur.key { cur.left } else { cur.right };
    if next { cur .= next; continue; } // narrowed by `if`: next is a Node&
    if key < cur.key { cur.left .= pool.push(Node { key: key }); }
    else { cur.right .= pool.push(Node { key: key }); }
    return;
  }
}
Ejemplo de un árbol de búsqueda binario implementado en un único array 'grow-only' usando referencias relativas de 12 bytes por nodo.

Fundamentos Teóricos

El problema de la gestión de memoria y la seguridad ha sido un tema central en la investigación de lenguajes de programación desde sus inicios. El concepto de 'stack allocation' para variables locales es un pilar de los lenguajes imperativos, pero la extensión de este modelo a estructuras de datos dinámicas sin un heap global es menos común. El trabajo de Tony Hoare sobre la verificación de programas y la lógica de Hoare (1969) sentó las bases para la verificación estática de propiedades de programas, un principio que Goose aplica para la seguridad de memoria. La idea de que el compilador infiera y garantice la validez de las referencias sin anotaciones explícitas se alinea con la investigación en 'type inference' y 'lifetime analysis' que ha sido prominente en lenguajes como ML y, más recientemente, Rust.

La eliminación del recolector de basura y la gestión manual de memoria ha sido un área de estudio continuo, con propuestas como 'region-based memory management' (Tofte y Talpin, 1997) que asignan memoria a regiones con tiempos de vida definidos. El modelo de Goose, con sus data stacks y la inferencia de por vida, puede verse como una forma especializada de gestión de memoria basada en regiones, donde las regiones son implícitamente definidas por los scopes de las variables y los data stacks. La optimización de la localidad de caché a través de estructuras de datos contiguas es un principio bien establecido en la literatura de rendimiento, a menudo asociado con el diseño de sistemas de bases de datos (ej. LSM-trees) y sistemas operativos. La idea de 'position-independent data structures' con referencias relativas tiene paralelos en formatos de serialización eficientes y sistemas de archivos que buscan minimizar la sobrecarga de punteros y permitir el mapeo directo de memoria.