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-usThu, 20 Aug 2026 12:00:00 +0000Going freestandinghttps://antonz.org/going-freestanding/Thu, 20 Aug 2026 12:00:00 +0000https://antonz.org/going-freestanding/Porting Go's standard library to platform-agnostic C.Creating a subset of Go that translates to C (which I named Solod) 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.

At some point I decided to make as many packages as possible freestanding — independent of any libc implementation or specific OS runtime. That went pretty well. Solod now has 37 standard library packages, and 31 of them work in freestanding mode.

This post describes the techniques I used to get there. There's nothing genuinely novel, and if you're experienced with C, you probably already know all of them. Still, I think it's useful to document the approach — both for me and for anyone interested.

Freestanding modeHeadersBuiltinsMemoryAtomicsPure CAllocationValuesHooksHosted-onlyTestingFinal thoughts

Freestanding mode

C has two types of environments. In a hosted environment, you get the full standard library — either the one required by the C standard or, even better, POSIX. In a freestanding environment, you get almost nothing.

The compiler tells you which one you're in:

#if __STDC_HOSTED__
// libc is available
#else
// you're on your own
#endif

Pass -ffreestanding and link with -nostdlib, and that's it: you no longer have printf, or malloc, or even memcpy. There is no entropy source, no file system operations, and no clock. If libc itself is "hard mode", this is "impossible".

Despite its limitations, freestanding mode can be really useful for microcontrollers, WebAssembly sandboxes, kernels, and anything else without an operating system to rely on.

Freestanding headers

Freestanding does not mean "just the C language". The C standard guarantees some headers even without libc, because they define types and macros instead of functions:

float.h   stdalign.h  stdbool.h  stdint.h
limits.h  stdarg.h    stddef.h   ...

Everything that requires actual function implementations is gone:

assert.h  math.h   stdlib.h  time.h
errno.h   stdio.h  string.h  ...

To reflect the hosted/freestanding split, let's introduce builtin.h, a common header included in every standard library package:

#if __STDC_HOSTED__

#include <assert.h>
#include <inttypes.h>
#include <stdalign.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define so_build_hosted

#else

#include <stdbool.h>
#include <stdint.h>
#include <stdalign.h>
#include <stddef.h>

#endif  // __STDC_HOSTED__

Individual packages follow the same approach: branch on so_build_hosted to distinguish between the hosted and freestanding implementations.

Compiler builtins

GCC and Clang implement some C standard functions without relying on libc. These are known as compiler builtins.

__builtin_trap causes the program to terminate abnormally. You can use it to implement poor man's assertion and panic:

#ifdef so_build_hosted

#define so_panic(msg)                                     \
    do {                                                  \
        fprintf(stderr, "panic: %s\n  %s:%d (func %s)\n", \
                msg, __FILE__, __LINE__, __func__);       \
        exit(1);                                          \
    } while (0)

#else

#define assert(cond)                   \
    do {                               \
        if (!(cond)) __builtin_trap(); \
    } while (0)

#define so_panic(msg)     \
    do {                  \
        (void)msg;        \
        __builtin_trap(); \
    } while (0)

#endif // so_build_hosted

From now on, I'll mainly show the freestanding versions and omit the hosted versions to keep things simple.

The __builtin_alloca function allocates memory on the stack. Its bounded wrapper limits the size of each allocation:

#define alloca __builtin_alloca

// The maximum size that can be allocated
// with alloca (64 KB by default).
#ifndef SO_MAX_ALLOCA_SIZE
#define SO_MAX_ALLOCA_SIZE (64 << 10)  // in bytes
#endif

#define so_alloca(size) ({                                \
    size_t _size = (size_t)(size);                        \
    if (_size > SO_MAX_ALLOCA_SIZE)                       \
        so_panic("alloca: size exceeds maximum allowed"); \
    _size ? alloca(_size) : NULL;                         \
})

Memory operations

The memxxx functions from string.h have matching builtins too, so you might expect a freestanding build to provide them for you:

// int memcmp(const void* lhs, const void* rhs, size_t n);
#define memcmp __builtin_memcmp

// void* memcpy(void* dst, const void* src, size_t n);
#define memcpy __builtin_memcpy

// void* memmove(void* dst, const void* src, size_t n);
#define memmove __builtin_memmove

