教程·阅读约 5 分钟·
用 C 语言实现 Go 风格的并发:goroutine 与 channel 的原理与手写实现

用 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:

code
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

code
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)是最优雅的设计之一。它迫使发送者和接收者同时就绪才能完成一次通信。

code
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 允许队列积压。当缓冲区未满时,发送者可以不等接收者直接返回;当缓冲区非空时,接收者可以不等发送者直接取走数据。

code
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,但保持类似的语义:

code
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 返回。

code
#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 模型的典型模式:

  1. 生产者(main goroutine)生成任务并发送到 channel
  2. 消费者(worker goroutines)从 channel 接收并处理任务
  3. channel 解耦了生产者和消费者——它们不知道对方的存在

如果使用传统线程同步方式,你需要用互斥锁保护一个共享队列,再用条件变量通知 worker。而在这里,channel 把这一切都封装好了。

select:多路复用

Go 的 select 语句允许 goroutine 同时监听多个 channel。在 C 中,我们可以用 pthread_cond_timedwait 配合超时机制来实现类似功能,但更简洁的方式是用非阻塞尝试

code
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 系统。通过这个练习,我们看到了:

  1. channel 的本质是一个用条件变量包装的线程安全队列
  2. goroutine 的本质是轻量级执行单元,C 中用 1:1 线程模拟
  3. CSP 模型的核心是通信而非共享内存

虽然 C 无法实现 Go 那样的动态栈伸缩和 M:N 调度,但通过 channel 封装同步原语,我们同样可以获得清晰、安全的并发编程体验。

参考来源:Go-Flavored Concurrency in C — Anton Zhiyanov 的技术博客

分享到
微博Twitter

© 2026 四月

原文链接:https://www.aprilzz.com/tutorials/go-concurrency-in-c