Anton ZhiyanovEverything about Go, SQL, and software in general.https://antonz.org/https://antonz.org/assets/favicon/favicon.pngAnton Zhiyanovhttps://antonz.org/Hugo -- gohugo.ioen-usSat, 26 Sep 2026 11:30:00 +0000Go concurrency distilledhttps://antonz.org/go-concurrency-distilled/Sat, 26 Sep 2026 11:30:00 +0000https://antonz.org/go-concurrency-distilled/Interactive mini-book on concurrent programming in Go.This mini-book provides a brief overview of many concurrency topics in Go. Each topic comes with interactive examples — feel free to experiment with them by changing the code and clicking Run. There's also a PDF version with static examples.

This is a quick refresher on Go concurrency, not a beginner's guide. If you want to learn concurrency from the ground up with practical exercises, check out my other book — Gist of Go: Concurrency.

The book is AI-free.

Goroutines • Channels • Select • Pipelines • Time • Context • Wait groups • Data races • Race conditions • Mutexes • Semaphores • Signaling • Run once • Object pool • Atomics • Testing • Scheduling • Diagnostics • Final thoughts

# Goroutines

The foundation of concurrency in Go is goroutines – functions started with the go keyword:

func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        defer wg.Done()
        fmt.Println("worker 1")
    }()
    go func() {
        defer wg.Done()
        fmt.Println("worker 2")
    }()
    wg.Wait()
}
worker 2
worker 1

The Go runtime juggles these goroutines and distributes them among operating system threads running on CPU cores. Compared to OS threads, goroutines are lightweight, so you can create hundreds or thousands of them.

Goroutines are completely independent. The main function is also a goroutine, but it starts implicitly when the program starts. When main ends, other goroutines also shut down.

We use a wait group (sync.WaitGroup) to wait for goroutines to finish in the example above. A wait group has a counter inside. Calling Add(n) increments it by n, while Done() decrements it by one. Wait() blocks the calling goroutine (in this case, main) until the counter reaches zero. This way, main waits for both workers to finish before it exits.

WaitGroup.Go automatically increments the wait group counter, runs a function in a goroutine, and decrements the counter when it's done:

func main() {
    var wg sync.WaitGroup
    wg.Go(func() {
        fmt.Println("worker 1")
    })
    wg.Go(func() {
        fmt.Println("worker 2")
    })
    wg.Wait()
}
worker 2
worker 1

# Channels

Goroutines can pass values to each other through channels. A channel is like a window where one goroutine can throw something and another can catch it:

func main() {
    messages := make(chan string)

    go func() { messages <- "ping" }()

    msg := <-messages
    fmt.Println(msg)
}
ping

Sending a value through a channel is a synchronous operation. When the sending goroutine writes a value to the channel (ch <- val), it blocks and waits for someone to receive that value (<-ch). Only then does it continue.

Output channel

Returning an output channel from a function and filling it within an internal goroutine is a common pattern in Go. This allows the caller to receive values through the channel while the owning function retains control of it:

func generate(start, stop int) chan int {
    out := make(chan int)
    go func() {
        for i := start; i < stop; i++ {
            out <- i
        }
    }()
    return out
}

Closing a channel

To signal readers that all data has been sent, the writer goroutine closes the channel with close():

func generate(start, stop int) chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := start; i < stop; i++ {
            out <- i
        }
    }()
    return out
}

The reader checks the channel's status with a second value ("comma OK") when reading:

func main() {
    in := generate(5, 10)
    for {
        num, ok := <-in
        if !ok {
            break
        }
        fmt.Print(num, " ")
    }
}
5 6 7 8 9

While the channel is open, the reader receives the next value and a true status. If the channel is closed, the reader gets a zero value and a false status.

A channel can only be closed once. Closing it again or writing to a closed channel causes a panic.

The only reason to close a channel is to signal to its readers that all data has been sent. If this isn't important to the readers, then you don't need to close it. When a channel is no longer used, Go's garbage collector will free its resources, whether it's closed or not.

Channel iteration

range automatically reads the next value from the channel and checks if it's closed. If the channel is closed, it exits the loop:

func main() {
    nums := generate(5, 10)
    for n := range nums {
        fmt.Print(n, " ")
    }
}
5 6 7 8 9

Range over a channel returns a single value, not a pair, unlike range over a slice.

Directional channels

You can protect yourself from accidental write/close errors by setting the channel direction. Channels can be:

  • chan (bidirectional): for reading and writing (default);
  • chan<- (send-only): for writing only;
  • <-chan (receive-only): for reading only.

You can't read from a send-only channel or write to a receive-only channel (nor can you close it).

Channels are usually initialized for both reading and writing, and specified as directional in function parameters. Go automatically converts a regular channel to a directional one:

stream := make(chan int)

go func(in chan<- int) {
    in <- 42
}(stream)

func(out <-chan int) {
    fmt.Println(<-out)
}(stream)
42

Buffered channels

Buffered channels work like a FIFO queue with a fixed-size buffer for storing values.

As long as the buffer has free space, writing to the channel doesn't block the goroutine. Similarly, as long as the buffer contains values, reading from the channel doesn't block the goroutine:

stream := make(chan int, 3)
stream <- 11
stream <- 12
stream <- 13

fmt.Println(<-stream)
fmt.Println(<-stream)
11
13

By default, if you don't specify a buffer size, a channel is unbuffered (buffer size equals zero).

Buffered channels work with the built-in len() and cap() functions:

stream := make(chan int, 3)
stream <- 11
fmt.Println(cap(stream), len(stream))
3 1

Reading from a closed buffered channel returns values from the buffer and a true status. Once all values are taken, it returns a zero value and a false status, like a regular channel:

stream := make(chan int, 1)
stream <- 11
close(stream)

val, ok := <-stream
fmt.Println(val, ok)
// 11 true

val, ok = <-stream
fmt.Println(val, ok)
// 0 false
11 true
0 false

nil channel

Like any type in Go, channels have a zero value, which is nil.

Writing to or reading from a nil channel blocks the goroutine indefinitely:

var stream chan int

go func() {
    // blocks forever
    stream <- 1
}()

// blocks forever
<-stream

Closing a nil channel causes a panic:

var stream chan int
close(stream)
// panic: close of nil channel