// void* memset(void* dst, int ch, size_t n);
#define memset __builtin_memset

Unfortunately, there's no free lunch here.

__builtin_memcpy is not a separate memcpy implementation. If n is small and known at compile time, the compiler expands it into a few load and store instructions. But if n is large or only known at runtime, it calls the actual memcpy from libc instead.

Even worse, you don't need to mention memcpy explicitly to use it. Suppose you copy a large struct like this:

typedef struct { char buf[4096]; } Big;

void copy(Big* a, const Big* b) {
    *a = *b;
}

When you compile the code for aarch64-freestanding, the object file contains an undefined reference to memcpy. Zero-initializing a local array produces the same issue with memset. Neither name appears in the source; both are introduced by the compiler.

So the freestanding environment must still provide memcpy, memmove, memset, and memcmp for memory operations to work in the general case.

WebAssembly covers three of the four: memcpy, memmove, and memset map to the memory.copy and memory.fill instructions. There is no instruction for comparison, so memcmp stays a real function call even there. On other targets, the toolchain often provides all four, as zig cc does (even with -nostdlib). If it doesn't, provide a plain C implementation:

#undef memcpy
void* memcpy(void* dst, const void* src, size_t n) {
    unsigned char* d = dst;
    const unsigned char* s = src;
    while (n--) *d++ = *s++;
    return dst;
}

#undef memset
void* memset(void* dst, int ch, size_t n) {
    unsigned char* d = dst;
    while (n--) *d++ = (unsigned char)ch;
    return dst;
}

#undef memmove
void* memmove(void* dst, const void* src, size_t n) {
    // omitted for brevity
}

#undef memcmp
int memcmp(const void* lhs, const void* rhs, size_t n) {
    const unsigned char* l = lhs;
    const unsigned char* r = rhs;
    for (; n--; l++, r++) {
        if (*l != *r) return *l - *r;
    }
    return 0;
}

