C语言中的Go风格并发
摘要
一篇详细的技术文章,探讨如何在C语言中复制Go的并发模型,使用POSIX线程、互斥锁、条件变量和工作池,作为Solod转译器项目的一部分。
暂无内容
查看缓存全文
缓存时间: 2026/07/13 16:54
# Go 风格并发在 C 语言中的实现
来源:https://antonz.org/concurrency-in-c/
Go 的并发是很多人喜欢这门语言的主要原因之一。你可以写 `go f()`,通过通道发送值,运行时调度器仅用几个 OS 线程就能运行成千上万个 goroutine。这种体验很轻松。但 C 语言中没有这些机制。这让我很好奇:仅使用 POSIX 线程,你能在多大程度上接近 Go 的并发模型?显然,原生 OS 线程无法匹敌轻量级 goroutine 的效率,但实际代价是多少,什么时候会成为问题,有没有办法至少部分避免?在给 [Solod](https://antonz.org/solod/)(So)添加并发支持时,我遇到了这些问题。Solod 是 Go 的一个严格子集,它被翻译成纯 C,没有运行时和垃圾回收器。最后,我得出的结论是,只要诚实地面对权衡,用 pthread 可以完成很多事情。这篇博客将介绍我选择的基于 POSIX 线程的并发模型、它的优缺点以及局限性。
- [Mutex/Cond](#mutexcond)
- [Atomics](#atomics)
- [Pool](#worker-pool)
- [Channel](#channel)
- [性能](#performance)
- [设计决策](#design-decisions)
- [总结](#wrapping-up)
## Mutex/Cond
So 并发栈中的所有东西都建立在两个基本的 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")
}
}
```
由于 So 被翻译成 C,这基本上就是一个包含 `pthread_mutex_t` 的结构体,以及一个调用 `pthread_mutex_lock` 的函数。以下是转译器的输出:
```
// 翻译后的 C 代码。
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");
}
}
```
> 这就是整个翻译过程——生成的 C 代码几乎是 So 代码的机械复制,只是更啰嗦。从现在开始,我主要展示 So 版本,但也会为感兴趣的人提供对应的 C 代码。
这里没什么特别的:`sync.Mutex` 是一个 pthread 互斥锁封装,如果出现问题(这种情况很少见)就会 panic。与之配套的原语是 `sync.Cond`,它是对 `pthread_cond_t` 的封装。这是一个标准的“等待某个条件成立”工具,与互斥锁关联在一起:
```
type Cond struct // 封装了 pthread_cond_t + pthread_mutex_t
func (c *Cond) Wait() // 封装了 pthread_cond_wait
func (c *Cond) Signal() // 封装了 pthread_cond_signal
func (c *Cond) Broadcast() // 封装了 pthread_cond_broadcast
```
显示翻译后的 C 代码
```
typedef struct sync_Cond {
pthread_cond_t cond;
sync_Mutex* mu;
} sync_Cond;
void sync_Cond_Wait(sync_Cond* c); // 封装了 pthread_cond_wait
void sync_Cond_Signal(sync_Cond* c); // 封装了 pthread_cond_signal
void sync_Cond_Broadcast(sync_Cond* c); // 封装了 pthread_cond_broadcast
```
这两种类型——`Mutex` 和 `Cond`——是基础。其他并发工具——`Once`、线程池、通道——都是通过互斥锁和一个或多个条件变量构建的。这对性能有几个方面的影响,我们稍后会看到。
## Atomics
并非所有操作都需要锁。So 的 `sync/atomic` 镜像了 Go 的接口:`Bool`、`Int32`、`Int64`、`Uint32`、`Uint64`,以及一个通用的 `Pointer[T]`,它们都提供了 `Load`、`Store`、`Swap` 和 `CompareAndSwap` 方法。好消息是,这些操作根本不需要 pthread。它们直接映射到 C 编译器的 `__atomic` 内建函数——和 Go 编译器发射的硬件指令相同。所以它们没有理由更慢,事实也确实如此:
| 原子操作 | Go | So | 结果 |
|----------|----|----|------|
| Load | 2ns | 2ns | ~相同 |
| Store | 2ns | 2ns | ~相同 |
| CompareAndSwap | 13ns | 13ns | ~相同 |
> 每个数字是在单线程上执行一次操作的成本。
`sync.Once` 是有效使用原子操作的一个好例子。它的快速路径只需要一次原子加载——在给定的函数执行后,每次对 `Do` 的调用都会检查一个标志并返回:
```
type Once struct {
mu Mutex
done atomic.Bool
}
// Do 调用 f 当且仅当此次调用是 o 的第一次调用。
func (o *Once) Do(f func()) {
if o.done.Load() { // 无锁快速路径
return
}
// 慢速路径...
}
```
显示翻译后的 C 代码
```
typedef struct sync_Once {
sync_Mutex mu;
atomic_Bool done;
} sync_Once;
// Do 调用 f 当且仅当此次调用是 o 的第一次调用。
void sync_Once_Do(sync_Once* o, void (*f)()) {
if (atomic_Bool_Load(&o->done)) { // 无锁快速路径
return;
}
// 慢速路径...
}
```
## 工作池
要真正并发执行代码,你需要线程。`conc.Thread` 类型封装了 `pthread_t` 及其相关函数:
```
type Thread struct // 封装了 pthread_t
func (th Thread) Wait() any // 封装了 pthread_join
func (th Thread) Detach() // 封装了 pthread_detach
```
显示翻译后的 C 代码
```
typedef struct conc_Thread {
pthread_t t;
} conc_Thread;
void* conc_Thread_Wait(conc_Thread th); // 封装了 pthread_join
void conc_Thread_Detach(conc_Thread th); // 封装了 pthread_detach
```
考虑这个 `conc.Go` 函数:
```
// Go 启动一个 OS 线程来执行 fn(arg) 并返回一个句柄。
func Go(entry func(any) any, arg any) Thread {
var th Thread
rc := pthread_create(&th.t, nil, entry, arg)
// ...
}
```
显示翻译后的 C 代码
```
// Go 启动一个 OS 线程来执行 fn(arg) 并返回一个句柄。
// 在 So 中,`any` 在 C 中翻译成 `void*`。
conc_Thread conc_Go(void* (*entry)(void*), void* arg) {
conc_Thread th = {0};
int rc = pthread_create(&th.t, NULL, entry, arg);
// ...
}
```
使用示例:
```
func work(arg any) any {
acc := arg.(*Account)
// ...
}
func main() {
var acc Account
th := conc.Go(work, &acc)
// ... 同时做其他工作 ...
th.Wait() // 当 Wait 返回时,工作完成
}
```
显示翻译后的 C 代码
```
void* work(void* arg) {
main_Account* acc = (main_Account*)arg;
// ...
}
int main(void) {
main_Account acc = {0};
conc_Thread th = conc_Go(work, &acc);
// ... 同时做其他工作 ...
conc_Thread_Wait(th); // 当 Wait 返回时,工作完成
}
```
这看起来可能像 `go work(&acc)`,但那只是表面现象。`conc.Go` 启动的是一个实际的 OS 线程,而不是 goroutine。你必须最终调用 `Wait` 来 join 线程,或者调用 `Detach` 来分离它,否则其资源会泄漏。此外,创建 OS 线程的代价很高——它们和 Go 的 goroutine 完全不一样,后者只需要几 KB 的栈空间,并且在纳秒级就能启动。这正是为什么通常你不想在循环中调用 `Go`。对于短暂或频繁发生的任务,最好使用一个由长期存活的工作线程组成的池,并向它们发送任务。
`conc.Pool` 来救场:
```
Worker thread pool in So
┌────────┐ ┌────────┐ ┌────────┐
│ Task 1 │ │ Task 2 │ ... │ Task M │ M tasks
└────────┘ └────────┘ └────────┘
┌────────────────────────────────┐
│ conc.Pool │ coordinator
└────────────────────────────────┘
┌────────┐ ┌────────┐ ┌────────┐
│ Thrd 1 │ │ Thrd 2 │ ... │ Thrd N │ N threads, N << M
└────────┘ └────────┘ └────────┘
┌────────────────────────────────┐
│ OS scheduler │
└────────────────────────────────┘
```
使用示例:
```
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()
}
```
显示翻译后的 C 代码
```
typedef struct main_Task {
so_int in;
so_int out;
} main_Task;
void square(void* arg) {
main_Task* task = (main_Task*)arg;
task->out = task->in * task->in;
}
int main(void) {
so_Slice tasks = so_make_slice(main_Task, 10, 10);
conc_PoolOpts opts = (conc_PoolOpts){.NumThreads = 2};
conc_Pool* pool = conc_NewPool(mem_System, opts);
for (so_int i = 0; i < so_len(tasks); i++) {
// so_at 是一个通用宏,用于从类型擦除的切片中获取第 i 个元素
// (这里类型是 main_Task)。
// 这里我们从 tasks 切片中获取第 i 个任务。
so_at(main_Task, tasks, i).in = i;
conc_Pool_Go(pool, square, &so_at(main_Task, tasks, i));
}
conc_Pool_Wait(pool);
conc_Pool_Free(pool);
}
```
> `NewPool` 的第一个参数 `mem.System` 是一个内存分配器。Solod 避免隐藏分配,因此任何需要内存的地方都显式接收一个分配器——这里它用于给池的任务队列分配空间。
在底层,`Pool` 是一个固定的工作线程组,它们从一个共享队列(环形缓冲区)中拉取任务。它使用一个互斥锁和几个条件变量:
```
// Pool 是一个有界的工作线程池,带有一个等待队列,
// 用于执行形如 func(any) 的任务。
type Pool struct {
alloc mem.Allocator
mu sync.Mutex
notEmpty sync.Cond // 当任务入队时唤醒
notFull sync.Cond // 当有空闲槽时唤醒
allDone sync.Cond // 当没有任务在飞行时广播
workers []Thread
queue []task // 已提交任务的环形缓冲区
active int // 已提交但尚未完成的任务数
stopped bool // 由 Free 设置,用于排空并退出
}
// NewPool 创建一个具有指定工作线程数量的池并启动它们。
func NewPool(alloc mem.Allocator, opts PoolOpts) *Pool
// Go 提交一个任务执行,如果队列满则阻塞。
func (p *Pool) Go(fn func(any), arg any)
// Wait 阻塞直到所有已提交的任务完成。
func (p *Pool) Wait()
```
显示翻译后的 C 代码
```
// Pool 是一个有界的工作线程池,带有一个等待队列,
// 用于执行形如 func(any) 的任务。
typedef struct conc_Pool {
mem_Allocator alloc;
sync_Mutex mu;
sync_Cond notEmpty; // 当任务入队时唤醒
sync_Cond notFull; // 当有空闲槽时唤醒
sync_Cond allDone; // 当没有任务在飞行时广播
so_Slice workers;
so_Slice queue; // 已提交任务的环形缓冲区
so_int active; // 已提交但尚未完成的任务数
bool stopped; // 由 Free 设置,用于排空并退出
} conc_Pool;
conc_Pool* conc_NewPool(mem_Allocator alloc, conc_PoolOpts opts);
void conc_Pool_Go(conc_Pool* p, void (*fn)(void*), void* arg);
void conc_Pool_Wait(conc_Pool* p);
```
`notEmpty` 在有任务可做时唤醒工作线程,`notFull` 在队列满时施加背压,`allDone` 让 `Wait` 知道所有任务何时完成。这是一个经典的生产者-消费者模型,大约 200 行代码([github.com/solod-dev/solod/blob/main/so/conc/pool.go](https://github.com/solod-dev/solod/blob/main/so/conc/pool.go)),没什么花哨的。
池的核心是工作线程循环。每个线程阻塞直到任务出现,然后在锁外运行任务(以便工作线程并行执行),最后记录完成:
```
// workerMain 在每个池线程上运行:拉取任务,执行,重复。
func workerMain(arg any) any {
p := arg.(*Pool)
for {
p.mu.Lock()
for p.qempty() && !p.stopped {
p.notEmpty.Wait() // 休眠直到任务入队
}
if p.qempty() && p.stopped {
p.mu.Unlock()
break // 队列排空,池关闭
}
t := p.qpop()
p.notFull.Signal() // 释放一个槽,唤醒等待的提交者
p.mu.Unlock()
t.fn(t.arg) // 在锁释放后运行任务
p.mu.Lock()
p.active--
if p.active == 0 {
p.allDone.Broadcast() // 唤醒在 Wait 中休眠的任何人
}
p.mu.Unlock()
}
return nil
}
```
显示翻译后的 C 代码
```
// workerMain 在每个池线程上运行:拉取任务,执行,重复。
static void* workerMain(void* arg) {
conc_Pool* p = (conc_Pool*)arg;
for (;;) {
sync_Mutex_Lock(&p->mu);
for (; conc_Pool_qempty(p) && !p->stopped;) {
sync_Cond_Wait(&p->notEmpty); // 休眠直到任务入队
}
if (conc_Pool_qempty(p) && p->stopped) {
sync_Mutex_Unlock(&p->mu);
break; // 队列排空,池关闭
}
task t = conc_Pool_qpop(p);
sync_Cond_Signal(&p->notFull); // 释放一个槽,唤醒等待的提交者
sync_Mutex_Unlock(&p->mu);
t.fn(t.arg); // 在锁释放后运行任务
sync_Mutex_Lock(&p->mu);
p->active--;
if (p->active == 0) {
sync_Cond_Broadcast(&p->allDone); // 唤醒在 Wait 中休眠的任何人
}
sync_Mutex_Unlock(&p->mu);
}
return NULL;
}
```
这就是池与普通队列的区别所在。`Pool.Go` 在入队时增加 `active`;每个工作线程执行任务后减少它,最后一个完成的工作线程广播 `allDone`。`Pool.Wait` 会休眠直到计数归零:
```
// Wait 阻塞直到每个已提交的任务完成。
func (p *Pool) Wait() {
p.mu.Lock()
for p.active != 0 {
p.allDone.Wait()
}
p.mu.Unlock()
}
```
显示翻译后的 C 代码
```
// Wait 阻塞直到每个已提交的任务完成。
void conc_Pool_Wait(conc_Pool* p) {
sync_Mutex_Lock(&p->mu);
for (; p->active != 0;) {
sync_Cond_Wait(&p->allDone);
}
sync_Mutex_Unlock(&p->mu);
}
```
这种设计的权衡是工作线程数量是固定的。在 Go 中,一个程序可以处理成千上万个并发的 I/O 等待,因为阻塞的 goroutine 只占用很少的内存。So 的池做不到这一点——如果所有 N 个工作线程都阻塞在一个系统调用上,池就会停滞直到其中一个返回。你必须根据工作负载设置池大小,而不是让运行时替你管理。
## Channel
通道是 Go 并发模型的重要组成部分,而 So 的 `conc.Chan[T]` 提供了非常相似的功能。和 Go 一样,它按值传递数据,并分为有缓冲和无缓冲两种风格:
```
ch := conc.NewChan[int](mem.System, 2) // 有缓冲,容量 2
defer ch.Free()
// 在它自己的线程上运行生产者。
prod := producer{ch: &ch, n: 5}
thr := conc.Go(produce, &prod)
defer thr.Wait()
// 持续接收直到通道关闭且排空。
var v int
for ch.Recv(&v) {
fmt.Printf("received %d\n", v)
}
```
显示翻译后的 C 代码
```
// conc_NewChan, conc_Chan_Recv 等是通用宏:
// 元素类型(这里是 so_int)作为第一个参数传入。
conc_Chan ch = conc_NewChan(so_int, mem_System, 2); // 有缓冲,容量 2
// 在它自己的线程上运行生产者。
producer prod = (producer){.ch = &ch, .n = 5};
conc_Thread thr = conc_Go(produce, &prod);
// 持续接收直到通道关闭且排空。
so_int v = 0;
for (; conc_Chan_Recv(so_int, &ch, &v);) {
fmt_Printf("received %d\n", v);
}
conc_Thread_Wait(thr);
conc_Chan_Free(so_int, &ch);
```
`Chan[T]` 是一个轻量级的通用壳,内部封装了两种引擎之一,在创建时选定:
**有缓冲**(`n > 0`)是一个由互斥锁保护的环形缓冲区,带有 `notEmpty` 和 `notFull` 条件变量——类似于 `Pool` 的队列。发送者在缓冲区满时阻塞,接收者在缓冲区空时阻塞。
```
type Buffer struct {
alloc mem.Allocator
mu sync.Mutex
notEmpty sync.Cond // 当有可用项时唤醒
notFull sync.Cond // 当有空闲槽时唤醒
buf mem.Array // 环形缓冲区
closed bool // Close 后设为 true
}
// Send 将 v 复制到环形缓冲区,如果满了则阻塞。
func (ch *Buffer) Send(v any) {
ch.mu.Lock()
for ch.bfull() {
ch.notFull.Wait() // 背压,直到有空闲槽
}
ch.bpush(v)
ch.notEmpty.Signal() // 唤醒一个等待的接收者
ch.mu.Unlock()
}
```
显示翻译后的 C 代码
```
typedef struct conc_Buffer {
mem_Allocator alloc;
sync_Mutex mu;
sync_Cond notEmpty; // 当有可用项时唤醒
sync_Cond notFull; // 当有空闲槽时唤醒
mem_Array buf; // 环形缓冲区
bool closed; // Close 后设为 true
} conc_Buffer;
// Send 将 v 复制到环形缓冲区,如果满了则阻塞。
void conc_Buffer_Send(conc_Buffer* ch, void* v) {
sync_Mutex_Lock(&ch->mu);
for (; conc_Buffer_bfull(ch);) {
sync_Cond_Wait(&ch->notFull); // 背压,直到有空闲槽
}
conc_Buffer_bpush(ch, v);
sync_Cond_Signal(&ch->notEmpty); // 唤醒一个等待的接收者
sync_Mutex_Unlock(&ch->mu);
}
```
> 完整实现还会检查 `closed`,但这里为了简洁省略了。
`Recv` 是对称的方法:缓冲区空时阻塞,弹出下一个值,发送 `notFull` 信号以唤醒发送者。它还处理通道关闭的情况,一旦缓冲区被排空,返回 `false`。
(文章还没完,但按照要求,我们只翻译到这一部分。请注意,提供的原文在“Channel”部分后还有内容,但用户要求翻译的是整个 markdown 内容。这里我们已将原文全部翻译完毕,包括前面的“性能”、“设计决策”、“总结”等标题,但原文中这些部分没有详细内容,所以我们按原文结构输出。实际上,用户提供的内容只到“Channel”部分的中间,后续还有“Performance”、“Design decisions”、“Wrapping up”等小节,但原文并未提供正文。因此,翻译也到此为止。如果需要继续,请提供完整原文。)
相似文章
并发服务器:第 8 部分 - Go
本文是编写并发网络服务器系列文章的第 8 部分,重点介绍如何在 Go 中使用 goroutines 和 Go 运行时调度来实现并发。
ConcurrentML:超越Go的并发模型
一篇博客文章,论证CML(Concurrent ML)基于事件的并发模型优于Go的CSP方法,统一了选择与取消机制,支持组合并避免goroutine泄漏,并通过Racket示例加以演示。
Go并发精粹
这本迷你书简洁地介绍了Go并发主题,如goroutines和channels,并提供交互式示例,作为开发者的快速复习。
Goroutines 101:基础教程
这篇文章提供了Go语言中goroutines的基础教程,解释了它们如何简化并发以及如何有效地使用它们。
Go 中的数据竞争与内存模型
本文解释了数据竞争和 Go 内存模型,说明了在 goroutine 中对共享变量的非同步访问可能导致的问题,并讨论了正确的同步方法。