# Select

The select statement is somewhat like switch, but specifically designed for channels. Here's what it does:

  • Checks which cases are not blocked.
  • If multiple cases are ready, randomly selects one to execute.
  • If all cases are blocked and there is a default case, executes it.
  • If all cases are blocked and there is no default case, waits until one is ready.

Select is used to manage data flow in pipelines:

// merge sends values from in1 and in2 to the output channel.
func merge(in1, in2 <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for in1 != nil || in2 != nil {
            select {
            case val1, ok := <-in1:
                if ok { out <- val1 } else { in1 = nil }
            case val2, ok := <-in2:
                if ok { out <- val2 } else { in2 = nil }
            }
        }
    }()
    return out
}

// Suppose we send 10..12 to in1, 20..22 to in2,
// and call merge(in1, in2)
10 11 20 12 21 22

To cancel goroutines:

// process modifies values from in and send them to out
// until in is exhausted or cancel is closed.
func process(cancel chan struct{}, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for val := range in {
            select {
            case out <- val*10:
            case <-cancel:
                fmt.Println("canceled")
                return
            }
        }
    }()
    return out
}

// Suppose we send values 11 and 12 to in
// and then call close(cancel)
110
120
canceled

For non-blocking operations:

// multiplier returns a function that multiplies
// the input by 10 and sends it to the channel
// or returns an error if the channel is busy.
func multiplier(ch chan<- int) func(n int) error {
    return func(n int) error {
        select {
        case ch <- n*10:
            return nil
        default:
            return errors.New("busy")
        }
    }
}

func main() {
    nums := make(chan int, 1)
    multiply := multiplier(nums)

    err := multiply(11)
    fmt.Println(<-nums, err)
    // 110 <nil>

    err = multiply(12)
    fmt.Println(<-nums, err)
    // 120 <nil>

    err = multiply(13)
    err = multiply(14)
    fmt.Println(err)
    // busy
}
110 <nil>
120 <nil>
busy

And for much more.

# Pipelines

A pipeline is a sequence of operations where each step takes input data, processes it in a specific way, and outputs it. The input and output of each operation is a channel.

A typical pipeline looks like this:

  • Reader: Reads input data from a file, database, or network.
  • N processors: Transform, filter, aggregate, or enrich data using external sources.
  • Writer: Writes the processed data to a file, database, or network.
func read[T any]() <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for {
            // read data from somewere
            data := // ...
            out <- data
        }
    }()
    return out
}

func process[T any](in <-chan T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for inData := range in {
            // process the data
            outData = // ...
            out <- outData
        }
    }()
    return out
}

func write[T any](in <-chan T) <-chan struct{} {
    done := make(chan struct{})
    go func() {
        defer close(done)
        for data := range in {
            // write the data
        }
    }()
    return done
}

Output channel

A goroutine can signal other goroutines that it has finished its work using an output channel:

func generate(start, stop int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := start; i < stop; i++ {
            out <- i
        }
    }()
    return out
}

func main() {
    nums := generate(5, 10)
    for n := range nums {
        fmt.Print(n, " ")
    }
}
5 6 7 8 9

Done channel

If a goroutine doesn't need to return results, it can signal completion using a done channel:

func work() <-chan struct{} {
    done := make(chan struct{})
    go func() {
        defer close(done)
        fmt.Println("work done")
    }()
    return done
}

func main() {
    done := work()
    <-done
}
work done

Cancel channel

To terminate a goroutine early, a calling goroutine can use a cancel channel:

func generate(cancel chan struct{}, n int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 1; i <= n; i++ {
            select {
            case out <- i:
            case <-cancel:
                return
            }
        }
    }()
    return out
}

func main() {
    cancel := make(chan struct{})
    defer close(cancel)

    nums := generate(cancel, 10)
    fmt.Println(<-nums)
    fmt.Println(<-nums)
    fmt.Println(<-nums)
}
1
2
3

Error handling

There are three approaches to error handling in concurrent pipelines.

➊ Return on the first error:

// calculate produces answers for the given numbers.
func process(in <-chan int) (<-chan int, <-chan error) {
	out := make(chan Answer)
	errc := make(chan error, 1)
	go func() {
		defer close(out)
		for n := range in {
			ans, err := fetchAnswer(n)
			if err != nil {
				errc <- err  // return with error
				return
			}
			out <- ans
		}
		errc <- nil          // return with nil
	}()
	return out, errc
}

➋ Use a result type:

// Result contains an answer or an error.
type Result struct {
	answer int
	err    error
}

// calculate produces answers for the given numbers.
func calculate(in <-chan int) <-chan Result {
	out := make(chan Result)
	go func() {
		defer close(out)
		for n := range in {
			ans, err := fetchAnswer(n)
			out <- Result{ans, err}  // return answer + error
		}
	}()
	return out
}

➌ Collect errors separately:

// calculate produces answers for the given numbers.
func calculate(in <-chan int, errc chan<- error) <-chan int {
	out := make(chan Answer)
	go func() {
		defer close(out)
		for n := range in {
			ans, err := fetchAnswer(n)
			if err == nil {
				out <- ans   // send answer
			} else {
				errc <- err  // or error
			}
		}
	}()
	return out
}

# Time

Besides handling date and time, the time package offers tools for managing time-sensitive operations in concurrent programs.

After

time.After() returns a channel that is initially empty, but receives a value after the timeout period. It's useful for timing out operations:

// withTimeout executes a function with a given timeout.
func withTimeout(timeout time.Duration, fn func()) error {
    done := make(chan struct{})
    go func() {
        defer close(done)
        fn()
    }()

    // blocks until fn completes or the timer expires,
    // whichever happens first
    select {
    case <-done:
        return nil
    case <-time.After(timeout):
        return errors.New("timeout")
    }
}

withTimeout() waits for fn() to complete, but thanks to time.After(), it won't wait longer than the timeout duration:

func main() {
    var err error

    // completes in time
    err = withTimeout(
        50*time.Millisecond,
        func() { fmt.Println("work done") },
    )
    fmt.Println("err =", err)

    // gets canceled on timeout
    err = withTimeout(
        50*time.Millisecond,
        func() {
            time.Sleep(100 * time.Millisecond)
            fmt.Println("work done")
        },
    )
    fmt.Println("err =", err)
}
work done
err = <nil>
err = timeout

