协程的起源
问题:协程存在的原因?协程能够解决哪些问题
在我们线程CS/BS开发模式下,服务器的吞吐量是一个很重要的参数。其实吞吐量是IO处理时间加上业务处理。
为了简单起见,比如,客户端与服务器之间是长连接的,客户端定期给服务器发送心跳包数据。客户端发送一次心跳包到服务器,服务器更新该新客户端状态的。心跳包发送的过程,业务处理时长等于IO读取(RECV系统调用)加上业务处理(更新客户状态)。吞吐量等于1s业务处理次数。
业务处理(更新客户端状态)时间,业务不一样的,处理时间不一样,我们就不做讨论。
那如何提升recv的性能。如果只有一个客户端,recv的性能也没有必要提升,也不能提升。如果有百万记的客户端长连接的情况,我们该如何提升。以Linux为例,在这里需要介绍一个“网红”就是epoll。服务器使用epoll管理百万计的客户端长连接,代码框架如下:
while (1) {
int nready = epoll_wait(epfd, events, EVENT_SIZE, -1);
for (i = 0;i < nready;i ++) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) {
int connfd = accept(listenfd, xxx, xxxx);
setnonblock(connfd);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
} else {
handle(sockfd);
}
}
}
对于响应式服务器,所有的客户端的操作驱动都是来源于这个大循环。来源于epoll_wait的反馈结果。
对于服务器处理百万计的IO。Handle(sockfd)实现方式有两种。
- handle(sockefd)函数内部对sockfd进行读写操作
int handle(int sockfd) {
recv(sockfd, rbuffer, length, 0);
parser_proto(rbuffer, length);
send(sockfd, sbuffer, length, 0);
}
- handler的io操作(send, recv)与epoll_wait是在同一个处理流程里面的。这就是IO同步操作
优点:
- sockfd管理方便
- 操作逻辑清晰
缺点:
- 服务器程序依赖epoll_wait的循环响应速度慢
- 程序性能差
当然,Handle(sockfd)有另外的处理方法
- handle(sockfd)函数内部将sockfd的操作,push到线程池中
int thread_cb(int sockfd){
// 此函数是在线程池创建的线程中运行
// 与handler不在一个线程上下文中运行
recv(sockfd, rbuffer, length, 0);
parser_proto(rbuffer, length);
send(sockfd, sbuffer, length, 0);
}
int handle(int sockfd){
// 此函数在主线程main_thread中运行
// 在此处之前,确保线程池已经启动
push_thread(sockfd, thread_cv); //将sockfd放到其他线程中运行
}
- handle函数是将sockfd处理方式放到另一个线程中运行,如此做法,将io操作(recv、send)与epoll_wait不在一个处理流程里面,使得io操作(recv、send)与epoll_wait实现解耦。这就叫做io异步操作
优点:
- 子模块好规划
- 程序性能高
缺点
- 正因为子模块好规划,使得模块之间的socket的管理异常麻烦。每一个子线程都需要管理好socket,必须在IO操作的时候,socket出现关闭或者其他异常。
有没有一种方式,有异步性能,同步的代码逻辑。来方便编程人员对IO操作的组件呢?有,采用一种轻量级的协程来实现。在每次send或者recv之前进行切换,再有调度器来处理epoll_wait的流程。这就是 NtyCo:一个C实现的协程库
协程的案例
问题:协程如何使用?与线程使用有何区别
在做网络IO编程的时候,有一个非常理想的情况,就是每次accept返回的时候,就为新来的客户端分配一个线程,这样一个客户端对应一个线程,就不会有多个线程共有一个sockfd。每请求每线程的方式,并且代码逻辑非常易读。但是这只是理想,线程创建代价、调度代价就很大了。
先来看一下每请求每线程的代码如下:
while(1){
int nfds = (epoll_wait(epoll_fd, events, curfds, -1));
if(nfds == -1){
perror("epoll_wait");
break;
}
for(i = 0; i < nfds; i++){
int sockfd = listenfd(events[i].data.fd, sockfds);
if(sockfd)[
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);
]else{
...
}
}
}
这样的做法,写完放到生产环境下面,如果你的老板不打死你,你来找我。我来帮你老板,为民除害。
如果我们有协程,我们就可以这样实现。参考代码如下:
while (1) {
int nfds = epoll_wait(epoll_fd, events, curfds, -1);
if (nfds == -1) {
perror("epoll_wait");
break;
}
for (i = 0;i < nfds;i ++) {
int sockfd = listenfd(events[i].data.fd, sockfds);
if (sockfd) {
socklen_t len = sizeof(struct sockaddr_in);
int clientfd = accept(sockfd, (struct sockaddr*)&remote, &len);
nty_coroutine *read_co;
nty_coroutine_create(&read_co, server_reader, &clientfd);
}
else
{
...
}
}
这样的代码是完全可以放在生成环境下面的。如果你的老板要打死你,你来找我,我帮你把你老板打死,为民除害。
线程的API思维来使用协程,函数调用的性能来测试协程。
NtyCo封装出来了若干接口,一类是协程本身的,二类是posix的异步封装
协程创建
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg)
协程调度器的运行
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的子工程。再比如需要监听多个端口的时候,创建一个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异步操作是如何实现的呢?也就是在send与recv调用的时候,该如何实现异步操作呢?
看一下下面的代码:
while (1) {
int nready = epoll_wait(epfd, events, EVENT_SIZE, -1);
for (i = 0;i < nready;i ++) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) {
int connfd = accept(listenfd, xxx, xxxx);
setnonblock(connfd);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
} else {
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
recv(sockfd, buffer, length, 0);
//parser_proto(buffer, length);
send(sockfd, buffer, length, 0);
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, NULL);
}
}
}
在进行IO操作(recv、send)之前,先执行了epoll_cl的del操作,将相应的sockfd从epfd中删除掉,在执行完IO操作(recv、send)再进行epoll_ctl的add的动作。这段代码看起来似乎没有什么作用。
如果是在多个上下文中,这样的做法就很有意义了。能够保证sockfd只在一个上下文中能够操作IO的。不会出现在多个上下文同时对一个IO进行操作的。协程的IO异步操作正式是采用此模式进行的。
把单一协程的工作和调度器的工作划分清楚,先引入两个原语操作resume、yield。yield就是让出运行,resuume就是恢复运行。调度器和协程的上下文切换如下图:
在协程的上下文 IO 异步操作(nty_recv、nty_send)函数,步骤如下:
- 将sockfd添加到epoll管理中
- 进行上下文环境切换,由协程上下文yield到调度器的上下文
- 调度器获取下一个协程上下文。由协程上下文yeild到调度器的上下文
- 调度器获取下一个协程上下文,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中,将相应的参数存储到相应的参数寄存器中。实现子工程调用的逻辑代码如下:
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
}
协程的实现之原语操作
协程的内部原语操作有哪些呢?分别是如何实现的呢?
协程的核心原语操作包括create、resume、yield。协程的原语操作有create就一定有exit。以NtyCo为例,协程一旦创建就不能由用户自己销毁,必须得以子过程执行结束,就会自动销毁协程的上下文数据。以_exec执行入口函数返回而销毁协程的上下文与相关信息。co->func(co->arg)是子过程,如果用户需要长久运行协程,就必须要在func函数里面写入循环等操作。所以NtyCo里面没有实现exit的原语操作。
create:创建一个协程
- 调度器是否存在,不存在也创建。调度器作为全局的单例。将调度器的实例存储在线程的私有空间pthread_setspecific
- 分配一个coroutine的内存空间,分别设置coroutine的数据项、栈空间、栈大小、初始状态、创建时间、子过程回调函数、子过程的调用参数
- 将新分配协程添加到就绪队列ready_queue中
实现代码如下
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg) {
assert(pthread_once(&sched_key_once, nty_coroutine_sched_key_creator) == 0);
nty_schedule *sched = nty_coroutine_get_sched();
if (sched == NULL) {
nty_schedule_create(0);
sched = nty_coroutine_get_sched();
if (sched == NULL) {
printf("Failed to create scheduler\n");
return -1;
}
}
nty_coroutine *co = calloc(1, sizeof(nty_coroutine));
if (co == NULL) {
printf("Failed to allocate memory for new coroutine\n");
return -2;
}
//
int ret = posix_memalign(&co->stack, getpagesize(), sched->stack_size);
if (ret) {
printf("Failed to allocate stack for new coroutine\n");
free(co);
return -3;
}
co->sched = sched;
co->stack_size = sched->stack_size;
co->status = BIT(NTY_COROUTINE_STATUS_NEW); //
co->id = sched->spawned_coroutines ++;
co->func = func;
co->fd = -1;
co->events = 0;
co->arg = arg;
co->birth = nty_coroutine_usec_now();
*new_co = co;
TAILQ_INSERT_TAIL(&co->sched->ready, co, ready_next);
return 0;
}
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:正在运行协程的上下文,寄存器列表
switch 返回后,执行即将运行协程的上下文。是实现上下文的切换
协程的实现之定义
协程如何定义? 调度器如何定义?
先来一道设计题:设计一个协程的运行体 R 与运行体调度器 S 的结构体
- 运行体 R:包含运行状态{就绪,睡眠,等待},运行体回调函数,回调参数,栈指针,栈大小,当前运行体
- 调度器 S:包含执行集合{就绪,睡眠,等待}
这道设计题拆分两个个问题,一个运行体如何高效地在多种状态集合更换。调度器与运行体的功能界限。
运行体如何高效的在多种状态集合更换
新创建的协程,创建完成后,加入到就绪集合,等待调度器的调度;协程在运行完成之后,进行IO操作,此时IO并未准备好,进入等待状态集合;IO准备就绪,协程开始运行,后续进行sleep操作,此时进入到睡眠状态集合。
就绪(ready),睡眠(sleep),等待(wait)集合该采用如何数据结构来存储?
- 就绪集合不需要设置优先级,所有这个集合中的协程优先级一致,所以可以使用队列来存储就绪的协程,简称为就绪队列(ready_queue)。
- 睡眠集合需要按照睡眠时长进行排序,采用红黑树来存储,,简称睡眠树(sleep_tree)红黑树在工程实用为< key, value>, key 为睡眠时长,value 为对应的协程结点。
- 等待集合,其功能是在等待IO准备就绪,等待IO也是有时长的,所以等待集合采用红黑树来存储,简称等待树(wait_tree)
数据结构如下图所示:
Coroutine 就是协程的相应属性,status 表示协程的运行状态。sleep 与wait 两颗红黑树,ready 使用的队列,比如某协程调用 sleep 函数,加入睡眠树(sleep_tree),status |= S 即可。比如某协程在等待树(wait_tree)中,而 IO 准备就绪放入 ready 队列中,只需要移出等待树(wait_tree),状态更改 status &= ~W 即可。有一个前提条件就是不管何种运行状态的协程,都在就绪队列中,只是同时包含有其他的运行状态。
调度器与协程的功能界限
每一个协程都需要使用的而且可能会不同属性的,就是协程属性。每一个协程都需要的而且数据一致的,就是调度器的属性。比如栈大小的数值,每个协程都一样的后不做更改可以作为调度器的属性,如果每个协程大小不一致,则可以作为协程的属性。
用来管理所有协程的属性,作为调度器的属性。比如 epoll 用来管理每一个协程对应的 IO,是需要作为调度器属性。
每一个协程有自己的上下文环境,需要保存 CPU 的寄存器 ctx;需要有子过程的回调函数 func;需要有子过程回调函数的参数 arg;需要定义自己的栈空间stack;需要有自己栈空间的大小 stack_size;需要定义协程的创建时间birth;需要定义协程当前的运行状态 status;需要定当前运行状态的结点(ready_next, wait_node, sleep_node);需要定义协程 id;需要定义调度器的全局对象 sched。
协程的核心结构体如下:
typedef struct _nty_coroutine {
nty_cpu_ctx ctx;
proc_coroutine func;
void *arg;
size_t stack_size;
nty_coroutine_status status;
nty_schedule *sched;
uint64_t birth;
uint64_t id;
void *stack;
RB_ENTRY(_nty_coroutine) sleep_node;
RB_ENTRY(_nty_coroutine) wait_node;
TAILQ_ENTRY(_nty_coroutine) ready_next;
TAILQ_ENTRY(_nty_coroutine) defer_next;
} nty_coroutine;
调度器是管理所有协程运行的组件,协程与调度器的运行关系。
调度器的属性,需要有保持CPU的寄存器上下文ctx,可以从协程运行状态yield到调度器运行的。从协程到调度器用yield,从调度器到协程用resume。
下面是协程的定义:
typedef struct _nty_coroutine_queue nty_coroutine_queue;
typedef struct _nty_coroutine_rbtree_sleep nty_coroutine_rbtree_sleep;
typedef struct _nty_coroutine_rbtree_wait nty_coroutine_rbtree_wait;
typedef struct _nty_schedule {
uint64_t birth;
nty_cpu_ctx ctx;
struct _nty_coroutine *curr_thread;
int page_size;
int poller_fd;
int eventfd;
struct epoll_event eventlist[NTY_CO_MAX_EVENTS];
int nevents;
int num_new_events;
nty_coroutine_queue ready;
nty_coroutine_rbtree_sleep sleeping;
nty_coroutine_rbtree_wait waiting;
} nty_schedule;
协程的实现之调度器
调度器的实现,有两种方案,一种是生产者/消费者模型,一种是多状态运行
生产者消费者模式
逻辑代码如下:
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);
}
}
多状态运行
实现逻辑代码如下:
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);
}
}