Solod 0.3: Concurrency, JSON, more safety
Anton Zhiyanov<br>projects<br>books<br>blog<br>about
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:<br>Go developers who want low-level control without having to learn another language.<br>C developers who like Go's style.<br>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.<br>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.<br>Threads •<br>Channels •<br>Worker pools •<br>Sharing state •<br>JSON •<br>Safety net •<br>Tooling •<br>Wrapping up<br>Threads<br>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.<br>// greet prints a label three times.<br>func greet(arg any) any {<br>from := arg.(string)<br>for i := range 3 {<br>println(from, "->", i)<br>return nil
func main() {<br>// Run greet on a separate OS thread, concurrently with main.<br>name := "thread"<br>th := conc.Go(greet, name)
// Wait blocks until the thread finishes.<br>th.Wait()<br>println("done")
thread -> 0<br>thread -> 1<br>thread -> 2<br>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.<br>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).<br>Channels<br>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.<br>// ping sends a single message on the given channel.<br>func ping(arg any) any {<br>messages := arg.(*conc.Chan[string])<br>messages.Send("ping")<br>return nil
func main() {<br>// An unbuffered channel (buffer size 0): each send blocks<br>// until a receiver is ready to take the value.<br>messages := conc.NewChan[string](mem.System, 0)<br>defer messages.Free()
// Launch a thread that sends "ping" into the channel.<br>th := conc.Go(ping, &messages)<br>defer th.Wait()
// Receive the message and print it.<br>var msg string<br>messages.Recv(&msg)<br>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.<br>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.<br>Worker pools<br>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.<br>// job holds input and the result.<br>type job struct {<br>id int<br>result int
// process handles one job.<br>func process(arg any) {<br>j := arg.(*job)<br>time.Sleep(100*time.Millisecond)<br>j.result = j.id * 2
func main() {<br>// A pool of 4 worker threads. Each submitted job is handled<br>// by the next available worker.<br>pool := conc.NewPool(mem.System, conc.PoolOptions{NumThreads: 4})<br>defer pool.Free()
// Submit 8 jobs. Each writes into its own struct, so keep<br>// the structs alive in a slice until the jobs finish.<br>start := time.Now()<br>jobs := make([]job, 8)<br>for i := range jobs {<br>jobs[i].id = i + 1<br>pool.Go(process, &jobs[i])
// Wait until all submitted jobs have finished.<br>pool.Wait()
for i := range jobs {<br>println("job", jobs[i].id, "->", jobs[i].result)<br>elapsed := time.Since(start) / 1_000_000<br>println("took", elapsed, "ms")
job 1 -> 2<br>job 2 -> 4<br>job 3 -> 6<br>job 4 -> 8<br>job 5 -> 10<br>job 6 -> 12<br>job 7 -> 14<br>job 8 -> 16<br>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...