Timer

A timer (time.Timer) is a structure with a C channel to which it sends the current time when it triggers (expires). Timers are useful for planning future executions:

done := make(chan struct{})

timer := time.NewTimer(50 * time.Millisecond)
go func() {
    eventTime := <-timer.C  // blocks for 50ms
    fmt.Println("work done at", eventTime)
    close(done)
}()

<-done
work done at 2009-11-10 23:00:00.05

Stop() stops the timer and returns true if it hasn't expired yet, and false otherwise:

// timer expires after 50ms
timer := time.NewTimer(50 * time.Millisecond)
go func() {
    eventTime := <-timer.C
    fmt.Println("work done at", eventTime)
}()

// after 10ms, the timer hasn't expired yet
time.Sleep(10 * time.Millisecond)

if timer.Stop() {
    fmt.Println("execution canceled")
} else {
    fmt.Println("too late to cancel")
}
execution canceled

It's often more convenient to use the time.AfterFunc() wrapper function. It waits for duration d and then executes function f:

done := make(chan struct{})
work := func() {
    fmt.Println("work done")
    close(done)
}

// executes work after 50ms
time.AfterFunc(50*time.Millisecond, work)
<-done
work done

time.AfterFunc() returns a timer that you can cancel before execution starts:

// executes the function after 50ms
timer := time.AfterFunc(50*time.Millisecond, func() {})

// after 10ms, the timer hasn't expired yet
time.Sleep(10 * time.Millisecond)

if timer.Stop() {
    fmt.Println("execution canceled")
}
execution canceled

If a timer is used in a loop, it's better to create a single timer and reset it instead of creating a new instance on each iteration:

// consumer reads tokens from the input channel and alerts
// if a value does not appear in a channel after an hour.
func consumer(in <-chan token) {
    const timeout = time.Hour
    timer := time.NewTimer(timeout)
    for {
        timer.Reset(timeout)
        select {
        case <-in:
            // do stuff
        case <-timer.C:
            // log warning
        }
    }
}

// Suppose we send 10,000 values to the in channel
// and measure memory usage.
Memory used: 4 KB, # allocations: 6

Ticker

A ticker is like a timer, but it keeps firing until you stop it. Tickers are useful for executing periodic tasks:

// fires every 50ms
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()

go func() {
    for {
        // waits for ticker to fire on each iteration
        at := <-ticker.C
        fmt.Println("work done at", at)
    }
}()

// enough time for the ticker to fire 3 times
time.Sleep(160*time.Millisecond)
ticker.Stop()
work done at 2009-11-10 23:00:00.05
work done at 2009-11-10 23:00:00.10
work done at 2009-11-10 23:00:00.15

NewTicker(d) creates a ticker that sends the current time to the channel C at interval d. You must stop the ticker eventually with Stop() to free up resources.

If the channel reader can't keep up with the ticker, the ticker will skip ticks.

# Context

The main purpose of context is to cancel operations, either manually or by timeout/deadline.

The function accepts a context and uses its Done() channel to listen for cancellation:

// work performs a task for 50 ms unless canceled.
// Returns an error when canceled.
func work(ctx context.Context) error {
    done := make(chan struct{})

    go func() {
        time.Sleep(50 * time.Millisecond)
        fmt.Println("work done")
        close(done)
    }()

    select {
    case <-done:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

Cancel manually (context.Canceled error):

func main() {
    // empty context
    ctx := context.Background()
    // manual canellation context
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    done := make(chan struct{})
    go func() {
        // takes 50 ms unless canceled
        err := work(ctx)
        fmt.Println("err =", err)
        close(done)
    }()

    // cancels after 10 ms
    time.Sleep(10 * time.Millisecond)
    cancel()
    <-done
}
err = context canceled

Cancel by timeout (context.DeadlineExceeded error):

func main() {
    ctx := context.Background()
    // cancels after 10 ms
    ctx, cancel := context.WithTimeout(ctx, 10*time.Millisecond)
    defer cancel()

    done := make(chan struct{})
    go func() {
        // takes 50 ms unless canceled
        err := work(ctx)
        fmt.Println("err =", err)
        close(done)
    }()

    <-done
}
err = context deadline exceeded

Cancel by deadline (context.DeadlineExceeded error):

func main() {
    ctx := context.Background()
    // cancels at now + 10 ms
    deadline := time.Now().Add(10 * time.Millisecond)
    ctx, cancel := context.WithDeadline(ctx, deadline)
    defer cancel()

    done := make(chan struct{})
    go func() {
        // takes 50 ms unless canceled
        err := work(ctx)
        fmt.Println("err =", err)
        close(done)
    }()

    <-done
}
err = context deadline exceeded

Context is layered. A context object is immutable. To add new properties to a context, a new (child) context is created based on the old (parent) context. The shorter timeout between the parent and child contexts always wins. The child context can only shorten the parent's timeout, not extend it:

func main() {
    // parent context with a 100 ms timeout
    const dur100ms = 100 * time.Millisecond
    parentCtx, cancel := context.WithTimeout(context.Background(), dur100ms)
    defer cancel()

    // child context with a 10 ms timeout
    const dur10ms = 10 * time.Millisecond
    childCtx, cancel := context.WithTimeout(parentCtx, dur10ms)
    defer cancel()

    // now the work gets canceled
    err := work(childCtx)
    fmt.Println("err =", err)
}
err = context deadline exceeded

Multiple cancels are safe. You can call cancel() on the context as many times as you want. The first cancel will work, and the rest will be ignored.

You can specify a custom cancellation cause using context.WithCancelCause(), context.WithTimeoutCause() and context.WithDeadlineCause(). This cause is accessible through context.Cause():

ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("the night is dark"))
fmt.Println(context.Cause(ctx))
the night is dark

You can register a function to execute when the context is canceled with context.AfterFunc():

ctx, cancel := context.WithCancel(context.Background())
cleanup := func() { fmt.Println("cleanup") }
context.AfterFunc(ctx, cleanup)
cancel()
time.Sleep(10 * time.Millisecond)
cleanup

Context can pass additional information about a call using context.WithValue(), which creates a context with a value for a specific key. But it's generally better to avoid passing values in context. It's better to use explicit parameters or custom structs instead.

# Wait groups

The sync.WaitGroup type lets you wait for one or more goroutines to finish:

const n = 10
var wg sync.WaitGroup
wg.Add(n)
for range n {
    go func() {
        defer wg.Done()
        fmt.Print(".")
    }()
}
wg.Wait()
..........

A WaitGroup doesn't know anything about the goroutines it manages. It works with an internal counter. Calling wg.Add(1) increments the counter by one, while wg.Done() decrements it. wg.Wait() blocks the calling goroutine until the counter reaches zero.

The Go method combines Add, starting a goroutine, and Done:

var wg sync.WaitGroup
for range 10 {
    wg.Go(func() {
        fmt.Print(".")
    })
}
wg.Wait()
..........

All methods are safe to use from multiple goroutines.

Normally, all Add calls happen before Wait. But technically, there's nothing stopping you from doing some of the Add calls before Wait and some after (from another goroutine).

You can call Wait from multiple goroutines. They will all block until the group's counter reaches zero.

# Data races

A data race happens when multiple goroutines access shared data, and at least one of them modifies it. We need to protect the data from this kind of concurrent access.

A data race doesn't always cause a runtime panic. That's why Go provides a special tool called the race detector. You can turn it on with the race flag, which works with the test, run, build, and install commands.

var total int

// There's a data race on total.
var wg sync.WaitGroup
wg.Go(func() { total++ })
wg.Go(func() { total++ })
wg.Wait()

fmt.Println("total:", total)
total: 2
go run -race main.go
==================
WARNING: DATA RACE
...
2
Found 1 data race(s)

Channels are safe for concurrent reading and writing, and they don't cause data races.

Ways to prevent data races:

  • Avoid concurrent data modification (typically by using channels).
  • Synchronize access with mutexes.
  • Use only atomic operations.

Race conditions

A race condition happens when an unpredictable order of operations from multiple goroutines leads to an incorrect system state:

// There's a race condition when working with balance.
withdraw := func(amount int) {
    if getBalance() < amount {
        return
    }
    time.Sleep(time.Millisecond)
    setBalance(getBalance() - amount)
}

setBalance(50)

var wg sync.WaitGroup
wg.Go(func() { withdraw(40) })
wg.Go(func() { withdraw(40) })
wg.Wait()

fmt.Println("balance:", getBalance())
balance: -30

If individual operations are concurrent-safe, Go's race detector won't find any issues. Because of this, it doesn't catch race conditions:

go run -race main.go
balance: -30

You can't fully eliminate uncertainty in a concurrent environment. Events will happen in an unpredictable order — that's just how concurrency works. However, you can prevent a race condition — often by protecting a composite operation with a mutex:

var mu sync.Mutex
withdraw := func(amount int) {
    mu.Lock()
    defer mu.Unlock()

    if getBalance() < amount {
        return
    }
    time.Sleep(time.Millisecond)
    setBalance(getBalance() - amount)
}

setBalance(50)

var wg sync.WaitGroup
wg.Go(func() { withdraw(40) })
wg.Go(func() { withdraw(40) })
wg.Wait()

fmt.Println("balance:", getBalance())
balance: 10

Compare-and-set

Sometimes you can prevent a race condition without using mutexes by applying an atomic compare-and-set operation or one of its flavors:

// CompareAndSet changes the value to new if the current value equals old.
// Returns true if the value was changed.
CompareAndSet(old, new any) bool

// CompareAndSwap changes the value to new if the current value equals old.
// Returns the old value.
CompareAndSwap(old, new any) any

// CompareAndDelete deletes the value if the current value equals old.
// Returns true if the value was deleted.
CompareAndDelete(old any) bool

// etc

The idea is always the same:

  • Check if the assumed (old) state matches reality.
  • If it does, change the state to new.
  • If not, do nothing.

# Mutexes

The sync.Mutex type protects shared data and parts of your code from being accessed concurrently:

var total int
var mu sync.Mutex

var wg sync.WaitGroup
for range 100 {
    wg.Go(func() {
        mu.Lock()
        time.Sleep(time.Millisecond)
        total++
        mu.Unlock()
    })
}
wg.Wait()
total: 100

The mutex guarantees that only one goroutine can run the code between Lock() and Unlock() at a time.

A mutex is used in these situations:

  • When multiple goroutines are modifying the same data.
  • When one goroutine is modifying the data and others are reading it.

If all goroutines are only reading the data, you don't need a mutex.

TryLock

The TryLock method tries to lock the mutex, just like a regular Lock. But if it can't, it returns false right away instead of blocking the goroutine:

var total int
var mu sync.Mutex

var wg sync.WaitGroup
for range 100 {
    wg.Go(func() {
        if !mu.TryLock() {
            return
        }
        defer mu.Unlock()
        time.Sleep(time.Millisecond)
        total++
    })
}
wg.Wait()
total: 1

RWMutex

The sync.RWMutex type distinguishes between readers and writers. It provides two sets of methods:

  • Lock / Unlock lock and unlock the mutex for both reading and writing.
  • RLock / RUnlock lock and unlock the mutex for reading only.
var total int
var mu sync.RWMutex
var wg sync.WaitGroup

// 10 writers.
for range 10 {
    wg.Go(func() {
        mu.Lock()
        defer mu.Unlock()
        time.Sleep(time.Millisecond)
        total++
    })
}

// 10 readers.
for range 10 {
    wg.Go(func() {
        // Try switching from RLock/RUnlock to Lock/Unlock
        //and see how it affects the elapsed time.
        mu.RLock()
        defer mu.RUnlock()
        time.Sleep(time.Millisecond)
        _ = total
    })
}

wg.Wait()
elapsed: 10ms

Here's how it works:

  • If a goroutine locks the mutex with Lock(), other goroutines will be blocked if they try to use Lock() or RLock().
  • If a goroutine locks the mutex with RLock(), other goroutines can also lock it with RLock() without being blocked.
  • If at least one goroutine has locked the mutex with RLock(), other goroutines will be blocked if they try to use Lock().

This creates a "single writer, multiple readers" setup.

Locker

Both sync.Mutex and sync.RWMutex implement the same sync.Locker interface:

type Locker interface {
    Lock()
    Unlock()
}

By using Locker instead of a specific mutex type, you can build components that don't depend on a specific lock implementation. This lets the client decide which lock to use.

Channel as mutex

You can use a channel instead of a mutex to protect shared data:

var total int
lock := make(chan struct{}, 1)

var wg sync.WaitGroup
wg.Go(func() {
    lock <- struct{}{}
    defer func() { <-lock }()
    total++
})
wg.Go(func() {
    lock <- struct{}{}
    defer func() { <-lock }()
    total++
})
wg.Wait()
total: 2

# Semaphores

A semaphore is like a container with N available slots and two operations: acquire to take a slot and release to free a slot. Here are the semaphore rules:

  • Calling acquire takes a free slot.
  • If there are no free slots, acquire blocks the goroutine that called it.
  • Calling release frees up a previously taken slot.
  • If there are any goroutines blocked on acquire when release is called, one of them will immediately take the freed slot and unblock.

You can implement a simple semaphore with a buffered channel, where N is the channel's size. To acquire the semaphore, send a value into the channel. To release it, take a value from the channel:

// Try changing nConc and see how the elapsed time changes.
const nConc = 4
const nCalls = 100
sema := make(chan struct{}, nConc)

var wg sync.WaitGroup
for range nCalls {
    sema <- struct{}{} // acquire
    wg.Go(func() {
        defer func() { <-sema }() // release
        time.Sleep(time.Millisecond) // do some work
    })
}
wg.Wait()
elapsed: 25ms

For more complex situations, use the golang.org/x/sync/semaphore package.

Rendezvous

A rendezvous lets two goroutines wait for each other:

  • There are two goroutines — G1 and G2 — and each one can signal that it's ready.
  • If G1 signals but G2 hasn't yet, G1 blocks and waits.
  • If G2 signals but G1 hasn't yet, G2 blocks and waits.
  • When both have signaled, they both unblock and continue running.

You can implement a simple rendezvous with a wait group:

var rend sync.WaitGroup
rend.Add(2)

var wg sync.WaitGroup
wg.Go(func() {
    fmt.Println("before rendezvous")
    rend.Done()
    rend.Wait()
    fmt.Println("after rendezvous")
})
wg.Go(func() {
    fmt.Println("before rendezvous")
    rend.Done()
    rend.Wait()
    fmt.Println("after rendezvous")
})
wg.Wait()
before rendezvous
before rendezvous
after rendezvous
after rendezvous

Barrier

A barrier is a general case of a rendezvous. It lets N goroutines wait for each other:

  • The barrier has a counter (starting at 0) and a threshold N.
  • Each goroutine that reaches the barrier increases the counter by 1.
  • The barrier blocks any goroutine that reaches it.
  • Once the counter reaches N, the barrier unblocks all waiting goroutines.

You can implement a simple barrier with a wait group:

const n = 4
var bar sync.WaitGroup
bar.Add(n)

var wg sync.WaitGroup
for range n {
    wg.Go(func() {
        fmt.Println("before the barrier")
        bar.Done()
        bar.Wait()
        fmt.Println("after the barrier")
    })
}
wg.Wait()
before the barrier
before the barrier
before the barrier
before the barrier
after the barrier
after the barrier
after the barrier
after the barrier

# Signaling

The sync.Cond (conditional variable) type lets one goroutine signal to another that it's ready, and lets the other goroutine wait for that signal.

A Cond includes a mutex and has two methods — Wait and Signal.

  • Wait unlocks the mutex and suspends the goroutine until it receives a signal.
  • Signal wakes the goroutine that is waiting on Wait.
  • When Wait wakes up, it locks the mutex again.
cond := sync.NewCond(&sync.Mutex{})
done := false

var wg sync.WaitGroup
wg.Go(func() {
    cond.L.Lock()
    fmt.Println("G1 is ready to signal")
    done = true
    cond.Signal()
    cond.L.Unlock()
})
wg.Go(func() {
    cond.L.Lock()
    for !done {
        cond.Wait()
    }
    fmt.Println("G2 received the signal")
    cond.L.Unlock()
})
wg.Wait()
G1 is ready to signal
G2 received the signal

If there are multiple waiting goroutines when Signal is called, only one of them will be resumed. If there are no waiting goroutines, Signal does nothing.

You can also use the Broadcast method. While Signal wakes up only one goroutine waiting on Cond.Wait, the Broadcast method wakes up all such goroutines.

You can signal with a channel:

signal := make(chan struct{}, 1)

go func() {
    // do something
    signal <- struct{}{}
}()

go func() {
    <-signal
    // do something
}()

And broadcast too:

broadcast := make(chan struct{})

go func() {
    // do something
    close(broadcast)
}()

go func() {
    <-broadcast
    // do something
}()

go func() {
    <-broadcast
    // do something
}()

Broadcasting with a condition variable is limited: it only sends a signal, not the actual data, and it only works once. With channels, you can build a publish/subscribe system that doesn't have these limitations:

type Publisher struct {
    sbox []chan int // subscription channels
    mu   sync.Mutex // protects the state
}

func (p *Publisher) Subscribe() <-chan int {
    p.mu.Lock()
    defer p.mu.Unlock()
    sub := make(chan int, 1)
    p.sbox = append(p.sbox, sub)
    return sub
}

func (p *Publisher) Broadcast(v int) {
    p.mu.Lock()
    defer p.mu.Unlock()
    for _, sub := range p.sbox {
        select {
        case sub <- v:
        default:
        }
    }
}

# Run once

The sync.Once type makes sure that the given function runs only once. If multiple goroutines call Once.Do at the same time, only one will run the function, while the others will wait until it returns:

total := 0
initState := func() {
    total += 1
}

var once sync.Once

var wg sync.WaitGroup
wg.Go(func() {
    once.Do(initState)
    // do something
})
wg.Go(func() {
    once.Do(initState)
    // do something
})
wg.Wait()
total: 1

Once is perfect for one-time initialization or cleanup in a concurrent environment.

Besides the Once type, the sync package also includes three convenience once-functions:

// Calls f only once.
func (o *Once) Do(f func())

// Returns a function that calls f only once.
func OnceFunc(f func()) func()

// Returns a function that calls f only once
// and returns the value from that first call.
func OnceValue[T any](f func() T) func() T

// Returns a function that calls f only once
// and returns the pair of values from that first call.
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)

