Anton ZhiyanovEverything about Go, SQL, and software in general.https://antonz.org/https://antonz.org/assets/favicon/favicon.pngAnton Zhiyanovhttps://antonz.org/Hugo -- gohugo.ioen-usSat, 25 Jul 2026 11:30:00 +0000Solod 0.3: Concurrency, JSON, more safetyhttps://antonz.org/solod-0.3/Sat, 25 Jul 2026 11:30:00 +0000https://antonz.org/solod-0.3/A strict subset of Go that translates to regular C.Solod (So) is a subset of Go that translates to regular C — with zero runtime, manual memory management, and source-level interop. It's designed for two main audiences:

  • Go developers who want low-level control without having to learn another language.
  • C developers who like Go's style.

At the end of the v0.2 post, I said the obvious goal for the next release was concurrency, along with the stdlib packages that support it. That's what v0.3 is about. So now has threads, channels, worker pools, mutexes, and atomics — enough tools for parallel data processing or handling network connections.

This release also adds a streaming JSON package, a bunch of safety checks (escape analysis, leak checking, nil-pointer panics, stack traces), and proper so test and so bench commands.

ThreadsChannelsWorker poolsSharing stateJSONSafety netToolingWrapping up

Threads

The new conc package is the foundation. It provides real OS threads, backed by pthreads. If you're familiar with Go's goroutines, the code will look similar — but there are some important differences.

// greet prints a label three times.
func greet(arg any) any {
    from := arg.(string)
    for i := range 3 {
        println(from, "->", i)
    }
    return nil
}

func main() {
    // Run greet on a separate OS thread, concurrently with main.
    name := "thread"
    th := conc.Go(greet, name)

    // Wait blocks until the thread finishes.
    th.Wait()
    println("done")
}
thread -> 0
thread -> 1
thread -> 2
done

Solod doesn't support closures, so conc.Go takes a function and an any argument, instead of just a func() like you'd expect in Go.

Other important differences: starting an OS thread isn't free, and you always have to Wait on it (or Detach it), or it will leak. That makes conc.Go a good fit for a small, fixed number of long-lived threads — but not for thousands of short-lived tasks. For those cases, it's better to use a pool (shown below).

Channels

Threads in Solod communicate with each other through channels, like goroutines in Go. A channel carries values of a specific type. By default, sending or receiving on a channel blocks until both sides are ready, so a channel also works as a synchronization point.

// ping sends a single message on the given channel.
func ping(arg any) any {
    messages := arg.(*conc.Chan[string])
    messages.Send("ping")
    return nil
}

func main() {
    // An unbuffered channel (buffer size 0): each send blocks
    // until a receiver is ready to take the value.
    messages := conc.NewChan[string](mem.System, 0)
    defer messages.Free()

    // Launch a thread that sends "ping" into the channel.
    th := conc.Go(ping, &messages)
    defer th.Wait()

    // Receive the message and print it.
    var msg string
    messages.Recv(&msg)
    println(msg)
}
ping

A couple of So-specific moments here. When you create a channel, you give it an allocator (mem.System in this case), and you call Free when you're done with it. Also, Recv writes to a pointer you pass in, instead of returning the value directly. It returns a bool, which is false when the channel is closed and empty. So, a typical for msg := range ch loop in Go becomes for ch.Recv(&msg) { ... } in Solod.

Allocators are a key concept in Solod. The language doesn't allow hidden heap allocations, so any function that needs to allocate memory must take an allocator (the mem.Allocator interface) as its first argument.

Buffered channels can hold a limited number of values without having a receiver ready — just pass a non-zero size with NewChan. If you don't want to block forever, use RecvTimeout or SendTimeout with a duration. They return conc.Ok or conc.Timeout instead of getting stuck.

Worker pools

Threads are expensive, so spawning one per task doesn't scale. For handling many short-lived tasks, use conc.Pool: it uses a fixed number of worker threads that take tasks from a queue.

// job holds input and the result.
type job struct {
    id     int
    result int
}

// process handles one job.
func process(arg any) {
    j := arg.(*job)
    time.Sleep(100*time.Millisecond)
    j.result = j.id * 2
}

func main() {
    // A pool of 4 worker threads. Each submitted job is handled
    // by the next available worker.
    pool := conc.NewPool(mem.System, conc.PoolOptions{NumThreads: 4})
    defer pool.Free()

    // Submit 8 jobs. Each writes into its own struct, so keep
    // the structs alive in a slice until the jobs finish.
    start := time.Now()
    jobs := make([]job, 8)
    for i := range jobs {
        jobs[i].id = i + 1
        pool.Go(process, &jobs[i])
    }

    // Wait until all submitted jobs have finished.
    pool.Wait()

    for i := range jobs {
        println("job", jobs[i].id, "->", jobs[i].result)
    }
    elapsed := time.Since(start) / 1_000_000
    println("took", elapsed, "ms")
}
job 1 -> 2
job 2 -> 4
job 3 -> 6
job 4 -> 8
job 5 -> 10
job 6 -> 12
job 7 -> 14
job 8 -> 16
took 200 ms

pool.Wait() works similar to Go's WaitGroup.Wait — it blocks until all submitted jobs are finished. This program takes about 200 ms to run (even though there's 800 ms of total work), because 4 workers run concurrently.

You might think OS threads are much slower than Go's goroutines, but for pools, that's not the case. On realistic workloads, conc.Pool is usually only about 10% slower than Go, whether the tasks are CPU-bound or waiting on I/O. Channels are a different story: handing off work between threads requires a kernel wakeup, while Go does this in user space, so it can be several times slower. Check out Go-flavored concurrency in C for more details.

Sharing state

One way to share state in Solod is by using channels to communicate it. However, sometimes you just need a shared counter or a lock. For that, the new release introduces the sync and sync/atomic packages.

Here's an example of an atomic counter being updated by 50 tasks running on 4 threads:

// increment atomically increases the shared counter 1000 times.
func increment(arg any) {
    ops := arg.(*atomic.Uint64)
    for range 1000 {
        ops.Add(1)
    }
}

func main() {
    // An atomic value is safe for concurrent reads and writes.
    var ops atomic.Uint64

    pool := conc.NewPool(mem.System, conc.PoolOptions{NumThreads: 4})
    defer pool.Free()

    // 50 tasks, each incrementing the counter 1000 times.
    for range 50 {
        pool.Go(increment, &ops)
    }
    pool.Wait()

    println("ops:", ops.Load())
}
ops: 50000

A regular int incremented with ops++ would cause a data race and give a different result each time. Here, the result is exactly 50,000 on every run, thanks to the atomic Uint64 counter. The atomic package provides Int64, Uint64, Bool, and Pointer[T] types, all of which are lock-free and safe for concurrent use.

For anything more complex than a counter, use sync, which provides Mutex, Cond (a condition variable), and Once (runs a function exactly once). One thing to watch out for: unlike Go, a So mutex's zero value isn't ready to use — you need to Init it before locking and Free it when done.

var mu sync.Mutex
mu.Init()
defer mu.Free()

mu.Lock()
defer mu.Unlock()
// ... critical section ...

JSON

Go's encoding/json relies on reflection to marshal arbitrary structs. Solod has no reflection, and uses a different approach: a token-level API. You read and write one JSON token at a time, and the Encoder and Decoder types take care of the syntax — adding commas and colons, checking UTF-8, and rejecting bad input.

Encoding is done through a series of calls that match the structure of your document:

out := make([]byte, 256)
sb := strings.FixedBuilder(out)
enc := json.NewEncoder(&sb)

enc.BeginObject()
enc.Str("name")
enc.Str("Alice")
enc.Str("age")
enc.Int(25)
enc.EndObject()
enc.Flush()

println(sb.String())
{"name":"Alice","age":25}

Decoding pulls one validated token at a time with Next. You can check each token with Kind and read its value using typed getters like Str, Int, or Bool:

src := `{"name":"Alice","age":25}`
dec := json.NewDecoder(mem.System, []byte(src))
defer dec.Free()

var name string
var age int64

dec.Next() // the opening {
for dec.Next() && dec.Kind() == json.KindString {
    switch dec.Str() {
    case "name":
        dec.Next()
        name = dec.Str()
    case "age":
        dec.Next()
        age = dec.Int()
    default:
        dec.Next()
        dec.Skip()
    }
}

println(name, age)
Alice 25

This is a simplified example that works only because the decoder doesn't allocate any memory. In a real-world situation, you'd need to use an allocator.

The decoder works the same way whether you're using an in-memory document (NewDecoder) or reading from a stream with an io.Reader (NewReader). This means you can decode data directly from a source without having to buffer the entire message first. Both the encoder and decoder use minimal memory and will reject invalid JSON or non-UTF-8 strings.

As you can see, API is low-level and not nearly as ergonomic as it is in Go, especially when it comes to decoding. But on the bright side, it's 10 times faster and almost doesn't allocate, unlike in Go.

Safety net

Solod compiles to plain C, which is fast but not very forgiving: if you use an out-of-bounds index, dereference nil, or divide by zero, you get undefined behavior that could crash the program or silently give wrong results. The new release addresses some of these issues.

Escape analysis. Returning a pointer to a stack-allocated value is a classic C footgun. So now catches the common cases at compile time:

type Point struct{ x, y int }

func newPoint(x, y int) *Point {
    return &Point{x: x, y: y}
    //     ^ compile-time error: stack-allocated
    //       value escapes function frame
}

func main() {
    p := newPoint(3, 4)
    println(p.x, p.y)
}
so run: /tmp/sandbox/main.go:26:12: stack-allocated value escapes function frame
    return &Point{x: x, y: y}
           ^here (exit status 1)

While the escape analyzer doesn't catch every case, it's still quite useful in practice. I actually found a couple of dangling pointers in the standard library code with it, even though I was sure there weren't any.

Leak detection. Solod has no garbage collector, so a forgotten Free is a real memory leak. mem.Tracker helps catch these leaks: it wraps an allocator and keeps track of every allocation and free that goes through it. This way, you can monitor the program's memory usage in real time instead of guessing.

Wrap mem.System once, allocate memory through the tracker, and have a background thread log the stats at regular intervals:

// monitor periodically logs live allocation stats.
func monitor(arg any) any {
    t := arg.(*mem.Tracker)
    for {
        time.Sleep(100 * time.Millisecond)
        s := t.Stats()
        println("live:", s.Mallocs-s.Frees, "allocations,", s.Alloc, "bytes")
    }
    return nil
}

func main() {
    // Wrap the system allocator to count every allocation and free.
    heap := &mem.Tracker{Allocator: mem.System}

    // Watch memory from a background thread.
    conc.Go(monitor, heap).Detach()

    // Allocate through heap so the monitor sees it.
    for i := range 10 {
        v := mem.Alloc[int](heap) // intentionally not freeing it
        *v = i
        time.Sleep(50*time.Millisecond)
    }
    // ...
}
live: 2 allocations, 16 bytes
live: 4 allocations, 32 bytes
live: 6 allocations, 48 bytes
live: 8 allocations, 64 bytes
live: 10 allocations, 80 bytes

The tracker is lock-free and only uses a few atomic operations for each allocation, so it's cheap enough to keep enabled in production.

Nil-pointer panics. If you try to dereference a nil pointer, it will cause a panic at runtime instead of a raw segmentation fault:

type Rect struct{ width, height int }

func (r *Rect) area() int {
    return r.width * r.height
    //     ^ runtime error: nil pointer dereference
}

func main() {
    var r *Rect
    println(r.area())
}
panic: nil pointer dereference

Stack traces. When a program panics, the -panic flag controls what happens next:

so run -panic=trace .  # print a stack trace, then exit(1) - the default
so run -panic=exit  .  # just exit(1) after the message
so run -panic=abort .  # raise SIGABRT for a debugger or core dump

Stack trace frames represent each function in the call chain:

func main() {
    Work()
}

func Work() {
    res := Calc(42)
    println(res)
}

func Calc(x int) int {
    if x == 42 {
        panic("can't handle 42")
    }
    return x * 2
}
panic: can't handle 42
/tmp/solod_build764532392/main.c:27 (func main_Calc)
/tmp/solod_run1106942066(main_Calc+0x51)
/tmp/solod_run1106942066(main_Work+0x12)
/tmp/solod_run1106942066(main+0x9)

The same system handles assertions like slice bounds, index-out-of-range, c.Assert, and similar checks. Instead of calling C's assert, they panic in a way that respects the -panic flag.

There's also a new -sanitize flag that enables C sanitizers (address and undefined by default) to help you catch more issues during development:

so run -sanitize -panic=abort example/play

Tooling

'so test' and 'so bench'. Solod now has built-in test and benchmark runners. so test finds TestXxx(t *testing.T) functions in a package's test subdirectory, creates a runner, transpiles it, and runs it. so bench does the same for BenchmarkXxx(b *testing.B).

A typical package layout with tests and benchmarks looks like this:

so/uuid
├── bench
│   ├── main.go
│   └── uuid.go
├── test
│   ├── main.go
│   └── uuid.go
└── uuid.go

There's also a quick check for memory leaks: t.Allocator() gives you a tracking allocator (described in the 'Safety net' section above), and the test will fail if anything allocated with it isn't freed by the end of the test.

=== RUN   TestAlloc
    memory leak: 1 unfreed allocation(s), 16 byte(s)
--- FAIL: TestAlloc

Fuzzing. Since Solod is a strict subset of Go, any So package is also a valid Go package. This means you get Go's built-in fuzzer for free, making fuzz testing pretty easy. So's encoding/json package takes advantage of this by using Go's own encoding/json as an oracle, making sure that every JSON document accepted by So is also accepted by Go.

Automatic linking. The new so:link directive lets a package specify which C library it needs, and so build gathers these libraries and passes them to the C compiler. The standard packages already use the new directive, so importing so/math links with -lm, and so/sync or so/conc links with -lpthread — you no longer have to set LDFLAGS manually.

Wrapping up

With v0.3, Solod reaches an important milestone: a program can now do multiple things at once. The JSON package gives programs a standard way to communicate, and the safety checks help prevent silent failures — both during development and in production.

There's still a lot to do, of course. In the next release, the standard library will keep growing, and the language and tooling will get better to make programming in So more convenient and safe.

If you're interested, take a look at So's readme — it has everything you need to get started. Or try So online without installing anything.

]]>
On interactive Go tourshttps://antonz.org/on-go-tours/Sat, 11 Jul 2026 12:30:00 +0000https://antonz.org/on-go-tours/Wrapping up the series.Over the past two years, I've published interactive tours for five Go releases, from 1.22 to 1.26.

I know some of you have read them, and I've received a lot of kind words from you (even some core Go team members reached out) — thank you so much for that!

Tour history: Go 1.221.231.241.251.26 + Go features by version

Unfortunately, at some point, writing these tours stopped being fun and started to feel like a part-time job. I'm not really excited about that, so I've decided to stop.

I still like Go (well, most of it). I read a lot of Go code, I write some Go code, and I write Solod code, which is also Go 🙂 (Solod is a systems language with Go syntax and a Go-like stdlib).

I'm still pretty close to the language and will probably continue to write about it.

But the interactive tours story is over.

]]>
Go-flavored concurrency in Chttps://antonz.org/concurrency-in-c/Fri, 10 Jul 2026 12:00:00 +0000https://antonz.org/concurrency-in-c/Worker pools, channels, and mutexes - backed by pthreads.Go's concurrency is one of the main reasons people like the language. You write go f(), send values through channels, and the runtime scheduler runs thousands of goroutines on just a few OS threads. It feels effortless.

None of that machinery exists in C. Which made me wonder: how close can you get to Go's concurrency model using only POSIX threads? Obviously, native OS threads can't match the efficiency of lightweight goroutines, but what is the actual cost, when does it become a problem, and is there any way to at least partially avoid it?

I ran into these questions while adding concurrency to Solod (So), a strict subset of Go that translates to plain C, with no runtime and no garbage collector. In the end, I came to the conclusion that you can do quite a lot with pthreads — as long as you're honest about the tradeoffs.

This post is about the POSIX threads-based concurrency model I chose, the benefits it offers, and its limitations.

Mutex/CondAtomicsPoolChannelPerformanceDesignWrapping up

Mutex/Cond

Everything in So's concurrency stack is built on two basic POSIX primitives: the mutex and the condition variable. sync.Mutex is a thin wrapper around pthread_mutex_t:

// Extracted from So's stdlib source code.
type Mutex struct {
    mu pthread_mutex_t
}

func (m *Mutex) Lock() {
    rc := pthread_mutex_lock(&m.mu)
    if rc != 0 {
        panic("sync: Mutex.Lock failed")
    }
}

Since So translates to C, this is basically a struct that holds a pthread_mutex_t and a function that calls pthread_mutex_lock. Here's the transpiler output:

// The translated C code.
typedef struct sync_Mutex {
    pthread_mutex_t mu;
} sync_Mutex;

void sync_Mutex_Lock(sync_Mutex* m) {
    int rc = pthread_mutex_lock(&m->mu);
    if (rc != 0) {
        so_panic("sync: Mutex.Lock failed");
    }
}

That is the whole translation — the generated C is a near-mechanical mirror of the So code, only noisier. From here on, I'll mainly show the So version, but I'll also provide the C code for those who are interested.

There's nothing exciting here: sync.Mutex is a pthread mutex wrapper that panics if something goes wrong (which is rare).

The companion primitive is sync.Cond, a wrapper around pthread_cond_t. It's the standard "wait until a condition holds" tool, associated with a mutex:

type Cond struct           // wraps pthread_cond_t + pthread_mutex_t
func (c *Cond) Wait()      // wraps pthread_cond_wait
func (c *Cond) Signal()    // wraps pthread_cond_signal
func (c *Cond) Broadcast() // wraps pthread_cond_broadcast
Show the translated C code
typedef struct sync_Cond {
    pthread_cond_t cond;
    sync_Mutex*    mu;
} sync_Cond;

void sync_Cond_Wait(sync_Cond* c);      // wraps pthread_cond_wait
void sync_Cond_Signal(sync_Cond* c);    // wraps pthread_cond_signal
void sync_Cond_Broadcast(sync_Cond* c); // wraps pthread_cond_broadcast

These two types — Mutex and Cond — are the foundation. Other concurrency tools — Once, the thread pool, channels — are built using a mutex and one or more condition variables. This has several effects on performance, as we'll see later.

Atomics

Not everything needs a lock. So's sync/atomic mirrors Go's: Bool, Int32, Int64, Uint32, Uint64, and a generic Pointer[T], all with Load, Store, Swap, and CompareAndSwap methods.

The nice thing is that these don't need pthreads at all. They map directly to the C compiler's __atomic builtins — the same hardware instructions that Go's compiler emits. So there's no reason for them to be any slower, and they're not:

Atomic op Go So Winner
Load 2ns 2ns ~same
Store 2ns 2ns ~same
CompareAndSwap 13ns 13ns ~same

Each number is the cost of one operation on a single thread.

sync.Once is a good example of using atomics effectively. Its fast path only needs a single atomic load — after the given function runs, every future call to Do checks a flag and returns:

type Once struct {
    mu   Mutex
    done atomic.Bool
}

// Do calls f if and only if Do is being called
// for the first time for this o.
func (o *Once) Do(f func()) {
    if o.done.Load() { // lock-free fast path
        return
    }
    // slow path...
}
Show the translated C code
typedef struct sync_Once {
    sync_Mutex mu;
    atomic_Bool done;
} sync_Once;

// Do calls f if and only if Do is being called
// for the first time for this o.
void sync_Once_Do(sync_Once* o, void (*f)()) {
    if (atomic_Bool_Load(&o->done)) { // lock-free fast path
        return;
    }
    // slow path...
}

Worker pool

To actually run code concurrently, you need threads. The conc.Thread type wraps pthread_t and its related functions:

type Thread struct          // wraps pthread_t
func (th Thread) Wait() any // wraps pthread_join
func (th Thread) Detach()   // wraps pthread_detach
Show the translated C code
typedef struct conc_Thread {
    pthread_t t;
} conc_Thread;

void* conc_Thread_Wait(conc_Thread th);   // wraps pthread_join
void  conc_Thread_Detach(conc_Thread th); // wraps pthread_detach

Consider this conc.Go function:

// Go launches an OS thread that runs fn(arg) and returns a handle to it.
func Go(entry func(any) any, arg any) Thread {
    var th Thread
    rc := pthread_create(&th.t, nil, entry, arg)
    // ...
}
Show the translated C code
// Go launches an OS thread that runs fn(arg) and returns a handle to it.
// `any` in So translates to `void*` in C.
conc_Thread conc_Go(void* (*entry)(void*), void* arg) {
    conc_Thread th = {0};
    int rc = pthread_create(&th.t, NULL, entry, arg);
    // ...
}

Usage example:

func work(arg any) any {
    acc := arg.(*Account)
    // ...
}

