
用 C 语言实现 Go 风格的并发:goroutine 与 channel 的原理与手写实现
深入浅出地讲解 Go 语言 goroutine 和 channel 的底层原理,并用 C 语言结合 POSIX 线程从零实现一个简化版的并发模型
原创。读完本文,你将理解 Go 语言 goroutine 和 channel 的底层设计思想,学会用 C 语言和 POSIX 线程从零实现一个简化版的并发调度模型。
为什么要在 C 里实现 Go 的并发?
Go 语言最受欢迎的特性莫过于 goroutine 和 channel 了。但如果你只学过 Go 怎么用,你可能没有真正理解它们"为什么这样设计"。而当你尝试用 C 语言去实现类似的东西时,你会发现很多"看似理所当然"的设计选择背后,其实是在特定的工程约束下做出的折中。
这篇文章的灵感来源于 Anton Zhiyanov 的一篇技术文章《Go-Flavored Concurrency in C》。我们会从 Go 的并发模型出发,谈谈它的核心思想,然后用 C 语言对照着实现一遍。这不仅是对 C 和 Go 的学习,也是对"不同语言如何表达并发"这一问题的探索。
—— 广告 ——
Go 并发模型的核心:CSP
说到 Go 的并发,绕不开 CSP(Communicating Sequential Processes)模型。它由 Tony Hoare 在 1978 年提出,核心理念就是那句名言:
不要通过共享内存来通信,而要通过通信来共享内存。
这与传统的多线程编程截然不同。传统做法中,多个线程通过互斥锁保护共享变量来协作。而 CSP 的做法是:每个并发单元独立运行,通过"通道"来传递消息。
Go 的 goroutine + channel 就是 CSP 模型在工业级语言中最成功的实现。
goroutine 与线程的区别
| 特性 | 操作系统线程 | Go goroutine |
|---|---|---|
| 创建成本 | 约 1MB 栈空间 | 初始 2-8KB,可伸缩 |
| 调度 | 内核调度(昂贵) | Go 运行时调度(轻量) |
| 切换开销 | 约 1μs(上下文切换) | 约 0.1μs(用户态切换) |
| 数量级 | 几千个 | 数百万个 |
Go 编译器在后台悄悄做了一件了不起的事:它把 goroutine 的初始栈压缩到几 KB,然后在运行时允许栈动态增长。这样一来,你可以在一个 Go 程序里创建数十万个 goroutine,而用操作系统线程的话,堆栈开销早就把内存吃光了。
在 C 中能复现什么?不能复现什么?
明确一下边界:
能做的事:
- 用 pthread 创建轻量级"goroutine"
- 实现阻塞式 channel(有缓冲和无缓冲)
- select 风格的多个 channel 监听
- channel 关闭与范围遍历
不能做的事(在标准 C 范围内):
- 动态栈伸缩(需要编译器支持)
- 真正的 M:N 调度(用户态调度 goroutine 到线程)
- go 关键字语法糖
但即使只有这些,实现之后你会发现,C 语言的表达能力比想象中要强大——前提是你要愿意多写点代码。
核心数据结构:Channel
channel 是 Go 并发的基石。一个 channel 本质上是一个线程安全的 FIFO 队列,配上了阻塞/唤醒机制。
设计思路
用一个结构体来表示 channel:
typedef struct {
void **buf; // 环形缓冲区
size_t cap; // 缓冲区容量(0 表示无缓冲)
size_t count; // 当前元素数量
size_t head; // 队头索引
size_t elem_size; // 元素大小
pthread_mutex_t mu; // 互斥锁
pthread_cond_t sendq; // 发送者等待队列
pthread_cond_t recvq; // 接收者等待队列
int closed; // 关闭标志
} channel_t;这里的关键设计决策是:将同步原语(锁和条件变量)封装在 channel 内部。发送者和接收者不需要自己管理锁,它们只需要调用 chan_send(ch, &val) 和 chan_recv(ch, &val),同步逻辑由 channel 内部处理。
这正是 CSP 模型的精髓:消除显式的锁管理,让开发者聚焦于数据流本身。
创建和销毁 channel
channel_t *chan_create(size_t cap, size_t elem_size) {
channel_t *ch = calloc(1, sizeof(channel_t));
ch->cap = cap;
ch->elem_size = elem_size;
if (cap > 0) {
ch->buf = calloc(cap, elem_size);
}
pthread_mutex_init(&ch->mu, NULL);
pthread_cond_init(&ch->sendq, NULL);
pthread_cond_init(&ch->recvq, NULL);
return ch;
}
void chan_destroy(channel_t *ch) {
free(ch->buf);
pthread_mutex_destroy(&ch->mu);
pthread_cond_destroy(&ch->sendq);
pthread_cond_destroy(&ch->recvq);
free(ch);
}无缓冲 channel:同步的舞蹈
无缓冲 channel(cap = 0)是最优雅的设计之一。它迫使发送者和接收者同时就绪才能完成一次通信。
int chan_send(channel_t *ch, void *data) {
pthread_mutex_lock(&ch->mu);
// 如果 channel 已关闭,返回错误
if (ch->closed) {
pthread_mutex_unlock(&ch->mu);
return -1;
}
// 等待接收者到来
while (ch->count >= ch->cap && !ch->closed) {
pthread_cond_wait(&ch->sendq, &ch->mu);
}
if (ch->closed) {
pthread_mutex_unlock(&ch->mu);
return -1;
}
// 有缓冲或接收者已就绪,写入数据
memcpy(&ch->buf[ch->head * ch->elem_size], data, ch->elem_size);
ch->head = (ch->head + 1) % (ch->cap > 0 ? ch->cap : 1);
ch->count++;
// 唤醒等待的接收者
pthread_cond_signal(&ch->recvq);
pthread_mutex_unlock(&ch->mu);
return 0;
}注意无缓冲 channel 的特殊处理:当 cap = 0 时,ch->count >= ch->cap 始终为真,所以发送者总是在 pthread_cond_wait 处阻塞,直到有一个接收者到来并消费数据。
这其实就是 Go 里 ch := make(chan int) 的底层机制。所谓"同步通信",就是让发送和接收在时间上严格配对。
有缓冲 channel:解耦的生产者-消费者
有缓冲 channel 允许队列积压。当缓冲区未满时,发送者可以不等接收者直接返回;当缓冲区非空时,接收者可以不等发送者直接取走数据。
int chan_recv(channel_t *ch, void *data) {
pthread_mutex_lock(&ch->mu);
// 等待数据到来
while (ch->count == 0 && !ch->closed) {
pthread_cond_wait(&ch->recvq, &ch->mu);
}
// channel 已关闭且无数据
if (ch->count == 0 && ch->closed) {
pthread_mutex_unlock(&ch->mu);
return -1;
}
// 读取数据(从队列尾部取出)
size_t tail = (ch->head + ch->cap - ch->count) %
(ch->cap > 0 ? ch->cap : 1);
memcpy(data, &ch->buf[tail * ch->elem_size], ch->elem_size);
ch->count--;
// 唤醒等待的发送者
pthread_cond_signal(&ch->sendq);
pthread_mutex_unlock(&ch->mu);
return 0;
}这里值得注意的一个细节是队列索引的计算。用环形缓冲区的实现中,"tail" 总是等于 head - count(模容量)。这个公式确保了 FIFO 顺序。
创建"goroutine"
在 C 中,我们没有 go 关键字,所以需要一点包装。思路很简单:用 pthread 替代 goroutine,但保持类似的语义:
typedef pthread_t goroutine_t;
goroutine_t go(void *(*func)(void *), void *arg) {
pthread_t tid;
pthread_create(&tid, NULL, func, arg);
pthread_detach(tid); // 自动回收资源,类似 Go 的 goroutine
return tid;
}pthread_detach 很重要。它告诉操作系统:这个线程结束时自动回收资源,不需要其他线程来 join。这跟 Go 中 goroutine 的"自动回收"语义是一致的——你不需要手动管理它的生命周期。
完整的例子:工作池
让我们把上面所有的代码拼起来,实现一个经典的工作池模式。主线程生成任务,多个 worker 处理任务,结果通过 channel 返回。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
// 前置:channel 实现代码放在这里
// ... (chan_create, chan_send, chan_recv, chan_destroy, go)
typedef struct {
int id;
int value;
} task_t;
void *worker(void *arg) {
channel_t *jobs = (channel_t *)arg;
task_t task;
while (chan_recv(jobs, &task) == 0) {
// 模拟处理耗时
printf("Worker processing task #%d (value=%d)\n",
task.id, task.value);
usleep(100000); // 100ms
}
printf("Worker exiting...\n");
return NULL;
}
int main() {
// 创建一个容量为 5 的任务队列
channel_t *jobs = chan_create(5, sizeof(task_t));
// 启动 3 个 worker
for (int i = 0; i < 3; i++) {
go(worker, jobs);
}
// 发送 10 个任务
for (int i = 0; i < 10; i++) {
task_t task = {.id = i, .value = i * 100};
chan_send(jobs, &task);
printf("Sent task #%d\n", i);
}
// 等待所有任务被处理
sleep(2);
// 关闭 channel 并清理
chan_close(jobs);
chan_destroy(jobs);
return 0;
}这个例子演示了 CSP 模型的典型模式:
- 生产者(main goroutine)生成任务并发送到 channel
- 消费者(worker goroutines)从 channel 接收并处理任务
- channel 解耦了生产者和消费者——它们不知道对方的存在
如果使用传统线程同步方式,你需要用互斥锁保护一个共享队列,再用条件变量通知 worker。而在这里,channel 把这一切都封装好了。
select:多路复用
Go 的 select 语句允许 goroutine 同时监听多个 channel。在 C 中,我们可以用 pthread_cond_timedwait 配合超时机制来实现类似功能,但更简洁的方式是用非阻塞尝试:
typedef struct {
channel_t *ch;
void *data;
int index; // 第几个 channel 有数据
} select_case_t;
// 检查哪些 channel 有数据可读
// 返回第一个有数据的 channel 的索引,-1 表示全部无数据
int chan_select(select_case_t *cases, int n) {
for (int i = 0; i < n; i++) {
channel_t *ch = cases[i].ch;
pthread_mutex_lock(&ch->mu);
int ready = (ch->count > 0 || ch->closed);
pthread_mutex_unlock(&ch->mu);
if (ready) {
cases[i].index = i;
chan_recv(cases[i].ch, cases[i].data);
return i;
}
}
return -1; // 全部阻塞
}这是一个简化版——真实的 Go runtime 中,select 需要处理随机公平性,还要避免"饥饿"问题。但这个版本已经能展示核心思想了。
Go 真正的秘密
写到这里,你可能会想:"原来 C 也能实现这些东西,那 Go 到底独特在哪里?"
答案藏在两个你可能没注意到的地方:
1. 栈管理的魔法
Go 的 goroutine 初始栈只有 2-8KB,但可以动态增长到 1GB。这是通过编译器插入的栈检查指令和栈拷贝机制实现的。
在 C 中,pthread 的栈是固定大小(默认 8MB)。这意味着 1000 个 goroutine 在 Go 中只需要 8MB 栈空间,而 1000 个 C 线程需要 8GB。这就是为什么服务器语言宁愿自己管理调度。
2. M:N 调度
Go runtime 维护了一个 M:N 调度器——M 个 goroutine 被调度到 N 个 OS 线程上执行。当一个 goroutine 阻塞在 channel 操作或系统调用上时,调度器会自动把它使用的线程分配给另一个就绪的 goroutine。
在 C 中我们用 1:1 模型(一个线程 = 一个 goroutine),这意味着线程阻塞就是真的阻塞了。
总结
本文从 Go 的 CSP 并发模型出发,用 C 语言和 POSIX 线程实现了一个简化版的 goroutine + channel 系统。通过这个练习,我们看到了:
- channel 的本质是一个用条件变量包装的线程安全队列
- goroutine 的本质是轻量级执行单元,C 中用 1:1 线程模拟
- CSP 模型的核心是通信而非共享内存
虽然 C 无法实现 Go 那样的动态栈伸缩和 M:N 调度,但通过 channel 封装同步原语,我们同样可以获得清晰、安全的并发编程体验。
参考来源:Go-Flavored Concurrency in C — Anton Zhiyanov 的技术博客
© 2026 四月
原文链接:https://www.aprilzz.com/tutorials/go-concurrency-in-c
相关文章
MCP(Model Context Protocol)从入门到实战:构建你的第一个 AI 工具服务器
手把手教你理解 MCP 协议的原理、架构,并用 Python 从零搭建一个支持实时数据查询的 MCP 服务器
Tailwind CSS v4 迁移指南:从 v3 升级到 v4 的完整实战
详细讲解 Tailwind CSS v4 的核心变化、配置方式革命性的简化、新特性一览,以及从 v3 迁移到 v4 的逐步操作指南
React 太重了?用 Python + HTMX 快速构建内部工具的实战教程
本文通过一个完整的实战项目,带你从零开始用 FastAPI + Jinja2 + HTMX 构建一个内部管理面板,无需构建工具、无需 API 层、无需前端状态管理