# Object pool

The sync.Pool type helps reuse memory instead of allocating it every time, which reduces the load on the garbage collector:

pool := sync.Pool{
    New: func() any {
        buf := make([]byte, 1024)
        return &buf
    },
}

// Only allocates 4*1024 B, despite 4000 loop iterations.
var wg sync.WaitGroup
for range 4 {
    wg.Go(func() {
        for range 1000 {
            buf := pool.Get().(*[]byte)
            sink = buf
            pool.Put(buf)
        }
    })
}
wg.Wait()
Memory allocated: 4 KB

Get takes an item from the pool. If there are no available items, it creates a new one using New (which we have to define ourselves, since the pool doesn't know anything about the items it creates). Put returns an item back to the pool.

Things to keep in mind:

  • New should return a pointer, not a value, to reduce memory copying and avoid extra allocations.
  • The pool has no size limit. If you start 1000 more goroutines that all call Get at the same time, 1000 more buffers will be allocated.
  • After an item is returned to the pool with Put, you shouldn't use it anymore (since another goroutine might already have taken and started using it).

# Atomics

An operation without synchronization can only be truly atomic if it translates to a single processor instruction. Such operations don't need locks and won't cause issues when called concurrently (even the write operations).

There are only a few atomics, and they're all found in the sync/atomic package:

Int32     Bool
Int64     Value
Uint32    Pointer
Uint64

Each atomic type provides the following methods:

  • Load reads the value of a variable.
  • Store sets a new value.
  • Swap sets a new value (like Store) and returns the old one.
  • CompareAndSwap sets a new value only if the current value is still what you expect it to be.
var n atomic.Int32
n.Store(10)
swapped := n.CompareAndSwap(10, 42)
fmt.Println("CompareAndSwap 10 -> 42:", swapped)
fmt.Println("n =", n.Load())
CompareAndSwap 10 -> 42: true
n = 42

Numeric types also provide an Add method that increments the value by the specified amount.

All methods are either translated into a single CPU instruction or are otherwise guaranteed to be atomic, so they are safe to use from multiple goroutines.

The composition of atomics is always non-atomic:

var delta atomic.Int32
var counter atomic.Int32

func increment() {
    // Not atomic; causes a race condition.
    delta.Add(1)
    sleep(10)
    counter.Add(delta.Load())
}

// After 100 concurrent increments,
// the final value is NOT guaranteed.
counter = 9386

A bulletproof way to make a composite operation atomic and prevent race conditions is to use a mutex:

var delta int32
var counter int32
var mu sync.Mutex

func increment() {
    // Atomic; doesn't cause a race condition.
    mu.Lock()
    delta += 1
    sleep(10)
    counter += delta
    mu.Unlock()
}

// After 100 concurrent increments, the final value is guaranteed:
// counter = 1+2+...+100 = 5050
counter = 5050

Sometimes you can use an atomic type instead of a mutex to exit early:

type Gate struct {
    closed atomic.Bool
}

func (g *Gate) Close() {
    if !g.closed.CompareAndSwap(false, true) {
        return // ignore repeated calls
    }
    // The gate is closed.
    // We can free resources now.
}

# Testing

If your concurrent program uses channels or custom types with synchronization methods like Wait, you can use those in your tests. This way, your tests won't be much more complicated than if the code were synchronous:

// Calc calculates something asynchronously.
func Calc() <-chan int {
    out := make(chan int, 1)
    go func() {
        out <- 42
    }()
    return out
}
func Test(t *testing.T) {
    // Wait for the Calc goroutine to finish.
    got := <-Calc()
    if got != 42 {
        t.Errorf("got: %v; want: 42", got)
    }
}
PASS

If there aren't any suitable synchronization "handles" in the code you're testing, you can use the synctest package. It exports two functions:

func Test(t *testing.T, f func(*testing.T))
func Wait()

synctest.Test runs an isolated bubble. The bubble uses a fake clock, and you can manually control goroutine synchronization with synctest.Wait.

synctest.Wait blocks until all goroutines in the bubble — except the one that called Wait — have either finished or are durably blocked. This lets you wait for a specific goroutine to finish or get blocked, so you can check the program's state:

// NewProc starts the calculation.
func NewProc() *Proc {
    p := &Proc{done: make(chan struct{})}
    go func() {
        p.res = 42
        <-p.done // (X)
        p.res = 0
    }()
    return p
}
func Test(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        p := NewProc()
        defer p.Stop()

        // Wait for the goroutine to block at point X.
        synctest.Wait()
        if got := p.Res(); got != 42 {
            t.Fatalf("got %v, want 42", got)
        }
    })
}
PASS