func main() {
    var acc Account
    th := conc.Go(work, &acc)
    // ... do other work concurrently ...
    th.Wait() // work is complete once Wait returns
}
Show the translated C code
void* work(void* arg) {
    main_Account* acc = (main_Account*)arg;
    // ...
}

int main(void) {
    main_Account acc = {0};
    conc_Thread th = conc_Go(work, &acc);
    // ... do other work concurrently ...
    conc_Thread_Wait(th); // work is complete once Wait returns
}

It might look like go work(&acc), but that's just on the surface. conc.Go starts an actual OS thread, not a goroutine. You have to eventually call Wait to join or Detach it, or else its resources will leak. Also, OS threads are expensive to create — they're nothing like Go's goroutines, which only need a few kilobytes of stack and start up in nanoseconds.

That's exactly why you usually don't want to call Go inside a loop. For tasks that are short-lived or happen often, it's better to use a pool of long-lived worker threads and send tasks to them.

conc.Pool to the rescue:

     Worker thread pool in So
┌────────┐ ┌────────┐   ┌────────┐
│ Task 1 │ │ Task 2 │...│ Task M │  M tasks
└────────┘ └────────┘   └────────┘
┌────────────────────────────────┐
│           conc.Pool            │  coordinator
└────────────────────────────────┘
┌────────┐ ┌────────┐   ┌────────┐
│ Thrd 1 │ │ Thrd 2 │...│ Thrd N │  N threads, N << M
└────────┘ └────────┘   └────────┘
┌────────────────────────────────┐
│          OS scheduler          │
└────────────────────────────────┘

Usage example:

type Task struct {
    in  int
    out int
}

func square(arg any) {
    task := arg.(*Task)
    task.out = task.in * task.in
}

func main() {
    tasks := make([]Task, 10)

    opts := conc.PoolOpts{NumThreads: 2}
    pool := conc.NewPool(mem.System, opts)
    defer pool.Free()

    for i := range tasks {
        tasks[i].in = i
        pool.Go(square, &tasks[i])
    }
    pool.Wait()
}
Show the translated C code
typedef struct main_Task {
    so_int in;
    so_int out;
} main_Task;

void square(void* arg) {
    main_Task* task = (main_Task*)arg;
    task->out = task->in * task->in;
}

int main(void) {
    so_Slice tasks = so_make_slice(main_Task, 10, 10);

    conc_PoolOpts opts = (conc_PoolOpts){.NumThreads = 2};
    conc_Pool* pool = conc_NewPool(mem_System, opts);

    for (so_int i = 0; i < so_len(tasks); i++) {
        // so_at is a generic macro to get the i-th element of a
        // specific type (main_Task here) from a type-erased slice.
        // Here we're getting the i-th task from the tasks slice.
        so_at(main_Task, tasks, i).in = i;
        conc_Pool_Go(pool, square, &so_at(main_Task, tasks, i));
    }
    conc_Pool_Wait(pool);
    conc_Pool_Free(pool);
}

The first argument to NewPool, mem.System, is a memory allocator. Solod avoids hidden allocations, so anything that needs memory takes an allocator explicitly — here it backs the pool's task queue.

Under the hood, a Pool is a fixed group of worker threads that pull tasks from a shared queue (a ring buffer). It uses one mutex and a few condition variables:

// Pool is a bounded pool of worker threads with a wait queue
// which execute tasks of the form func(any).
type Pool struct {
    alloc mem.Allocator

    mu       sync.Mutex
    notEmpty sync.Cond // signaled when a task is enqueued
    notFull  sync.Cond // signaled when a slot frees
    allDone  sync.Cond // broadcast when no task is in flight

    workers []Thread
    queue   []task // ring buffer of submitted tasks
    active  int    // tasks submitted but not yet finished
    stopped bool   // set by Free to drain and exit
}

// NewPool creates a pool with a given number
// of worker threads and starts them.
func NewPool(alloc mem.Allocator, opts PoolOpts) *Pool

// Go submits a task for execution, blocking while the queue is full.
func (p *Pool) Go(fn func(any), arg any)

// Wait blocks until all submitted tasks finish.
func (p *Pool) Wait()
Show the translated C code
// Pool is a bounded pool of worker threads with a wait queue
// which execute tasks of the form func(any).
typedef struct conc_Pool {
    mem_Allocator alloc;

    sync_Mutex mu;
    sync_Cond  notEmpty; // signaled when a task is enqueued
    sync_Cond  notFull;  // signaled when a slot frees
    sync_Cond  allDone;  // broadcast when no task is in flight

    so_Slice workers;
    so_Slice queue;      // ring buffer of submitted tasks
    so_int   active;     // tasks submitted but not yet finished
    bool     stopped;    // set by Free to drain and exit
} conc_Pool;

conc_Pool* conc_NewPool(mem_Allocator alloc, conc_PoolOpts opts);
void       conc_Pool_Go(conc_Pool* p, void (*fn)(void*), void* arg);
void       conc_Pool_Wait(conc_Pool* p);

notEmpty wakes up a worker when there are tasks to do, notFull applies back-pressure when the queue is full, and allDone lets Wait know when everything is finished. It's a classic producer-consumer setup, about 200 lines of code, and there's nothing fancy about it.

The heart of the pool is the worker loop. Each thread blocks until a task appears, runs it outside the lock so workers execute in parallel, then records that it finished:

// workerMain runs on every pool thread: pull a task, run it, repeat.
func workerMain(arg any) any {
    p := arg.(*Pool)
    for {
        p.mu.Lock()
        for p.qempty() && !p.stopped {
            p.notEmpty.Wait() // sleep until a task is enqueued
        }
        if p.qempty() && p.stopped {
            p.mu.Unlock()
            break // queue drained and pool shutting down
        }
        t := p.qpop()
        p.notFull.Signal() // a slot freed for a waiting submitter
        p.mu.Unlock()

        t.fn(t.arg) // run the task with the lock released

        p.mu.Lock()
        p.active--
        if p.active == 0 {
            p.allDone.Broadcast() // wake anyone parked in Wait
        }
        p.mu.Unlock()
    }
    return nil
}
Show the translated C code
// workerMain runs on every pool thread: pull a task, run it, repeat.
static void* workerMain(void* arg) {
    conc_Pool* p = (conc_Pool*)arg;
    for (;;) {
        sync_Mutex_Lock(&p->mu);
        for (; conc_Pool_qempty(p) && !p->stopped;) {
            sync_Cond_Wait(&p->notEmpty); // sleep until a task is enqueued
        }
        if (conc_Pool_qempty(p) && p->stopped) {
            sync_Mutex_Unlock(&p->mu);
            break; // queue drained and pool shutting down
        }
        task t = conc_Pool_qpop(p);
        sync_Cond_Signal(&p->notFull); // a slot freed for a waiting submitter
        sync_Mutex_Unlock(&p->mu);

        t.fn(t.arg); // run the task with the lock released

        sync_Mutex_Lock(&p->mu);
        p->active--;
        if (p->active == 0) {
            sync_Cond_Broadcast(&p->allDone); // wake anyone parked in Wait
        }
        sync_Mutex_Unlock(&p->mu);
    }
    return NULL;
}

This is what separates a pool from a plain queue. Pool.Go bumps active as it enqueues; each worker decrements it after running a task, and the last one out broadcasts allDone.

Pool.Wait sleeps until the count hits zero:

// Wait blocks until every submitted task has finished.
func (p *Pool) Wait() {
    p.mu.Lock()
    for p.active != 0 {
        p.allDone.Wait()
    }
    p.mu.Unlock()
}
Show the translated C code
// Wait blocks until every submitted task has finished.
void conc_Pool_Wait(conc_Pool* p) {
    sync_Mutex_Lock(&p->mu);
    for (; p->active != 0;) {
        sync_Cond_Wait(&p->allDone);
    }
    sync_Mutex_Unlock(&p->mu);
}

The tradeoff is that the number of worker threads is fixed. In Go, a program can handle thousands of concurrent I/O waits because blocked goroutines use very little memory. A So pool can't do this — if all N workers are parked on a blocking syscall, the pool is stalled until one returns. You have to set the pool size based on the workload, instead of letting the runtime manage it for you.

Channel

Channels are an important part of Go's concurrency model, and So's conc.Chan[T] gives you something quite similar. Just like in Go, it passes values by copy and comes in buffered and unbuffered flavors:

ch := conc.NewChan[int](mem.System, 2) // buffered, capacity 2
defer ch.Free()

// Producer on its own thread.
prod := producer{ch: &ch, n: 5}
thr := conc.Go(produce, &prod)
defer thr.Wait()

// Consume until the channel is closed and drained.
var v int
for ch.Recv(&v) {
    fmt.Printf("received %d\n", v)
}
Show the translated C code
// conc_NewChan, conc_Chan_Recv, and friends are generic macros:
// the element type (so_int here) is passed as the first argument.
conc_Chan ch = conc_NewChan(so_int, mem_System, 2); // buffered, capacity 2

// Producer on its own thread.
producer prod = (producer){.ch = &ch, .n = 5};
conc_Thread thr = conc_Go(produce, &prod);

// Consume until the channel is closed and drained.
so_int v = 0;
for (; conc_Chan_Recv(so_int, &ch, &v);) {
    fmt_Printf("received %d\n", v);
}

conc_Thread_Wait(thr);
conc_Chan_Free(so_int, &ch);

Chan[T] is a thin generic shell over one of two engines, picked at creation time:

Buffered (n > 0) is a mutex-guarded ring buffer with notEmpty and notFull condition variables — like the Pool queue. Senders block when it's full, receivers block when it's empty.

type Buffer struct {
    alloc mem.Allocator

    mu       sync.Mutex
    notEmpty sync.Cond // signaled when an item becomes available
    notFull  sync.Cond // signaled when a slot frees

    buf    mem.Array   // ring buffer
    closed bool        // true after Close
}

// Send copies v into the ring, blocking while it is full.
func (ch *Buffer) Send(v any) {
    ch.mu.Lock()
    for ch.bfull() {
        ch.notFull.Wait() // back-pressure until a slot frees
    }
    ch.bpush(v)
    ch.notEmpty.Signal() // wake one waiting receiver
    ch.mu.Unlock()
}
Show the translated C code
typedef struct conc_Buffer {
    mem_Allocator alloc;

    sync_Mutex mu;
    sync_Cond  notEmpty; // signaled when an item becomes available
    sync_Cond  notFull;  // signaled when a slot frees

    mem_Array buf;       // ring buffer
    bool closed;         // true after Close
} conc_Buffer;

// Send copies v into the ring, blocking while it is full.
void conc_Buffer_Send(conc_Buffer* ch, void* v) {
    sync_Mutex_Lock(&ch->mu);
    for (; conc_Buffer_bfull(ch);) {
        sync_Cond_Wait(&ch->notFull); // back-pressure until a slot frees
    }
    conc_Buffer_bpush(ch, v);
    sync_Cond_Signal(&ch->notEmpty); // wake one waiting receiver
    sync_Mutex_Unlock(&ch->mu);
}

The full implementation also checks for closed, but I left it out for brevity.

Recv is the mirror method: block while empty, pop the next value, signal notFull to wake a sender. It also handles the closed channel, returning false once the buffer is closed and drained. The rest is this lock-wait-signal core.

Buffer source code

Unbuffered (n == 0) is a rendezvous: each send blocks until a receiver takes the value, copying vsize bytes directly from the sender's stack to the receiver's destination without using an intermediate buffer.

type Rendezvous struct {
    alloc mem.Allocator
    vsize int // size in bytes of a handed-off value

    mu   sync.Mutex
    cond sync.Cond // broadcast on every slot state change

    src     any  // the sender's published value (valid while full)
    full    bool // a value is published and not yet freed
    claimed bool // the published value has been taken by a receiver
    closed  bool // true after Close
}

// Send publishes v and waits for a receiver to take it.
func (ch *Rendezvous) Send(v any) {
    ch.mu.Lock()
    for ch.full {
        ch.cond.Wait()  // wait for the previous hand-off to finish
    }
    ch.src, ch.full, ch.claimed = v, true, false // publish
    ch.cond.Broadcast() // wakeup #1: wake a receiver
    for !ch.claimed {
        ch.cond.Wait()  // wait until the value is taken
    }
    ch.src, ch.full = nil, false // free the slot
    ch.cond.Broadcast()
    ch.mu.Unlock()
}
Show the translated C code
typedef struct conc_Rendezvous {
    mem_Allocator alloc;
    so_int vsize; // size in bytes of a handed-off value

    sync_Mutex mu;
    sync_Cond  cond; // broadcast on every slot state change

    void* src;     // the sender's published value (valid while full)
    bool  full;    // a value is published and not yet freed
    bool  claimed; // the published value has been taken by a receiver
    bool  closed;  // true after Close
} conc_Rendezvous;

// Send publishes v and waits for a receiver to take it.
void conc_Rendezvous_Send(conc_Rendezvous* ch, void* v) {
    sync_Mutex_Lock(&ch->mu);
    for (; ch->full;) {
        sync_Cond_Wait(&ch->cond);  // wait for the previous hand-off to finish
    }
    ch->src = v;                    // publish
    ch->full = true;
    ch->claimed = false;
    sync_Cond_Broadcast(&ch->cond); // wakeup #1: wake a receiver
    for (; !ch->claimed;) {
        sync_Cond_Wait(&ch->cond);  // wait until the value is taken
    }
    ch->full = false;               // free the slot
    ch->src = NULL;
    sync_Cond_Broadcast(&ch->cond);
    sync_Mutex_Unlock(&ch->mu);
}

Recv is the other half: it waits for a published, unclaimed value, copies vsize bytes straight from the sender's stack into dst (no intermediate buffer), marks it as claimed, and broadcasts to wake the sender back, creating wakeup #2. One hand-off, two wakeups.

Copying directly from the sender's stack is safe because of that second wakeup. src is a pointer to v, which lives on the sender's stack. While the receiver is reading it, the sender is parked in for !ch.claimed { ch.cond.Wait() }, so its stack frame stays alive. The sender only returns (and reclaims that memory) after the receiver sets claimed and wakes it up. There's no need to copy into a shared buffer because the source is guaranteed to outlive the read.

Rendezvous source code

As you can see, the API is pretty similar to Go. Now let's look at the numbers.

Performance

Here's the main tradeoff: pthread-based concurrency primitives are fast when no one has to block, but they get slow when someone does. And it's always for the same reason.

Go schedules goroutines in userspace. When one goroutine blocks on a channel and another wakes it up, the runtime moves them between its own queues — no kernel involved. POSIX threads, on the other hand, don't provide a userland scheduler. When a thread blocks on a condition variable, it parks in the kernel, and waking it up requires a syscall. Every hand-off between threads that actually parks pays the cost of a syscall on both ends.

You can clearly see the difference in the mutex benchmarks. With 8 competing threads, it all comes down to whether the waiting threads have to park or not:

Mutex benchmark Go So Winner
Uncontended, 1 thread 14ns 9ns So - 1.6x
Contended spin, 8 threads 75ns 27ns So - 2.8x
Contended work, 8 threads 1.1µs 2.0µs Go - 1.8x

Each number is the average time for a single Lock/Unlock pair. The uncontended benchmark runs on one thread, while the contended benchmarks have multiple threads fighting over the same mutex.

Notice that So actually wins the first two benchmarks, and for good reason. So's Lock is a plain pthread_mutex_lock call with nothing extra, while Go's sync.Mutex adds more overhead — like starvation-mode tracking and a runtime that stays involved because a goroutine can be preempted in the middle of a critical section.

When nobody parks, that overhead is the main cost, and the thinner wrapper is closer to the hardware. With an empty critical section (the spin benchmark), a waiting thread grabs the lock while still spinning and almost never parks — So wins by 2.8x. The uncontended benchmark (a single thread, no contention) shows the same thing: less code between the call and the lock, so 9ns versus 14ns.

The picture flips the moment threads have to park. Give the critical section about a microsecond of real work (the work benchmark) and waiters exhaust their spin budget and park. Now every hand-off costs a wakeup syscall, and So drops to half of Go's throughput. The work is identical in both cases — the difference comes from the parking cost.

Condition variables demonstrate this clearly because they always park:

Cond benchmark Go So Winner
1 waiter 150ns 1.5µs Go - 10x
8 waiters 2.0µs 14µs Go - 7.0x
32 waiters 9.0µs 60µs Go - 6.7x

Each number is the cost of one rendezvous round: a single broadcast that wakes every waiter and hands control back, with N waiters plus one broadcaster.

Pthread-based condition variable is consistently 7-10 times slower. There's no trick to close this gap — it's just the cost of waking up a real OS thread instead of a goroutine.

Channels have the same issue because they're built using mutexes and condition variables:

Chan benchmark Go So Winner
Uncontended, 1 thread 24ns 21ns So - 1.1x
Unbuffered, 2 threads 130ns 3.0µs Go - 23x
Buffered (10), 2 threads 44ns 400ns Go - 9.1x
Buffered (100), 2 threads 33ns 70ns Go - 2.1x

Each number is the cost of moving one value through the channel (send plus its matching receive). The number in parentheses is the buffer capacity.

The uncontended case fills and drains a buffer from a single thread, so nothing ever blocks — it's just a lock plus a copy, which gives So a slight advantage. But the moment a producer and consumer actually start handing off work, So has to wake up a thread for every transfer that gets parked. It's worst for the unbuffered channel, where every value is a rendezvous with two wakeups: 23x slower. A larger buffer helps a lot — with room for 100 items, most sends go through without waking anyone, and the gap narrows to about 2x.

The consequence is that the larger your tasks are, the better pthread-based concurrency works. If you use a channel for fine-grained, value-at-a-time streaming between threads, performance will suffer. But if you use a channel to pass whole work items to a pool, where each item takes tens of microseconds to process, the wakeup cost becomes negligible. The pool benchmarks on realistic workloads confirms this:

Pool benchmark Go So Winner
1000 CPU tasks (~40µs each) 7ms 8ms Go - 1.1x
64 IO tasks (1ms block each) 9ms 10ms Go - 1.1x

Each number is the wall-clock time for 8 workers to process the whole batch.

Here, So is within 1.1x of Go. The per-task dispatch cost is still present, but it's spread out over real work, and the performance penalty is pretty small.

Benchmarking

All benchmarks were run on an Apple M1 CPU running macOS. The C code was compiled with Clang 16 using these CFLAGS and mimalloc as the system allocator:

-Ofast -march=native -flto -funroll-loops -DNDEBUG

The results shown are the medians from several benchmark runs. Each benchmark ran many iterations, following the same logic as Go's own benchmarking.

The Go benchmarks used Go 1.26 and go test -bench=..

Source code for both So's and Go's benchmarks: concsync

Here's a summary of the strengths and weaknesses of the pthread-based approach:

  • ➕ Coarse-grained pooled workloads are within about 10% of Go's performance.
  • ➕ Uncontended locks and spin-friendly critical sections perform quite well.
  • ➕ Atomic operations are as fast as in Go.
  • ➕ The implementation is 100x simpler.
  • ➖ Anything that needs to park and wake an OS thread is much slower than Go's userspace scheduler.
  • ➖ The pool can't handle thousands of blocked waiters like goroutines can.

If you're looking for "thousands of cheap goroutines", the pthread-based approach will let you down. But if you're fine with "a few worker threads handling lots of tasks", it holds up well.

Design decisions

Three decisions influenced the way I implemented concurrency in Solod.

Pthreads, not fibers. I know there are coroutine/fiber libraries for C that avoid the kernel wakeup cost — single-threaded ones like neco, and multi-threaded ones like libfiber. A userspace scheduler is exactly what would help to match Go in the benchmarks above.

I decided not to use one. I wanted something dead simple — an approach I could explain in a paragraph, using tools every C programmer already knows. The trade-off is that you lose some performance with fine-grained blocking, but in many real-world situations, pthreads work fine if you use a worker pool. For me, keeping things simple is more important than saving a few microseconds during task hand-offs. For now, at least.

Standard library, not language. Go bakes goroutines, channels, and select right into the language. I decided to keep everything in the stdlib for two reasons.

➀ It follows So's "no hidden allocations" rule. In Go, go f() quietly allocates a goroutine stack, and make(chan T, n) allocates a buffer. In So, all allocations are explicit: you pass an allocator to NewChan and NewPool, and you always know exactly where the memory comes from — whether it's the system allocator, an arena, or something else.

➁ A library is more flexible. Since a pool is a regular value, you can have as many as you need, each sized for its specific purpose. In a multi-stage pipeline where each stage needs a different capacity, you can start one pool per stage, each with its own NumThreads and QueueSize, instead of being given a single global scheduler. The language stays simple, and the flexibility is in code you can easily read.