The defines (#define memcpy __builtin_memcpy and others above) are still worth keeping, even if you implement the functions yourself. This way, the compiler can still use its own implementation when applicable.

Fun fact: at -O2 and above, GCC can fold your custom memcpy implementation back into a call to memcpy, which is infinite recursion. -ffreestanding prevents this because it implies -fno-builtin, but the guarantee is weak. You can use -fno-tree-loop-distribute-patterns to disable this behavior for good.

The rest of string.h is not covered. No compiler provides memchr or strlen, so those you always have to write yourself — more on that below.

Atomic operations

Another useful group of compiler builtins is __atomic_xxx, which provide atomic, thread-safe memory access. They operate on regular objects instead of _Atomic objects:

// so_atomic_load atomically loads the value at p.
#define so_atomic_load(p) \
    (__atomic_load_n((p), __ATOMIC_SEQ_CST))

// so_atomic_store atomically stores v at p.
#define so_atomic_store(p, v) \
    (__atomic_store_n((p), (v), __ATOMIC_SEQ_CST))

This makes porting Go's sync/atomic types straightforward. All types — atomic integers, unsigned integers, booleans, and pointers — use the same two load/store macros:

// Bool is an atomic boolean value. The zero value is false.
typedef struct atomic_Bool {
    bool v;
} atomic_Bool;

// Load atomically loads and returns the value stored in x.
bool atomic_Bool_Load(atomic_Bool* x) {
    return so_atomic_load(&x->v);
}

// Store atomically stores val into x.
void atomic_Bool_Store(atomic_Bool* x, bool val) {
    so_atomic_store(&x->v, val);
}

A separate atomic_Bool type isn't strictly required — atomic_Bool_Load and atomic_Bool_Store would work with a plain bool*. Still, it can be useful. With a plain pointer, *x = true creates a silent data race that looks like ordinary code, while the wrapper makes you explicitly write x->v = true.

No stdatomic.h include is needed. However, the CPU must natively support the integer width you use. For example, a 64-bit atomic on a 32-bit target becomes a call to libatomic instead of a single instruction. But that's a different story.

Pure C implementations

If the compiler doesn't provide an implementation, you have to write one yourself. Preferably, use the libc name so all call sites remain unchanged.

A good example is memchr, which is required by bytes.IndexByte. There is a __builtin_memchr, but it is not an implementation, so you need to provide your own:

#ifndef so_build_hosted
// memchr implementation for freestanding environments.
static inline void* memchr(const void* s, int c, size_t n) {
    const unsigned char* p = s;
    unsigned char target = (unsigned char)c;
    while (n--) {
        if (*p == target) return (void*)p;
        p++;
    }
    return NULL;
}
#endif

Some of these DIY implementations aren't trivial, of course. Fortunately, Go's standard library includes many standalone algorithms, such as the string-to-number conversion functions in strconv or integer math in math/bits. Porting them to C is almost mechanical:

// Go version.
const m3 = 0x00ff00ff00ff00ff

// ReverseBytes32 returns the value of x
// with its bytes in reversed order.
func ReverseBytes32(x uint32) uint32 {
    const m = 1<<32 - 1
    x = x>>8&(m3&m) | x&(m3&m)<<8
    return x>>16 | x<<16
}
// C version.
static const int64_t m3 = 0x00ff00ff00ff00ff;

uint32_t bits_ReverseBytes32(uint32_t x) {
    const int64_t m = ((int64_t)1 << 32) - 1;
    x = ((x >> 8) & (m3 & m)) | ((x & (m3 & m)) << 8);
    return (x >> 16) | (x << 16);
}

Memory allocation

Memory allocation calls for a different technique. A naive approach would be to implement a freestanding malloc that uses a static buffer:

extern char so_heap[SO_HEAP_SIZE];
extern size_t so_heap_offset;

static inline void* malloc(size_t size) {
    // Simplified version without alignment.
    if (size > SO_HEAP_SIZE - so_heap_offset) {
        return NULL;
    }
    void* ptr = &so_heap[so_heap_offset];
    so_heap_offset += size;
    return ptr;
}

It might be sufficient for testing, but I'd avoid using it in production.

Instead of reimplementing malloc, let's remove the need for it, and make the caller provide the memory. Start with an allocator interface, so callers don't depend on a specific implementation:

// Allocator defines the interface for memory allocators.
// Simplified version without Realloc and alignment.
typedef struct {
    void* self;
    so_R_ptr_err (*Alloc)(void* self, so_int size);
    void (*Free)(void* self, void* ptr, so_int size);
} mem_Allocator;

What's with the so-types?

so_int is an integer of the target width:

#if SIZE_MAX == 0xFFFFFFFFu
typedef int32_t so_int;
#else
typedef int64_t so_int;
#endif

so_String is a pointer to the underlying string bytes and their count:

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

so_Error is an interface value that wraps the error data:

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

so_R_ptr_err is a result-type implementation for a (pointer + error) pair:

typedef struct {
    void* val;
    so_Error err;
} so_R_ptr_err;

There are other similar types like so_R_int_err (int + error) or so_R_f32_bool (float32 + bool).

Then provide an arena allocator, which is freestanding by design:

// Arena is a memory allocator that bump-allocates
// linearly within a fixed buffer.
typedef struct {
    so_Slice buf;
    so_int offset;
} mem_Arena;

mem_Arena mem_NewArena(so_Slice buf) {
    return (mem_Arena){.buf = buf};
}

so_R_ptr_err mem_Arena_Alloc(void* self, so_int size) {
    // Simplified version without alignment.
    mem_Arena* a = self;
    assert(size > 0 && "mem: invalid allocation size");
    if (size > so_len(a->buf) - a->offset) {
        return (so_R_ptr_err){.val = NULL, .err = mem_ErrOutOfMemory};
    }
    void* ptr = &so_at(so_byte, a->buf, a->offset);
    a->offset += size;
    return (so_R_ptr_err){.val = ptr, .err = (so_Error){}};
}

void mem_Arena_Free(void* self, void* ptr, so_int size) {
    // Free in arena is a no-op.
    (void)self; (void)ptr; (void)size;
}

void mem_Arena_Reset(void* self) {
    mem_Arena* a = self;
    a->offset = 0;
}

Usage example:

typedef struct Point {
    so_int x;
    so_int y;
} Point;

// Prepare the arena.
so_byte data[1024];
so_Slice buf = {.ptr = data, .len = sizeof(data)};
mem_Arena arena = mem_NewArena(buf);
mem_Allocator alloc = {
    .self = &arena,
    .Alloc = mem_Arena_Alloc,
    .Free = mem_Arena_Free};

// Allocate a Point. mem_Alloc is a macro that calls
// the Alloc "method" and panics on failure.
Point* p = mem_Alloc(Point, alloc);
p->x = 11;
p->y = 22;

On a freestanding target, an arena is a better choice than a buffer-backed malloc, because the caller decides how much memory is available and when it's released.

Values, not pointers

Constructor functions in Go typically return a pointer:

// A string reader.
type Reader struct {
    s        string
    i        int64 // current reading index
    prevRune int   // index of previous rune; or < 0
}

// NewReader returns a new Reader reading from s.
func NewReader(s string) *Reader {
    return &Reader{s, 0, -1}
}

This roughly translates to the following code, using the memory allocator from the previous section:

// A string reader.
typedef struct {
    so_String s;
    int64_t i;
    so_int prevRune;
} strings_Reader;

// NewReader returns a new Reader reading from s.
// The returned reader is allocated; the caller owns it.
strings_Reader* strings_NewReader(mem_Allocator alloc, so_String s) {
    strings_Reader* r = mem_Alloc(strings_Reader, alloc);
    r->s = s;
    r->i = 0;
    r->prevRune = -1;
    return r;
}

Instead of blindly following Go idioms, it's better to get rid of allocations altogether and return a value:

// NewReader returns a new Reader reading from s.
strings_Reader strings_NewReader(so_String s) {
    return (strings_Reader){.s = s, .prevRune = -1};
}

This isn't a technique specific to writing freestanding code — it's helpful for almost any C library.

Target hooks

Some things you can't write in a target-agnostic way at all. Only the target knows how to print a byte, read the clock, or generate a random number; these all depend on the hardware.

What you can do is declare functions (hooks) and let the user's code define them:

Hook Description
so_write_out send some bytes to the output
so_crand_read read some random bytes
so_time_wall get the current wall clock time
so_time_mono get the current monotonic time
so_time_sleep pause for a given duration

Then the user can call specific APIs available on their hardware:

so_int so_write_out(const uint8_t* buf, so_int size) {
    return board_uart_write(buf, size);
}

int64_t so_time_mono(void) {
    return (int64_t)board_uptime_ms() * 1000000;
}

What happens if the user doesn't provide an implementation? You still want the standard library to compile and work unless someone calls the missing functions. To achieve that, use weak definitions:

// so_write_out drops the bytes and reports a full write,
// so panic and fmt print nothing and report no error.
__attribute__((weak)) so_int so_write_out(const uint8_t* buf, so_int size) {
    (void)buf;
    return size;
}

// so_crand_read reads no bytes. The interpretation is left to the caller.
__attribute__((weak)) so_int so_crand_read(uint8_t* buf, so_int size) {
    (void)buf;
    (void)size;
    return 0;
}

// so_time_wall panics, because no default date is correct.
__attribute__((weak)) so_R_i64_i32 so_time_wall(void) {
    so_panic("time: define so_time_wall for this target");
}

Now every hook gets a default, and a definition in the user code silently wins over the default one.

Note that the defaults above behave differently on purpose. Dropping output is fine because a board with no UART (serial interface) has nowhere to print. Inventing a date is not fine, because no date would be correct.

For the same reason, crypto/rand panics instead of falling back to a software generator. A "random" source that silently returns predictable bytes would be a terrible idea:

// crand_read fills buf with size cryptographically secure random bytes.
// Panics if the target does not define so_crand_read.
static inline void crand_read(uint8_t* buf, so_int size) {
    if (size <= 0) return;
    if (so_crand_read(buf, size) != size) {
        so_panic("crypto/rand: no entropy source");
    }
}

You can still use a fallback when cryptographic security isn't needed, such as for hashing map keys or math/rand:

// runtime_Seed returns a random 64-bit seed.
static inline uint64_t runtime_Seed(void) {
    uint64_t seed = 0;
    // Use cryptographically secure random if available.
    if (so_crand_read((uint8_t*)&seed, 8) == 8 && seed != 0) {
        return seed;
    }
    // Fallback to deterministic xorshift64 sequence.
    // ...
}

Hosted-only

Some things aren't worth solving with hooks, such as the os and net packages, which require a lot of target-specific code. In these cases, it's better to use a header-level guard that fails in freestanding mode:

// so/os/os.h
#include "so/builtin/builtin.h"

#ifndef so_build_hosted
#error "os: hosted environment required"
#endif

If user code imports os in a freestanding environment, the compiler reports an error at compile time instead of at link time or runtime.

Testing

The only way to know if the freestanding implementation actually works is to test it.

My approach in Solod is to run the freestanding packages' test suites with a WASI runtime and a small harness. The harness defines all five hooks from the Target hooks section as WASI imports:

// ciovec is the buffer descriptor that fd_write reads.
// The WASI ABI is 32-bit, so both fields are 32-bit.
typedef struct {
    const uint8_t* buf;
    uint32_t len;
} ciovec;

// wasi_fd_write writes the buffers to the file descriptor
// and stores the number of bytes written in nwritten.
__attribute__((import_module("wasi_snapshot_preview1"), import_name("fd_write")))
extern uint32_t wasi_fd_write(uint32_t fd, const ciovec* iovs,
                              uint32_t iovs_len, uint32_t* nwritten);

// so_write_out writes size bytes to the standard output of the WASI host.
so_int so_write_out(const uint8_t* buf, so_int size) {
    ciovec iov = {.buf = buf, .len = (uint32_t)size};
    uint32_t written = 0;
    if (wasi_fd_write(1, &iov, 1, &written) != 0) {
        return 0;
    }
    return (so_int)written;
}

The freestanding make task builds tests from stdlib packages into a single wasm32-freestanding binary and runs it with wasmtime. This uses the same tests for the freestanding logic as in hosted mode, so I don't need to write separate freestanding tests.

Final thoughts

Here's a summary of the approach I used write a freestanding stdlib in C:

  • Choose between hosted and freestanding at compile time.
  • Use the compiler builtins when possible.
  • Implement the missing parts and port the standalone code.
  • Use explicit allocators; prefer values to pointers.
  • Declare hooks for the hardware, with weak defaults.
  • Fail fast for packages that can't work in freestanding.
  • Test in a freestanding build, not just hosted.

I hope you find it useful too.

If you're interested in trying this in practice, take a look at Solod's README — it has everything you need to get started. Or try it online without installing anything.

]]>
Relying on Gohttps://antonz.org/relying-on-go/Sun, 09 Aug 2026 12:00:00 +0000https://antonz.org/relying-on-go/Reusing Go's tooling and standard library for a systems language.Everyone is creating a new programming language these days, often one that's "like Go but with more features" or "like Rust but simpler".

