Go-flavored concurrency in C
Go-flavored concurrency in C
C 语言中的 Go 风格并发
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.
Go 语言的并发特性是人们喜爱它的主要原因之一。你只需编写 go f(),通过通道(channel)发送数值,运行时调度器就能在极少数操作系统线程上运行成千上万个 goroutine。这种体验非常轻松。
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? C 语言中并不存在这些机制。这让我不禁思考:仅使用 POSIX 线程(pthreads),能多大程度上模拟 Go 的并发模型?显然,原生操作系统线程无法比拟轻量级 goroutine 的效率,但其实际成本是多少?何时会成为问题?有没有办法至少部分地规避这些问题?
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. 在为 Solod (So) 添加并发支持时,我遇到了这些问题。Solod 是 Go 的一个严格子集,它会被翻译成纯 C 代码,且没有运行时和垃圾回收器。最终,我得出的结论是:只要你清楚地认识到其中的权衡,使用 pthreads 其实可以做很多事情。
This post is about the POSIX threads-based concurrency model I chose, the benefits it offers, and its limitations. 本文将介绍我所选择的基于 POSIX 线程的并发模型,以及它带来的好处和局限性。
Mutex/Cond • Atomics • Pool • Channel • Performance • Design • 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:
So 并发栈中的一切都构建在两个基本的 POSIX 原语之上:互斥锁(mutex)和条件变量(condition variable)。sync.Mutex 是对 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:
由于 So 会被翻译成 C,这本质上就是一个包含 pthread_mutex_t 的结构体,以及一个调用 pthread_mutex_lock 的函数。以下是转译后的输出:
// 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. 这就是完整的翻译过程——生成的 C 代码几乎是 So 代码的机械镜像,只是显得更繁琐一些。从现在起,我将主要展示 So 版本,但也会为感兴趣的读者提供 C 代码。
There’s nothing exciting here: sync.Mutex is a pthread mutex wrapper that panics if something goes wrong (which is rare).
这里没什么特别的:sync.Mutex 就是一个 pthread 互斥锁的包装器,如果出错(这种情况很少见)就会触发 panic。
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:
与之配套的原语是 sync.Cond,它是对 pthread_cond_t 的封装。它是标准的“等待条件满足”工具,并与互斥锁关联:
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
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. 这两个类型(Mutex 和 Cond)是基础。其他的并发工具(如 Once、线程池、通道)都是使用互斥锁和一个或多个条件变量构建的。正如我们稍后将看到的,这对性能有一定影响。
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.
并非所有操作都需要锁。So 的 sync/atomic 镜像了 Go 的实现:包含 Bool、Int32、Int64、Uint32、Uint64 以及泛型 Pointer[T],它们都拥有 Load、Store、Swap 和 CompareAndSwap 方法。
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:
好消息是,这些操作根本不需要 pthreads。它们直接映射到 C 编译器的 __atomic 内置函数——这与 Go 编译器生成的硬件指令相同。因此,它们没有任何理由变慢,事实也确实如此:
| Atomic op | Go | So | 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:
sync.Once 是有效利用原子操作的一个好例子。它的快速路径只需要一次原子加载——在给定的函数运行后,后续对 Do 的每次调用都会检查标志并直接返回:
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...
}
Worker pool
工作线程池
To actually run code concurrently, you need threads. The conc.Thread type wraps pthread_t and its related functions:
要真正并发运行代码,你需要线程。conc.Thread 类型封装了 pthread_t 及其相关函数:
type Thread struct // wraps pthread_t
func (th Thread) Wait() any // wraps pthread_join
func (th Thread) Detach() // wraps pthread_detach
Consider this conc.Go function:
考虑这个 conc.Go 函数:
// 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)
// ...
}
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…
它看起来可能像 go work(&acc),但这只是表面现象。conc.Go 启动的是一个真正的操作系统线程,而不是 goroutine。你最终必须调用 Wait 来回收(join)它,或者调用 Detach,否则会导致资源泄漏。此外,操作系统线程的开销很大……