Timeouts, not select. Go's select waits on several channel operations at once and proceeds with whichever is ready first. Implementing it would require a lot of work — a thread has to register interest on multiple channels, block once, and then wake up when any of them is ready — so I left it out. Instead, Chan offers SendTimeout and RecvTimeout, which cover two common uses of select with a single channel:

  • "Do this, but give up after a while" (Go's case <-time.After(...) idiom).
  • "Do this only if it won't block" (Go's non-blocking default branch).

What's missing is the ability to block on multiple channels at once and continue with whichever one is ready first, as well as the option to mix sends and receives in the same selection.

Wrapping up

How close can you get to Go's concurrency using only pthreads? Close enough to be useful, but not enough to really match Go. You can wrap real OS threads with familiar APIs — mutexes, condition variables, pools, channels — and the code will look and act a lot like Go, at least until a thread needs to block. But there's no scheduler underneath, so when a thread blocks, it's an actual thread waiting in the kernel, not a goroutine that's paused for free. That's the main limitation of this approach.

What you get in return is brutal simplicity. Every primitive is a thin wrapper with no runtime hiding behind it, so the performance is exactly what the OS gives you: fast atomics, fast uncontended locks, and pooled throughput within ~10% of Go on coarse-grained work. But as soon as you switch to fine-grained, one-value-at-a-time hand-offs, the cost of kernel wakeups becomes the main factor, and you'll notice the slowdown.

If you think the pthread approach might work for you, I invite you to try Solod. It includes the sync and conc packages, along with many others ported from Go's standard library.

]]>
Solod 0.2: Networking, new targets, friendlier interophttps://antonz.org/solod-0.2/Fri, 26 Jun 2026 12:30:00 +0000https://antonz.org/solod-0.2/A strict subset of Go that translates to regular C.Solod (So) is a subset of Go that translates to regular C — with zero runtime, manual memory management, and source-level interop. It's designed for two main audiences:

  • Go developers who want low-level control without having to learn another language.
  • C developers who like Go's style.

The previous version (v0.1) focused on porting core Go stdlib packages and providing convenient C interop. At the end of that post, I said the next release would focus on networking, concurrency, or both. Now, networking is here — the v0.2 release I'm sharing today includes support for TCP, UDP, and Unix domain sockets. Concurrency is still planned for the future, so for now, servers handle one connection at a time.

This release also lets you compile So to more targets, like 32-bit platforms, WebAssembly, and bare metal. And C interop even smoother!

NetworkingTCP serverTCP clientDeadlinesIP addressesTargetsInteropStdlibWrapping up

Networking

The main feature in v0.2 is the net package. It's a simplified version of Go's net package which supports the three most commonly used transports:

  • TCP (networks tcp, tcp4, tcp6) via ResolveTCPAddr, DialTCP, and ListenTCP, with the TCPConn and TCPListener types.
  • UDP (networks udp, udp4, udp6) via ResolveUDPAddr, DialUDP (a connected socket), and ListenUDP (an unconnected socket with ReadFrom/WriteTo).
  • Unix domain sockets (unix for streams, unixgram for datagrams) via ResolveUnixAddr, DialUnix, ListenUnix, and ListenUnixgram.

The API mirrors Go closely, so most of it will feel familiar. The big difference is that So has no goroutines, so there's no concurrent server support — you accept and serve connections sequentially. More on that in a moment.

TCP server

Let's build a classic: an echo server that accepts a connection, reads a message, and sends it back.

package main

import "solod.dev/so/net"

func main() {
    // Resolve the local address to listen on.
    laddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
    if err != nil {
        panic(err)
    }

    // Start listening on the local address.
    ln, err := net.ListenTCP("tcp", &laddr)
    if err != nil {
        panic(err)
    }
    defer ln.Close()
    println("listening on", "127.0.0.1:8080")

    // Accept connections and serve them in a loop.
    for {
        conn, err := ln.Accept()
        if err != nil {
            panic(err)
        }
        serve(&conn)
    }
}

// serve reads one message from the connection, echoes it back,
// and closes the connection.
func serve(conn *net.TCPConn) {
    defer conn.Close()

    var buf [256]byte
    n, err := conn.Read(buf[:])
    if err != nil {
        return
    }
    conn.Write(buf[:n])
}
listening on 127.0.0.1:8080

If you've written a TCP server in Go, this should look familiar — ListenTCP, an Accept loop, and Read/Write on the connection. The only thing missing is a go serve(conn): without goroutines, each connection is handled to completion before moving on to the next Accept.

TCP client

The client starts the connection using DialTCP, then uses Write to send a request and Read to get the reply:

package main

import "solod.dev/so/net"

func main() {
    // Resolve the server address.
    raddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
    if err != nil {
        panic(err)
    }

    // A nil laddr lets the system choose the local address.
    conn, err := net.DialTCP("tcp", nil, &raddr)
    if err != nil {
        panic(err)
    }
    defer conn.Close()

    // Send a request and read the reply.
    conn.Write([]byte("hello"))

    var buf [256]byte
    n, err := conn.Read(buf[:])
    if err != nil {
        panic(err)
    }
    println(string(buf[:n]))
}
hello

UDP and Unix domain sockets work in a similar way. For UDP, an unconnected ListenUDP socket uses ReadFrom to get data and the sender's address, and WriteTo to send a reply. For Unix sockets, there are ListenUnix (stream) and ListenUnixgram (datagram).

Deadlines

By default, Accept, Read, and Write are blocking. In Go, you'd typically use goroutines and contexts to prevent getting stuck forever. Since that's not available in So (yet), every connection and listener supports deadlines instead:

// Give the client 5 seconds to send something.
conn.SetReadDeadline(time.Now().Add(5 * time.Second))

n, err := conn.Read(buf[:])
if err == net.ErrTimeout {
    // The client went quiet; drop the connection.
    return
}

SetDeadline, SetReadDeadline, and SetWriteDeadline are available on TCPConn, UDPConn, UnixConn, and listener types. When the deadline passes, any pending call fails with net.ErrTimeout. If you don't set a deadline, a blocked call will wait forever. This isn't concurrency, but it's enough to keep a single-threaded server responsive.

IP addresses

Along with net, v0.2 ports Go's net/netip package, which provides small, allocation-free value types for IP addresses. Addr represents an IP address, AddrPort combines an IP address with a port, and Prefix is an IP with a prefix length (a CIDR block):

addr, err := netip.ParseAddr("192.168.1.10")
if err != nil {
    panic(err)
}
println(addr.Is4())            // true

ap := netip.AddrPortFrom(addr, 8080)
println(ap.Port())             // 8080

prefix := netip.MustParsePrefix("192.168.1.0/24")
println(prefix.Contains(addr)) // true

These are simple value types that don't use any heap allocation, which fits well with So's explicit-memory approach. The net package also provides SplitHostPort and JoinHostPort functions to help you work with host:port strings.

New targets

Solod compiles to plain C, which (in theory) means it can target anything a C compiler can. Because of this, v0.2 adds new targets:

  • 32-bit platforms. The compiler and stdlib now work correctly on 32-bit platforms, where int and pointers are narrower.
  • WebAssembly (WASI). You can compile a So program to wasm32-wasi and run it under any WASI runtime.
  • Freestanding mode. So programs can run on bare-metal systems without any C standard library. No libc means no malloc, but you can use mem.Arena instead.

Here's the complete toolchain you need to build a freestanding wasm32 binary using zig cc:

export CC="zig cc"
export CFLAGS="-Oz --target=wasm32-freestanding -nostdlib -Wl,--no-entry -Wl,--export=main"
so build -o main.wasm .

A large part of the standard library (bytes, strings, strconv, slices, maps, math, encoding/binary, and more) works just fine in freestanding mode. For more details, check out the freestanding guide.

Friendlier interop

A bunch of smaller changes make Solod nicer to write.

Three new directives for low-level work, all documented in the interop guide:

//so:volatile
var counter int       // emits a C volatile

//so:thread_local
var perThread int     // emits C11 _Thread_local

//so:attr packed
type header struct {  // emits __attribute__((packed))
    version byte
    length  int
}

so:attr works with variables, constants, types, and functions. You can use it on multiple lines, and the attributes will stack. For example, //so:attr aligned(16) will combine with //so:attr packed.

Type aliases. So now supports Go-style type aliases:

type Byte = uint8

Numeric C types. The so/c package now includes named types for C's numeric types — Int, UInt, Long, Short, UChar, LongLong, and others. When you declare an extern function, you can use the actual C types in its signature instead of trying to guess the correct fixed-width Go type for your platform.

Third-party packages. You can now add external So packages using go get or by vendoring, and you can organize your own code into multiple modules. So doesn't have a real package ecosystem yet, but it's a good start.

Better diagnostics. By default, panic messages report the C file and line. Pass --track-source to report the original So source location instead:

so run --track-source .

There's also an optional --check-nil flag that adds nil-pointer checks when accessing struct fields and calling interface methods. This way, if there's a bad dereference, the program will panic cleanly instead of causing a segmentation fault. Both options are off by default to keep the generated code more readable.

More stdlib

Beyond net and net/netip, v0.2 adds a few more packages:

  • encoding/hex — hex encoding and decoding, including Dump for hexdump-style output.
  • uuid — generating and parsing UUIDs (v4 and v7), with random components from a cryptographically secure source.

And a small but handy update to memory management: mem.Arena.Free now reclaims the last allocation if you give it the matching pointer. It's a minor optimization, but it means a quick alloc/free pair on an arena no longer wastes space.

Stdlib documentation

Wrapping up

With v0.2, Solod has evolved from just "command-line tools and C glue" into something you can actually use on a network — like a TCP or UDP server, a small protocol client, or a Unix-socket daemon. The new targets (32-bit, WASM, freestanding) mean the same code can now run in more places, even down to bare metal.

The big thing that's still missing is concurrency. A server that handles requests one at a time works for some tasks, but a real network service needs to manage many connections at once. That's the obvious goal for the next release — adding some kind of concurrency, along with the stdlib packages that support it.

If you're interested, take a look at So's readme — it has everything you need to get started. Or try So online without installing anything.

]]>
Solod 0.1: Go ergonomics, practical stdlib, native C interophttps://antonz.org/solod-0.1/Wed, 06 May 2026 11:00:00 +0000https://antonz.org/solod-0.1/A strict subset of Go that translates to regular C.Solod (So) is a subset of Go that translates to regular C — with zero runtime, manual memory management, and source-level interop. It's designed for two main audiences:

  • Go developers who want low-level control without having to learn another language.
  • C developers who like Go's style.

The initial version (let's call it v0) was focused on picking a subset of Go and translating it to C. The next logical step was to port Go's standard library and make it easier to interop with C. That's what the v0.1 release I'm presenting today is all about.

Standard librarySQLite bindingsPersistent mapStore and retrieveCommand-line interfacePerformanceWrapping up

Standard library

Solod 0.1 ships with the following stdlib packages ported from Go:

  • io, bufio, and fmt — Abstractions and types for general-purpose I/O.
  • bytes, strings, strconv, and unicode/utf8 — Common byte and text operations.
  • slices and maps — Generic heap-allocated data structures.
  • crypto/rand and math/rand — Generating random data.
  • flag, os, and path — Working with the command line and files.
  • log/slog — Structured logging.
  • time — Measuring and displaying time.

And a couple of its own packages:

  • mem — Memory allocation with a pluggable allocator interface.
  • c — Low-level C interop helpers.

Stdlib documentation

In the following sections, I'll demonstrate some of the features using a simple example: a persistent key-value store backed by SQLite.

SQLite bindings

Since So doesn't provide database/sql yet, we'll call SQLite directly through its C API. To do this, let's import the necessary headers with the so:include directive and generate extern declarations using the sobind tool:

package main

import "solod.dev/so/c"

//so:include <sqlite3.h>

// SQLite constants.
//
//so:extern SQLITE_OK
const sqliteOK = 0
//so:extern SQLITE_ROW
const sqliteRow = 100
//so:extern SQLITE_DONE
const sqliteDone = 101

// SQLite types.
//
//so:extern
type sqlite3 struct{}
//so:extern
type sqlite3_stmt struct{}
//so:extern
type sqlite3_value struct{}
//so:extern
type sqlite3_callback func(any, int32, **c.Char, **c.Char) int32

// SQLite functions.
func sqlite3_open(filename string, ppDb **sqlite3) int32
func sqlite3_prepare_v2(db *sqlite3, zSql string, nByte int32, ppStmt **sqlite3_stmt, pzTail **c.ConstChar) int32
func sqlite3_step(arg0 *sqlite3_stmt) int32
func sqlite3_finalize(pStmt *sqlite3_stmt) int32
func sqlite3_close(arg0 *sqlite3) int32
func sqlite3_exec(arg0 *sqlite3, sql string, callback sqlite3_callback, arg3 any, errmsg **c.Char) int32

// more declarations...

The so:extern directive is required for constants (sqliteOK) and types (sqlite3_stmt). As for functions (sqlite3_prepare_v2), we can just declare them without a body — the transpiler will treat them as extern declarations even without so:extern.

Persistent map

With the SQLite API in place, let's implement a key-value type that wraps the database connection:

// SQLMap is a simple key-value store backed by an SQLite database.
type SQLMap struct {
    db *sqlite3
}

Add a constructor that connects to an SQLite database and creates a table to store the items:

var ErrCreate = errors.New("sqlmap: create schema failed")
const sqlCreate = "create table if not exists kv (key text primary key, val)"

// NewSQLMap creates a new SQLMap using the provided connection string.
// It opens a connection to the SQLite database and creates the underlying
// key-value table if it does not already exist.
//
// The caller is responsible for calling Close on the returned SQLMap
// when it is no longer needed.
func NewSQLMap(connStr string) (SQLMap, error) {
    var db *sqlite3
    rc := sqlite3_open(connStr, &db)
    if rc != sqliteOK {
        return SQLMap{}, ErrCreate
    }

    rc = sqlite3_exec(db, sqlCreate, nil, nil, nil)
    if rc != sqliteOK {
        sqlite3_close(db)
        return SQLMap{}, ErrCreate
    }
    return SQLMap{db}, nil
}

// Close releases resources associated with the SQLMap.
func (m *SQLMap) Close() {
    sqlite3_close(m.db)
}

As you can see, this So code looks a lot like regular Go code. However, there are some key differences:

  • When compiled, the code is first translated to plain C, then compiled into a native binary using GCC or Clang.
  • Unlike Go, there is no runtime (no automatic heap memory allocation, no garbage collection, no goroutine scheduler).
  • There is no overhead when calling C functions, unlike Go's Cgo.
  • The interop syntax is a bit cleaner. For example, Go's string (sqlCreate in the sqlite3_exec call) automatically decays to C's const char*.

Store and retrieve

First, let's implement the Set method:

var (
    ErrPrepare = errors.New("sqlmap: prepare failed")
    ErrExec    = errors.New("sqlmap: exec failed")
)

const sqlSet = "insert or replace into kv (key, val) values (?, ?)"

// Set stores a string value for the specified key.
func (m *SQLMap) Set(key string, val string) error {
    var stmt *sqlite3_stmt
    rc := sqlite3_prepare_v2(m.db, sqlSet, -1, &stmt, nil)
    if rc != sqliteOK {
        return ErrPrepare
    }
    defer sqlite3_finalize(stmt)

    sqlite3_bind_text(stmt, 1, key, int32(len(key)), nil)
    sqlite3_bind_text(stmt, 2, val, int32(len(val)), nil)

    rc = sqlite3_step(stmt)
    if rc != sqliteDone {
        return ErrExec
    }
    return nil
}

No surprises here, just a bunch of SQLite API calls.

The Get method is more interesting:

var ErrNotFound = errors.New("sqlmap: not found")
const sqlGet = "select val from kv where key = ?"

// Get returns the value associated with the specified key.
// The caller owns the returned string and must free it with mem.FreeString.
func (m *SQLMap) Get(a mem.Allocator, key string) (string, error) {
    var stmt *sqlite3_stmt
    rc := sqlite3_prepare_v2(m.db, sqlGet, -1, &stmt, nil)
    if rc != sqliteOK {
        return "", ErrPrepare
    }
    defer sqlite3_finalize(stmt)

    sqlite3_bind_text(stmt, 1, key, int32(len(key)), nil)
    rc = sqlite3_step(stmt)
    if rc == sqliteDone {
        return "", ErrNotFound
    }
    if rc != sqliteRow {
        return "", ErrExec
    }

    text := sqlite3_column_text(stmt, 0)
    tmp := c.String(text)
    result := strings.Clone(a, tmp)
    return result, nil
}

The pointer returned by sqlite3_column_text is managed by SQLite. It becomes invalid after calling sqlite3_finalize (which Get does before returning). Because of this, we need to allocate a copy of the returned value, using strings.Clone in this case.

So's approach to memory allocation is similar to Zig's — all heap allocations must be done explicitly by providing a specific instance of the mem.Allocator interface.

The caller, of course, must free the allocated string:

func main() {
    m, err := NewSQLMap(":memory:")
    if err != nil {
        panic(err)
    }
    defer m.Close()

    m.Set("name", "Alice")
    name, err := m.Get(mem.System, "name")
    if err != nil {
        panic(err)
    }
    println("name =", name)
    mem.FreeString(mem.System, name)
}
name = Alice

Here, mem.System is a specific allocator that uses libc's malloc and free. Alternatively, we could use mem.Arena or any other implementation of the mem.Allocator interface:

var buf [1024]byte // stack-allocated
arena := mem.NewArena(buf[:])

name, _ := m.Get(&arena, "name")
mem.FreeString(&arena, name) // no-op for arena; can be omitted

Command-line interface

With the SQLMap type in place, let's create a simple CLI using the flag package:

var (
    opFlag  string
    keyFlag string
    valFlag string
)

func parseFlags() {
    flag.StringVar(&opFlag, "op", "", "operation: get, set, or del")
    flag.StringVar(&keyFlag, "key", "", "key name")
    flag.StringVar(&valFlag, "val", "", "value (for set operation)")
    flag.Parse()
}

func main() {
    parseFlags()
    // ...
}

Then add command routing:

m, err := NewSQLMap("sqlmap.db")
check(err)
defer m.Close()

switch opFlag {
case "set":
    err = m.Set(keyFlag, valFlag)
    check(err)
case "get":
    val, err := m.Get(mem.System, keyFlag)
    check(err)
    println(val)
    mem.FreeString(mem.System, val)
case "del":
    err = m.Delete(keyFlag)
    check(err)
default:
    flag.Usage()
    os.Exit(1)
}
sqlmap -op=set -key=name -val=alice
sqlmap -op=get -key=name
alice

Again, no surprises here — the flag package works just as it does in Go.

Performance

Solod isn't trying to outperform hand-tuned C. Still, performance matters: the code is benchmarked and optimized to run reasonably fast. Since So compiles to plain C and then to native code with full optimizations, the results are sometimes better than Go's.

Here are some highlights from the benchmarks:

  • Buffered I/O is 3x faster than Go.
  • String and byte operations are up to 2.5x faster.
  • Maps are 1.5x faster for modifications.
  • Integer formatting is 2x faster.

There're no GC pauses and no Cgo bridge cost when calling C libraries. The tradeoff is that you have to handle memory yourself, but as the SQLite example above shows, So's allocator interface makes that pretty manageable.

Solod vs. Go benchmarks

Wrapping up

Solod is still in its early days, but with the v0.1 release, it's ready for hobby projects. The already-ported parts of the Go standard library make it easy to write command-line tools (check out the cat, head, sort, and wc examples). Plus, with native C interop, you can build just about anything else you need.

The next release will likely focus on networking, concurrency, or both — along with more stdlib packages.

If you're interested, take a look at So's readme — it has all the information you need to get started. Or try So online without installing anything.

]]>
Porting Go's strings package to Chttps://antonz.org/porting-go-strings/Fri, 03 Apr 2026 13:00:00 +0000https://antonz.org/porting-go-strings/With allocators, benchmarks, and some optimizations.Creating a subset of Go that translates to C was never my end goal. I liked writing C code with Go, but without the standard library it felt pretty limited. So, the next logical step was to port Go's stdlib to C.

Of course, this isn't something I could do all at once. I started with the io package, which provides core abstractions like Reader and Writer, as well as general-purpose functions like Copy. But io isn't very interesting on its own, since it doesn't include specific reader or writer implementations. So my next choices were naturally bytes and strings — the workhorses of almost every Go program. This post is about how the porting process went.

Bits and UTF-8BytesAllocatorsBuffers and buildersBenchmarksOptimizing searchOptimizing builderWrapping up

Bits and UTF-8

Before I could start porting bytes, I had to deal with its dependencies first:

  • math/bits implements bit counting and manipulation functions.
  • unicode/utf8 implements functions for UTF-8 encoded text.

Both of these packages are made up of pure functions, so they were pretty easy to port. The only minor challenge was the difference in operator precedence between Go and C — specifically, bit shifts (<<, >>). In Go, bit shifts have higher precedence than addition and subtraction. In C, they have lower precedence:

// Go: shift has HIGHER precedence than +
var x uint32 = 1<<2 + 3  // (1 << 2) + 3 == 7
// C: shift has LOWER precedence than +
uint32_t x = 1 << 2 + 3; // 1 << (2 + 3) == 32

The simplest solution was to just use parentheses everywhere shifts are involved:

// Go: Mul64 returns the 128-bit product of x and y: (hi, lo) = x * y
func Mul64(x, y uint64) (hi, lo uint64) {
    const mask32 = 1<<32 - 1
    x0 := x & mask32
    x1 := x >> 32
    y0 := y & mask32
    y1 := y >> 32
    w0 := x0 * y0
    t := x1*y0 + w0>>32
    // ...
}
// C: Mul64 returns the 128-bit product of x and y: (hi, lo) = x * y
so_Result bits_Mul64(uint64_t x, uint64_t y) {
    const so_int mask32 = ((so_int)1 << 32) - 1;
    uint64_t x0 = (x & mask32);
    uint64_t x1 = (x >> 32);
    uint64_t y0 = (y & mask32);
    uint64_t y1 = (y >> 32);
    uint64_t w0 = x0 * y0;
    uint64_t t = x1 * y0 + (w0 >> 32);
    // ...
}

With bits and utf8 done, I moved on to bytes.

Bytes

The bytes package provides functions for working with byte slices:

// Count counts the number of non-overlapping instances of sep in s.
func Count(s, sep []byte) int

// Equal reports whether a and b are the
// same length and contain the same bytes.
func Equal(a, b []byte) bool

// Index returns the index of the first instance
// of sep in s, or -1 if sep is not present in s.
func Index(s, sep []byte) int

// Repeat returns a new byte slice consisting of count copies of b.
func Repeat(b []byte, count int) []byte

// and others

Some of them were easy to port, like Equal. Here's how it looks in Go:

// Equal reports whether a and b are the
// same length and contain the same bytes.
func Equal(a, b []byte) bool {
    // Neither cmd/compile nor gccgo allocates for these string conversions.
    return string(a) == string(b)
}

And here's the C version:

// bytes_string reinterprets a byte slice as a string (zero-copy).
#define so_bytes_string(bs) ({                  \
    so_Slice _bs = (bs);                        \
    (so_String){(const char*)_bs.ptr, _bs.len}; \
})

// string_eq returns true if two strings are equal.
static inline bool so_string_eq(so_String s1, so_String s2) {
    return s1.len == s2.len &&
        (s1.len == 0 || memcmp(s1.ptr, s2.ptr, s1.len) == 0);
}

// Equal reports whether a and b are the
// same length and contain the same bytes.
bool bytes_Equal(so_Slice a, so_Slice b) {
    return so_string_eq(so_bytes_string(a), so_bytes_string(b));
}

Just like in Go, the so_bytes_string ([]bytestring) macro doesn't allocate memory; it just reinterprets the byte slice's underlying storage as a string. The so_string_eq function (which works like == in Go) is easy to implement using memcmp from the libc API.

Another example is the IndexByte function, which looks for a specific byte in a slice. Here's the pure-Go implementation:

// IndexByte returns the index of the first instance
// of c in b, or -1 if c is not present in b.
func IndexByte(b []byte, c byte) int {
    for i, x := range b {
        if x == c {
            return i
        }
    }
    return -1
}

And here's the C version:

// IndexByte returns the index of the first instance
// of c in b, or -1 if c is not present in b.
so_int bytes_IndexByte(so_Slice b, so_byte c) {
    for (so_int i = 0; i < so_len(b); i++) {
        so_byte x = so_at(so_byte, b, i);
        if (x == c) {
            return i;
        }
    }
    return -1;
}

I used a regular C for loop to mimic Go's for-range:

  • Loop over the slice indexes with for (so_len is a macro that returns b.len, similar to Go's len built-in).
  • Access the i-th byte with so_at (a bounds-checking macro that returns *((so_byte*)b.ptr + i)).

But Equal and IndexByte don't allocate memory. What should I do with Repeat, since it clearly does? I had a decision to make.

Allocators

The Go runtime handles memory allocation and deallocation automatically. In C, I had a few options:

  • Use a reliable garbage collector like Boehm GC to closely match Go's behavior.
  • Allocate memory with libc's malloc and have the caller free it later with free.
  • Introduce allocators.

An allocator is a tool that reserves memory (typically on the heap) so a program can store its data structures there. See Allocators from C to Zig if you want to learn more about them.

For me, the winner was clear. Modern systems programming languages like Zig and Odin clearly showed the value of allocators:

  • It's obvious whether a function allocates memory or not: if it has an allocator as a parameter, it allocates.
  • It's easy to use different allocation methods: you can use malloc for one function, an arena for another, and a stack allocator for a third.
  • It helps with testing and debugging: you can use a tracking allocator to find memory leaks, or a failing allocator to test error handling.

An Allocator is an interface with three methods: Alloc, Realloc, and Free. In C, it translates to a struct with function pointers:

// Allocator defines the interface for memory allocators.
typedef struct {
    void* self;
    so_Result (*Alloc)(void* self, so_int size, so_int align);
    so_Result (*Realloc)(void* self, void* ptr,
        so_int oldSize, so_int newSize, so_int align);
    void (*Free)(void* self, void* ptr, so_int size, so_int align);
} mem_Allocator;

As I mentioned in the post about porting the io package, this interface representation isn't as efficient as using a static method table, but it's simpler. If you're interested in other options, check out the post on interfaces.

By convention, if a function allocates memory, it takes an allocator as its first parameter. So Go's Repeat:

// Repeat returns a new byte slice consisting of count copies of b.
func Repeat(b []byte, count int) []byte

Translates to this C code:

// Repeat returns a new byte slice consisting of count copies of b.
//
// If the allocator is nil, uses the system allocator.
// The returned slice is allocated; the caller owns it.
so_Slice bytes_Repeat(mem_Allocator a, so_Slice b, so_int count)

If the caller doesn't care about using a specific allocator, they can just pass an empty allocator, and the implementation will use the system allocator — calloc, realloc, and free from libc.

Here's a simplified version of the system allocator (I removed safety checks to make it easier to read):

// SystemAllocator uses the system's malloc, realloc, and free functions.
// It zeros out new memory on allocation and reallocation.
typedef struct {} mem_SystemAllocator;

so_Result mem_SystemAllocator_Alloc(void* self, so_int size, so_int align) {
    void* ptr = calloc(1, (size_t)(size));
    if (ptr == NULL) {
        return (so_Result){.val.as_ptr = NULL, .err = mem_ErrOutOfMemory};
    }
    return (so_Result){ .val.as_ptr = ptr, .err = NULL};
}

so_Result mem_SystemAllocator_Realloc(void* self, void* ptr, so_int oldSize,
    so_int newSize, so_int align) {
    void* newPtr = realloc(ptr, (size_t)(newSize));
    if (newPtr == NULL) {
        return (so_Result){.val.as_ptr = NULL, .err = mem_ErrOutOfMemory};
    }
    if (newSize > oldSize) {
        // Zero new memory beyond the old size.
        memset((char*)newPtr + oldSize, 0, (size_t)(newSize - oldSize));
    }
    return (so_Result){.val.as_ptr = newPtr, .err = NULL};
}

void mem_SystemAllocator_Free(void* self, void* ptr, so_int size, so_int align) {
    free(ptr);
}

The system allocator is stateless, so it's safe to have a global instance:

// System is an instance of a memory allocator that uses
// the system's malloc, realloc, and free functions.
mem_Allocator mem_System = {
    .self = &(mem_SystemAllocator){},
    .Alloc = mem_SystemAllocator_Alloc,
    .Free = mem_SystemAllocator_Free,
    .Realloc = mem_SystemAllocator_Realloc};

Here's an example of how to call Repeat with an allocator:

so_Slice src = so_string_bytes(so_str("abc"));
so_Slice got = bytes_Repeat(mem_System, src, 3);
so_String gotStr = so_bytes_string(got);
if (so_string_ne(gotStr, so_str("abcabcabc"))) {
    so_panic("want Repeat(abc) == abcabcabc");
}
mem_FreeSlice(so_byte, mem_System, got);

Way better than hidden allocations!

Buffers and builders

Besides pure functions, bytes and strings also provide types like bytes.Buffer, bytes.Reader, and strings.Builder. I ported them using the same approach as with functions.

For types that allocate memory, like Buffer, the allocator becomes a struct field:

// A Buffer is a variable-sized buffer of bytes
// with Read and Write methods.
typedef struct {
    mem_Allocator a;
    so_Slice buf;
    so_int off;
} bytes_Buffer;
// Usage example.
bytes_Buffer buf = bytes_NewBuffer(mem_System, (so_Slice){0});
bytes_Buffer_WriteString(&buf, so_str("hello"));
bytes_Buffer_WriteString(&buf, so_str(" world"));
so_String str = bytes_Buffer_String(&buf);
if (so_string_ne(str, so_str("hello world"))) {
    so_panic("Buffer.WriteString failed");
}
bytes_Buffer_Free(&buf);

The code is pretty wordy — most C developers would dislike using bytes_Buffer_WriteString instead of something shorter like buf_writestr. My solution to this problem is to automatically translate Go code to C (which is actually what I do when porting Go's stdlib). If you're interested, check out the post about this approach — Solod: Go can be a better C.

Types that don't allocate, like bytes.Reader, need no special treatment — they translate directly to C structs without an allocator field.

The strings package is the twin of bytes, so porting it was uneventful. Here's strings.Builder usage example in Go and C side by side:

// go
var sb strings.Builder
sb.WriteString("Hello")
sb.WriteByte(',')
sb.WriteRune(' ')
sb.WriteString("world")
s := sb.String()
if s != "Hello, world" {
    panic("want sb.String() == 'Hello, world'")
}
// c
strings_Builder sb = {.a = mem_System};
strings_Builder_WriteString(&sb, so_str("Hello"));
strings_Builder_WriteByte(&sb, ',');
strings_Builder_WriteRune(&sb, U' ');
strings_Builder_WriteString(&sb, so_str("world"));
so_String s = strings_Builder_String(&sb);
if (so_string_ne(s, so_str("Hello, world"))) {
    so_panic("want sb.String() == 'Hello, world'");
}
strings_Builder_Free(&sb);

Again, the C code is just a more verbose version of Go's implementation, plus explicit memory allocation.

Benchmarks

What's the point of writing C code if it's slow, right? I decided it was time to benchmark the ported C types and functions against their Go versions.

To do that, I ported the benchmarking part of Go's testing package. Surprisingly, the simplified version was only 300 lines long and included everything I needed:

  • Figuring out how many iterations to run.
  • Running the benchmark function in a loop.
  • Recording metrics (ns/op, MB/s, B/op, allocs/op).
  • Reporting the results.

Here's a sample benchmark for the strings.Builder type:

static so_String someStr = so_str("some string sdljlk jsklj3lkjlk djlkjw");
static const so_int numWrite = 16;
volatile so_String sink = {0};

void main_WriteString_AutoGrow(testing_B* b) {
    mem_Allocator a = testing_B_Allocator(b);
    for (; testing_B_Loop(b);) {
        strings_Builder sb = strings_NewBuilder(a);
        for (so_int i = 0; i < numWrite; i++) {
            strings_Builder_WriteString(&sb, someStr);
        }
        sink = strings_Builder_String(&sb);
        strings_Builder_Free(&sb);
    }
}

// more benchmarks...

Reads almost like Go's benchmarks.

To monitor memory usage, I created Tracker — a memory allocator that wraps another allocator and keeps track of allocations:

// A Stats records statistics about the memory allocator.
typedef struct {
    uint64_t Alloc;
    uint64_t TotalAlloc;
    uint64_t Mallocs;
    uint64_t Frees;
} mem_Stats;

// A Tracker wraps an Allocator and tracks all
// allocations and deallocations made through it.
typedef struct {
    mem_Allocator Allocator;
    mem_Stats Stats;
} mem_Tracker;

so_Result mem_Tracker_Alloc(void* self, so_int size, so_int align) {
    mem_Tracker* t = self;
    so_Result res = t->Allocator.Alloc(t->Allocator.self, size, align);
    // ...
    t->Stats.Alloc += (uint64_t)(size);
    t->Stats.TotalAlloc += (uint64_t)(size);
    t->Stats.Mallocs++;
    return (so_Result){.val.as_ptr = res.val.as_ptr, .err = NULL};
}

void mem_Tracker_Free(void* self, void* ptr, so_int size, so_int align) {
    mem_Tracker* t = self;
    t->Allocator.Free(t->Allocator.self, ptr, size, align);
    t->Stats.Alloc -= (uint64_t)(size);
    t->Stats.Frees++;
}

The benchmark gets an allocator through the testing_RunBenchmarks function and wraps it in a Tracker to keep track of allocations:

int main(void) {
    so_Slice benchs = {(testing_Benchmark[4]){
        {.Name = so_str("WriteS_AutoGrow"), .F = main_WriteString_AutoGrow},
        {.Name = so_str("WriteS_PreGrow"), .F = main_WriteString_PreGrow},
        {.Name = so_str("WriteB_AutoGrow"), .F = main_Write_AutoGrow},
        {.Name = so_str("WriteB_PreGrow"), .F = main_Write_PreGrow}},
        4, 4};
    testing_RunBenchmarks(mem_System, benchs);
}

There's no auto-discovery, but the manual setup is quite straightforward.

With the benchmarking setup ready, I ran benchmarks on the strings package. Some functions did well — about 1.5-2x faster than their Go equivalents:

go
Benchmark_Clone-8      12143073      98.50 ns/op    1024 B/op    1 allocs/op
Benchmark_Fields-8       791077    1524 ns/op        288 B/op    1 allocs/op
Benchmark_Repeat-8      9197040     127.3 ns/op     1024 B/op    1 allocs/op

c
Benchmark_Clone        27935466      41.84 ns/op    1024 B/op    1 allocs/op
Benchmark_Fields        1319384     907.7 ns/op      272 B/op    1 allocs/op
Benchmark_Repeat       18445929      64.11 ns/op    1024 B/op    1 allocs/op

But Index (searching for a substring in a string) was a total disaster — it was nearly 20 times slower than in Go:

go
Benchmark_Index-8      47874408      25.14 ns/op       0 B/op    0 allocs/op

c
Benchmark_Index          483787     483.1 ns/op        0 B/op    0 allocs/op

The problem was caused by the IndexByte function we looked at earlier:

// IndexByte returns the index of the first instance
// of c in b, or -1 if c is not present in b.
func IndexByte(b []byte, c byte) int {
    for i, x := range b {
        if x == c {
            return i
        }
    }
    return -1
}

This "pure" Go implementation is just a fallback. On most platforms, Go uses a specialized version of IndexByte written in assembly.

For the C version, the easiest solution was to use memchr, which is also optimized for most platforms:

static inline so_int bytealg_IndexByte(so_Slice b, so_byte c) {
    void* at = memchr(b.ptr, (int)c, b.len);
    if (at == NULL) return -1;
    return (so_int)((char*)at - (char*)b.ptr);
}

With this fix, the benchmark results changed drastically:

go
Benchmark_Index-8        47874408    25.14 ns/op    0 B/op    0 allocs/op
Benchmark_IndexByte-8    54982188    21.98 ns/op    0 B/op    0 allocs/op

c
Benchmark_Index          33552540    35.21 ns/op    0 B/op    0 allocs/op
Benchmark_IndexByte      36868624    32.81 ns/op    0 B/op    0 allocs/op

Still not quite as fast as Go, but it's close. Honestly, I don't know why the memchr-based implementation is still slower than Go's assembly here, but I decided not to pursue it any further.

After running the rest of the strings function benchmarks, the ported versions won all of them except for two:

Benchmark Go C (mimalloc) C (arena) Winner
Clone 99ns 42ns 34ns C - 2.4x
Compare 47ns 36ns 36ns C - 1.3x
Fields 1524ns 908ns 912ns C - 1.7x
Index 25ns 35ns 34ns Go - 0.7x
IndexByte 22ns 33ns 33ns Go - 0.7x
Repeat 127ns 64ns 67ns C - 1.9x
ReplaceAll 243ns 200ns 203ns C - 1.2x
Split 1899ns 1399ns 1423ns C - 1.3x
ToUpper 2066ns 1602ns 1622ns C - 1.3x
Trim 501ns 373ns 375ns C - 1.3x

Benchmarking details

Optimizing builder

strings.Builder is a common way to compose strings from parts in Go, so I tested its performance too. The results were worse than I expected:

go
Benchmark_WriteS_AutoGrow-8   5385492   224.0 ns/op   1424 B/op   5 allocs/op
Benchmark_WriteS_PreGrow-8   10692721   112.9 ns/op    640 B/op   1 allocs/op

c
Benchmark_WriteS_AutoGrow     5659255   212.9 ns/op   1147 B/op   5 allocs/op
Benchmark_WriteS_PreGrow      9811054   122.1 ns/op    592 B/op   1 allocs/op

Here, the C version performed about the same as Go, but I expected it to be faster. Unlike Index, Builder is written entirely in Go, so there's no reason the ported version should lose in this benchmark.

The WriteString method looked almost identical in Go and C:

// WriteString appends the contents of s to b's buffer.
// It returns the length of s and a nil error.
func (b *Builder) WriteString(s string) (int, error) {
    b.buf = append(b.buf, s...)
    return len(s), nil
}
static so_Result strings_Builder_WriteString(void* self, so_String s) {
    strings_Builder* b = self;
    strings_Builder_grow(b, so_len(s));
    b->buf = so_extend(so_byte, b->buf, so_string_bytes(s));
    return (so_Result){.val.as_int = so_len(s), .err = NULL};
}

Go's append automatically grows the backing slice, while strings_Builder_grow does it manually (so_extend, on the contrary, doesn't grow the slice — it's merely a memcpy wrapper). So, there shouldn't be any difference. I had to investigate.

Looking at the compiled binary, I noticed a difference in how the functions returned results. Go returns multiple values in separate registers, so (int, error) uses three registers: one for 8-byte int, two for the error interface (implemented as two 8-byte pointers). But in C, so_Result was a single struct made up of two so_Value unions and a so_Error pointer:

typedef union {
    bool as_bool;        // 1 byte
    so_int as_int;       // 8 bytes
    int64_t as_i64;      // 8 bytes
    so_String as_string; // 16 bytes (ptr + len)
    so_Slice as_slice;   // 24 bytes (ptr + len + cap)
    void* as_ptr;        // 8 bytes
    // ... other types
} so_Value;

typedef struct {
    so_Value val;        // 24 bytes
    so_Value val2;       // 24 bytes
    so_Error err;        // 8 bytes
} so_Result;

Of course, this 56-byte monster can't be returned in registers — the C calling convention passes it through memory instead. Since WriteString is on the hot path in the benchmark, I figured this had to be the issue. So I switched from a single monolithic so_Result type to signature-specific types for multi-return pairs:

  • so_R_bool_err for (bool, error);
  • so_R_int_err for (so_int, error);
  • so_R_str_err for (so_String, error);
  • etc.

Now, the Builder.WriteString implementation in C looked like this:

typedef struct {
    so_int val;
    so_Error err;
} so_R_int_err;

static so_R_int_err strings_Builder_WriteString(void* self, so_String s) {
    // ...
}

so_R_int_err is only 16 bytes — small enough to be returned in two registers. Problem solved! But it wasn't — the benchmark only showed a slight improvement.

After looking into it more, I finally found the real issue: unlike Go, the C compiler wasn't inlining WriteString calls. Adding inline and moving strings_Builder_WriteString to the header file made all the difference:

go
Benchmark_WriteS_AutoGrow-8   5385492   224.0 ns/op   1424 B/op   5 allocs/op
Benchmark_WriteS_PreGrow-8   10692721   112.9 ns/op    640 B/op   1 allocs/op

c
Benchmark_WriteS_AutoGrow    10344024   115.9 ns/op   1147 B/op   5 allocs/op
Benchmark_WriteS_PreGrow     41045286    28.74 ns/op   592 B/op   1 allocs/op

2-4x faster. That's what I was hoping for!

Wrapping up

Porting bytes and strings was a mix of easy parts and interesting challenges. The pure functions were straightforward — just translate the syntax and pay attention to operator precedence. The real design challenge was memory management. Using allocators turned out to be a good solution, making memory allocation clear and explicit without being too difficult to use.

The benchmarks showed that the C versions outperformed Go in most cases, sometimes by 2-4x. The only exceptions were Index and IndexByte, where Go relies on hand-written assembly. The strings.Builder optimization was an interesting challenge: what seemed like a return-type issue was actually an inlining problem, and fixing it gave a nice speed boost.

There's a lot more of Go's stdlib to port. In the next post, we'll cover time — a very unique Go package. In the meantime, if you'd like to write Go that translates to C — with no runtime and manual memory management — I invite you to try Solod. The bytes and strings packages are included, of course.

]]>
Porting Go's io package to Chttps://antonz.org/porting-go-io/Wed, 25 Mar 2026 14:00:00 +0000https://antonz.org/porting-go-io/Interfaces, slices, multi-returns and alloca.Creating a subset of Go that translates to C was never my end goal. I liked writing C code with Go, but without the standard library it felt pretty limited. So, the next logical step was to port Go's stdlib to C.

