协程的原理与实现
为什么需要协程
我们知道操作系统在线程等待IO的时候,会阻塞当前线程,切换到其它线程,这样在当前线程等待IO的过程中,其它线程可以继续执行。当系统线程较少的时候没有什么问题,但是当线程数量非常多的时候,却产生了问题。一是系统线程会占用非常多的内存空间,二是过多的线程切换会占用大量的系统时间。
协程刚好可以解决上述2个问题。协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
协程是是一个更加轻量级的单位,是组成线程的各个函数,由程序员去调度的。
yield 只能实现单纯的切换函数和保存函数状态的功能
不能实现:当某一个函数遇到io阻塞时,自动的切换到另一个函数去执行(如write阻塞不能自动切换到另外的函数)
协程的注意事项
协程只有在等待IO的过程中才能重复利用线程,线程在等待IO的过程中会陷入阻塞状态
实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞IO操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度,这往往是不能接受的(操作系统也不会调度已经阻塞的协程,所以协程需要主动让出)。
因此在协程中不能调用导致线程阻塞的操作。也就是说,协程只有和异步IO结合起来
协程对计算密集型的任务也没有太大的好处,计算密集型的任务本身不需要大量的线程切换,因此协程的作用也十分有限,反而还增加了协程切换的开销。
异步变同步的调用方式只是一种编程方式,不管是用线程还是用协程都可以实现这种编程方式,好处是不用在处理非常多的回调。
async function getProcessedData(url) {
let v;
try {
v = await downloadData(url);
} catch(e) {
v = await downloadFallbackData(url);
}
try {
return await processDataInWorker(v);
} catch (e) {
return null;
}
}
在有大量IO操作业务的情况下,我们采用协程替换线程,可以到达很好的效果,一是降低了系统内存,二是减少了系统切换开销,因此系统的性能也会提升。
在协程中尽量不要调用阻塞IO的方法,比如打印,读取文件,Socket接口等,除非改为异步调用的方式,并且协程只有在IO密集型的任务中才会发挥作用。
协程只有和异步IO结合起来才能发挥出最大的威力。
协程的案例
在做网络 IO 编程的时候 有一个非常理想的情况 就是每次 accept 返回的时候 就为新来的客户端分配 一个线程,这样一个客户端对应一个线程 。 就不会有 多个线程共用一个 sockfd 。 每请求每线程的方式, 并且代码逻辑非常易读。 但是这只是理想 ,线程创建代价,调度代价就呵呵了。先来看一下每请求每线程的代码如下
while(1) {
socklen_t len = sizeof(struct sockaddr_in);
int clientfd = accept(sockfd, (struct sockaddr*)&remote, &len);
pthread_t thread_id;
pthread_create(&thread_id, NULL, client_cb, &clientfd);
}
这样的做法写完 放到生产环境下面是不行的,线程切换开销过大,协程参考代码如下:
while (1) {
socklen_t len = sizeof(struct sockaddr_in);
int cli_fd = nty_accept(fd, (struct sockaddr*)&remote, &len);
nty_coroutine *read_co;
nty_coroutine_create(&read_co, server_reader, &cli_fd);
}
以协程库ntyco为例NtyCo封装出来了若干接口,一类是协程本身的,二类是 posix 的异步封装协程
1. 协程创建
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg)
2. 协程调度器的运行
void nty_schedule_run(void)
POSIX异步封装 API
int nty_socket(int domain, int type, int protocol)
int nty_accept(int fd, struct sockaddr *addr, socklen_t *len)
int nty_recv(int fd, void *buf, int length)
int nty_send(int fd, const void *buf, int length)
int nty_close(int fd)
创建协程
当我们需要异步调用的时候,我们会创建一个协程。比如accept 返回一个新的sockfd ,创建一个客户端处理的子过程。
再比如 需要监听多个端口的时候 ,创建一个 server的子过程,这样多个端口同时工作。
创建协程的时候进行了如何的工作 创建 API 如下:
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg)
参数1 nty_coroutine **new_co,需要传入空的协程的对象,这个对象是由内部创建的,并且在函数返回的时候,会返回一个内部创建的协程对象。
参数2:proc_coroutine func,协程的子过程。当协程被调度的时候,就会执行该函数。
参数3:void *arg,需要传入到新协程中的参数。
协程不存在亲属关系,都是一致的调度关系,接受调度器的调度。调用create API就会创建一个新协程,新协程就会加入到调度器的就绪队列中。
实现 IO 异步操作
在进行IO 操作 recv send 之前,先执行了 epoll _ctl 的 del 操作 ,将相应的 sockfd 从 epfd中删除掉 在执行完 IO 操作 recv send.再进行 epoll_ctl 的 add 的动作 。
这段代码看起来似乎好像 没有什么作用。如果是在多个上下文中 这样的做法就很有意义了 。
能够保证 sockfd 只在一个上下文中能够操作 IO 的。不会出现在多个上下文同时对一个 IO 进行操作的,协程的 IO 异步操作正式 是采用此模式进行的。
yield 就是让出运行, resume就是恢复运行 。 调度器与协程的上下文切换如下图所示
在协程的上下文 IO 异步操作 nty _recv nty_send 函数 ,步骤如下
- 将 sockfd 添加到 epoll 管理中 。
- 进行上下文环境切换 由协程上下文 yield 到调度器 的上下文 。
- 调度器获取下一个协程上下文。 Resume 新的协程
IO异步操作的上下文切换 的时序图如下:
回调 协程 的子过程
在 create 协程后 何时回调子过程 何种 方式回调 子过程?
首先来回顾一下
x86 _64 寄存器的相关知识。 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 用作数据存储 就是使用前要先保存原值
以NtyCo 的实现为例 ,来分析这个过程 。 CPU 有一个非常重要的寄存器叫做 EIP ,用来存储 CPU 运行下一条指令的地址。 我们可以把回调函数的地址存储到 EIP 中,将相应的参数存储到相应的 参数 寄存器中 。 实现子过程调用的逻辑代码如下:
协程的实现之原语操作
协程的 核心 原语操作 create, resume, yield 。协程的原语操作有create没有 exit.也就是说,协程一旦创建就不能由用户自己销毁,必须得以子过程(函数体)执行结束,才会自动销毁协程的上下文数据。
co->func(co->args) 是子过程 ,若用户需要长久运行协程,就必须要在 func 函数里面写入循环操作,比如while(1)。
create 创建一个协程 。
1.调度器是否存在 不存在也创建 。 调度器作为全局的单例 。 将调度器的实例存储在线程的私有空间 pthread_setspecific
2.分配一个 coroutine 的内存空间 ,分别设置 coroutine 的 数据项,栈空间,栈大小,初始状态,创建时间,子过程回调函数, 子过程的调用参数。
3.将新分配协程添加到就绪队列 ready_queue 中
yield 让出 CPU 。
void nty_coroutine_yield(nty_coroutine *co)
参数:当前运行的协程实例
调用后该函数不会立即返回而是切换到 最近 执行 resume 的上下文 。 该函数返回是在执行 resume 的时候 ,会有调度器统一选择 resume 的,然后再次调用 yield 的。 resume 与 yield 是两个可逆过程的原子操作 。
resume 恢复 协程的运行权
int nty_coroutine_resume(nty_coroutine *co)
调用后该函数也不会立即返回,而是切换到运行协程实例的 yield 的位置 。 返回是在等协程相应事务处理完成后 主动 yield 会返回到 resume 的地方 。
协程的上下文如何切换
上下文切换就是将 CPU 的寄存器暂时保存 再将即将运行的协程的上下文寄存器 分别mov 到相对应的寄存器上 。 此时上下文完成切换 。 如下图所示
切换_switch 函数定义
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);
参数1 即将运行协程的上下文,寄存器列表
参数2 :正在运行协程的上下文 寄存器列表
我们nty_cpu_ctx 结构体的定义 为了兼容 x86 结构体项命令采用的是 x86 的寄存器名字命名 。
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;
_switch返回后 执行即将运行协程的上下文 。 是实现上下文的切换
_switch的实现代码:
按照x86 64 的 寄存器定义, ,%rdi 保存第一个参数的值,即 new _ctx 的值 rsi 保存第二个参数的值 即保存 cur _ctx 的值。 X 86 _64 每个寄存器是 64bit 8byte 。
Movq %rsp , 0 (%rsi) 保存在栈指针到 cur _ctx 实例的 rsp 项
Movq %rbp,8(%rsi)
Movq (%rsp), %rax 将栈顶地址里面的值存储到 rax 寄存器中 。 Ret 后出栈 执行栈顶
Movq %rbp, 8(%rsi) 后续的指令都是用来保存 CPU 的寄存器到 new_ctx 的每一项中
Movq 8(% rdi), %rbp 将 new _ctx 的值
Movq 16(%rdi), %rax 将 指令指针 rip 的值 存储到 rax 中
Movq %rax, (%rsp) 将存储的 rip 值的 rax 寄存器赋值给 栈指针的地址的值。
Ret 出栈 回到栈指针 执行 rip 指向的指令 。
上下文环境的切换完成。
问题:协程如何被调度?
调度器的实现,有两种方案,一种是生产者消费者模式,另一种多状态运行。
调度器实现
生产者消费者模式
逻辑代码如下:
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);
}
}