Solod, a systems language for C and Go developers, might look like one of those languages, but it takes a different approach.

Go's tooling

Solod is not "Go-like" in the usual sense, nor is it an attempt to "fix Go's mistakes". At the language level, Solod is literally a subset of Go. Solod reuses much of Go's existing tooling, including syntax highlighting, LSP, linters, and the package management system.

Take this quick-start guide, for example:

Quick start

Install the Solod command line tool:

go install solod.dev/cmd/so@latest

Create a new Go project and add the dependency to use the Solod standard library:

go mod init example
go get solod.dev@latest

Write regular Go code, but use Solod packages instead of the standard Go packages:

package main

import "solod.dev/so/math"

func main() {
    ans := math.Sqrt(1764)
    println("Hello, world! The answer is", int(ans))
}

Run without saving the binary:

so run .

That's it!

There's nothing new here. It's mostly standard Go workflow, except for so run, which is a Go program that mimics go run.

Go's standard library

Solod also reuses a lot of Go's standard library code and tests. Some of it is taken verbatim from Go's source code, like these two string functions:

// CutPrefix returns s without the provided leading prefix string
// and reports whether it found the prefix.
func CutPrefix(s, prefix string) (string, bool) {
    if !HasPrefix(s, prefix) {
        return s, false
    }
    return s[len(prefix):], true
}