Of course, this isn't something I could do all at once. So I started with the standard library packages that had the fewest dependencies, and one of them was the io package. This post is about how that went.

io packageSlicesMultiple returnsErrorsInterfacesType assertionSpecialized readersCopyWrapping up

The io package

io is one of the core Go packages. It introduces the concepts of readers and writers, which are also common in other programming languages.

In Go, a reader is anything that can read some raw data (bytes) from a source into a slice:

type Reader interface {
    Read(p []byte) (n int, err error)
}

A writer is anything that can take some raw data from a slice and write it to a destination:

type Writer interface {
    Write(p []byte) (n int, err error)
}

The io package defines many other interfaces, like Seeker and Closer, as well as combinations like ReadWriter and WriteCloser. It also provides several functions, the most well-known being Copy, which copies all data from a source (represented by a reader) to a destination (represented by a writer):

func Copy(dst Writer, src Reader) (written int64, err error)

C, of course, doesn't have interfaces. But before I get into that, I had to make several other design decisions.

Slices

In general, a slice is a linear container that holds N elements of type T. Typically, a slice is a view of some underlying data. In Go, a slice consists of a pointer to a block of allocated memory, a length (the number of elements in the slice), and a capacity (the total number of elements that can fit in the backing memory before the runtime needs to re-allocate):

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

Interfaces in the io package work with fixed-length slices (readers and writers should never append to a slice), and they only use byte slices. So, the simplest way to represent this in C could be:

typedef struct {
    uint8_t* ptr;
    size_t len;
} Bytes;

But since I needed a general-purpose slice type, I decided to do it the Go way instead:

typedef struct {
    void* ptr;
    size_t len;
    size_t cap;
} so_Slice;

Plus a bound-checking helper to access slice elements:

#define so_at(T, s, i) (*so_at_ptr(T, s, i))
#define so_at_ptr(T, s, i) ({            \
    so_Slice _s_at = (s);                \
    size_t _i = (size_t)(i);             \
    if (_i >= _s_at.len)                 \
        so_panic("index out of bounds"); \
    (T*)_s_at.ptr + _i;                  \
})

Usage example:

// go
nums := make([]int, 3)
nums[0] = 11
nums[1] = 22
nums[2] = 33
n1 := nums[1]
// c
so_Slice nums = so_make_slice(int, 3, 3);
so_at(int, nums, 0) = 11;
so_at(int, nums, 1) = 22;
so_at(int, nums, 2) = 33;
so_int n1 = so_at(int, nums, 1);

So far, so good.

Multiple returns

Let's look at the Read method again:

Read(p []byte) (n int, err error)

It returns two values: an int and an error. C functions can only return one value, so I needed to figure out how to handle this.

The classic approach would be to pass output parameters by pointer, like read(p, &n, &err) or n = read(p, &err). But that doesn't compose well and looks nothing like Go. Instead, I went with a result struct:

typedef union {
    bool as_bool;
    so_int as_int;
    int64_t as_i64;
    so_String as_string;
    so_Slice as_slice;
    void* as_ptr;
    // ... other types
} so_Value;

typedef struct {
    so_Value val;
    so_Error err;
} so_Result;

The so_Value union can store any primitive type, as well as strings, slices, and pointers. The so_Result type combines a value with an error. So, our Read method (let's assume it's just a regular function for now):

func Read(p []byte) (n int, err error)

Translates to:

so_Result Read(so_Slice p);

And the caller can access the result like this:

so_Result res = Read(p);
if (res.err) {
    so_panic(res.err->msg);
}
so_println("read", res.val.as_int, "bytes");

Errors

For the error type itself, I went with a simple pointer to an immutable string:

struct so_Error_ {
    const char* msg;
};
typedef struct so_Error_* so_Error;

Plus a constructor macro:

#define errors_New(s) (&(struct so_Error_){s})

I wanted to avoid heap allocations as much as possible, so decided not to support dynamic errors. Only sentinel errors are used, and they're defined at the file level like this:

so_Error io_EOF = errors_New("EOF");
so_Error io_ErrOffset = errors_New("io: invalid offset");

Errors are compared by pointer identity (==), not by string content — just like sentinel errors in Go. A nil error is a NULL pointer. This keeps error handling cheap and straightforward.

Interfaces

This was the big one. In Go, an interface is a type that specifies a set of methods. Any concrete type that implements those methods satisfies the interface — no explicit declaration needed. In C, there's no such mechanism.

For interfaces, I decided to use "fat" structs with function pointers. That way, Go's io.Reader:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Becomes an io_Reader struct in C:

typedef struct {
    void* self;
    so_Result (*Read)(void* self, so_Slice p);
} io_Reader;

The self pointer holds the concrete value, and each method becomes a function pointer that takes self as its first argument. This is less efficient than using a static method table, especially if the interface has a lot of methods, but it's simpler. So I decided it was good enough for the first version.

Now functions can work with interfaces without knowing the specific implementation:

// ReadFull reads exactly len(buf) bytes from r into buf.
so_Result io_ReadFull(io_Reader r, so_Slice buf) {
    so_int n = 0;
    so_Error err = NULL;
    for (; n < so_len(buf) && err == NULL;) {
        so_Slice curBuf = so_slice(so_byte, buf, n, buf.len);
        so_Result res = r.Read(r.self, curBuf);
        err = res.err;
        n += res.val.as_int;
    }
    // ...
}

// A custom reader.
typedef struct {
    so_Slice b;
} reader;

static so_Result reader_Read(void* self, so_Slice p) {
    // ...
}

int main(void) {
    // We'll read from a string literal.
    so_String str = so_str("hello world");
    reader rdr = (reader){.b = so_string_bytes(str)};

    // Wrap the specific reader into an interface.
    io_Reader r = (io_Reader){
        .self = &rdr,
        .Read = reader_Read,
    };

    // Read the first 4 bytes from the string into a buffer.
    so_Slice buf = so_make_slice(so_byte, 4, 4);
    // ReadFull doesn't care about the specific reader implementation -
    // it could read from a file, the network, or anything else.
    so_Result res = io_ReadFull(r, buf);
}

Calling a method on the interface just goes through the function pointer:

// r.Read(buf) becomes:
r.Read(r.self, buf);

Type assertion

Go's interface is more than just a value wrapper with a method table. It also stores type information about the value it holds:

type iface struct {
    tab  *itab
    data unsafe.Pointer  // specific value
}

type itab struct {
    Inter *InterfaceType // method table
    Type  *Type          // type information
    // ...
}

Since the runtime knows the exact type inside the interface, it can try to "upgrade" the interface (for example, a regular Reader) to another interface (like WriterTo) using a type assertion:

// copyBuffer copies from src to dst using the provided buffer
// until either EOF is reached on src or an error occurs.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    // If the reader has a WriteTo method, use it to do the copy.
    if wt, ok := src.(WriterTo); ok {  // try "upgrading" to WriterTo
        return wt.WriteTo(dst)
    }
    // src is not a WriterTo, proceed with the default copy implementation.

The last thing I wanted to do was reinvent Go's dynamic type system in C, so dropping this feature was an easy decision.

There's another kind of type assertion, though — when we unwrap the interface to get the value of a specific type:

// Does r (a Reader) hold a pointer to a value of concrete type LimitedReader?
// If true, lr will get the unwrapped pointer.
lr, ok := r.(*LimitedReader)

And this kind of assertion is quite possible in C. All we have to do is compare function pointers:

// Are r.Read and LimitedReader_Read the same function?
bool ok = (r.Read == LimitedReader_Read);
if (ok) {
    io_LimitedReader* lr = r.self;
}

If two different types happened to share the same method implementation, this would break. In practice, each concrete type has its own methods, so the function pointer serves as a reliable type tag.

Specialized readers

After I decided on the interface approach, porting the actual io types was pretty easy. For example, LimitedReader wraps a reader and stops with EOF after reading N bytes:

type LimitedReader struct {
    R Reader
    N int64
}

func (l *LimitedReader) Read(p []byte) (int, error) {
    if l.N <= 0 {
        return 0, EOF
    }
    if int64(len(p)) > l.N {
        p = p[0:l.N]
    }
    n, err := l.R.Read(p)
    l.N -= int64(n)
    return n, err
}

The logic is straightforward: if there are no bytes left, return EOF. Otherwise, if the buffer is bigger than the remaining size, shorten it. Then, call the underlying reader, and decrease the remaining size.

Here's what the ported C code looks like:

typedef struct {
    io_Reader R;
    int64_t N;
} io_LimitedReader;

so_Result io_LimitedReader_Read(void* self, so_Slice p) {
    io_LimitedReader* l = self;
    if (l->N <= 0) {
        return (so_Result){.val.as_int = 0, .err = io_EOF};
    }
    if ((int64_t)(so_len(p)) > l->N) {
        p = so_slice(so_byte, p, 0, l->N);
    }
    so_Result res = l->R.Read(l->R.self, p);
    so_int n = res.val.as_int;
    l->N -= (int64_t)(n);
    return (so_Result){.val.as_int = n, .err = res.err};
}

A bit more verbose, but nothing special. The multiple return values, the interface call with l.R.Read, and the slice handling are all implemented as described in previous sections.

Copy

Copy is where everything comes together. Here's the simplified Go version:

// Copy copies from src to dst until either
// EOF is reached on src or an error occurs.
func Copy(dst Writer, src Reader) (written int64, err error) {
    // Allocate a temporary buffer for copying.
    size := 32 * 1024
    buf := make([]byte, size)
    // Copy from src to dst using the buffer.
    for {
        nr, er := src.Read(buf)
        if nr > 0 {
            nw, ew := dst.Write(buf[0:nr])
            written += int64(nw)
            if ew != nil {
                err = ew
                break
            }
        }
        if er != nil {
            if er != EOF {
                err = er
            }
            break
        }
    }
    return written, err
}

In Go, Copy allocates its buffer on the heap with make([]byte, size). I could take a similar approach in C — make Copy take an allocator and use it to create the buffer like this:

so_Result io_Copy(mem_Allocator a, io_Writer dst, io_Reader src) {
    so_int size = 32 * 1024;
    so_Slice buf = mem_AllocSlice(so_byte, a, size, size);
    // ...
}

But since this is just a temporary buffer that only exists during the function call, I decided stack allocation was a better choice:

so_Result io_Copy(io_Writer dst, io_Reader src) {
    so_int size = 8 * 1024;
    so_Slice buf = so_make_slice(so_byte, size, size);
    // ...
}

so_make_slice allocates memory on a stack with a bounds-checking macro that wraps C's alloca. It moves the stack pointer and gives you a chunk of memory that's automatically freed when the function returns.

People often avoid using alloca because it can cause a stack overflow, but using a bounds-checking wrapper fixes this issue. Another common concern with alloca is that it's not block-scoped — the memory stays allocated until the function exits. However, since we only allocate once, this isn't a problem.

Here's the simplified C version of Copy:

so_Result io_Copy(io_Writer dst, io_Reader src) {
    so_int size = 8 * 1024; // smaller buffer, 8 KiB
    so_Slice buf = so_make_slice(so_byte, size, size);
    int64_t written = 0;
    so_Error err = NULL;
    for (;;) {
        so_Result resr = src.Read(src.self, buf);
        so_int nr = resr.val.as_int;
        if (nr > 0) {
            so_Result resw = dst.Write(dst.self, so_slice(so_byte, buf, 0, nr));
            so_int nw = resw.val.as_int;
            written += (int64_t)(nw);
            if (resw.err != NULL) {
                err = resw.err;
                break;
            }
        }
        if (resr.err != NULL) {
            if (resr.err != io_EOF) {
                err = resr.err;
            }
            break;
        }
    }
    return (so_Result){.val.as_i64 = written, .err = err};
}

Here, you can see all the parts from this post working together: a function accepting interfaces, slices passed to interface methods, a result type wrapping multiple return values, error sentinels compared by identity, and a stack-allocated buffer used for the copy.

Wrapping up

Porting Go's io package to C meant solving a few problems: representing slices, handling multiple return values, modeling errors, and implementing interfaces using function pointers. None of this needed anything fancy — just structs, unions, functions, and some macros. The resulting C code is more verbose than Go, but it's structurally similar, easy enough to read, and this approach should work well for other Go packages too.

The io package isn't very useful on its own — it mainly defines interfaces and doesn't provide concrete implementations. So, the next two packages to port were naturally bytes and strings — I'll talk about those in the next post.

In the meantime, if you'd like to write Go that translates to C — with no runtime and manual memory management — I invite you to try Solod. The io package is included, of course.

]]>
Solod: Go can be a better Chttps://antonz.org/solod/Sat, 21 Mar 2026 14:00:00 +0000https://antonz.org/solod/A subset of Go that transpiles to regular C, with zero runtime.I'm working on a new programming language named Solod (So). It's a strict subset of Go that translates to regular C.

Highlights:

  • Go in, C out. You write regular Go code and get readable C11 as output.
  • Zero runtime. No garbage collection, no reference counting, no hidden allocations.
  • Rich standard library. Use familiar types and functions ported from Go's stdlib.
  • Native C interop. Call C from So and So from C — no CGO, no overhead.
  • Go tooling works out of the box. Syntax highlighting, LSP, linting and "go test".

So supports structs, methods, interfaces, slices, maps, multiple returns, and defer. Everything is stack-allocated by default; heap is opt-in through the standard library. There is limited support for generics, and concurrency is provided by the standard library instead of being built into the language.

So is for Go developers who want systems-level control without learning a new language. And for C programmers who like Go's safety, structure, and tooling.

Hello worldLanguage tourCompatibilityDesign decisionsFAQFinal thoughts

'Hello world' example

This Go code in a file main.go:

package main

import (
    "solod.dev/so/conc"
    "solod.dev/so/mem"
    "solod.dev/so/sync/atomic"
)

// Account is a thread-safe money account.
type Account struct {
    Balance atomic.Int64
}

// Deposit adds an amount to the balance.
func (a *Account) Deposit(amount int64) {
    a.Balance.Add(amount)
}

// pay deposits $10 into the shared account.
func pay(arg any) {
    acc := arg.(*Account)
    acc.Deposit(10)
}

func main() {
    var acc Account

    // Run 100 payments across 4 worker threads.
    opts := conc.PoolOptions{NumThreads: 4}
    pool := conc.NewPool(mem.System, opts)
    defer pool.Free()
    for range 100 {
        pool.Go(pay, &acc)
    }
    pool.Wait()

    println("balance is", acc.Balance.Load())
}

Translates to a header file main.h:

#pragma once
#include "so/builtin/builtin.h"
#include "so/conc/conc.h"
#include "so/mem/mem.h"
#include "so/sync/atomic/atomic.h"

// Account is a thread-safe money account.
typedef struct main_Account {
    atomic_Int64 Balance;
} main_Account;

// Deposit adds an amount to the balance.
void main_Account_Deposit(void* self, int64_t amount);

Plus an implementation file main.c:

#include "main.h"

// Deposit adds an amount to the balance.
void main_Account_Deposit(void* self, int64_t amount) {
    main_Account* a = self;
    atomic_Int64_Add(&a->Balance, amount);
}

// pay deposits $10 into the shared account.
static void pay(void* arg) {
    main_Account* acc = (main_Account*)arg;
    main_Account_Deposit(acc, 10);
}

int main(void) {
    main_Account acc = {0};
    // Run 100 payments across 4 worker threads.
    conc_PoolOptions opts = (conc_PoolOptions){.NumThreads = 4};
    conc_Pool* pool = conc_NewPool(mem_System, opts);
    for (so_int _i = 0; _i < 100; _i++) {
        conc_Pool_Go(pool, pay, &acc);
    }
    conc_Pool_Wait(pool);
    so_println("%s %" PRId64, "balance is", atomic_Int64_Load(&acc.Balance));
    conc_Pool_Free(pool);
    return 0;
}

Language tour

In terms of features, So is an intersection between Go and C, making it one of the simplest C-like languages out there — on par with Hare.

And since So is a strict subset of Go, you already know it if you know Go. It's pretty handy if you don't want to learn another syntax.

Let's briefly go over the language features and see how they translate to C.

VariablesStringsArraysSlicesMapsIf/else and forFunctionsMultiple returnsStructsMethodsInterfacesEnumsErrorsDeferC interopPackages

Values and variables

So supports basic Go types and variable declarations:

// so
const n = 100_000
f := 3.14
var r = '本'
var v any = 42
// c
const so_int n = 100000;
double f = 3.14;
so_rune r = U'本';
void* v = &(so_int){42};

byte is translated to so_byte (uint8_t), rune to so_rune (int32_t), and int to so_int (int64_t).

any is not treated as an interface. Instead, it's translated to void*. This makes handling pointers much easier and removes the need for unsafe.Pointer.

nil is translated to NULL (for pointer types).

Strings

Strings are represented as so_String type in C:

// c
typedef struct {
    const char* ptr;
    so_int len;
} so_String;

All standard string operations are supported, including indexing, slicing, and iterating with a for-range loop.

// so
str := "Hi 世界!"
println("str[1] =", str[1])
for i, r := range str {
    println("i =", i, "r =", r)
}
// c
so_String str = so_str("Hi 世界!");
so_println("%s %u", "str[1] =", so_at(so_byte, str, 1));
for (so_int i = 0, _iw = 0; i < so_len(str); i += _iw) {
    _iw = 0;
    so_rune r = so_utf8_decode(str, i, &_iw);
    so_println("%s %" PRId64 " %s %d", "i =", i, "r =", r);
}

Converting a string to a byte slice and back is a zero-copy operation:

// so
s := "1世3"
bs := []byte(s)
s1 := string(bs)
// c
so_String s = so_str("1世3");
so_Slice bs = so_string_bytes(s);   // wraps s.ptr
so_String s1 = so_bytes_string(bs); // wraps bs.ptr

Converting a string to a rune slice and back allocates on the stack with alloca:

// so
s := "1世3"
rs := []rune(s)
s1 := string(rs)
// c
so_String s = so_str("1世3");
so_Slice rs = so_string_runes(s);   // allocates
so_String s1 = so_runes_string(rs); // allocates

There's a so/strings stdlib package for heap-allocated strings and various string operations.

Arrays

Arrays are represented as plain C arrays (T name[N]):

// so
var a [5]int                       // zero-initialized
b := [5]int{1, 2, 3, 4, 5}         // explicit values
c := [...]int{1, 2, 3, 4, 5}       // inferred size
d := [...]int{100, 3: 400, 500}    // designated initializers
// c
so_int a[5] = {0};
so_int b[5] = {1, 2, 3, 4, 5};
so_int c[5] = {1, 2, 3, 4, 5};
so_int d[5] = {100, [3] = 400, 500};

len() on arrays is emitted as compile-time constant.

Slicing an array produces a so_Slice.

Slices

Slices are represented as so_Slice type in C:

// c
typedef struct {
    void* ptr;
    so_int len;
    so_int cap;
} so_Slice;

All standard slice operations are supported, including indexing, slicing, and iterating with a for-range loop.

// so
s1 := []string{"a", "b", "c", "d", "e"}
s2 := s1[1 : len(s1)-1]
for i, v := range s2 {
    println(i, v)
}
// c
so_Slice s1 = (so_Slice){(so_String[5]){
    so_str("a"), so_str("b"), so_str("c"),
    so_str("d"), so_str("e")}, 5, 5};
so_Slice s2 = so_slice(so_String, s1, 1, so_len(s1) - 1);
for (so_int i = 0; i < so_len(s2); i++) {
    so_String v = so_at(so_String, s2, i);
    so_println("%" PRId64 " %.*s", i, v.len, v.ptr);
}

As in Go, a slice is a value type. Unlike in Go, a nil slice and an empty slice are the same thing:

// so
var nils []int = nil
var empty []int = []int{}
// c
so_Slice nils = (so_Slice){0};
so_Slice empty = (so_Slice){0};

make() allocates a fixed amount of memory on the stack (sizeof(T)*cap). append() only works up to the initial capacity and panics if it's exceeded. There's no automatic reallocation; use the so/slices stdlib package for heap allocation and dynamic arrays.

Maps

Maps are fixed-size and stack-allocated, backed by "mask-step-index" hashtables. They are pointer-based reference types, represented as so_Map* in C. No delete, no resize.

// c
typedef struct {
    void* keys;
    void* vals;
    so_int len;
    so_int cap;
} so_Map;

Only use maps when you have a small, fixed number of items (<1024). For anything else, use heap-allocated maps from the so/maps package.

