协程与线程的区别
协程是一种用户态的轻量级执行单元,可以在单线程内让多个函数交替执行。它常被称作"用户态线程",但与操作系统线程有本质区别:
- 资源归属:协程由程序自行创建和调度,不依赖操作系统;线程由内核管理,创建和切换涉及用户态与内核态转换,开销更大。
- 运行模型:协程共享同一线程的地址空间(堆、全局变量等),但各自拥有独立的栈空间;切换时保存和恢复执行上下文(程序计数器、栈指针及通用寄存器),无需陷入内核。线程拥有独立栈空间,可被调度到不同 CPU 核心上并行执行。
- 适用场景:协程适合 IO 密集型任务,在等待期间让出 CPU 给其他协程;线程适合 CPU 密集型任务,利用多核并行加速计算。
为什么要在 C 语言中实现协程
C 语言标准本身不提供协程原语,但可以通过以下手段模拟:
- setjmp / longjmp:保存和恢复执行环境(至少包括程序计数器和栈指针,具体保存哪些寄存器由实现定义)。
- 函数指针 + 上下文结构体:记录每个协程的入口函数和参数,由调度器决定何时调用哪个。
- 显式让出:协程在需要等待或让出 CPU 时主动调用 yield 函数,调度器据此切换到下一个协程。
下面从最简版本逐步演进,展示核心思路。
最简实现:函数指针 + 上下文结构体
#include <stdio.h>
#include <stdlib.h>
// 协程上下文
typedef struct Context {
int id; // 协程ID
void* stack; // 协程栈
void (*func)(void*); // 指向协程函数的函数指针
void *arg; // 协程函数参数
} Context;
// 协程调度器
void scheduler();
// 当前运行的协程
Context* currCtx = NULL;
// 协程函数
void co1(void *arg) {
printf("coroutine 1\n");
scheduler();
}
void co2(void *arg) {
printf("coroutine 2\n");
scheduler();
}
// 协程调度器
void scheduler() {
static int cid = 0; // 协程ID
switch(cid) {
case 0:
currCtx = (Context*)malloc(sizeof(Context));
currCtx->id = cid++;
currCtx->func = co1;
currCtx->arg = NULL;
break;
case 1:
free(currCtx->stack);
currCtx->stack = NULL;
currCtx = (Context*)malloc(sizeof(Context));
currCtx->id = cid++;
currCtx->func = co2;
currCtx->arg = NULL;
break;
}
// 执行当前协程
currCtx->func(currCtx->arg);
}
int main() {
scheduler();
return 0;
}
这段代码定义了一个 Context 结构体保存协程 ID、栈指针、入口函数和参数,scheduler 根据静态变量 cid 依次创建并调用 co1、co2。
主要流程:
main 调用 scheduler,cid 为 0,创建 co1 上下文并执行 co1。
co1 打印后再次调用 scheduler,cid 为 1,创建 co2 上下文并执行 co2。
co2 打印后再次调用 scheduler,此时 cid 为 2,switch 无匹配分支,但函数末尾的 currCtx->func(currCtx->arg) 仍会执行,再次调用 co2,形成无限递归直至栈溢出。
需要注意的局限:
- 这个版本并没有真正切换栈,
stack 字段从未被赋值为有效指针(case 1 中对未初始化的 currCtx->stack 调用 free 属于未定义行为),co1 和 co2 实际上在同一栈上顺序调用,只是通过函数指针间接调用,形式上模拟了"调度"。
cid 是静态变量,不支持嵌套或递归调用 scheduler。
- 没有真正的挂起/恢复能力,协程函数一旦返回就结束,无法中途让出。
- 如上所述,
switch 未覆盖所有 cid 值时,末尾的函数调用仍会执行,导致无限递归。实际使用需增加 default 分支或边界判断。
基于 setjmp/longjmp 的协程调度器
为了解决上述问题,引入 setjmp/longjmp 来保存和恢复执行环境,使协程可以在函数内部任意位置挂起并稍后恢复。
#include <stdio.h>
#include <stdlib.h>
#include <setjmp.h>
// 协程上下文
typedef struct Context {
jmp_buf env; // 协程环境
void* stack; // 协程栈
void (*func)(void*); // 指向协程函数的函数指针
void *arg; // 协程函数参数
struct Context* prev; // 链表指针,用于实现双向链表
struct Context* next; // 链表指针,用于实现双向链表
} Context;
// 当前线程的协程数组
static thread_local Context** ctxArray = NULL;
// 当前线程当前运行协程在数组中的位置
static thread_local int currPos = 0;
// 当前线程协程数量
static thread_local int ctxCount = 0;
// 当前线程协程链表头结点
static thread_local Context* ctxHead = NULL;
// 当前线程协程链表尾结点
static thread_local Context* ctxTail = NULL;
// 协程栈大小
#define STACK_SIZE 1024
// 协程栈池大小
#define STACK_POOL_SIZE 10
// 协程栈池
static thread_local void* stackPool[STACK_POOL_SIZE];
// 协程栈池中可用栈的数量
static thread_local int stackCount = 0;
// 创建一个新的协程
void create(void (*func)(void*), void* arg);
// 切换到下一个协程
void yield();
// 销毁当前协程
void destroy();
// 协程函数
void co1(void *arg) {
printf("coroutine 1\n");
yield();
}
void co2(void *arg) {
printf("coroutine 2\n");
yield();
}
// 创建协程
void create(void (*func)(void*), void* arg) {
// 如果数组为空,就分配内存并初始化
if (ctxArray == NULL) {
ctxArray = malloc(sizeof(Context*) * STACK_POOL_SIZE);
for (int i = 0; i < STACK_POOL_SIZE; i++) {
ctxArray[i] = NULL;
}
}
// 如果数组已满,就扩容两倍
if (ctxCount == STACK_POOL_SIZE) {
int newSize = STACK
int newSize = STACK_POOL_SIZE * 2;
Context** newArray = malloc(sizeof(Context*) * newSize);
for (int i = 0; i < newSize; i++) {
if (i < STACK_POOL_SIZE) {
newArray[i] = ctxArray[i];
} else {
newArray[i] = NULL;
}
}
free(ctxArray);
ctxArray = newArray;
}
// 创建一个新的协程上下文
Context* ctx = malloc(sizeof(Context));
ctx->func = func;
ctx->arg = arg;
ctx->prev = NULL;
ctx->next = NULL;
// 从协程栈池中获取一个可用的栈空间,如果没有就分配一个新的
if (stackCount > 0) {
ctx->stack = stackPool[--stackCount];
} else {
ctx->stack = malloc(STACK_SIZE);
}
// 将新的协程上下文加入到数组和链表中
ctxArray[ctxCount++] = ctx;
if (ctxHead == NULL) {
ctxHead = ctxTail = ctx;
} else {
ctxTail->next = ctx;
ctx->prev = ctxTail;
ctxTail = ctx;
}
}
// 切换到下一个协程
void yield() {
// 如果没有协程,就直接返回
if (ctxCount == 0) {
return;
}
// 保存当前协程的上下文
if (setjmp(ctxArray[currPos]->env) == 0) {
// 切换到下一个协程的位置
currPos = (currPos + 1) % ctxCount;
// 恢复下一个协程的上下文
longjmp(ctxArray[currPos]->env, 1);
} else {
// 如果是从其他协程切换过来,就执行当前协程的函数
Context* currCtx = ctxArray[currPos];
currCtx->func(currCtx->arg);
// 执行完毕后,销毁当前协程
destroy();
}
}
// 销毁当前协程
void destroy() {
// 如果没有协程,就直接返回
if (ctxCount == 0) {
return;
}
// 获取当前协程的上下文
Context* currCtx = ctxArray[currPos];
// 将其栈空间回收到协程栈池中,如果池已满,就释放掉
if (stackCount < STACK_POOL_SIZE) {
stackPool[stackCount++] = currCtx->stack;
} else {
free(currCtx->stack);
}
// 将其从数组和链表中移除,并释放其内存
for (int i = currPos; i < ctxCount - 1; i++) {
ctxArray[i] = ctxArray[i + 1];
}
if (currCtx == ctxHead) {
ctxHead = currCtx->next;
if (ctxHead != NULL) {
ctxHead->prev = NULL;
}
} else if (currCtx == ctxTail) {
ctxTail = currCtx->prev;
if (ctxTail != NULL) {
ctxTail->next = NULL;
}
} else {
currCtx->prev->next = currCtx->next;
currCtx->next->prev = currCtx->prev;
}
free(currCtx);
// 减少协程数量
ctxCount--;
}
这个版本的关键设计:
- 数组 + 双向链表管理协程上下文,支持 O(1) 的尾插和 O(n) 的删除。
- thread_local 变量隔离各线程的协程状态(C11 特性,需编译器支持)。
- 栈池(固定 10 个槽位)复用协程栈,减少频繁 malloc/free。
- yield() 通过
setjmp/longjmp 实现挂起与恢复:首次进入时 setjmp 返回 0,调度器将 currPos 移到下一个协程并 longjmp 过去;被恢复时 setjmp 返回非 0,执行协程函数体。
- destroy() 将栈归还栈池,从数组和链表中摘除节点。
必须注意的问题:
- 代码中协程必须显式调用
yield() 才会让出,这是协作式(cooperative)调度,不是抢占式。如果某个协程陷入死循环且不调用 yield(),其他协程将永远得不到执行。
setjmp/longjmp 不会执行被跳过栈帧上的清理逻辑。在 C 语言中,longjmp 后,setjmp 所在帧中在两次调用之间被修改过的非 volatile 自动变量值是不确定的;如果将来迁移到 C++,还会跳过析构函数,造成资源泄漏。
- 代码中数组扩容部分存在一行残缺赋值(
int newSize = STACK 后紧跟 int newSize = STACK_POOL_SIZE * 2;),第一行缺少分号和完整表达式,实际编译前需要修正。
thread_local 是 C11 标准特性,在较老的 GCC(< 4.9)或某些嵌入式工具链中可能不可用,需替换为普通全局变量或手动管理。
- 栈池大小固定为 10,超出后每次创建协程都会 malloc,销毁时若池满则直接 free,没有动态扩容逻辑。
destroy() 中通过移动数组元素来删除节点,时间复杂度为 O(n);若协程数量较大,应考虑用空闲链表或哈希表替代。
- 整个实现假设单线程使用。如果多线程同时操作
thread_local 变量以外的共享资源(如全局缓冲区),需要额外的同步机制。
- 本实现中每个协程使用独立栈,跨栈的
setjmp/longjmp 行为在 C 标准中未明确定义,属于实现定义行为,需查阅具体工具链文档确认是否支持。
生产者-消费者示例
基础版:交替执行
#include <stdio.h>
#include <stdlib.h>
#include "coroutine.h" // 协程实现头文件
// 生产者协程
void producer(void* arg) {
int i = 0;
while (1) {
printf("produce %d\n", i++);
yield(); // 切换到消费者协程
}
}
// 消费者协程
void consumer(void* arg) {
int i = 0;
while (1) {
printf("consume %d\n", i++);
yield(); // 切换到生产者协程
}
}
int main() {
// 创建生产者和消费者协程
create(producer, NULL);
create(consumer, NULL);
while (1) {
// 重要:主while循环必须调用yield切换协程
// 否则协程将无法运行
yield();
}
}
producer 和 consumer 各自在 while(1) 中打印数据并调用 yield() 让出。main 中的 while(1) { yield(); } 是调度入口——如果主循环不调用 yield(),协程永远不会被激活。
这个版本中生产者和消费者各自维护独立的计数器,数据并没有真正传递,只是演示了交替执行的调度机制。
带信号量的版本:真正的数据传递
#include <stdio.h>
#include <stdlib.h>
#include "coroutine.h" // 协程实现头文件
#include "semaphore.h" // 信号量实现头文件
// 缓冲区大小
#define BUFFER_SIZE 10
// 缓冲区
int buffer[BUFFER_SIZE];
// 缓冲区中有效数据的数量
int count = 0;
// 缓冲区中可写入数据的位置
int in = 0;
// 缓冲区中可读取数据的位置
int out = 0;
// 缓冲区不满的信号量
sem_t not_full;
// 缓冲区不空的信号量
sem_t not_empty;
// 生产者协程
void producer(void* arg) {
int i = 0;
while (1) {
sem_wait(¬_full); // 等待缓冲区不满
buffer[in] = i; // 写入数据到缓冲区
printf("produce %d\n", i++);
in = (in + 1) % BUFFER_SIZE; // 更新写入位置
count++; // 更新有效数据数量
sem_post(¬_empty); // 通知缓冲区不空
yield(); // 切换到消费者协程
}
}
// 消费者协程
void consumer(void* arg) {
int i = 0;
while (1) {
sem_wait(¬_empty); // 等待缓冲区不空
i = buffer[out]; // 读取数据从缓冲区
printf("consume %d\n", i);
out = (out + 1) % BUFFER_SIZE; // 更新读取位置
count--; // 更新有效数据数量
sem_post(¬_full); // 通知缓冲区不满
yield(); // 切换到生产者协程
}
}
int main() {
// 初始化信号量
sem_init(¬_full, BUFFER_SIZE);
sem_init(¬_empty, 0);
// 创建生产者和消费者协程
create(producer, NULL);
create(consumer, NULL);
while (1) {
// 重要:主while循环必须调用yield切换协程
// 否则协程将无法运行
yield();
}
}
这个示例引入了环形缓冲区和两个信号量(not_full、not_empty),实现了经典的生产者-消费者同步:
- 初始化
not_full 初始值为 BUFFER_SIZE,not_empty 初始值为 0。
- 生产者先
sem_wait(¬_full),写入后 sem_post(¬_empty)。
- 消费者先
sem_wait(¬_empty),读取后 sem_post(¬_full)。
- 主循环持续
yield() 驱动调度。
平台依赖提醒:sem_t、sem_init、sem_wait、sem_post 来自 POSIX(<semaphore.h>),在 Windows 或裸机环境下不可用,需要自行实现或替换为互斥锁 + 条件变量。此外,代码中 sem_init(¬_full, BUFFER_SIZE) 的调用参数有误:sem_init 的签名为 sem_init(sem_t *sem, int pshared, unsigned int value),第二个参数应为 0(进程内共享),第三个参数才是初始值。正确写法应为 sem_init(¬_full, 0, BUFFER_SIZE) 和 sem_init(¬_empty, 0, 0)。
局限性与验证建议
- 未经验证:以上代码为教学性质的简化实现,未经过完整编译和硬件运行验证。实际使用前请修正语法错误、补充边界检查(如
ctxArray 为 NULL 时的访问、currPos 越界等)。
- 内存安全:
destroy() 在移动数组元素后直接 free(currCtx),如果 currCtx 恰好是 ctxHead 或 ctxTail,指针更新逻辑需要仔细审查,避免悬空指针。
- 栈溢出:
STACK_SIZE 设为 1024 字节,对于包含较多局部变量或递归调用的协程函数可能不够,需要根据实际函数栈帧大小调整。
- 可移植性:
setjmp/longjmp 在信号处理和多线程场景下有明确限制。POSIX 规定 longjmp 不得用于在不同线程之间转移控制;在信号处理函数中使用 longjmp 返回时,信号会被重新启用(除非使用了 SA_NODEFER)。本实现中每个协程使用独立栈,跨栈跳转属于实现定义行为,需查阅具体工具链文档确认支持情况。jmp_buf 的大小因平台和编译器而异,嵌入式 RTOS 中需确认工具链的具体实现。
- 验证建议:编写单元测试时,至少覆盖以下场景——单协程创建/销毁、多协程交替 yield、协程函数中不调用 yield 的死循环(预期:其他协程饿死)、栈池耗尽后的行为、并发创建和销毁(若扩展到多线程)。
小结
本文从最简的函数指针调度出发,逐步引入 setjmp/longjmp 实现了协作式协程的挂起与恢复,并用生产者-消费者模型演示了数据传递和同步。核心思路是:用上下文结构体保存每个协程的"现场",用调度器决定下一个执行谁,用 longjmp 跳回目标协程的挂起点。这套机制在嵌入式实时系统中可以替代部分线程调度,降低上下文切换开销,但需要开发者自行保证协作式让出的纪律和边界安全。
<!-- csdn-article-id: 131470266 -->
本文最初于 2023/6/30 发布在 CSDN。