Solod 0.3: Concurrency, JSON, more safety

Solod (So) is a subset of Go that translates to regular C — with zero runtime, manual memory management, and source-level interop. It's designed for two main audiences:

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

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

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

ThreadsChannelsWorker poolsSharing stateJSONSafety netToolingWrapping up

Threads

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

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

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

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

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

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

Channels

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

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

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

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

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

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

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

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

Worker pools

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

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

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

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

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

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

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

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

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

Sharing state

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

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

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

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

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

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

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

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

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

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

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

JSON

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

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

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

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

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

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

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

var name string
var age int64

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

println(name, age)
Alice 25

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

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

Safety net

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

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

type Point struct{ x, y int }

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

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

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

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

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

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

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

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

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

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

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

type Rect struct{ width, height int }

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

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

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

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

Stack trace frames represent each function in the call chain:

func main() {
    Work()
}

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

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

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

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

so run -sanitize -panic=abort example/play

Tooling

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

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

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

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

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

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

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

Wrapping up

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

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

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

★ Subscribe to keep up with new posts.