// HasPrefix reports whether the string s begins with prefix.
func HasPrefix(s, prefix string) bool {
    return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}

Of course, Solod retains the Go authors' copyright.

Some code requires changes to support the manual memory management with explicit allocators used by Solod:

// Go version.
func Clone(s string) string {
    if len(s) == 0 {
        return ""
    }
    b := make([]byte, len(s))
    copy(b, s)
    return unsafe.String(&b[0], len(b))
}
// Solod version.
func Clone(a mem.Allocator, s string) string {
    if len(s) == 0 {
        return ""
    }
    b := mem.AllocSlice[byte](a, len(s), len(s))
    copy(b, s)
    return string(b)
}

You can probably see the resemblance.

A grain of salt

Go tools don't know that Solod is a subset of the full Go language, so they won't flag features Solod doesn't support, like function literals or iterators. These diagnostics come from the custom so tooling:

package main

func main() {
    f := func(n int) {
        println(n)
    }
    f(42)
}
main.go:4:7: function literals are not supported
    f := func(n int) {
         ^here

Also, although a substantial part of Go's standard library is ported verbatim or with minimal changes from the original source, that doesn't mean the code is automatically correct. Solod still needs its own tests, including ones that run under sanitizers and static analyzers.

It's all C in the end

All Solod code is translated to regular C11 and then compiled with GCC or Clang. Solod therefore relies on C tooling and decades of optimization work just as much as on Go's.

Solod code:

package main

import "solod.dev/so/math"

func main() {
    // What might it be?
    ans := math.Sqrt(1764)
    println("Hello, world! The answer is", int(ans))
}

Translated C code:

// -- main.h --
#pragma once
#include "so/builtin/builtin.h"
#include "so/math/math.h"

// -- main.c --
#include "main.h"

int main(void) {
    // What might it be?
    double ans = math_Sqrt(1764.0);
    so_println("%s %" PRIdINT, "Hello, world! The answer is", (so_int)(ans));
    return 0;
}

The C version is noisier, of course, especially for more complex programs than this one. But it remains readable.

And since there's no runtime, interoperability between Solod and C costs nothing.

Final thoughts

A new language doesn't necessarily need a new ecosystem.

Solod relies heavily on Go, and I see that as a strength, not a weakness. Reusing Go's proven tools and standard library makes Solod more reliable and easier to work with.

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

]]>
Going Backward: Reinventing Go's iteratorhttps://antonz.org/going-backward/Mon, 03 Aug 2026 12:00:00 +0000https://antonz.org/going-backward/Building a generic iterator wheel from scratch.Go's standard library has a slices package with a function called Backward. It lets you iterate over the elements of a slice in reverse order:

// Backward returns an iterator over index-value pairs in the slice,
// traversing it backward with descending indices.
func Backward[Slice ~[]E, E any](s Slice) iter.Seq2[int, E]

If you're not deeply familiar with generics and iterators, the natural reaction to this signature (and to the others in the slices package) is: "couldn't this have been made simpler somehow?"

To answer that, let's run a thought experiment. Let's picture ourselves as a distant ancestor, living in the pre-iterator era, who decided to implement Backward from scratch.

Our imaginary ancestor doesn't work at Google, so don't project their decisions onto the Go development team. They had their own reasons — and no Jira.

1. A slice in reverse

A pleasant, sunny summer day, birds singing. You're at the keyboard as usual, and suddenly you decide to write a function for walking a slice in reverse order. Anything beats working on yet another Jira ticket.

// Backward returns the slice in reverse order.
func Backward[T any](s []T) []T {
    n := len(s)
    res := make([]T, n)
    for i := n - 1; i >= 0; i-- {
        res[n-1-i] = s[i]
    }
    return res
}

Usage example:

s := []int{11, 22, 33, 44, 55}
b := Backward(s)
fmt.Println(b)
// [55 44 33 22 11]

The implementation is simple and works reliably. There's one drawback, though: Backward creates a copy of the slice, which can be wasteful for large slices.

Besides, the sun has hidden behind a cloud, and it looks like rain is coming. You decide to work a bit more.

2. Gimme, gimme, gimme

To avoid copying the slice, you decide to return a closure that knows the current position in the original slice and returns the next element on each call:

// Backward returns a function that, on each call, returns the next
// element of the slice (in reverse order) and a flag indicating
// whether to continue iterating (false means done).
func Backward[T any](s []T) func() (T, bool) {
    i := len(s)
    return func() (T, bool) {
        if i == 0 {
            var zero T
            return zero, false
        }
        i--
        return s[i], true
    }
}

