← 返回信息流
AI 资讯Hacker News·6 小时前

C语言引入Go风格并发

原标题:Go-Flavored Concurrency in C

速览

该技术将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 的接口:BoolInt32Int64Uint32Uint64 以及泛型 Pointer[T],均提供 LoadStoreSwapCompareAndSwap 方法。

这些原子操作不需要 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_join
  • Detach() 对应 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_createconc_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 操作SignalBroadcast 开销与 Go 接近。
  • 线程创建pthread_create + join 约需 60 µs,而 goroutine 创建仅约 0.1 µs,差距很大。使用线程池(conc.PoolGoWait)可将每个任务的开销降低到约 1.2 µs(包含任务队列与唤醒)。

作者还强调,线程池方案使任务粒度应适中:任务太短则线程池开销占主导,任务太长则线程数量成为限制。总体而言,对于中等粒度的任务(几十微秒到几毫秒),线程池是可以接受的。

关键要点

  • Go 的 goroutine 与 OS 线程有本质区别:goroutine 轻量(KB 级栈、纳秒级创建、运行时调度),而 POSIX 线程重量级(MB 级栈、微秒级创建、内核调度)。用 pthreads 不可能完全复制 goroutine 的效率,但通过线程池可以缩小差距。
  • Solod 的并发基础设施建立在 POSIX 的 mutex 和 condition variable 上sync.Mutexsync.Condsync.Once 等均直接或间接封装 pthread 原语。
  • 原子操作无需 OS 线程支持:直接映射到 C 编译器的 __atomic 内建,性能与 Go 原生一致。
  • 不应在循环中每次创建线程:对于频繁的短时任务,必须使用 worker 线程池(conc.Pool)来复用
查看原文 →antonz.org