Go-Flavored Concurrency in C
Implementing Go-Style Concurrency within C
By Anton Zhiyanov
One of the primary draws of the Go programming language is its approach to concurrency. By simply typing go f(), developers can launch thousands of goroutines that the runtime scheduler efficiently maps onto a small number of OS threads, communicating via channels.
This sparked a curiosity: How closely can we replicate this model using only POSIX threads (pthreads)?
While it is a given that native OS threads cannot match the extreme efficiency of lightweight goroutines, I wanted to determine the actual overhead, identify where the bottlenecks occur, and find ways to mitigate them. These questions arose during the development of Solod (So), a language that acts as a strict subset of Go, translating directly to C without a garbage collector or a complex runtime.
My conclusion: You can achieve a surprising amount of Go-like functionality with pthreads, provided you are transparent about the inherent tradeoffs.
This article details the pthread-based concurrency architecture I implemented for So, highlighting its advantages and its constraints.
🛠️ The Foundation: Mutexes and Condition Variables
The entire concurrency stack in So is built upon two fundamental POSIX primitives: the mutex and the condition variable.
1. The Mutex
The sync.Mutex type is essentially a thin wrapper around pthread_mutex_t.
So Implementation:
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")
}
}
Because So compiles to C, the translation is nearly mechanical. The generated C code is simply a struct and a function call:
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");
}
}
2. The Condition Variable
Similarly, sync.Cond wraps pthread_cond_t. This is the standard mechanism for pausing execution until a specific condition is met, always used in conjunction with a mutex.
So Interface:
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
Translated C Code:
typedef struct sync_Cond {
pthread_cond_t cond;
sync_Mutex *mu;
} sync_Cond;
void sync_Cond_Wait(sync_Cond *c);
void sync_Cond_Signal(sync_Cond *c);
void sync_Cond_Broadcast(sync_Cond *c);
These two primitives serve as the building blocks for more complex tools like sync.Once, thread pools, and channels.
⚡ Atomic Operations
The sync/atomic package in So mirrors Go's implementation, providing types like Bool, Int32, Int64, Uint32, Uint64, and a generic Pointer[T]. Each supports Load, Store, Swap, and CompareAndSwap.
These map directly to the C compiler's __atomic builtins. Since these are the same hardware instructions used by the Go compiler, the performance is virtually identical.
Performance Comparison
The cost per operation on a single thread is as follows:
| Atomic Op | Go | So | Winner |
|---|---|---|---|
Load | Same | ||
Store | Same | ||
CompareAndSwap | Same |
🎯 The sync.Once Pattern
sync.Once ensures a function is executed exactly once. To optimize this, it uses a "fast path" consisting of a single atomic load.
So Implementation:
type Once struct {
mu Mutex
done atomic.Bool
}
func (o *Once) Do(f func()) {
if o.done.Load() {
return // Fast path: already executed
}
// Slow path: lock and execute...
}
Translated C Code:
typedef struct sync_Once {
sync_Mutex mu;
atomic_Bool done;
} sync_Once;
void sync_Once_Do(sync_Once *o, void (*f)()) {
if (atomic_Bool_Load(o->done)) {
return; // lock-free fast path
}
// slow path...
}
🧵 Thread Management
To execute code concurrently, So uses the conc.Thread type, which wraps pthread_t.
So Interface:
type Thread struct // wraps pthread_t
func (th Thread) Wait() any // wraps pthread_join
func (th Thread) Detach() // wraps pthread_detach
}
The conc.Go function is used to launch a new OS thread:
So Code:
func Go(entry func(any) any, arg any) Thread {
var th Thread
// calls pthread_create...
return th
}
C Translation:
conc_Thread conc_Go(void *(*entry)(void *), void *arg) {
conc_Thread th = { 0 };
int rc = pthread_create(&(th.t), NULL, entry, arg);
return th;
}
⚠️ The Reality Check
While conc.Go looks like Go's go keyword, the underlying reality is different:
OS threads are lightweight.They are expensive to create.- You must call
Wait()orDetach(), otherwise, you will leak system resources. - Launching threads inside a loop is a performance anti-pattern.
Example Usage:
func work(arg any) any {
acc := arg.(*Account)
// logic here...
return nil
}
func main() {
acc := Account{}
th := conc.Go(work, acc)
th.Wait() // Block until work is complete
}
🏗️ Architecture Overview
The following diagram illustrates how the So concurrency layers map down to the system level:
Implementation Checklist
- Wrap
pthread_mutex_tfor mutual exclusion. - Wrap
pthread_cond_tfor signaling. - Map atomic types to compiler builtins.
- Implement
sync.Oncewith a fast-path atomic check. - Create a
Threadwrapper forpthread_t. - Implement a
conc.Poolto mitigate OS thread overhead.
🏊 Toward a Worker Pool
Because creating OS threads is costly, the final piece of the puzzle is the conc.Pool. Instead of spawning a new thread for every short-lived task, a pool of long-lived worker threads is maintained, and tasks are dispatched to them via a queue.
(The original text concludes here as it introduces the concept of the Worker Pool)