The fake clock in synctest.Test move forward only if: ➊ all goroutines in the bubble are durably blocked; ➋ there's a future moment when at least one goroutine will unblock; and ➌ synctest.Wait isn't running. Thanks to this, time-dependent tests run instantly:

// Calc processes a value from the input channel.
// Times out if no input is received after 3 seconds.
func Calc(in chan int) (int, error) {
    select {
    case v := <-in:
        return v * 2, nil
    case <-time.After(3 * time.Second):
        return 0, ErrTimeout
    }
}
func Test(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ch := make(chan int)
        got, err := Calc(ch) // runs instantly

        if err != ErrTimeout {
            t.Errorf("got: %v; want: %v", err, ErrTimeout)
        }
        if got != 0 {
            t.Errorf("got: %v; want: 0", got)
        }
    })
}
PASS

The following operations durably block a goroutine:

  • A blocking send or receive on a channel created within the bubble.
  • A blocking select statement where every case is a channel created within the bubble.
  • Calling Cond.Wait.
  • Calling WaitGroup.Wait if all WaitGroup.Add calls were made inside the bubble.
  • Calling time.Sleep.

Blocking on mutexes, I/O, or system calls is not considered durable, and the synctest bubble can't handle them.

# Scheduling

At the hardware level, CPU cores are responsible for running parallel tasks.

At the operating system level, a thread is the basic unit of execution. There are usually many more threads than CPU cores, so the operating system's scheduler decides which threads to run and which ones to pause.

