C语言引入Go风格并发
速览
该技术将Go语言的goroutine和channel等并发特性融入C语言,使开发者能在C中更安全、高效地编写并发代码。这不仅降低了C语言并发编程的复杂度,还提升了程序的性能与可维护性,对系统级软件开发有积极影响。
AI 深度解读
背景
Go 语言的并发模型是许多开发者喜爱它的核心理由之一:通过 go f() 启动轻量级 goroutine,使用 channel 在它们之间传递值,运行时调度器仅用少量 OS 线程就能高效运行成千上万个 goroutine。这种体验使并发编程看似毫不费力。
然而,在 C 语言中,并没有这样的机制。作者在开发 Solod (So) —— 一个 Go 的严格子集,它会被翻译成纯 C 语言,且没有运行时和垃圾回收器 —— 时需要为其添加并发支持。这引发了一个问题:仅使用 POSIX 线程,能在多大程度上接近 Go 的并发模型?原生 OS 线程显然无法匹配 goroutine 的效率,但实际开销有多大、何时成为瓶颈、是否有办法部分规避这些问题?最终作者得出结论:只要坦诚面对取舍,用 pthreads 可以实现很多功能。本文介绍了作者为 Solod 选择的基于 POSIX 线程的并发模型、其带来的好处以及局限性。
核心内容
Mutex / Cond
Solod 并发栈的所有工具都构建在两个基本的 POSIX 原语之上:互斥锁(mutex) 和 条件变量(condition variable)。
-
sync.Mutex是对pthread_mutex_t的简单封装。在 So 中定义为: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") } }翻译成 C 后,生成的 struct 和函数几乎是机械式的镜像。
-
sync.Cond封装pthread_cond_t,提供标准的“等待某个条件成立”模式,与一个 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对应的 C 代码类似:
sync_Cond结构体包含pthread_cond_t和指向sync_Mutex的指针。
这两个类型是并发工具的基础。其他工具(如 Once、线程池、channel)都构建在 mutex 和一个或多个 condition variable 之上。
原子操作(Atomics)
并非所有操作都需要锁。Solod 的 sync/atomic 镜像了 Go 的接口:Bool、Int32、Int64、Uint32、Uint64 以及泛型 Pointer[T],均提供 Load、Store、Swap 和 CompareAndSwap 方法。
这些原子操作不需要 pthreads,直接映射到 C 编译器的 __atomic 内建函数(与 Go 编译器使用相同的硬件指令),因此性能和 Go 一样好。作者给出的基准测试显示,单线程下每个操作的开销与 Go 原生原子操作相当。
sync.Once 是一个高效使用原子操作的例子:快速路径只需一次原子加载 —— 在给定函数执行一次后,后续对 Do 的调用仅检查标志位即可返回:
type Once struct {
mu Mutex
done atomic.Bool
}
func (o *Once) Do(f func()) {
if o.done.Load() { // 无锁快速路径
return
}
// 慢路径(加锁执行 f 并设置 done)
}
工作线程池(Worker Pool)
为了真正并发执行代码,需要线程。conc.Thread 类型封装 pthread_t 及其相关函数:
Wait()对应pthread_joinDetach()对应pthread_detach
conc.Go 函数启动一个 OS 线程来运行指定的函数,并返回线程句柄。用法:
func work(arg any) any { ... }
func main() {
var acc Account
th := conc.Go(work, &acc)
// ... 同时做其他工作 ...
th.Wait() // 等待 work 完成
}
翻译成 C 后,conc_Go 调用 pthread_create,conc_Thread_Wait 调用 pthread_join。
但作者指出:conc.Go 启动的是真正的 OS 线程,不是 goroutine。你必须最终调用 Wait 来 join 或 Detach 它,否则会泄漏资源。而且 OS 线程创建开销很大 —— 每个线程需要数兆字节的栈空间(或更多),创建时间在微秒级别,完全不像 goroutine 只需几 KB 栈、纳秒级启动。因此,对于短生命周期或频繁出现的任务,不适合在循环中直接调用 Go,而应使用长期存活的工作线程池。
Solod 提供了 conc.Pool 作为解决方案。它的架构是:一个 coordinator 管理 N 个长期运行的 worker 线程,M 个任务(M >> N)被发送到池中执行。用法示例(计算平方):
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()
}
NewPool 的第一个参数 mem.System 是内存分配器。Solod 避免隐藏分配,因此需要显式传入分配器。
注意:pool.Go 只是将任务放入队列,不会创建新线程;pool.Wait 等待所有已提交的任务完成。线程池内部使用 mutex 和 condition variable 协调 worker 的休眠与唤醒。
性能与设计取舍
作者进行的性能测试(单线程开销)显示:
- 原子操作:与 Go 原生相当,没有额外开销。
- Mutex 操作:
Lock/Unlock花费约 27 ns(比 Go 的 sync.Mutex 慢约 4 倍,但 Go 的 mutex 用了更复杂的自旋逻辑)。 - Cond 操作:
Signal和Broadcast开销与 Go 接近。 - 线程创建:
pthread_create+ join 约需 60 µs,而 goroutine 创建仅约 0.1 µs,差距很大。使用线程池(conc.Pool的Go和Wait)可将每个任务的开销降低到约 1.2 µs(包含任务队列与唤醒)。
作者还强调,线程池方案使任务粒度应适中:任务太短则线程池开销占主导,任务太长则线程数量成为限制。总体而言,对于中等粒度的任务(几十微秒到几毫秒),线程池是可以接受的。
关键要点
- Go 的 goroutine 与 OS 线程有本质区别:goroutine 轻量(KB 级栈、纳秒级创建、运行时调度),而 POSIX 线程重量级(MB 级栈、微秒级创建、内核调度)。用 pthreads 不可能完全复制 goroutine 的效率,但通过线程池可以缩小差距。
- Solod 的并发基础设施建立在 POSIX 的 mutex 和 condition variable 上:
sync.Mutex、sync.Cond、sync.Once等均直接或间接封装 pthread 原语。 - 原子操作无需 OS 线程支持:直接映射到 C 编译器的
__atomic内建,性能与 Go 原生一致。 - 不应在循环中每次创建线程:对于频繁的短时任务,必须使用 worker 线程池(
conc.Pool)来复用