Most of the standard map operations are supported, including getting/setting values and iterating with a for-range loop:

// so
m := map[string]int{"a": 11, "b": 22}
for k, v := range m {
    println(k, v)
}
// c
so_Map* m = &(so_Map){(so_String[2]){
    so_str("a"), so_str("b")},
    (so_int[2]){11, 22}, 2, 2};
for (so_int _i = 0; _i < (so_int)m->len; _i++) {
    so_String k = ((so_String*)m->keys)[_i];
    so_int v = ((so_int*)m->vals)[_i];
    so_println("%.*s %" PRId64, k.len, k.ptr, v);
}

As in Go, a map is a pointer type. A nil map emits as NULL in C.

If/else and for

If-else and for come in all shapes and sizes, just like in Go.

Standard if-else with chaining:

// so
if x > 0 {
    println("positive")
} else if x < 0 {
    println("negative")
} else {
    println("zero")
}
// c
if (x > 0) {
    so_println("%s", "positive");
} else if (x < 0) {
    so_println("%s", "negative");
} else {
    so_println("%s", "zero");
}

Init statement (scoped to the if block):

// so
if num := 9; num < 10 {
    println(num, "has 1 digit")
}
// c
{
    so_int num = 9;
    if (num < 10) {
        so_println("%" PRId64 " %s", num, "has 1 digit");
    }
}

Traditional for loop:

// so
for j := 0; j < 3; j++ {
    println(j)
}
// c
for (so_int j = 0; j < 3; j++) {
    so_println("%" PRId64, j);
}

While-style loop:

// so
i := 1
for i <= 3 {
    println(i)
    i = i + 1
}
// c
so_int i = 1;
for (; i <= 3;) {
    so_println("%" PRId64, i);
    i = i + 1;
}

Range over an integer:

// so
for k := range 3 {
    println(k)
}
// c
for (so_int k = 0; k < 3; k++) {
    so_println("%" PRId64, k);
}

Functions

Regular functions translate to C naturally:

// so
func sumABC(a, b, c int) int {
    return a + b + c
}
// c
static so_int sumABC(so_int a, so_int b, so_int c) {
    return a + b + c;
}

Named function types become typedefs:

// so
type SumFn func(int, int, int) int

fn1 := sumABC           // infer type
var fn2 SumFn = sumABC  // explicit type
s := fn2(7, 8, 9)
// main.h
typedef so_int (*main_SumFn)(so_int, so_int, so_int);

// main.c
main_SumFn fn1 = sumABC;
main_SumFn fn2 = sumABC;
so_int s = fn2(7, 8, 9);

Exported functions (capitalized) become public C symbols prefixed with the package name (package_Func). Unexported functions are static.

Variadic functions use the standard ... syntax and translate to passing a slice:

// so
func sum(nums ...int) int {
    total := 0
    for _, num := range nums {
        total += num
    }
    return total
}

func main() {
    sum(1, 2, 3, 4, 5)
}
// c
static so_int sum(so_Slice nums) {
    so_int total = 0;
    for (so_int _ = 0; _ < so_len(nums); _++) {
        so_int num = so_at(so_int, nums, _);
        total += num;
    }
    return total;
}

int main(void) {
    sum((so_Slice){(so_int[5]){1, 2, 3, 4, 5}, 5, 5});
}

Function literals (anonymous functions and closures) are not supported.

Multiple returns

So supports two-value multiple returns in two patterns: (T, error) and (T1, T2). Both cases translate to signature-specific C types:

// so
func divide(a, b int) (int, error) {
    return a / b, nil
}

func divmod(a, b int) (int, int) {
    return a / b, a % b
}
// c
typedef struct { so_int val; so_Error err; } so_R_int_err;
typedef struct { so_int val; so_int val2; } so_R_int_int;
// c
static so_R_int_err divide(so_int a, so_int b) {
    return (so_R_int_err){.val = a / b, .err = NULL};
}

static so_R_int_int divmod(so_int a, so_int b) {
    return (so_R_int_int){.val = a / b, .val2 = a % b};
}

Named return values are not supported.

Structs

Structs translate to C naturally:

// so
type person struct {
    name string
    age  int
}

bob := person{"Bob", 20}
alice := person{name: "Alice", age: 30}
fred := person{name: "Fred"}
// c
typedef struct person {
    so_String name;
    so_int age;
} person;

person bob = (person){so_str("Bob"), 20};
person alice = (person){.name = so_str("Alice"), .age = 30};
person fred = (person){.name = so_str("Fred")};

new() works with types and values:

// so
n := new(int)                    // *int, zero-initialized
p := new(person)                 // *person, zero-initialized
n2 := new(42)                    // *int with value 42
p2 := new(person{name: "Alice"}) // *person with values
// c
so_int* n = &(so_int){0};
person* p = &(person){0};
so_int* n2 = &(so_int){42};
person* p2 = &(person){.name = so_str("Alice")};

Methods

Methods are defined on struct types with pointer or value receivers:

// so
type Rect struct {
    width, height int
}

func (r *Rect) Area() int {
    return r.width * r.height
}

func (r Rect) resize(x int) Rect {
    r.height *= x
    r.width *= x
    return r
}

Pointer receivers pass void* self in C and cast to the struct pointer. Value receivers pass the struct by value, so modifications operate on a copy:

// c
typedef struct main_Rect {
    so_int width;
    so_int height;
} main_Rect;

so_int main_Rect_Area(void* self) {
    main_Rect* r = (main_Rect*)self;
    return r->width * r->height;
}

static main_Rect main_Rect_resize(main_Rect r, so_int x) {
    r.height *= x;
    r.width *= x;
    return r;
}

Calling methods on values and pointers emits pointers or values as necessary:

// so
r := Rect{width: 10, height: 5}
r.Area()      // called on value (address taken automatically)
r.resize(2)   // called on value (passed by value)

rp := &r
rp.Area()     // called on pointer
rp.resize(2)  // called on pointer (dereferenced automatically)
// c
main_Rect r = (main_Rect){.width = 10, .height = 5};
main_Rect_Area(&r);
main_Rect_resize(r, 2);

main_Rect* rp = &r;
main_Rect_Area(rp);
main_Rect_resize(*rp, 2);

Interfaces

Interfaces in So are like Go interfaces, but they don't include runtime type information.

Interface declarations list the required methods:

// so
type Shape interface {
    Area() int
    Perim(n int) int
}

In C, an interface is a struct with a void* self pointer and function pointers for each method (less efficient than using a static method table, but simpler; this might change in the future):

// c
typedef struct main_Shape {
    void* self;
    so_int (*Area)(void* self);
    so_int (*Perim)(void* self, so_int n);
} main_Shape;

Just as in Go, a concrete type implements an interface by providing the necessary methods:

// so
func (r *Rect) Area() int {
    // ...
}

func (r *Rect) Perim(n int) int {
    // ...
}
// c
so_int main_Rect_Area(void* self) {
    // ...
}

so_int main_Rect_Perim(void* self, so_int n) {
    // ...
}

Passing a concrete type to functions that accept interfaces:

// so
func calcShape(s Shape) int {
    return s.Perim(2) + s.Area()
}

r := Rect{width: 10, height: 5}
calcShape(&r)         // implicit conversion
calcShape(Shape(&r))  // explicit conversion
// c
static so_int calcShape(main_Shape s) {
    return s.Perim(s.self, 2) + s.Area(s.self);
}

main_Rect r = (main_Rect){.width = 10, .height = 5};
calcShape((main_Shape){.self = &r,
    .Area = main_Rect_Area,
    .Perim = main_Rect_Perim});
calcShape((main_Shape){.self = &r,
    .Area = main_Rect_Area,
    .Perim = main_Rect_Perim});

Type assertion works for concrete types (v := iface.(*Type)), but not for interfaces (iface.(Interface)). Type switch is not supported.

Empty interfaces (interface{} and any) are translated to void*.

Enums

So supports typed constant groups as enums:

// so
type ServerState string

const (
    StateIdle      ServerState = "idle"
    StateConnected ServerState = "connected"
    StateError     ServerState = "error"
)

Each constant is emitted as a C const:

// main.h
typedef so_String main_ServerState;
static const main_ServerState main_StateIdle = so_str("idle");
static const main_ServerState main_StateConnected = so_str("connected");
static const main_ServerState main_StateError = so_str("error");

iota is supported for integer-typed constants:

// so
type Day int

const (
    Sunday Day = iota
    Monday
    Tuesday
)

Iota values are evaluated at compile time and translated to integer literals:

// c
typedef so_int main_Day;
static const main_Day main_Sunday = 0;
static const main_Day main_Monday = 1;
static const main_Day main_Tuesday = 2;

Errors

The error type is a regular interface with an Error() string method. In C, it is represented as so_Error an interface struct:

// c
typedef struct {
    void* self;
    so_String (*Error)(void* self);
} so_Error;

Use errors.New to create sentinel errors at the package level:

// so
import "solod.dev/so/errors"

var ErrOutOfTea = errors.New("no more tea available")
// c
#include "so/errors/errors.h"

so_Error main_ErrOutOfTea = errors_New("no more tea available");

Errors are compared using ==. This is an O(1) operation (compares pointers, not strings):

// so
func makeTea(arg int) error {
    if arg == 42 {
        return ErrOutOfTea
    }
    return nil
}

err := makeTea(42)
if err == ErrOutOfTea {
    println("out of tea")
}
// c
static so_Error makeTea(so_int arg) {
    if (arg == 42) {
        return main_ErrOutOfTea;
    }
    return NULL;
}

so_Error err = makeTea(42);
if (err == main_ErrOutOfTea) {
    so_println("%s", "out of tea");
}

Dynamic errors (fmt.Errorf) and error wrapping are not supported.

Defer

defer schedules a function or method call to run at the end of the enclosing function (as in Go):

// so
func funcScope() {
    xopen(&state)
    defer xclose(&state)
    if state != 1 {
        panic("unexpected state")
    }
}

Deferred calls are emitted inline (before returns, panics, and function end) in LIFO order:

// c
static void funcScope(void) {
    xopen(&state);
    if (state != 1) {
        xclose(&state);
        so_panic("unexpected state");
    }
    xclose(&state);
}

C interop

Include a C header file with so:include:

//so:include <stdio.h>

Declare an external C type (excluded from emission) with so:extern:

//so:extern FILE
type os_file struct{}

Declare an external C function:

//so:extern
func fopen(path string, mode string) *os_file { return nil }

When calling extern functions, string and []T arguments are automatically decayed to their C equivalents: string literals become raw C strings ("hello"), string values become char*, and slices become raw pointers. This makes interop cleaner:

// so
f := fopen("/tmp/test.txt", "w")
// c
os_file* f = fopen("/tmp/test.txt", "w");
// not like this:
// fopen(so_str("/tmp/test.txt"), so_str("w"))

The decay behavior can be turned off with the nodecay flag:

//so:extern nodecay
func set_name(acc *Account, name string)

The so/c package includes helpers for converting C pointers back to So string and slice types. The unsafe package is also available and is implemented as compiler built-ins.

Packages

Each Go package is translated into a single .h + .c pair, regardless of how many .go files it contains. Multiple .go files in the same package are merged into one .c file, separated by // -- filename.go -- comments.

Exported symbols (capitalized names) are prefixed with the package name:

// geom/geom.go
package geom

const Pi = 3.14159

func RectArea(width, height float64) float64 {
    return width * height
}

Becomes:

// geom.h
extern const double geom_Pi;
double geom_RectArea(double width, double height);

// geom.c
const double geom_Pi = 3.14159;
double geom_RectArea(double width, double height) { ... }

Unexported symbols (lowercase names) keep their original names and are marked static:

// c
static double rectArea(double width, double height);

Exported symbols are declared in the .h file (with extern for variables). Unexported symbols only appear in the .c file.

Importing a So package translates to a C #include:

// so
import "example/geom"
// c
#include "geom/geom.h"

Calling imported symbols uses the package prefix:

// so
a := geom.RectArea(5, 10)
_ = geom.Pi
// c
double a = geom_RectArea(5, 10);
(void)geom_Pi;

That's it for the language tour!

Compatibility

So generates C11 code that relies on several GCC/Clang extensions:

  • Binary literals (0b1010) in generated code.
  • Statement expressions (({...})) in macros.
  • __attribute__((constructor)) for package-level initialization.
  • __auto_type for local type inference in generated code.
  • __typeof__ for type inference in generic macros.
  • alloca and VLAs for make() and other dynamic stack allocations.

You can use GCC, Clang, or zig cc to compile the transpiled C code. MSVC is not supported.

Supported operating systems: Linux, macOS, and Windows (core language only).

Supported platforms: amd64, arm64, riscv64, i386, wasm32, and freestanding environments.

Design decisions

So is highly opinionated.

Simplicity is key. Fewer features are always better. Every new feature is strongly discouraged by default and should be added only if there are very convincing real-world use cases to support it. This applies to the standard library too — So tries to export as little of Go's stdlib API as possible while still remaining highly useful for real-world use cases.

No heap allocations are allowed in language built-ins (like maps, slices, new, or append). Heap allocations are allowed in the standard library, but they have to be explicit. If a function or type allocates memory, it must take an allocator and clearly state ownership in the documentation.

Fast and easy C interop. Even though So uses Go syntax, it's basically C with its own standard library. Calling C from So, and So from C, should always be simple to write and run efficiently. The So standard library (translated to C) should be easy to add to any C project.

Performance. You can definitely write C code by hand that runs faster than code produced by So. Also, some features in So, like interfaces, are currently implemented in a way that's not the most efficient, mainly to keep things simple. Still, performance matters: the code is benchmarked and optimized to match or beat Go in speed and resource usage.

Readability. There are several languages that claim they can transpile to readable C code. Unfortunately, the C code they generate is usually unreadable or barely readable at best. So isn't perfect in this area either (though it's arguably better than others), but it aims to produce C code that's as readable as possible.

Go compatibility. So code is syntactically valid Go code, with no exceptions. Semantics may differ.

Non-goals:

Hiding C entirely. So is a cleaner way to write C, not a replacement for it. You should be familiar with C to use So effectively.

Go feature parity. Less is more. Iterators aren't coming, and neither are "true" generics.

Frequently asked questions

I have heard these several times, so it's worth answering.

Why not Rust/Zig/Odin/other language?

Because I like C and Go.

Why not TinyGo?

TinyGo is lightweight, but it still has a garbage collector, a runtime, and aims to support all Go features. What I'm after is something even simpler, with no runtime at all, source-level C interop, and eventually, Go's standard library ported to plain C so it can be used in regular C projects.

How does So handle memory?

Everything is stack-allocated by default. There's no garbage collector or reference counting. The standard library provides explicit heap allocation in the so/mem package when you need it.

Is it safe?

So has extra safeguards beyond Go's default type checking:

  • It will panic on out-of-bounds array access and nil dereference.
  • It won't let you return stack-allocated memory in common situations.
  • Programs can detect memory leaks with a tracking allocator.

However, escape analysis doesn't catch every case, and the leak checker won't detect double-free or use-after-free errors by itself.

Most memory-related problems can be caught with AddressSanitizer in modern compilers. I strongly recommend turning on sanitizers with the -sanitize flag while developing. Or set the sanitize flags in CFLAGS yourself.

Is it fast?

Usually on par with Go or faster — see the benchmark link at the end of the post for details.

Can I use So code from C (and vice versa)?

Yes. So compiles to plain C, therefore calling So from C is just calling C from C. Calling C from So is equally straightforward.

Can I compile existing Go packages with So?

Not really. Go uses automatic memory management, while So uses manual memory management. So also supports far fewer features than Go. Neither Go's standard library nor third-party packages will work with So without changes.

How stable is this?

Not for production at the moment.

Where's the standard library?

There is a growing set of high-level packages (so/conc, so/mem, so/time, ...), and a low-level so/c package to help with C interop. Check out the standard library overview for more details.

Final thoughts

Even though So isn't ready for production yet, I encourage you to try it out on a hobby project or just keep an eye on it if you like the concept.

Further reading: InstallationUsageLanguage tourStandard librarySo by examplePlaygroundBenchmarksSource code

]]>
Allocators from C to Zighttps://antonz.org/allocators/Thu, 12 Feb 2026 12:00:00 +0000https://antonz.org/allocators/Exploring allocator design in C, C3, Hare, Odin, Rust, and Zig.An allocator is a tool that reserves memory (typically on the heap) so a program can store its data structures there. Many C programs use the standard libc allocator, or at best, let you switch it out for another one like jemalloc or mimalloc.

Unlike C, modern systems languages usually treat allocators as first-class citizens. Let's look at how they handle allocation and then create a C allocator following their approach.

RustZigOdinC3HareCFinal thoughts

Rust

Rust is one of the older languages we'll be looking at, and it handles memory allocation in a more traditional way. Right now, it uses a global allocator, but there's an experimental Allocator API implemented behind a feature flag (issue #32838). We'll set the experimental API aside and focus on the stable one.

Global allocator

The documentation begins with a clear statement:

In a given program, the standard library has one "global" memory allocator that is used for example by Box<T> and Vec<T>.

Followed by a vague one:

Currently the default global allocator is unspecified.

It doesn't mean that a Rust program will abort an allocation, of course. In practice, Rust uses the system allocator as the global default (but the Rust developers don't want to commit to this, hence the "unspecified" note):

  • malloc on Unix platforms;
  • HeapAlloc on Windows;
  • dlmalloc in WASM.

The global allocator interface is defined by the GlobalAlloc trait in the std::alloc module. It requires the implementor to provide two essential methods — alloc and dealloc, and provides two more based on them — alloc_zeroed and realloc:

pub unsafe trait GlobalAlloc {
    // Allocates memory as described by the given `layout`.
    // Returns a pointer to newly-allocated memory,
    // or null to indicate allocation failure.
    unsafe fn alloc(&self, layout: Layout) -> *mut u8;

    // Deallocates the block of memory at the given `ptr`
    // pointer with the given `layout`.
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);

    // Behaves like `alloc`, but also ensures that the contents
    // are set to zero before being returned.
    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        // ...
    }

    // Shrinks or grows a block of memory to the given `new_size` in bytes.
    // The block is described by the given `ptr` pointer and `layout`.
    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        // ...
    }
}

Layout

The Layout struct describes a piece of memory we want to allocate — its size in bytes and alignment:

pub struct Layout {
    // private fields
    size: usize,
    align: Alignment,
}

Memory alignment

Alignment restricts where a piece of data can start in memory. The memory address for the data has to be a multiple of a certain number, which is always a power of 2.

Alignment depends on the type of data:

  • u8: alignment = 1. Can start at any address (0, 1, 2, 3...).
  • i32: alignment = 4. Must start at addresses divisible by 4 (0, 4, 8, 12...).
  • f64: alignment = 8. Must start at addresses divisible by 8 (0, 8, 16...).

CPUs are designed to read "aligned" memory efficiently. For example, if you read a 4-byte integer starting at address 0x03 (which is unaligned), the CPU has to do two memory reads — one for the first byte and another for the other three bytes — and then combine them. But if the integer starts at address 0x04 (which is aligned), the CPU can read all four bytes at once.

Aligned memory is also needed for vectorized CPU operations (SIMD), where one processor instruction handles a group of values at once instead of just one.

The compiler knows the size and alignment for each type, so we can use the Layout constructor or helper functions to create a valid layout:

use std::alloc::Layout;

// 64-bit integer.
let i64_layout = Layout::new::<i64>();
println!("{:?}", i64_layout);

// Ten 32-bit integers.
let array_layout = Layout::array::<i32>(10).unwrap();
println!("{:?}", array_layout);

// Custom structure.
struct Cat {
    name: String,
    is_grumpy: bool,
}

let cat_layout = Layout::new::<Cat>();
println!("{:?}", cat_layout);

// Layout from a value.
let fluffy = Cat {
    name: String::from("Fluffy"),
    is_grumpy: true,
};

let fluffy_layout = Layout::for_value(&fluffy);
println!("{:?}", fluffy_layout);
Layout { size: 8, align: 8 (1 << 3) }
Layout { size: 40, align: 4 (1 << 2) }
Layout { size: 32, align: 8 (1 << 3) }
Layout { size: 32, align: 8 (1 << 3) }

Don't be surprised that a Cat takes up 32 bytes. In Rust, the String type can grow, so it stores a data pointer, a length, and a capacity (3 × 8 = 24 bytes). There's also 1 byte for the boolean and 7 bytes of padding (because of 8-byte alignment), making a total of 32 bytes.

System allocator

System is the default memory allocator provided by the operating system. The exact implementation depends on the platform. It implements the GlobalAlloc trait and is used as the global allocator by default, but the documentation does not guarantee this (remember the "unspecified" note?). If you want to explicitly set System as the global allocator, you can use the #[global_allocator] attribute:

use std::alloc::System;

#[global_allocator]
static GLOBAL: System = System;

fn main() {
    // ...
}