At the Go runtime level, a goroutine is the basic unit of execution. The runtime scheduler runs a fixed number of OS threads, often one per CPU core. There can be many more goroutines than threads, so the scheduler decides which goroutines to run on the available threads and which ones to pause. The scheduler keeps switching between goroutines to make sure each one gets a turn to run on a thread, instead of waiting in line forever.

  CPU                  OS                   Go runtime
┌──────────┐  run on ┌──────────┐  run on ┌────────────┐
│ Cores    │ <────── │ Threads  │ <────── │ Goroutines │
└──────────┘         └──────────┘         └────────────┘

This is how Go handles concurrency.

Goroutine scheduler

The goroutine scheduler's job is to run M goroutines on N operating system threads, where M can be much larger than N. Here's a very simplified version of it's algorithm:

  • If there's a free thread, assign it a goroutine from the queue.
  • If a running goroutine gets blocked (for example, while reading from a channel), put it back in the queue and assign a different goroutine to the thread.
  • If a running goroutine gets stuck in a syscall, start a new thread to run other goroutines until the blocked goroutine finishes the syscall.
  • Check the running goroutines every 10 ms. Preempt long-running goroutines and return them to the queue to prevent starvation.
┌─────┐┌─────┐┌─────┐┌─────┐
│ G17 ││ G18 ││ G19 ││ G20 │                        queue
└─────┘└─────┘└─────┘└─────┘

┌─────┐      ┌─────┐      ┌─────┐      ┌─────┐
│ G15 │      │ G16 │      │ G13 │      │ G14 │      running
└─────┘      └─────┘      └─────┘      └─────┘
  │            │            │            │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Thread E │ │ Thread F │ │ Thread C │ │ Thread D │
└──────────┘ └──────────┘ └──────────┘ └──────────┘

┌─────┐      ┌─────┐
│ G11 │      │ G12 │                                syscalls
└─────┘      └─────┘
  │            │
┌──────────┐ ┌──────────┐
│ Thread A │ │ Thread B │
└──────────┘ └──────────┘

The number of threads running Go code is controlled by the GOMAXPROCS environment variable or the runtime.GOMAXPROCS function.

A goroutine is a structure that starts out using about 2 KB of memory, mostly for its stack. The stack can grow if needed. Since goroutines are so lightweight, you can run tens of thousands or even hundreds of thousands of them on a small machine.

# Diagnostics

To troubleshoot concurrent programs in production, we use metrics, profiling, and tracing.

Metrics show how the Go runtime is performing, like how much heap memory it uses or how long garbage collection pauses take. Each metric has a unique name and a value, which can be a number or a histogram.

You can use the runtime/metrics package to get a complete list of metrics or check the values of specific ones:

samples := []metrics.Sample{
    {Name: "/sched/gomaxprocs:threads"},
    {Name: "/sched/goroutines:goroutines"},
}
metrics.Read(samples)

for _, s := range samples {
    fmt.Printf("%s: %v\n", s.Name, s.Value.Uint64())
}
/sched/gomaxprocs:threads: 8
/sched/goroutines:goroutines: 1

In practice, people rarely do this manually. Instead, all metrics are automatically exported using Prometheus or OpenTelemetry libraries.

Profiling helps you understand exactly what the program is doing, what resources it uses, and where in the code this happens. Go uses a sampling profiler that's suitable for production.

The most commonly used profiles are CPU, which shows how much processor time each function uses, and heap, which shows how much heap memory each function uses. Goroutine, block, and mutex profiles help identify problems related to concurrency.

The easiest way to add a profiler to your app is by using the net/http/pprof package. To collect a profile with the given name, call the /debug/pprof/{name} endpoint. To view the collected profile, use the go tool pprof utility:

go tool pprof -proto \
  "http://localhost:6060/debug/pprof/profile?seconds=N" > cpu.pprof
go tool pprof -http=localhost:8080 cpu.pprof

You can also profile manually:

// CPU profile.
file, _ := os.Create("cpu.prof")
defer file.Close()
pprof.StartCPUProfile(file)
defer pprof.StopCPUProfile()
// ...
// Any other profile.
file, _ := os.Create(name + ".prof")
defer file.Close()
pprof.Lookup(name).WriteTo(file, 0)

Tracing records certain types of events while the program is running, mainly those related to concurrency and memory. When the profiling server from the net/http/pprof package is running, call the /debug/pprof/trace endpoint to collect a trace. To view the results, use the go tool trace utility.

You can also collect a trace manually:

file, _ := os.Create("trace.out")
defer file.Close()
trace.Start(file)
defer trace.Stop()
// ...

You can set up automatic tracing with a sliding window that's limited by size or duration. This is called "flight recording". It lets you always keep a recent trace available in case something goes wrong:

cfg := trace.FlightRecorderConfig{
    MinAge:   5 * time.Second,
    MaxBytes: 3 << 20, // 3MB
}
rec := trace.NewFlightRecorder(cfg)
rec.Start()
defer rec.Stop()

# Final thoughts

We've covered a number of Go tools for writing concurrent programs:

  • Goroutines for running concurrent tasks.
  • Channels and select as flexible communication tools.
  • Timers and tickers for working with time.
  • Context for canceling operations.
  • Wait groups for synchronizing goroutines.
  • Mutexes to prevent race conditions.
  • Condition variables for signaling events.
  • Once for safe one-time initialization.
  • Pools to reduce garbage collector load.
  • Atomic operations.

If you like the book, please recommend it to your friends or colleagues. If you're interested, check out my other books and projects.

I'm glad you finished the book. Thank you, and I'll see you next time!

]]>
Solod 0.4: Better C interophttps://antonz.org/solod-0.4/Sat, 12 Sep 2026 13:00:00 +0000https://antonz.org/solod-0.4/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 new Solod release provides an easy way to call third-party C libraries, makes a large part of the standard library freestanding, and impoves the tooling.

Automatic bindings • Freestanding packages • Type assertions • C interop • Multi-package testing • Checks and targets • Windows • Wrapping up

Automatic binding generator

Sobind generates bindings — stubs for calling third-party C libraries from Solod. It parses .h files and emits a Solod source file with necessary structs, unions, constants, variables, function pointer typedefs, and function declarations.

You can then use the generated types and functions in regular Solod code:

package main

import (
    "solod.dev/raylib/libraylib"
    "solod.dev/so/c"
)

