目录
协程是什么?
协程就是用同步的编程思维,实现异步的操作。
为什么需要协程?
原因
在主线程上的大量I/O操作中,当前执行的I/O操作此时不可读也不可写,如果是同步操作,那我只能一直等到当前这个I/O可操作并且将它执行完成后才能走下一个I/O操作,等待的这个时间就被白白浪费了。
而使用异步操作,如果当前等待的I/O事件处于等待的状态,就直接切换到其他I/O事件,执行效率就会大大提高,因为I/O事件处理是很慢的。
但是使用异步来处理,如果I/O操作涉及的步骤较多就会出现回调地狱的情况,代码可读性很差,而且处理临界资源,避免一个I/O被多个线程操作。而且线程的切换也会有额外的开销。
协程的出现就解决了同步操作效率低下,异步操作逻辑不好理解和开销的问题。用同步的编程方式实现异步的操作。
协程的优点
用户态调度:协程由程序(或运行时库)自行调度,无需内核介入,切换开销极小(通常为微秒级)。
非抢占式:协程只有在主动yield或await时才会让出控制权,避免线程抢占带来的竞争问题。
协程的缺点
协程实际上还是一个单线程的程序,无法利用到多核资源。不过golang的协程现在已经可以充分利用多核 CPU 资源,但我不太了解这部分。
协程适合I/O密集型任务,对于一些 CPU 密集型任务(如科学计算、图像处理),仅使用协程的的话,由于无法利用多核资源,会导致整个线程阻塞,失去并发优势。
协程如何实现?
参考的项目地址在文章结尾给出。
主要围绕该项目中的core目录下的nty_coroutine.c、nty_schedule.c和nty_socket.c进行总结。
上下文切换
这部分几乎皆取自Ntyco的wiki。
既然要切换前后不同的协程,那前一个协程的内容肯定需要保存下来,例如:
寄存器状态:
- 通用寄存器:如 RAX、RBX、RCX 等(x86_64 架构),保存临时数据和计算结果。
- 指令指针(IP/EIP/RIP):指向下一条要执行的指令地址,恢复时从此处继续执行。
- 栈指针(SP/ESP/RSP):指向当前栈顶位置,保存局部变量和函数调用上下文。
- 基址指针(BP/EBP/RBP):指向栈帧底部,用于访问局部变量和参数。
栈内容:协程的局部变量、函数调用参数和返回地址都存储在栈中,切换时需要保存整个栈的状态(或差异部分)。
协程私有数据:如用户定义的上下文变量、协程状态(就绪 / 运行 / 阻塞)等。
所以上下文切换,就是将CPU的寄存器暂时保存,再将即将运行的协程的上下文寄存器,分别mov到相对应的寄存器上。此时上下文完成切换。
实现的方式有:
- 汇编指令:直接操作 CPU 寄存器(如 x86 的 EAX、EBX)保存 / 恢复上下文。
- C 语言技巧:使用
setjmp/longjmp
或ucontext
系列函数(Linux)。 - 现代语言:Go 的
runtime
包、Python 的asyncio
通过生成器(Generator)实现。
Ntyco中实现方式是使用汇编或ucontext。
汇编实现
x86_64 的寄存器有16个64位寄存器,分别是:%rax, %rbx, %rcx, %esi, %edi, %rbp, %rsp, %r8, %r9, %r10, %r11, %r12, %r13, %r14, %r15。
%rax 作为函数返回值使用的。
%rsp 栈指针寄存器,指向栈顶
%rdi, %rsi, %rdx, %rcx, %r8, %r9 用作函数参数,依次对应第1参数,第2参数。。。
%rbx, %rbp, %r12, %r13, %r14, %r15 用作数据存储,遵循调用者使用规则,换句话说,就是随便用。调用子函数之前要备份它,以防它被修改 %r10, %r11 用作数据存储,就是使用前要先保存原值。
cpu上下文信息结构体:
typedef struct _nty_cpu_ctx {
void *esp; //
void *ebp;
void *eip;
void *edi;
void *esi;
void *ebx;
void *r1;
void *r2;
void *r3;
void *r4;
void *r5;
} nty_cpu_ctx;
这里可以对照本小节开头寄存器状态介绍对照。例如 eip 用来存储CPU运行下一条指令的地址。我们可以把回调函数的地址存储到EIP中,将相应的参数存储到相应的参数寄存器中。实现子过程调用的逻辑代码如下:
void _exec(nty_coroutine *co) {
co->func(co->arg); //子过程的回调函数
}
void nty_coroutine_init(nty_coroutine *co) {
//ctx 就是协程的上下文
co->ctx.edi = (void*)co; //设置参数
co->ctx.eip = (void*)_exec; //设置回调函数入口
//当实现上下文切换的时候,就会执行入口函数_exec , _exec 调用子过程func
}
切换上下文函数定义:
// 新协程:new_ctx 当前协程:cur_ctx
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);
实现代码:
#ifdef __i386__
__asm__ (
" .text \n"
" .p2align 2,,3 \n"
".globl _switch \n"
"_switch: \n"
"__switch: \n"
"movl 8(%esp), %edx # fs->%edx \n"
"movl %esp, 0(%edx) # save esp \n"
"movl %ebp, 4(%edx) # save ebp \n"
"movl (%esp), %eax # save eip \n"
"movl %eax, 8(%edx) \n"
"movl %ebx, 12(%edx) # save ebx,esi,edi \n"
"movl %esi, 16(%edx) \n"
"movl %edi, 20(%edx) \n"
"movl 4(%esp), %edx # ts->%edx \n"
"movl 20(%edx), %edi # restore ebx,esi,edi \n"
"movl 16(%edx), %esi \n"
"movl 12(%edx), %ebx \n"
"movl 0(%edx), %esp # restore esp \n"
"movl 4(%edx), %ebp # restore ebp \n"
"movl 8(%edx), %eax # restore eip \n"
"movl %eax, (%esp) \n"
"ret \n"
);
#elif defined(__x86_64__)
__asm__ (
" .text \n"
" .p2align 4,,15 \n"
".globl _switch \n"
".globl __switch \n"
"_switch: \n"
"__switch: \n"
" movq %rsp, 0(%rsi) # save stack_pointer \n"
" movq %rbp, 8(%rsi) # save frame_pointer \n"
" movq (%rsp), %rax # save insn_pointer \n"
" movq %rax, 16(%rsi) \n"
" movq %rbx, 24(%rsi) # save rbx,r12-r15 \n"
" movq %r12, 32(%rsi) \n"
" movq %r13, 40(%rsi) \n"
" movq %r14, 48(%rsi) \n"
" movq %r15, 56(%rsi) \n"
" movq 56(%rdi), %r15 \n"
" movq 48(%rdi), %r14 \n"
" movq 40(%rdi), %r13 # restore rbx,r12-r15 \n"
" movq 32(%rdi), %r12 \n"
" movq 24(%rdi), %rbx \n"
" movq 8(%rdi), %rbp # restore frame_pointer \n"
" movq 0(%rdi), %rsp # restore stack_pointer \n"
" movq 16(%rdi), %rax # restore insn_pointer \n"
" movq %rax, (%rsp) \n"
" ret \n"
);
#endif
原语操作
需要以上上下文切换实现
yield
协程yield(让出)CPU,协程对应的I/O操作尚未就绪、协程对应操作全部完成等情况下执行;
// 暂停协程执行
void nty_coroutine_yield(nty_coroutine *co) {
co->ops = 0;
#ifdef _USE_UCONTEXT
if ((co->status & BIT(NTY_COROUTINE_STATUS_EXITED)) == 0) {
// 保存协程栈
_save_stack(co);
}
// 切换上下文
swapcontext(&co->ctx, &co->sched->ctx);
#else
// 上下文切换
_switch(&co->sched->ctx, &co->ctx);
#endif
}
resume
resume(恢复)协程运行。在协程对应的I/O操作处理完成、睡眠时间结束或延迟时间结束等情况下执行。
// 恢复协程执行
int nty_coroutine_resume(nty_coroutine *co) {
if (co->status & BIT(NTY_COROUTINE_STATUS_NEW)) {
// 初始化新协程
nty_coroutine_init(co);
}
#ifdef _USE_UCONTEXT
else {
// 加载协程栈
_load_stack(co);
}
#endif
nty_schedule *sched = nty_coroutine_get_sched();
// 设置当前正在运行的协程
sched->curr_thread = co;
#ifdef _USE_UCONTEXT
// 切换上下文
swapcontext(&sched->ctx, &co->ctx);
#else
// 上下文切换
_switch(&co->ctx, &co->sched->ctx);
// 对协程栈进行内存优化
nty_coroutine_madvise(co);
#endif
sched->curr_thread = NULL;
#if 1
if (co->status & BIT(NTY_COROUTINE_STATUS_EXITED)) {
if (co->status & BIT(NTY_COROUTINE_STATUS_DETACH)) {
// 释放已退出且分离的协程资源
nty_coroutine_free(co);
}
return -1;
}
#endif
return 0;
}
调度器(scheduler)
调度器用于统一管理协程的状态。
为什么一定需要调度器?
协程的状态包括就绪、等待、可读可写等多个状态,如果没有调度器,不利于维护这么多个状态,对于协程生命周期的管理会分散在不同的模块里,就会使得代码不那么好实现。
调度器通过 epoll 来获取当前需要执行的协程。当协程满足恢复执行的条件时,协程对应的fd会被 epoll 移除,而让出时会被添加进去,这样操作能够保证 fd 只在一个上下文中 能够操作I/O的。不会出现在多个上下文同时对一个 I/O 进行操作的。
调度器主要通过队列和红黑树管理协程:例如:就绪队列、存放处于等待状态协程的红黑树、存放处于睡眠状态的红黑树。
这些数据结构对应的协程状态不一定是冲突的,比如一个协程既能加入到等待状态的红黑树中等待I/O就绪,也能加入睡眠状态的红黑树中处理超时操作;延迟队列可作为辅助,与主状态结构共存,用于实现超时机制。
// 定义协程调度器结构体
typedef struct _nty_schedule {
uint64_t birth; // 调度器创建时间
#ifdef _USE_UCONTEXT
ucontext_t ctx; // 使用ucontext的上下文
#else
nty_cpu_ctx ctx; // 自定义的CPU上下文
#endif
void *stack; // 调度器栈指针
size_t stack_size; // 调度器栈大小
int spawned_coroutines; // 已创建的协程数量
uint64_t default_timeout; // 默认超时时间
struct _nty_coroutine *curr_thread; // 当前正在运行的协程
int page_size; // 页面大小
int poller_fd; // epoll文件描述符
int eventfd; // 事件文件描述符
struct epoll_event eventlist[NTY_CO_MAX_EVENTS]; // epoll事件列表
int nevents; // 事件数量
int num_new_events; // 新事件数量
pthread_mutex_t defer_mutex; // 延迟队列互斥锁
nty_coroutine_queue ready; // 就绪队列
nty_coroutine_queue defer; // 延迟队列
nty_coroutine_link busy; // 忙碌链表
nty_coroutine_rbtree_sleep sleeping; // 睡眠红黑树
nty_coroutine_rbtree_wait waiting; // 等待红黑树
//private
} nty_schedule;
调度器运行代码:
// 该函数用于启动调度器的运行
void nty_schedule_run(void) {
// 获取当前的调度器
nty_schedule *sched = nty_coroutine_get_sched();
if (sched == NULL) return ;
// 循环执行,直到调度器完成所有任务
while (!nty_schedule_isdone(sched)) {
// 1. 处理过期的协程(睡眠红黑树)
nty_coroutine *expired = NULL;
while ((expired = nty_schedule_expired(sched)) != NULL) {
// 恢复过期协程的执行
nty_coroutine_resume(expired);
}
// 2. 处理就绪队列中的协程
nty_coroutine *last_co_ready = TAILQ_LAST(&sched->ready, _nty_coroutine_queue);
while (!TAILQ_EMPTY(&sched->ready)) {
// 从就绪队列中取出第一个协程
nty_coroutine *co = TAILQ_FIRST(&sched->ready);
// 从就绪队列中移除该协程
TAILQ_REMOVE(&co->sched->ready, co, ready_next);
// 检查协程是否处于文件描述符结束状态
if (co->status & BIT(NTY_COROUTINE_STATUS_FDEOF)) {
// 如果是,则释放该协程的资源
nty_coroutine_free(co);
break;
}
// 恢复该协程的执行
nty_coroutine_resume(co);
if (co == last_co_ready) break;
}
// 3. 处理等待红黑树中的协程
nty_schedule_epoll(sched);
while (sched->num_new_events) {
// 从新事件数量中减 1
int idx = --sched->num_new_events;
// 获取当前事件
struct epoll_event *ev = sched->eventlist+idx;
// 获取事件对应的文件描述符
int fd = ev->data.fd;
// 检查事件是否为文件描述符结束事件
int is_eof = ev->events & EPOLLHUP;
if (is_eof) errno = ECONNRESET;
// 在等待红黑树中查找该文件描述符对应的协程
nty_coroutine *co = nty_schedule_search_wait(fd);
if (co != NULL) {
if (is_eof) {
// 如果是文件描述符结束事件,设置协程的状态
co->status |= BIT(NTY_COROUTINE_STATUS_FDEOF);
}
// 恢复该协程的执行
nty_coroutine_resume(co);
}
is_eof = 0;
}
}
// 释放调度器的资源
nty_schedule_free(sched);
return ;
}
hook
Hook操作核心就是用自定义函数替换系统函数,保证所有代码在不更改流程的情况下使用协程。使调用系统函数时实际执行的是我们编写的代码。
在协程中,hook操作就是把系统调用中的阻塞 IO 函数进行替换,使其在等待 IO 结果时主动让出控制权。
在nty_socket.c中例如send()和nty_send()
ssize_t nty_send(int fd, const void *buf, size_t len, int flags) {
int sent = 0;
int ret = send(fd, ((char*)buf)+sent, len-sent, flags);
if (ret == 0) return ret;
if (ret > 0) sent += ret;
while (sent < len) {
struct pollfd fds;
fds.fd = fd;
fds.events = POLLOUT | POLLERR | POLLHUP;
nty_poll_inner(&fds, 1, 1);
ret = send(fd, ((char*)buf)+sent, len-sent, flags);
//printf("send --> len : %d\n", ret);
if (ret <= 0) {
break;
}
sent += ret;
}
if (ret <= 0 && sent == 0) return ret;
return sent;
}
ssize_t send(int fd, const void *buf, size_t len, int flags) {
if (!send_f) init_hook();
nty_schedule *sched = nty_coroutine_get_sched();
if (sched == NULL) {
return send_f(fd, buf, len, flags);
}
int sent = 0;
int ret = send_f(fd, ((char*)buf)+sent, len-sent, flags);
if (ret == 0) return ret;
if (ret > 0) sent += ret;
while (sent < len) {
struct pollfd fds;
fds.fd = fd;
fds.events = POLLOUT | POLLERR | POLLHUP;
nty_poll_inner(&fds, 1, 1);
ret = send_f(fd, ((char*)buf)+sent, len-sent, flags);
//printf("send --> len : %d\n", ret);
if (ret <= 0) {
break;
}
sent += ret;
}
if (ret <= 0 && sent == 0) return ret;
return sent;
}
nty_poll_inner() 用于将 fd 加入到 epoll 中监听,并让出当前协程,待文件描述符就绪后再从 epoll 中移除该 fd。代码就不再列出了。
而上面给出的send(),nty_send()函数,均实现了异步操作,这两个函数实现的主要功能没有区别。nty_send() 中调用的 send() 就是上面的 send() 。
协程整体的流程
这部分皆取自Ntyco的wiki。
在hook操作的基础上,通过调度器来实现对协程的管理。
在协程的上下文IO异步操作(nty_recv,nty_send)函数,步骤如下:
1. 将 sockfd 添加到epoll管理中。
2. 进行上下文环境切换,由协程上下文yield到调度器的上下文。
3. 调度器获取下一个协程上下文。Resume新的协程。
IO 异步操作的上下文切换的时序图如下:
创建协程及调度器 --> 运行调度器 --> 调度器分配协程 --> 协程运行时阻塞让出控制权给调度器-->调度器分配协程执行...
协程如何被调度
这部分皆取自Ntyco的wiki。
生产者-消费者模式
满足运行条件的协程都需要交给就绪队列,由就绪队列将协程resume。
实现代码:
while (1) {
//遍历睡眠集合,将满足条件的加入到ready
nty_coroutine *expired = NULL;
while ((expired = sleep_tree_expired(sched)) != ) {
TAILQ_ADD(&sched->ready, expired);
}
//遍历等待集合,将满足添加的加入到ready
nty_coroutine *wait = NULL;
int nready = epoll_wait(sched->epfd, events, EVENT_MAX, 1);
for (i = 0;i < nready;i ++) {
wait = wait_tree_search(events[i].data.fd);
TAILQ_ADD(&sched->ready, wait);
}
// 使用resume回复ready的协程运行权
while (!TAILQ_EMPTY(&sched->ready)) {
nty_coroutine *ready = TAILQ_POP(sched->ready);
resume(ready);
}
}
多状态运行
每个数据结构中的协程只要满足条件都可以直接 resume 协程。
实现代码:
while (1) {
//遍历睡眠集合,使用resume恢复expired的协程运行权
nty_coroutine *expired = NULL;
while ((expired = sleep_tree_expired(sched)) != ) {
resume(expired);
}
//遍历等待集合,使用resume恢复wait的协程运行权
nty_coroutine *wait = NULL;
int nready = epoll_wait(sched->epfd, events, EVENT_MAX, 1);
for (i = 0;i < nready;i ++) {
wait = wait_tree_search(events[i].data.fd);
resume(wait);
}
// 使用resume恢复ready的协程运行权
while (!TAILQ_EMPTY(sched->ready)) {
nty_coroutine *ready = TAILQ_POP(sched->ready);
resume(ready);
}
}
协程具体的使用场景举例
高并发网络服务
比如图床服务器处理客户端上传、下载图片请求,rpc服务器解析客户端请求、组织回复内容这类I/O密集型的高并发网络服务。
游戏开发
角色技能的冷却时间可以使用协程计时,加入睡眠状态红黑树,无需轮询检查冷却时间。多人游戏中,发送 / 接收数据时继续游戏运行。资源加载与初始化,使用协程异步加载资源(如模型、纹理、音频)。
其实这就是一个组件,只要合适就可以用,没有那么绝对的非他不可的场景。