Usage example:

s := []int{11, 22, 33, 44, 55}
next := Backward(s)
for {
    v, ok := next()
    if !ok {
        break
    }
    fmt.Print(v, " ")
}
fmt.Println()
// 55 44 33 22 11

Now it allocates O(1) memory instead of O(n). That's better.

Before moving on, you glance out of the window. Yep, sure enough, the rain has started, and the sky is even cloudier than before. Excellent working weather!

3. A callback-based iterator

Something about the calling code keeps bothering you. It came out quite imperative. You'd like to hand the loop mechanics over to Backward and leave the caller with nothing but the application logic (whatever it is you do with the slice elements).

You decide to complicate Backward's signature a little. Now it will return an iterator function that takes a callback as an argument and applies it to each element of the slice:

// Backward returns a function that takes a yield callback.
// The callback is invoked for each element of the slice (in reverse order).
func Backward[T any](s []T) func(yield func(T) bool) {
    return func(yield func(T) bool) {
        for i := len(s) - 1; i >= 0; i-- {
            if !yield(s[i]) {
                return
            }
        }
    }
}

The yield function returns a bool — that's so the callback can signal when it wants to stop the traversal early.

Now you can turn the for loop body in the calling code into a callback, and you don't need the loop anymore:

work := func(x int) bool {
    if x < 30 {
        return false // early exit
    }
    fmt.Print(x, " ")
    return true
}

s := []int{11, 22, 33, 44, 55}
it := Backward(s)
it(work)
fmt.Println()
// 55 44 33

Mmm, very functional.

One small nuance: Backward's signature looks a bit heavy. You add a separate type for the return value:

// Seq is an iterator over sequences of individual values.
// When called as seq(yield), seq calls yield(v) for each value
// v in the sequence, stopping early if yield returns false.
type Seq[T any] func(yield func(T) bool)

The function looks much better now:

func Backward[T any](s []T) Seq[T] {
    // body unchanged
}

Praising yourself for inventing the iterator, you walk over to the window. It looks like the weather's gotten worse. The rain is coming down in buckets, and the sky is so overcast that it's grown as dark as evening.

4. Iterator 2: Return of the Iterator

It's all great, but then it hits you: an ordinary range over a slice returns both the index and the element's value. Your iterator returns only the value. You decide to fix this vexing oversight:

func Backward[T any](s []T) func(yield func(int, T) bool) {
    return func(yield func(int, T) bool) {
        for i := len(s) - 1; i >= 0; i-- {
            if !yield(i, s[i]) {
                return
            }
        }
    }
}

Usage example:

work := func(i int, x int) bool {
    fmt.Print(i, ":", x, " ")
    return true
}

s := []int{11, 22, 33, 44, 55}
it := Backward(s)
it(work)
fmt.Println()
// 4:55 3:44 2:33 1:22 0:11

Since the result's signature has changed, it no longer fits the Seq type. What can you do — you'll have to add a new type. After ten minutes of deliberation, you decide to call it Seq2:

// Seq2 is an iterator over sequences of key-value pairs.
// When called as seq(yield), seq calls yield(k, v) for each pair
// (k, v) in the sequence, stopping early if yield returns false.
type Seq2[K any, V any] func(yield func(K, V) bool)
func Backward[T any](s []T) Seq2[int, T] {
    // body unchanged
}

You get up to stretch your legs, and go to the window. The downpour is so heavy you can't make anything out. Lightning is flashing. Hail the size of your fist is falling — you've never seen anything like it in your life. Well, these things happen!

5. Not quite a slice

Have you thought of everything? Seems so. But you're not going back to Jira tickets just yet. Refreshing your memory of the Go spec, you realize that besides ordinary slices there are "user-defined" ones — types whose underlying type is a slice:

// IDs is a slice of identifiers.
type IDs []int

Backward works perfectly well with IDs — the compiler accepts a value of type IDs since its underlying type is []int:

ids := IDs{11, 22, 33, 44, 55}
it := Backward(ids)
it(work)
fmt.Println()
// 4:55 3:44 2:33 1:22 0:11

But what about this?