You can also set a custom allocator as global, like jemalloc in this example:

use jemallocator::Jemalloc;

#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;

fn main() {}

Allocation helpers

To use the global allocator directly, call the alloc and dealloc functions:

use std::alloc::{alloc, dealloc, Layout};

unsafe {
    let layout = Layout::new::<u16>();
    let ptr = alloc(layout); // no OOM check for now
    dealloc(ptr, layout);
}
ok

In practice, people rarely use alloc or dealloc directly. Instead, they work with types like Box, String or Vec that handle allocation for them:

let num = Box::new(42); // allocates
println!("{:?}", num);

let mut vec = Vec::new();
vec.push(1); // allocates
vec.push(2);
println!("{:?}", vec);

// num and vec automatically deallocate
// when they go out of scope.
42
[1, 2]

Error handling

The System allocator doesn't abort if it can't allocate memory; instead, it returns null (which is exactly what GlobalAlloc recommends):

use std::alloc::{alloc, dealloc, handle_alloc_error, Layout};

unsafe {
    // Attempt to allocate a ton of memory.
    let layout = Layout::array::<u8>(usize::MAX / 2).unwrap();
    let ptr = alloc(layout);

    if ptr.is_null() {
        println!("Out of memory!");
        // Uncomment to abort.
        // handle_alloc_error(layout);
    } else {
        println!("Allocation succeeded.");
        dealloc(ptr, layout);
    }
}
Out of memory!

The documentation recommends using the handle_alloc_error function to signal out-of-memory errors. It immediately aborts the process, or panics if the binary isn't linked to the standard library.

Unlike the low-level alloc function, types like Box or Vec call handle_alloc_error if allocation fails, so the program usually aborts if it runs out of memory:

let v: Vec<u8> = Vec::with_capacity(usize::MAX/2);
println!("{}", v.len());
memory allocation of 9223372036854775807 bytes failed (exit status 139)

Further reading

Allocator APIMemory allocation APIs

Zig

Memory management in Zig is explicit. There is no default global allocator, and any function that needs to allocate memory accepts an allocator as a separate parameter. This makes the code a bit more verbose, but it matches Zig's goal of giving programmers as much control and transparency as possible.

Allocator interface

An allocator in Zig is a std.mem.Allocator struct with an opaque self-pointer and a method table with four methods:

const Allocator = @This();

ptr: *anyopaque,
vtable: *const VTable,

pub const VTable = struct {
    /// Return a pointer to `len` bytes with specified `alignment`,
    /// or return `null` indicating the allocation failed.
    alloc: *const fn (*anyopaque, len: usize, alignment: Alignment,
                      ret_addr: usize) ?[*]u8,

    /// Attempt to expand or shrink memory in place.
    resize: *const fn (*anyopaque, memory: []u8, alignment: Alignment,
                       new_len: usize, ret_addr: usize) bool,

    /// Attempt to expand or shrink memory, allowing relocation.
    remap: *const fn (*anyopaque, memory: []u8, alignment: Alignment,
                      new_len: usize, ret_addr: usize) ?[*]u8,

    /// Free and invalidate a region of memory.
    free: *const fn (*anyopaque, memory: []u8, alignment: Alignment,
                     ret_addr: usize) void,
};

Unlike Rust's allocator methods, which take a raw pointer and a size as arguments, Zig's allocator methods take a slice of bytes ([]u8) — a type that combines both a pointer and a length.

Another interesting difference is the optional ret_addr parameter, which is the first return address in the allocation call stack. Some allocators, like the DebugAllocator, use it to keep track of which function requested memory. This helps with debugging issues related to memory allocation.

Just like in Rust, allocator methods don't return errors. Instead, alloc and remap return null if they fail.

Allocation helpers

Zig also provides type-safe wrappers that you can use instead of calling the allocator methods directly:

// Allocate / deallocate a single object.
pub fn create(a: Allocator, comptime T: type) Error!*T
pub fn destroy(self: Allocator, ptr: anytype) void

// Allocate / deallocate multiple objects.
pub fn alloc(self: Allocator, comptime T: type, n: usize) Error![]T
pub fn free(self: Allocator, memory: anytype) void

Example:

const allocator = std.heap.page_allocator;

// Create and destroy a single integer.
const num = try allocator.create(i32);
num.* = 42;
allocator.destroy(num);

// Allocate and free a slice of 10 bytes.
const slice = try allocator.alloc(u8, 100);
@memset(slice, 'A');
allocator.free(slice);
ok

Unlike the allocator methods, these allocation functions return an error if they fail.

If a function or method allocates memory, it expects the developer to provide an allocator instance:

const allocator = std.heap.page_allocator;

var list: std.ArrayList(u8) = .empty;
defer list.deinit(allocator);

try list.append(allocator, 'z');
try list.append(allocator, 'i');
try list.append(allocator, 'g');
ok

Standard allocators

Zig's standard library includes several built-in allocators in the std.heap namespace.

page_allocator asks the operating system for entire pages of memory, each allocation is a syscall:

const allocator = std.heap.page_allocator;
const memory = try allocator.alloc(u8, 100);
allocator.free(memory);
ok

FixedBufferAllocator allocates memory into a fixed buffer and doesn't make any heap allocations:

var buffer: [1000]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = .init(&buffer);
const allocator = fba.allocator();

const memory = try allocator.alloc(u8, 100);
allocator.free(memory);
ok

ArenaAllocator wraps a child allocator and allows you to allocate many times and only free once:

var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer arena.deinit();

const allocator = arena.allocator();

const mem1 = try allocator.alloc(u8, 100);
const mem2 = try allocator.alloc(u8, 100);
allocator.free(mem1); // not needed
allocator.free(mem2); // not needed
ok

The arena.deinit() call frees all memory. Individual allocator.free() calls are no-ops.

DebugAllocator (aka GeneralPurposeAllocator) is a safe allocator that can prevent double-free, use-after-free and can detect leaks:

var gpa: std.heap.DebugAllocator(.{}) = .init;
const allocator = gpa.allocator();

const memory = try allocator.alloc(u8, 100);
allocator.free(memory);
allocator.free(memory); // aborts

SmpAllocator is a general-purpose thread-safe allocator designed for maximum performance on multithreaded machines:

const allocator = std.heap.smp_allocator;
const memory = try allocator.alloc(u8, 100);
allocator.free(memory);
ok

c_allocator is a wrapper around the libc allocator:

const allocator = std.heap.c_allocator; // requires linking libc
const memory = try allocator.alloc(u8, 100);
allocator.free(memory);

Error handling

Zig doesn't panic or abort when it can't allocate memory. An allocation failure is just a regular error that you're expected to handle:

const allocator = std.heap.page_allocator;
const n = std.math.maxInt(i64);
const memory = allocator.alloc(u8, n) catch |err| {
    if (err == error.OutOfMemory) {
        print("Out of memory!\n", .{});
    }
    return err;
};
defer allocator.free(memory);
Out of memory!

Further reading

Allocatorsstd.mem.Allocatorstd.heap

Odin

Odin supports explicit allocators, but, unlike Zig, it's not the only option. In Odin, every scope has an implicit context variable that provides a default allocator:

Context :: struct {
	allocator:          Allocator,
	temp_allocator:     Allocator,
	// ...
}

// Returns the default `context` for each scope
@(require_results)
default_context :: proc "contextless" () -> Context {
	c: Context
	__init_context(&c)
	return c
}

If you don't pass an allocator to a function, it uses the one currently set in the context.

Allocator interface

An allocator in Odin is a runtime.Allocator struct with an opaque self-pointer and a single function pointer:

Allocator_Mode :: enum byte {
	Alloc,
	Free,
	Resize,
	// ...
}

Allocator_Error :: enum byte {
	None                 = 0,
	Out_Of_Memory        = 1,
	// ...
}

Allocator_Proc :: #type proc(
    allocator_data: rawptr,
    mode: Allocator_Mode,
    size, alignment: int,
    old_memory: rawptr,
    old_size: int,
    location: Source_Code_Location = #caller_location,
) -> ([]byte, Allocator_Error)

Allocator :: struct {
	procedure: Allocator_Proc,
	data:      rawptr,
}

Unlike other languages, Odin's allocator uses a single procedure for all allocation tasks. The specific action — like allocating, resizing, or freeing memory — is decided by the mode parameter.

The allocation procedure returns the allocated memory (for .Alloc and .Resize operations) and an error (.None on success).

Allocation helpers

Odin provides low-level wrapper functions in the core:mem package that call the allocator procedure using a specific mode:

alloc :: proc(
    size: int,
    alignment: int = DEFAULT_ALIGNMENT,
    allocator := context.allocator,
    loc := #caller_location,
) -> (rawptr, runtime.Allocator_Error)

free :: proc(
    ptr: rawptr,
    allocator := context.allocator,
    loc := #caller_location,
) -> runtime.Allocator_Error

// and others

There are also type-safe builtins like new/free (for a single object) and make/delete (for multiple objects) that you can use instead of the low-level interface:

num := new(int)
defer free(num)

slice := make([]int, 100)
defer delete(slice)
ok

By default, all builtins use the context allocator, but you can pass a custom allocator as an optional parameter:

ptr := new(int, allocator=context.allocator)
defer free(ptr, allocator=context.allocator)

slice := make([]int, 10, allocator=context.allocator)
defer delete(slice, allocator=context.allocator)
ok

To use a different allocator for a specific block of code, you can reassign it in the context:

alloc := custom_allocator()
context.allocator = alloc

// Uses the custom allocator.
ptr := new(int)
defer free(ptr)

Temp allocator

Odin's context provides two different allocators:

  • context.allocator is for general-purpose allocations. It uses the operating system's heap allocator.
  • context.temp_allocator is for short-lived allocations. It uses a scratch allocator (a kind of growing arena).
// Temporary allocation (no manual free required).
temp_mem, _ := mem.alloc(100, allocator=context.temp_allocator)

// Persistent allocation (requires manual free).
perm_mem, _ := mem.alloc(100, allocator=context.allocator)
defer mem.free(perm_mem, context.allocator)

// Clear the entire scratchpad at the end of the work cycle.
free_all(context.temp_allocator)
ok

When using the temp allocator, you only need a single free_all call to clear all the allocated memory.

Standard allocators

Odin's standard library includes several allocators, found in the base:runtime and core:mem packages.

The heap_allocator procedure returns a general-purpose allocator:

allocator := runtime.heap_allocator()
memory, err := mem.alloc(100, allocator=allocator)
mem.free(memory, allocator=allocator)
ok

Arena uses a single backing buffer for allocations, allowing you to allocate many times and only free once:

arena: mem.Arena
buffer := make([]byte, 1024, runtime.heap_allocator())
mem.arena_init(&arena, buffer)
defer mem.arena_free_all(&arena)

allocator := mem.arena_allocator(&arena)
m1, err1 := mem.alloc(100, allocator=allocator)
m2, err2 := mem.alloc(100, allocator=allocator)
ok

Tracking_Allocator detects leaks and invalid memory access, similar to DebugAllocator in Zig:

track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, runtime.default_allocator())
defer mem.tracking_allocator_destroy(&track)

allocator := mem.tracking_allocator(&track)
memory, err := mem.alloc(100, allocator=allocator)
free(memory, allocator=allocator)
free(memory, allocator=allocator) // aborts
Tracking allocator error: Bad free of pointer 139851252672688 (exit status 132)

There are also others, such as Stack or Buddy_Allocator.

Error handling

Like Zig, Odin doesn't panic or abort when it can't allocate memory. Instead, it returns an error code as the second return value:

data, err := mem.alloc(1 << 62)
if err != .None {
    fmt.println("Allocation failed:", err)
    return
}
defer mem.free(data)
Allocation failed: Out_Of_Memory

Further reading

Allocatorsbase:runtimecore:mem

C3

Like Zig and Odin, C3 supports explicit allocators. Like Odin, C3 provides two default allocators: heap and temp.

Allocator interface

An allocator in C3 is a core::mem::allocator::Allocator interface with an additional option of zeroing or not zeroing the allocated memory:

enum AllocInitType
{
	NO_ZERO,
	ZERO
}

interface Allocator
{
	<*
	 Acquire memory from the allocator, with the given
     alignment and initialization type.
	*>
	fn void*? acquire(usz size, AllocInitType init_type, usz alignment = 0);

	<*
	 Resize acquired memory from the allocator,
     with the given new size and alignment.
	*>
	fn void*? resize(void* ptr, usz new_size, usz alignment = 0);

	<*
	 Release memory acquired using `acquire` or `resize`.
	*>
	fn void release(void* ptr, bool aligned);
}

Unlike Zig and Odin, the resize and release methods don't take the (old) size as a parameter — neither directly like Odin nor through a slice like Zig. This makes it a bit harder to create custom allocators because the allocator has to keep track of the size along with the allocated memory. On the other hand, this approach makes C interop easier (if you use the default C3 allocator): data allocated in C can be freed in C3 without needing to pass the size parameter from the C code.

Like in Odin, allocator methods return an error if they fail.

Allocation helpers

C3 provides low-level wrapper macros in the core::mem::allocator module that call allocator methods:

macro void* malloc(Allocator allocator, usz size)
macro void*? malloc_try(Allocator allocator, usz size)

macro void* realloc(Allocator allocator, void* ptr, usz new_size)
macro void*? realloc_try(Allocator allocator, void* ptr, usz new_size)

macro void free(Allocator allocator, void* ptr)

// and others

These either return an error (the _try-suffix macros) or abort if they fail.

Example:

// `mem` is the global allocator instance.
int* ptr = allocator::malloc(mem, int.sizeof);
defer allocator::free(mem, ptr);
ok

There are also functions and macros with similar names in the core::mem module that use the global allocator::mem allocator instance:

// Call the core::mem::allocator macros directly.
fn void* malloc(usz size)
fn void free(void* ptr)

