logo
Kurashizu Blogwhere ideas flow
  • Home
  • News
  • Blog
  • About
  • Home
  • News
  • Blog
  • About

© 2026 Kurashizu. All rights reserved.

Admin·New Post·Service Status
Powered by Cloudflare·01.11.26
← Back to news

Go-Flavored Concurrency in C

Original article
antonz.org|29 points|6 comments|by ibobev|Jul 13, 2026

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 OpGoSoWinner
Load2ns2\text{ns}2ns2ns2\text{ns}2ns≈\approx≈ Same
Store2ns2\text{ns}2ns2ns2\text{ns}2ns≈\approx≈ Same
CompareAndSwap13ns13\text{ns}13ns13ns13\text{ns}13ns≈\approx≈ 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() or Detach(), 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_t for mutual exclusion.
  • Wrap pthread_cond_t for signaling.
  • Map atomic types to compiler builtins.
  • Implement sync.Once with a fast-path atomic check.
  • Create a Thread wrapper for pthread_t.
  • Implement a conc.Pool to 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)

Concurrency Diagram Placeholder