Go's concurrency is one of the main reasons people like the language. You write go f(), send values through channels, and the runtime scheduler runs thousands of goroutines on just a few OS threads. It feels effortless.

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

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

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

Mutex/CondAtomicsPoolChannelPerformanceDesignWrapping up

Mutex/Cond

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

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

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

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

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

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

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

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

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

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

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

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

Atomics

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

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

Atomic opGoSoWinner
Load2ns2ns~same
Store2ns2ns~same
CompareAndSwap13ns13ns~same

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

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

type Once struct {
    mu   Mutex
    done atomic.Bool
}

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

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

Worker pool

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

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

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

Consider this conc.Go function:

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

Usage example:

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

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

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

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

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

conc.Pool to the rescue:

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

Usage example:

type Task struct {
    in  int
    out int
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Pool.Wait sleeps until the count hits zero:

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

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

Channel

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

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

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

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

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

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

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

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

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

type Buffer struct {
    alloc mem.Allocator

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

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

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

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

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

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

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

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

Buffer source code

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

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

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

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

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

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

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

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

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

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

Rendezvous source code

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

Performance

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

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

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

Mutex benchmarkGoSoWinner
Uncontended, 1 thread14ns9nsSo - 1.6x
Contended spin, 8 threads75ns27nsSo - 2.8x
Contended work, 8 threads1.1µs2.0µsGo - 1.8x

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

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

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