func main() {
    // Using Raylib bindings.
    libraylib.InitWindow(screenWidth, screenHeight, "☀️ Solod / Raylib")
    defer libraylib.CloseWindow()
    // ...
}

Usually, the generated bindings are good enough to use as they are, without any manual changes. I have also prepared bindings for popular C libraries like libuv, raylib, sodium, and sqlite.

Unlike Go, calling C from Solod has zero overhead — Solod code is just regular C in the end.

More freestanding packages

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.

These packages work in freestanding mode with no restrictions:

bufio  bytealg  bytes  c  cmp  encoding  encoding/binary
encoding/hex  encoding/json  errors  io  maps  math/bits
math/rand  mem  path  runtime  slices  strconv  strings
unicode  unicode/utf8  unsafe

These packages work in freestanding mode with certain limitations:

  • crypto/crand depends on a user-provided hook to read random bytes.
  • fmt depends on a user-provided hook to print formatted text.
  • math offers a working subset of features.
  • net/netip works fully, except it can't resolve an IPv6 zone name.
  • sync/atomic works on targets that support lock-free instructions.
  • testing depends on a user-provided hook to print test results.
  • time reads the clock using user-provided hooks.
  • uuid depends on hooks from both crypto/crand and time.

There's a separate post with more details if you're interested.

Type assertions

A comma-ok type assertion is now fully supported for non-empty interfaces:

var s1 Shape = &rect
r, ok := s1.(*Rect)   // r is &rect, ok is true

var s2 Shape = &circle
c, ok := s2.(*Rect)   // c is nil, ok is false

Which translates to the following C code:

main_Shape s1 = (main_Shape){.self = &rect, .Area = main_Rect_Area};
bool ok = (s1.Area == main_Rect_Area);
main_Rect* r = ok ? (main_Rect*)s1.self : NULL;
// ok == true, r == &rect

main_Shape s2 = (main_Shape){.self = &circle, .Area = main_Circle_Area};
ok = (s2.Area == main_Rect_Area);
main_Rect* c = ok ? (main_Rect*)s2.self : NULL;
// ok == false, c == NULL

Previously, the only two supported forms were a direct assertion like r := s.(*Rect) and a check-only form like _, ok := s.(*Rect).

C interop helpers

The c package now supports more common C types:

size_t      - c.Size
ssize_t     - c.SSize
ptrdiff_t   - c.Ptrdiff
intptr_t    - c.Intptr
long double - c.LongDouble

There's also a c.ConstVoid type, which maps to a C const void. You can use it where C expects a const void* pointer:

// in c
so_ssize_t find_first(const void* items, size_t count, size_t size,
                      bool (*match)(const void*));
// in solod
//so:extern
func find_first(items *c.ConstVoid, count c.Size, size c.Size,
    match func(item *c.ConstVoid) bool) c.SSize

Finally, there are some useful cast functions.

c.Bitcast reads the bits of a value as another type of the same size:

bits := c.Bitcast[uint64](1.0)   // 0x3ff0000000000000
f := c.Bitcast[float64](bits)    // 1.0

You can use c.Bitcast instead of a pointer conversion such as *(*float64)(unsafe.Pointer(&b)).

c.StringData and c.SliceData return a typed pointer to the string or slice data:

b := []byte{1, 2, 3}
p := c.SliceData[c.UChar](b)     // unsigned char*
q := c.StringData[c.UChar]("ab") // unsigned char*

They replace (*T)(unsafe.SliceData(b)) and (*T)(unsafe.StringData(s)).

Multi-package testing

so test can now run tests from multiple packages at once. If you use a pattern that ends with ..., it will select every package that has a test subdirectory under its base directory:

so test ./so/...      # the whole stdlib
so test ./so/net/...  # only the networking packages

The entire run only needs one translation, one compilation, and one execution, which is much faster than running it separately for each package.

The -pkg-file flag restricts the run to only the packages listed in a file:

# freestanding.txt
so/bytes
so/mem
so/time
so test -pkg-file=freestanding.txt ./so/...

Checks and targets

so build, so test, so bench and so run take two new flags: -target and -check.

-target specifies the target platform for cross-compilation. Use the same value that clang and zig cc accept after --target=:

export CC="zig cc"
so build -target=x86_64-windows-gnu -o app.exe .
so build -target=wasm32-freestanding -o main.wasm .

-check enables code analysis:

so test -check=warn .      # -Wall -Wextra -Werror -Wno-shadow -Wno-unused-label
so test -check=sanitize .  # warn + AddressSanitizer + UndefinedBehaviorSanitizer
so test -check=analyze .   # warn + GCC static analyzer

The default optimization level is -O2. You can use CFLAGS to change it.

Limited Windows support

The standard library now builds for windows/amd64 and windows/arm64. All packages in the freestanding set work. Packages that require POSIX (conc, flag, log/slog, net, os, sync) are not supported.

You can use zig cc to cross-compile for Windows:

export CC="zig cc"
export CFLAGS="--target=x86_64-windows-gnu"
export LDFLAGS="-lbcrypt -liphlpapi"
so build -o app.exe .

Not the first-class Windows support that Go offers, but it's better than nothing.

Wrapping up

With v0.4, Solod can work with almost any C library thanks to automatic bindings. The freestanding-aware standard library makes the language a viable option for bare metal programming. Extra interop helpers make C-calling code easy to read, and better tooling keeps tests fast.

There's still a lot to do, of course. In the next release, I plan to focus on the standard library and bring over some hashing and crypto packages from Go. More C library integrations are on the way too!

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.

]]>
Going 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 mode • Headers • Builtins • Memory • Atomics • Pure C • Allocation • Values • Hooks • Hosted-only • Testing • Final 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.

Threads • Channels • Worker pools • Sharing state • JSON • Safety net • Tooling • Wrapping 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.22 • 1.23 • 1.24 • 1.25 • 1.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/Cond • Atomics • Pool • Channel • Performance • Design • Wrapping 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: conc • sync

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!

Networking • TCP server • TCP client • Deadlines • IP addresses • Targets • Interop • Stdlib • Wrapping 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 library • SQLite bindings • Persistent map • Store and retrieve • Command-line interface • Performance • Wrapping 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.

]]>