Go-Flavored Concurrency in C

Hacker News Top Tools

Summary

A detailed technical post exploring how to replicate Go's concurrency model in C using POSIX threads, mutexes, condition variables, and a worker pool, as part of the Solod transpiler project.

No content available
Original Article
View Cached Full Text

Cached at: 07/13/26, 04:54 PM

# Go-flavored concurrency in C Source: [https://antonz.org/concurrency-in-c/](https://antonz.org/concurrency-in-c/) 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](https://antonz.org/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/Cond](https://antonz.org/concurrency-in-c/#mutexcond)•[Atomics](https://antonz.org/concurrency-in-c/#atomics)•[Pool](https://antonz.org/concurrency-in-c/#worker-pool)•[Channel](https://antonz.org/concurrency-in-c/#channel)•[Performance](https://antonz.org/concurrency-in-c/#performance)•[Design](https://antonz.org/concurrency-in-c/#design-decisions)•[Wrapping up](https://antonz.org/concurrency-in-c/#wrapping-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 opGoSoWinnerLoad2ns2ns~sameStore2ns2ns~sameCompareAndSwap13ns13ns~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](https://github.com/solod-dev/solod/blob/main/so/conc/pool.go), 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](https://github.com/solod-dev/solod/blob/main/so/conc/buffer.go) **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](https://github.com/solod-dev/solod/blob/main/so/conc/rendezvous.go) 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 benchmarkGoSoWinnerUncontended, 1 thread14ns9nsSo \- 1\.6xContended spin, 8 threads75ns27nsSo \- 2\.8xContended 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 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 So 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 benchmarkGoSoWinner1 waiter150ns1\.5µsGo \- 10x8 waiters2\.0µs14µsGo \- 7\.0x32 waiters9\.0µs60µsGo \- 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 benchmarkGoSoWinnerUncontended, 1 thread24ns21nsSo \- 1\.1xUnbuffered, 2 threads130ns3\.0µsGo \- 23xBuffered \(10\), 2 threads44ns400nsGo \- 9\.1xBuffered \(100\), 2 threads33ns70nsGo \- 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 So a slight advantage\. But the moment a producer and consumer actually start handing off work, So 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 benchmarkGoSoWinner1000 CPU tasks \(~40µs each\)7ms8msGo \- 1\.1x64 IO tasks \(1ms block each\)9ms10msGo \- 1\.1x> Each number is the wall\-clock time for 8 workers to process the whole batch\. Here, So 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 So's and Go's benchmarks:[conc](https://github.com/solod-dev/solod/tree/main/so/conc/bench)•[sync](https://github.com/solod-dev/solod/tree/main/so/sync/bench) 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](https://github.com/tidwall/neco), and multi\-threaded ones like[libfiber](https://github.com/iqiyi/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 So's "no hidden allocations" rule\. In Go,`go f\(\)`quietly allocates a goroutine stack, and`make\(chan T, n\)`allocates a buffer\. In So, 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](https://github.com/solod-dev/solod)\. It includes the`sync`and`conc`packages, along with many others ported from Go's standard library\. [★ Subscribe](https://antonz.org/subscribe/)to keep up with new posts\.

Similar Articles

Just Fucking Use Go

Lobsters Hottest

An opinionated developer essay advocates for the Go programming language, emphasizing its straightforward syntax, robust standard library, efficient concurrency model, and single-binary deployment as practical alternatives to overly complex modern technology stacks.

Solod: Go can be a better C

Hacker News Top

Solod is a strict subset of Go that compiles to readable C11 with zero runtime, no GC, and rich standard library, enabling systems-level control for Go developers and familiar tooling for C programmers.

Notes from Optimizing CPU-Bound Go Hot Paths

Hacker News Top

The article discusses performance optimization techniques for CPU-bound Go code, highlighting the limitations of generics and interface abstractions due to lack of inlining, and advocates for code duplication in hot paths. It provides examples from a Brotli port and deep-dive benchmarking.

Gossamer: a Rust-flavoured language with real goroutines and pause-free memory

Hacker News Top

Gossamer is a new programming language inspired by Rust that features real goroutines, pause-free deterministic memory management with reference counting and arenas, and a bytecode VM with LLVM compilation. It aims to provide expressive syntax without a borrow checker or garbage collector pauses.