// Accept a type instead of a size.
macro new($Type, #init = ...)
macro alloc($Type)

// Allocate multiple objects.
macro new_array($Type, usz elements)
macro alloc_array($Type, usz elements)

// and others

Example:

// `malloc` and `free` are builtins,
// so they don't require the namespace.
int* num = malloc(int.sizeof);
defer free(num);

// `new_array` requires the namespace.
int[] slice = mem::new_array(int, 100);
defer free(slice);
ok

If a function or method allocates memory, it often expects the developer to provide an allocator instance:

List{int} list;
list.init(mem); // use the heap allocator
defer list.free();

list.push(11);
list.push(22);
list.push(33);
ok

Temp allocator

C3 provides two thread-local allocator instances:

  • allocator::mem is for general-purpose allocations. It uses a operating system's heap allocator (typically a libc wrapper).
  • allocator::tmem is for short-lived allocations. It uses an arena allocator.

There are functions and macros in the core::mem module that use the allocator::tmem temporary allocator:

// Calls the core::mem::allocator macro directly.
fn void* tmalloc(usz size, usz alignment = 0)

// Accept a type instead of a size.
macro tnew($Type, #init = ...)
macro talloc($Type)

// Allocate multiple objects.
macro talloc_array($Type, usz elements)

To @pool macro releases all temporary allocations when leaving the scope:

@pool()
{
    int* p1 = tmalloc(int.sizeof);
    int* p2 = tmalloc(int.sizeof);
    int* p3 = tmalloc(int.sizeof);
    // no manual free required
};  // p1, p2, p3 are freed here
ok

Some types, like List or DString, use the temp allocator by default if they are not initialized:

@pool()
{
    List{int} list;
    list.push(11);  // implicitly initialize with the temp allocator
    list.push(22);

    DString str;
    str.appendf("Hello %s", "World");  // same
};
ok

Standard allocators

C3's standard library includes several built-in allocators, found in the core::mem::allocator module.

LibcAllocator is a wrapper around libc's malloc/free:

LibcAllocator libc;
char* memory = allocator::malloc(&libc, 100*char.sizeof);
allocator::free(&libc, memory);
ok

ArenaAllocator uses a single backing buffer for allocations, allowing you to allocate many times and only free once:

char[1024] buf;
ArenaAllocator* arena = allocator::wrap(&buf);
defer arena.clear();

char* m1 = allocator::malloc(arena, 100*char.sizeof);
char* m2 = allocator::malloc(arena, 100*char.sizeof);
ok

TrackingAllocator detects leaks and invalid memory access:

TrackingAllocator track;
track.init(mem);
defer track.clear();

char* memory = allocator::malloc(&track, 100*char.sizeof);
allocator::free(&track, memory);
allocator::free(&track, memory); // aborts
ERROR: 'Attempt to release untracked pointer 0x55f5b0333330, this is likely a bug.'

There are also others, such as BackedArenaAllocator or OnStackAllocator.

Error handling

Like Zig and Odin, C3 can return an error in case of allocation failure:

void*? data = allocator::malloc_try(mem, 1uLL << 62);
if (catch err = data) {
    io::printfn("Allocation failed: %s", err);
    return;
};
defer mem::free(data);
Allocation failed: mem::OUT_OF_MEMORY

C3 can also abort in case of allocation failure:

void* data = allocator::malloc(mem, 1uLL << 62);
// void* data = malloc(1uLL << 62); // same thing
defer free(data);
ERROR: 'Unexpected fault 'mem::OUT_OF_MEMORY' was unwrapped!'

Since the functions and macros in the core::mem module use allocator::malloc instead of allocator::malloc_try, it looks like aborting on failure is the preferred approach.

Further reading

Memory Handlingcore::mem::alocatorcore::mem

Hare

Unlike other languages, Hare doesn't support explicit allocators. The standard library has multiple allocator implementations, but only one of them is used at runtime.

Global allocator

Hare's compiler expects the runtime to provide malloc and free implementations:

fn malloc(n: size) nullable *opaque;
@symbol("rt.free") fn free_(_p: nullable *opaque) void;

The programmer isn't supposed to access them directly (although it's possible by importing rt and calling rt::malloc or rt::free). Instead, Hare uses them to provide higher-level allocation helpers.

Allocation helpers

Hare offers two high-level allocation helpers that use the global allocator internally: alloc and free.

alloc can allocate individual objects. It takes a value, not a type:

let n: *int = alloc(42)!;
defer free(n);

let s: *str = alloc("hello world")!;
defer free(s);

// coords is defined as struct { x: int, y: int }
let p: *coords = alloc(coords{x=3, y=5})!;
defer free(p);
ok

alloc can also allocate slices if you provide a second parameter (the number of items):

// Allocate a slice of 100 integers.
let nums: []int = alloc([0...], 100)!;
defer free(nums);
ok

free works correctly with both pointers to single objects (like *int) and slices (like []int).

Standard allocators

Hare's standard library has three built-in memory allocators:

  • The default allocator is based on the algorithm from the Verified sequential malloc/free paper.
  • The libc allocator uses the operating system's malloc and free functions from libc.
  • The debug allocator uses a simple mmap-based method for memory allocation.

The allocator that's actually used is selected at compile time.

Error handling

Like other languages, Hare returns an error in case of allocation failure:

match (alloc([0...], 1 << 62)) {
case let nums: []int =>
    defer free(nums);
case nomem =>
    fmt::println("Out of memory")!;
};
Out of memory

You can abort on error with !:

let nums: []int = alloc([0...], 1 << 62)!;
defer free(nums);
Aborted (core dumped) (exit status 134)

Or propagate the error with ?:

let nums: []int = alloc([0...], 1 << 62)?;
defer free(nums);

Further reading

Dynamic memory allocationmalloc.ha

C

Many C programs use the standard libc allocator, or at most, let you swap it out for another one using macros:

#define LIB_MALLOC malloc
#define LIB_FREE free

Or using a simple setter:

static void *(*_lib_malloc)(size_t);
static void (*_lib_free)(void*);

void lib_set_allocator(void *(*malloc)(size_t), void (*free)(void*)) {
    _lib_malloc = malloc;
    _lib_free = free;
}

While this might work for switching the libc allocator to jemalloc or mimalloc, it's not very flexible. For example, trying to implement an arena allocator with this kind of API is almost impossible.

Now that we've seen the modern allocator design in Zig, Odin, and C3 — let's try building something similar in C. There are a lot of small choices to make, and I'm going with what I personally prefer. I'm not saying this is the only way to design an allocator — it's just one way out of many.

Allocator interface

Our allocator should return an error instead of NULL if it fails, so we'll need an error enum:

// Allocation errors.
typedef enum {
    Error_None = 0,
    Error_OutOfMemory,
    Error_SizeOverflow,
} Error;

The allocation function needs to return either a tagged union (value | error) or a tuple (value, error). Since C doesn't have these built in, let's use a custom tuple type:

// Allocation result.
typedef struct {
    void* ptr;
    Error err;
} AllocResult;

The next step is the allocator interface. I think Odin's approach of using a single function makes the implementation more complicated than it needs to be, so let's create separate methods like Zig does:

// Allocator interface.
struct _Allocator {
    AllocResult (*alloc)(void* self, size_t size, size_t align);
    AllocResult (*realloc)(void* self, void* ptr, size_t oldSize,
                           size_t newSize, size_t align);
    void (*free)(void* self, void* ptr, size_t size, size_t align);
};

typedef struct {
    const struct _Allocator* m;
    void* self;
} Allocator;

This approach to interface design is explained in detail in a separate post: Interfaces in C.

Zig uses byte slices ([]u8) instead of raw memory pointers. We could make our own byte slice type, but I don't see any real advantage to doing that in C — it would just mean more type casting. So let's keep it simple and stick with void* like our ancestors did.

Allocation helpers

Now let's create generic Alloc and Free wrappers:

// Allocates an item of type T.
// `AllocResult Alloc[T](Allocator a, T)`
#define Alloc(a, T) \
    ((a).m->alloc((a).self, sizeof(T), alignof(T)))

// Frees an item allocated with Alloc.
// Only accepts typed pointers, not void*.
// `void Free[T](Allocator a, T* ptr)`
#define Free(a, ptr) \
    ((a).m->free((a).self, (ptr), sizeof(*(ptr)), alignof(typeof(*(ptr)))))

I'm taking typeof for granted here to keep things simple. A more robust implementation should properly check if it is available or pass the type to Free directly.

We can even create a separate pair of helpers for collections:

// Helper to prevent integer overflow during N-item allocation.
static inline size_t calcSize(size_t size, size_t count) {
    if (count > 0 && size > SIZE_MAX / count) {
        return 0;
    }
    return size * count;
}

// Allocates n items of type T.
// `AllocResult AllocN[T](Allocator a, T, size_t n)`
#define AllocN(a, T, n) \
    ((a).m->alloc((a).self, calcSize(sizeof(T), (n)), alignof(T)))

// Frees n items allocated with AllocN.
// Only accepts typed pointers, not void*.
// `void FreeN[T](Allocator a, T* ptr, size_t n)`
#define FreeN(a, ptr, n)               \
    ((a).m->free(                      \
        (a).self, (ptr),               \
        calcSize(sizeof(*(ptr)), (n)), \
        alignof(typeof(*(ptr)))))

We could use some __VA_ARGS__ macro tricks to make Alloc and Free work for both a single object and a collection. But let's not do that — I prefer to avoid heavy-magic macros in this post.

Libc allocator

As for the custom allocators, let's start with a libc wrapper. It's not particularly interesting, since it ignores most of the parameters, but still:

// The libc allocator wrapper.
// Ignores alignment and treats zero-size allocations as errors.
// Doesn't support reallocation to keep things simple.
AllocResult Libc_Alloc(void* self, size_t size, size_t align) {
    (void)self;
    (void)align;

    if (size == 0) return (AllocResult){NULL, Error_SizeOverflow};
    void* ptr = malloc(size);
    if (!ptr) return (AllocResult){NULL, Error_OutOfMemory};
    return (AllocResult){ptr, Error_None};
}

void Libc_Free(void* self, void* ptr, size_t size, size_t align) {
    (void)self;
    (void)size;
    (void)align;
    free(ptr);
}

Allocator LibcAllocator(void) {
    static const struct _Allocator mtab = {
        .alloc = Libc_Alloc,
        .free = Libc_Free,
    };
    return (Allocator){.m = &mtab, .self = NULL};
}

Usage example:

int main(void) {
    Allocator allocator = LibcAllocator();

    {
        // Allocate a single integer.
        AllocResult res = Alloc(allocator, int64_t);
        if (res.err != Error_None) {
            printf("Error: %d\n", res.err);
            return 1;
        }

        int64_t* x = res.ptr;
        *x = 42;

        Free(allocator, x);
    }

    {
        // Allocate an array of integers.
        size_t n = 100;
        AllocResult res = AllocN(allocator, int64_t, n);
        if (res.err != Error_None) {
            printf("Error: %d\n", res.err);
            return 1;
        }

        int64_t* arr = res.ptr;
        for (size_t i = 0; i < n; i++) {
            arr[i] = i + 1;
        }

        FreeN(allocator, arr, n);
    }
}
ok

Arena allocator

Now let's use that self field to implement an arena allocator backed by a fixed-size buffer:

// A simple arena allocator.
// Doesn't support reallocation.
typedef struct {
    uint8_t* buf;
    size_t cap;
    size_t offset;
} Arena;

Arena NewArena(uint8_t* buf, size_t cap) {
    return (Arena){.buf = buf, .cap = cap, .offset = 0};
}

static AllocResult Arena_Alloc(void* self, size_t size, size_t align) {
    Arena* arena = (Arena*)self;

    // 1. Calculate the alignment padding.
    if (size == 0) return (AllocResult){NULL, Error_SizeOverflow};
    uintptr_t currentPtr = (uintptr_t)arena->buf + arena->offset;
    uintptr_t alignedPtr = (currentPtr + (align - 1)) & ~(align - 1);
    size_t newOffset = (alignedPtr - (uintptr_t)arena->buf) + size;

    // 2. Check for errors.
    if (newOffset < arena->offset) {
        return (AllocResult){NULL, Error_SizeOverflow};
    }
    if (newOffset > arena->cap) {
        return (AllocResult){NULL, Error_OutOfMemory};
    }

    // 3. Commit the allocation.
    arena->offset = newOffset;
    return (AllocResult){(void*)alignedPtr, Error_None};
}

static void Arena_Free(void* self, void* ptr, size_t size, size_t align) {
    // Individual deallocations are no-ops.
    (void)self;
    (void)ptr;
    (void)size;
    (void)align;
}

static void Arena_Reset(Arena* arena) {
    arena->offset = 0;
}

Allocator Arena_Allocator(Arena* arena) {
    static const struct _Allocator mtab = {
        .alloc = Arena_Alloc,
        .free = Arena_Free,
    };
    return (Allocator){.m = &mtab, .self = arena};
}

Usage example:

int main(void) {
    uint8_t buf[1024];
    Arena arena = NewArena(buf, sizeof(buf));
    Allocator allocator = Arena_Allocator(&arena);

    {
        // Allocate a single integer.
        AllocResult res = Alloc(allocator, int64_t);
        if (res.err != Error_None) {
            printf("Error: %d\n", res.err);
            return 1;
        }

        int64_t* x = res.ptr;
        *x = 42;

        // No need for Free.
    }

    {
        // Allocate an array of integers.
        size_t n = 100;
        AllocResult res = AllocN(allocator, int64_t, n);
        if (res.err != Error_None) {
            printf("Error: %d\n", res.err);
            return 1;
        }

        int64_t* arr = res.ptr;
        for (size_t i = 0; i < n; i++) {
            arr[i] = i + 1;
        }

        // No need for FreeN.
    }

    Arena_Reset(&arena);
}
ok

Nice!

Error handling

As shown in the examples above, the allocation method returns an error if something goes wrong. While checking for errors might not be as convenient as it is in Zig or Odin, it's still pretty straightforward:

int main(void) {
    Allocator allocator = LibcAllocator();

    size_t n = SIZE_MAX;
    AllocResult res = AllocN(allocator, int64_t, n);
    if (res.err != Error_None) {
        printf("Allocation failed: %d\n", res.err);
        return 1;
    }

    FreeN(allocator, res.ptr, n);
}
Allocation failed: 2 (exit status 1)

source

Final thoughts

Here's an informal table comparing allocation APIs in the languages we've discussed:

          Single object   Collection
        ┌──────────────────────────────────────────┐
Rust    │ Box::new(42)    vec![0; 100]             │
        │                                          │
Zig     │ a.create(i32)   a.alloc(i32, 100)        │
        │                                          │
Odin    │ new(int)        make([]int, 100)         │
        │ new(int, a)     make([]int, 100, a)      │
        │                                          │
C3      │ mem::new(int)   mem::new_array(int, 100) │
        │                                          │
Hare    │ alloc(42)       alloc([0...], 100)       │
        │                                          │
C       │ Alloc(a, int)   AllocN(a, int, 100)      │
        └──────────────────────────────────────────┘

In Zig, you always have to specify the allocator. In Odin, passing an allocator is optional. In C3, some functions require you to pass an allocator, while others just use the global one. In Hare, there's a single global allocator.

As we've seen, there's nothing magical about the allocators used in modern languages. While they're definitely more ergonomic and safe than C, there's nothing stopping us from using the same techniques in plain C.

]]>
(Un)portable defer in Chttps://antonz.org/defer-in-c/Thu, 05 Feb 2026 12:00:00 +0000https://antonz.org/defer-in-c/Eight ways to implement defer in C.Modern system programming languages, from Hare to Zig, seem to agree that defer is a must-have feature. It's hard to argue with that, because defer makes it much easier to free memory and other resources correctly, which is crucial in languages without garbage collection.

The situation in C is different. There was a N2895 proposal by Jens Gustedt and Robert Seacord in 2021, but it was not accepted for C23. Now, there's another N3734 proposal by JeanHeyd Meneide, which will probably be accepted in the next standard version.

Since defer isn't part of the standard, people have created lots of different implementations. Let's take a quick look at them and see if we can find the best one.

C23/GCC • C11/GCC • GCC/Clang • MSVC • Long jump • For loop • Stack • Simplified GCC/Clang • Final thoughts

C23/GCC

Jens Gustedt offers this brief version:

#define defer __DEFER(__COUNTER__)
#define __DEFER(N) __DEFER_(N)
#define __DEFER_(N) __DEFER__(__DEFER_FUNCTION_##N, __DEFER_VARIABLE_##N)

#define __DEFER__(F, V)        \
    auto void F(int*);         \
    [[gnu::cleanup(F)]] int V; \
    auto void F(int*)

Usage example:

void loud_free(void* p) {
    printf("freeing %p\n", p);
    free(p);
}

int main(void) {
    int* p = malloc(sizeof(int));
    if (!p) return 1;
    defer { loud_free(p); }

    *p = 42;
    printf("p = %d\n", *p);
}
p = 42
freeing 0x127e05b30

This approach combines C23 attribute syntax ([[attribute]]) with GCC-specific features: nested functions (auto void F(int*)) and the cleanup attribute. It also uses the non-standard __COUNTER__ macro (supported by GCC, Clang, and MSVC), which expands to an automatically increasing integer value.

Nested functions and cleanup in GCC

A nested function (also known as a local function) is a function defined inside another function:

void outer() {
    int x = 10;

    void inner() {
        x += 10;
    }

    inner();
}

Nested functions can access variables from the enclosing scope, similar to closures in other languages, but they are not first-class citizens and cannot be passed around like function pointers.

The cleanup attribute runs a function when the variable goes out of scope:

void safe_free(int **ptr) {
    if (!ptr || !*ptr) return;
    free(*ptr);
}

int main(void) {
    __attribute__((cleanup(safe_free))) int *p = malloc(sizeof(int));
    if (!p) return 1;
    *p = 42;

    // safe_free(&p) will be called automatically
    // when p goes out of scope.
}

The function should take one parameter, which is a pointer to a type that's compatible with the variable. If the function returns a value, it will be ignored.

On the plus side, this version works just like you'd expect defer to work. On the downside, it's only available in C23+ and only works with GCC (not even Clang supports it, because of the nested function).

Another downside is that using nested functions requires an executable stack, which security experts strongly discourage.

Executable stack vulnerability

When we use nested functions in GCC, the compiler often creates trampolines (small pieces of machine code) on the stack at runtime. These trampolines let the nested function access variables from the parent function's scope. For the CPU to run these code fragments, the stack's memory pages need to be marked as executable.

An executable stack is a serious security risk because it makes buffer overflow attacks much easier. In these attacks, a hacker sends more data than a program can handle, which overwrites the stack with harmful "shellcode". If the stack non-executable (which is the standard today), the CPU won't run that code and the program will just crash. But since our defer macro makes the stack executable, an attacker can jump straight to their injected code and run it, giving them complete control over the process.

C11/GCC

We can easily adapt the above version to use C11:

#define defer _DEFER(__COUNTER__)
#define _DEFER(N) __DEFER(N)
#define __DEFER(N) ___DEFER(__DEFER_FUNC_##N, __DEFER_VAR_##N)

#define ___DEFER(F, V)                                         \
    auto void F(void*);                                        \
    __attribute__((cleanup(F))) int V __attribute__((unused)); \
    auto void F(void* _dummy_ptr)

Usage example:

int main(void) {
    int* p = malloc(sizeof(int));
    if (!p) return 1;
    defer { loud_free(p); }

    *p = 42;
    printf("p = %d\n", *p);
}
p = 42
freeing 0x127e05b30

The main downside remains: it's GCC-only.

GCC/Clang

Clang fully supports the cleanup attribute, but it doesn't support nested functions. Instead, it offers the blocks extension, which works somewhat similar:

void outer() {
    __block int x = 10;

    void (^inner)(void) = ^{
        x += 10;
    };

    inner();
}

We can use Clang blocks to make a defer version that works with both GCC and Clang:

#if defined(__clang__)

// Clang implementation.
#define _DEFER_CONCAT(a, b) a##b
#define _DEFER_NAME(a, b) _DEFER_CONCAT(a, b)

static inline void _defer_cleanup(void (^*block)(void)) {
    if (*block) (*block)();
}

#define defer                                                                   \
    __attribute__((unused)) void (^_DEFER_NAME(_defer_var_, __COUNTER__))(void) \
        __attribute__((cleanup(_defer_cleanup))) = ^

#elif defined(__GNUC__)

// GCC implementation.
#define defer _DEFER(__COUNTER__)
#define _DEFER(N) __DEFER(N)
#define __DEFER(N) ___DEFER(__DEFER_FUNC_##N, __DEFER_VAR_##N)

#define ___DEFER(F, V)                                         \
    auto void F(void*);                                        \
    __attribute__((cleanup(F))) int V __attribute__((unused)); \
    auto void F(void* _dummy_ptr)

#else

// Runtime error for unsupported compilers.
#define defer assert(!"unsupported compiler");

#endif

Usage example:

int main(void) {
    int* p = malloc(sizeof(int));
    if (!p) return 1;
    defer { loud_free(p); };

    *p = 42;
    printf("p = %d\n", *p);
}
p = 42
freeing 0x127e05b30

Now it works with Clang, but there are several things to be aware of:

  1. We must compile with -fblocks.
  2. We must put a ; after the closing brace in the deferred block: defer { ... };.
  3. If we need to modify a variable inside the defer block, the variable must be declared with __block:
__block int x = 0;
defer { x += 10; };

On the plus side, this implementation works with both GCC and Clang. The downside is that it's still not standard C, and won't work with other compilers like MSVC.

MSVC

MSVC, of course, doesn't support the cleanup attribute. But it provides "structured exception handling" with the __try and __finally keywords:

int main(void) {
    int* p = malloc(sizeof(int));
    if (!p) return 1;
    __try {
        *p = 42;
        printf("p = %d\n", *p);
    }
    __finally {
        loud_free(p);
    }
}

The code in the __finally block will always run, no matter how the __try block exits — whether it finishes normally, returns early, or crashes (for example, from a null pointer dereference).

This isn't the defer we're looking for, but it's a decent alternative if you're only programming for Windows.

Long jump

There are well-known defer implementations by Jens Gustedt and moon-chilled that use setjmp and longjmp. I'm mentioning them for completeness, but honestly, I would never use them in production. The first one is extremely large, and the second one is extremely hacky. Also, I'd rather not use long jumps unless it's absolutely necessary.

Still, here's a usage example from Gustedt's library:

guard {
    void * const p = malloc(25);
    if (!p) break;
    defer free(p);

    void * const q = malloc(25);
    if (!q) break;
    defer free(q);

    if (mtx_lock(&mut)==thrd_error) break;
    defer mtx_unlock(&mut);
}

Here, all deferred statements run at the end of the guarded block, no matter how we exit the block (normally or through break).

For loop

The stc library probably has the simplest defer implementation ever:

#define defer(...) \
    for (int _c_i3 = 0; _c_i3++ == 0; __VA_ARGS__)

Usage example:

int main(void) {
    int* p = malloc(sizeof(int));
    if (!p) return 1;
    defer(loud_free(p)) {
        *p = 42;
        printf("p = %d\n", *p);
    }
}
p = 42
freeing 0x127e05b30

Here, the deferred statement is passed as __VA_ARGS__ and is used as the loop increment. The "defer-aware" block of code is the loop body. Since the increment runs after the body, the deferred statement executes after the main code.

This approach works with all mainstream compilers, but it falls apart if you try to exit early with break or return:

int main(void) {
    int* p = malloc(sizeof(int));
    if (!p) return 1;
    defer(loud_free(p)) {
        *p = 42;
        if (*p == 42) {
            printf("early exit, defer is not called\n");
            break;
        }
        printf("p = %d\n", *p);
    }
}
early exit, defer is not called

Stack

Dmitriy Kubyshkin provides a defer implementation that adds a "stack frame" of deferred calls to any function that needs them. Here's a simplified version:

#define countof(A) ((sizeof(A)) / (sizeof((A)[0])))

// Deferred function and its argument.
struct _defer_ctx {
    void (*fn)(void*);
    void* arg;
};

// Calls all deferred functions in LIFO order.
static inline void _defer_drain(
    const struct _defer_ctx* it,
    const struct _defer_ctx* end) {
    for (; it != end; it++) it->fn(it->arg);
}

// Initializes the defer stack with the given size
// for the current function.
#define defers(n)                     \
    struct {                          \
        struct _defer_ctx* first;     \
        struct _defer_ctx items[(n)]; \
    } _deferred = {&_deferred.items[(n)], {0}}

// Pushes a deferred function call onto the stack.
#define defer(_fn, _arg)                              \
    do {                                              \
        if (_deferred.first <= &_deferred.items[0]) { \
            assert(!"defer stack overflow");          \
        }                                             \
        struct _defer_ctx* d = --_deferred.first;     \
        d->fn = (void (*)(void*))(_fn);               \
        d->arg = (void*)(_arg);                       \
    } while (0)

// Calls all deferred functions and returns from the current function.
#define returnd                                          \
    while (                                              \
        _defer_drain(                                    \
            _deferred.first,                             \
            &_deferred.items[countof(_deferred.items)]), \
        1) return

Usage example:

int main(void) {
    // The function supports up to 16 deferred calls.
    defers(16);

    int* p = malloc(sizeof(int));
    if (!p) returnd 1;
    defer(loud_free, p);

    *p = 42;
    printf("p = %d\n", *p);

    // We must exit through returnd to
    // ensure deferred functions are called.
    returnd 0;
}
p = 42
freeing 0x127e05b30

This version works with all mainstream compilers. Also, unlike the STC version, defers run correctly in case of early exit:

int main(void) {
    defers(16);

    int* p = malloc(sizeof(int));
    if (!p) returnd 1;
    defer(loud_free, p);

    *p = 42;
    if (*p == 42) {
        printf("early exit\n");
        returnd 0;
    }

    printf("p = %d\n", *p);
    returnd 0;
}
early exit
freeing 0x127e05b30

Unfortunately, there are some drawbacks:

  • Defer only supports single-function calls, not code blocks.
  • We always have to call defers at the start of the function and exit using returnd. In the original implementation, Dmitriy overrides the return keyword, but this won't compile with strict compile flags (which I think we should always use).
  • The deferred function runs before the return value is evaluated, not after.

Simplified GCC/Clang

The Stack version above doesn't support deferring code blocks. In my opinion, that's not a problem, since most defers are just "free this resource" actions, which only need a single function call with one argument.

If we accept this limitation, we can simplify the GCC/Clang version by dropping GCC's nested functions and Clang's blocks:

#define _DEFER_CONCAT(a, b) a##b
#define _DEFER_NAME(a, b) _DEFER_CONCAT(a, b)

// Deferred function and its argument.
struct _defer_ctx {
    void (*fn)(void*);
    void* arg;
};

// Calls the deferred function with its argument.
static inline void _defer_cleanup(struct _defer_ctx* ctx) {
    if (ctx->fn) ctx->fn(ctx->arg);
}

// Create a deferred function call for the current scope.
#define defer(fn, ptr)                                      \
    struct _defer_ctx _DEFER_NAME(_defer_var_, __COUNTER__) \
        __attribute__((cleanup(_defer_cleanup))) =          \
            {(void (*)(void*))(fn), (void*)(ptr)}

Works like a charm:

int main(void) {
    int* p = malloc(sizeof(int));
    if (!p) return 1;
    defer(loud_free, p);

    *p = 42;
    printf("p = %d\n", *p);
}
p = 42
freeing 0x127e05b30

Final thoughts

Personally, I like the simpler GCC/Clang version better. Not having MSVC support isn't a big deal, since we can run GCC on Windows or use the Zig compiler, which works just fine.

But if I really need to support GCC, Clang, and MSVC — I'd probably go with the Stack version.

Anyway, I don't think we need to wait for defer to be added to the C standard. We already have defer at home!

]]>