Most compiled languages make you choose: either you get high-level ergonomics with a garbage collector, or you get manual control with verbose syntax. I wanted to see if there was a middle ground.
Koral is a compiled language that targets C. It uses reference counting and escape analysis instead of a GC, and tries to keep the syntax clean enough that you can read code without fighting it. It's experimental, but the core ideas are working and I think some of the design choices are worth discussing.
The Inspiration: Swift meets Go
When designing Koral's memory model, I looked closely at two languages that took very different paths: Swift and Go.
Swift uses Automatic Reference Counting (ARC). It provides deterministic destruction and avoids the unpredictable pauses of a tracing garbage collector. However, ARC can introduce overhead due to constant atomic retain/release operations, especially for short-lived objects.
Go uses a tracing garbage collector, but it relies heavily on escape analysis. The Go compiler is incredibly smart about proving when an object doesn't outlive its scope, allowing it to allocate those objects on the stack instead of the heap. Stack allocation is practically free and completely bypasses the GC.
I wondered: what if we combined Go's aggressive escape analysis with Swift's ARC?
The Koral Approach: ARC + Escape Analysis
In Koral, there is no tracing garbage collector. Instead, the compiler uses a two-pronged approach to memory management:
- Escape Analysis First: Every allocation is analyzed at compile time. If the compiler can prove that an object does not escape its current scope (e.g., it's not returned, not stored in a global variable, and not passed to a function that holds onto it), it is allocated on the stack.
- ARC for the Rest: If an object does escape, it is allocated on the heap and managed via Automatic Reference Counting.
This combination gives you the best of both worlds. You get the predictable, pause-free performance of ARC, but the escape analysis strips away the traditional ARC overhead for the vast majority of local, short-lived objects.
How it looks in practice
In Koral, you don't have to think about whether to use malloc or free. You just write your code:
// The compiler sees this doesn't escape.
// It's allocated on the stack. No ARC overhead.
let local_point = Point(1, 2)
// The 'ref' keyword explicitly creates a reference type.
// If it escapes, it goes to the heap with a refcount.
let heap_point = ref Point(3, 4)
// Bumping the refcount, no deep copy
let shared_point = heap_point
Because Koral compiles to C, this memory model translates into highly efficient C code. Stack allocations become standard C local variables. Heap allocations become malloc calls paired with injected retain and release calls, automatically inserted by the compiler.
Under the Hood: The C Ref Struct
To make this work seamlessly, Koral compiles references into a simple two-pointer C struct:
struct __koral_Ref {
void* ptr; // Pointer to the actual data
void* control; // Pointer to the reference count control block
};
When the compiler's escape analysis determines that an object escapes its scope, it generates code to allocate both the data and a control block on the heap:
// Heap allocation (escapes)
struct __koral_Ref heap_ref;
heap_ref.ptr = malloc(sizeof(Point));
heap_ref.control = malloc(sizeof(struct __koral_Control));
// ... initialize ref counts to 1 ...
However, if the compiler proves the object does not escape, it allocates the data directly on the C stack as a standard local variable. When a reference to this stack data is needed, it creates a __koral_Ref where the control pointer is simply NULL:
// Stack allocation (does not escape)
Point local_point = {1, 2};
struct __koral_Ref stack_ref;
stack_ref.ptr = &local_point;
stack_ref.control = NULL; // No control block needed!
The magic happens in the runtime's retain and release functions. They are designed to safely ignore NULL control blocks:
void __koral_retain(void* raw_control) {
if (!raw_control) return; // Stack references are ignored!
struct __koral_Control* control = (struct __koral_Control*)raw_control;
atomic_fetch_add(&control->strong_count, 1);
}
This means that even if a stack-allocated reference is passed around locally, the retain and release calls compile down to a fast NULL check and an immediate return. There are no atomic operations, no heap allocations, and no GC pauses. The backend compiler can often inline these functions and optimize the NULL checks away entirely, resulting in zero-cost abstractions for local data.
The Trade-offs
No memory model is perfect. By choosing ARC over a tracing GC, Koral inherits the classic ARC weakness: reference cycles.
If object A holds a reference to object B, and object B holds a reference to object A, their reference counts will never reach zero, causing a memory leak. To handle this, Koral provides weakref, allowing developers to explicitly break cycles when building complex data structures like doubly-linked lists or graphs.
// Breaking a cycle with a weak reference
let parent = weakref node.parent
However, in everyday application code, reference cycles are relatively rare. For the vast majority of use cases, the combination of stack allocation and ARC provides a seamless, high-performance experience without the cognitive load of manual memory management or the runtime unpredictability of a GC.
The Result: Predictable Performance
Koral is still experimental, but the core idea—combining Go's escape analysis with Swift's ARC to get predictable, high-performance memory management without a GC—is working.
If this approach to memory management interests you — or if you think it's a terrible idea — I'd love to hear your thoughts!
GitHub: https://github.com/kulics/koral
Top comments (0)