// backwardIDs builds an iterator over a slice of identifiers
// in reverse order.
var backwardIDs func(IDs) Seq2[int, int] = Backward[int]
// ERROR: cannot use Backward[int]
// (value of type func(s []int) Seq2[int, int])
// as func(IDs) Seq2[int, int] value in variable declaration

Here's where the difference between IDs and []int shows up.

When you assign the function itself, it's the signatures that get compared: func(IDs) Seq2[int, int] versus func([]int) Seq2[int, int]. Signatures match only if the parameter types are identical. But IDs and []int are different, even though one is based on the other. The signatures differ → you get an error.

Scratching your head, you turn to the spec once again and find a special generic syntax: ~T. It represents the set of all types whose underlying type is T. Just what you need!

Now you'll have to parameterize not only the element type (E) but the slice type (Slice) as well. E is needed for the returned values, while Slice lets the function accept not just []E, but any types based on it:

func Backward[Slice ~[]E, E any](s Slice) Seq2[int, E] {
    return func(yield func(int, E) bool) {
        for i := len(s) - 1; i >= 0; i-- {
            if !yield(i, s[i]) {
                return
            }
        }
    }
}

Now the example:

var backwardIDs func(IDs) Seq2[int, int] = Backward[IDs, int]

ids := IDs{11, 22, 33, 44, 55}
work := func(i int, x int) bool {
    fmt.Print(i, ":", x, " ")
    return true
}
it := backwardIDs(ids)
it(work)
fmt.Println()
// 4:55 3:44 2:33 1:22 0:11

It works! You've ended up with something similar to Backward from the slices package.

You exhale wearily and walk over to the window. The downpour and hail have given way to a hurricane. Trees and billboards go flying past. Toads, for some reason, are falling from the sky.

6. Iterator 3: Judgment Day

To take your mind off the strange events outside the window, you keep pondering.

An ordinary Backward is already great. But it would be even better if the traversal logic itself were configurable. On the other hand, if you end up with a lot of parameters, a strategy would suit better. And, by the way, it wouldn't hurt to add a factory that produces iterator factories according to given criteria...

Before you can finish the thought, the ground outside the window tears open with a deafening roar. An enormous black hand, streaming molten lava and flickering flames, bursts out of the fissure, seizes you, and drags you straight down to hell.

P.S. Despite the article's tongue-in-cheek tone, the "complicated" version in the standard library is justified (Backward just follows suite with other package functions). But if you're doing something similar in a project that solves a specific problem — it might make sense to stop at the simpler option.

]]>
Solod 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 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. Solod 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 Solod-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 Solod 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. Solod 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 Solod package is also a valid Go package. This means you get Go's built-in fuzzer for free, making fuzz testing pretty easy. Solod'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 Solod 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 Solod more convenient and safe.

If you're interested, take a look at Solod's readme — it has everything you need to get started. Or try Solod 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, 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 Solod'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 Solod'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 Solod 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 Solod code, only noisier. From here on, I'll mainly show the Solod 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. Solod'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:

Atomic op Go Solod 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 Solod 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 Solod
┌────────┐ ┌────────┐   ┌────────┐
│ 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 Solod 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 Solod'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 Solod Winner
Uncontended, 1 thread 14ns 9ns Solod - 1.6x
Contended spin, 8 threads 75ns 27ns Solod - 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 Solod actually wins the first two benchmarks, and for good reason. Solod'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 — Solod 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 Solod 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 Solod 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 Solod Winner
Uncontended, 1 thread 24ns 21ns Solod - 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 Solod a slight advantage. But the moment a producer and consumer actually start handing off work, Solod 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 Solod 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, Solod 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 Solod'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 Solod's "no hidden allocations" rule. In Go, go f() quietly allocates a goroutine stack, and make(chan T, n) allocates a buffer. In Solod, 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 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 Solod 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 Solod 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 Solod (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 Solod'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 Solod program to wasm32-wasi and run it under any WASI runtime.
  • Freestanding mode. Solod 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. Solod 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 Solod packages using go get or by vendoring, and you can organize your own code into multiple modules. Solod 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 Solod 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 Solod's readme — it has everything you need to get started. Or try Solod 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 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 Solod 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 Solod 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.

Solod'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 Solod 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, Solod'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 Solod's readme — it has all the information you need to get started. Or try Solod 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.

]]>