【协程】协程的设计原理

一、协程的起源

1. 为什么会有协程?

对于响应式服务器,所有的客户端的操作驱动都是来源于这个大循环。来源于
epoll_wait 的反馈结果。

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);
 		}
 	} 
}

对于服务器处理百万计的 IO。Handle(sockfd)实现方式有两种。

Handle方式一

handle(sockfd)函数内部对 sockfd 进行读写动作。代码如下

int handle(int sockfd) {
	recv(sockfd, rbuffer, length, 0);
	parser_proto(rbuffer, length);
	send(sockfd, sbuffer, length, 0);
}

handle 的 io 操作(send,recv)与 epoll_wait 是在同一个处理流程里面的。这就是 IO 同步操作。

优点:

  1. sockfd 管理方便。
  2. 操作逻辑清晰。

缺点:

  1. 服务器程序依赖 epoll_wait 的循环响应速度慢。
  2. 程序性能差
Handle方式二

handle(sockfd)函数内部将 sockfd 的操作,push 到线程池中,代码如下:

int thread_cb(int sockfd) {
	// 此函数是在线程池创建的线程中运行。
	// 与 handle 不在一个线程上下文中运行
	recv(sockfd, rbuffer, length, 0);
	parser_proto(rbuffer, length);
	send(sockfd, sbuffer, length, 0);
}
int handle(int sockfd) {
	//此函数在主线程 main_thread 中运行
	//在此处之前,确保线程池已经启动。
	push_thread(sockfd, thread_cb); //将 sockfd 放到其他线程中运行。
}

Handle 函数是将 sockfd 处理方式放到另一个已经其他的线程中运行,如此做法,将 io 操作(recv,send)与 epoll_wait 不在一个处理流程里面,使得 io操作(recv,send)与epoll_wait 实现解耦。这就叫做 IO 异步操作。

优点:

  1. 子模块好规划。
  2. 程序性能高。

缺点:

正因为子模块好规划,使得模块之间的 sockfd 的管理异常麻烦。每一个子线程都需要管理好 sockfd,避免在 IO 操作的时候,sockfd 出现关闭或其他异常。

2. 协程解决了什么问题?

实验证明,IO 同步操作,程序响应慢,IO 异步操作,程序响应快

IO 异步操作与 IO 同步操作对比:
在这里插入图片描述
有没有一种方式,有异步性能,同步的代码逻辑。来方便编程人员对 IO 操作的组件呢? 有,采用一种轻量级的协程来实现。在每次 send 或者 recv 之前进行切换,再由调度器来处理 epoll_wait 的流程。

总结

协程解决的问题是:通过异步IO的方式,实现看起来是同步IO的代码逻辑。

3. 协程如何使用?与线程使用有何区别?

在做网络 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);
}

线程的 API 思维来使用协程,函数调用的性能来测试协程

4.协程可以用在什么地方?

协程可以使用的地方如:

  1. 文件操作
  2. mysql的操作
  3. 网络io
  4. redis的操作

这些需要等待IO完成耗时的地方,通过协程变成异步非阻塞IO,在等待IO事件完成时,把协程让出给协程调度器,由协程调度器决定调度某个已经完成IO的协程执行。

这样的好处是

  1. 代码看起来是同步阻塞IO的逻辑,比较好理解,但实际上是异步非阻塞IO。(注:如果操作没有IO,那么用协程的意义不大。)
  2. 切换协程执行比内核切换线程的代价小得多。一个线程运行期间,其中某个协程阻塞了,马上切换另一个协程执行(由协程调度器决定切换到哪一个协程执行),切换代价很小。如果是直接调用系统阻塞API,那么当前线程就会阻塞,内核切换线程,切换代价很大。因此协程有必要对系统阻塞API进行一层封装。

所有可能引起阻塞的系统API,都可以通过协程调度器封装一层API,使得本来是同步阻塞的IO操作,通过我们封装好的协程调度器API把它转变成异步非阻塞IO的操作。

不需要封装的API(不可能导致阻塞)如:socket、close、fcntl、setsockopt、getsockopt、listen等。

需要封装的API(可能导致阻塞)如:系统API中的read、write、connect、accept、send、sendto、recv、recvfrom、sleep等。

封装API示例:

nty_recv(){
	//添加到epoll管理。原理就是利用epoll管理所有的IO
	epoll_ctl(add)
	//直接让出CPU
	yield();
	//执行下面代码说明已经被epoll捕捉到sockfd有数据了,并且协程调度器主动resume了当前协程
	//首先移除epoll管理
	epoll_ctl(del)
	//可以直接调用系统API读数据了,因为一定有数据可以读,不会出现阻塞或者无数据可读情况
	recv(sockfd);
}

如果想要封装系统API中的read、write、connect、accept、send、sendto、recv、recvfrom、sleep等,成为自己的同名API,可参考如下代码:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>

#include <fcntl.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <arpa/inet.h>

#include <mysql.h>

//原生系统API
typedef int(*connect_t)(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect_t connect_f;

typedef int(*accept_t)(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept_t accept_f;

typedef ssize_t(*read_t)(int fd, void *buf, size_t count);
read_t read_f;

typedef ssize_t(*write_t)(int fd, const void *buf, size_t count);
write_t write_f;

typedef ssize_t(*send_t)(int sockfd, const void *buf, size_t len, int flags);
send_t send_f;

typedef ssize_t(*recv_t)(int sockfd, void *buf, size_t len, int flags);
recv_t recv_f;

typedef ssize_t(*sendto_t)(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
sendto_t sendto_f;

typedef ssize_t(*recvfrom_t)(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
recvfrom_t recvfrom_f;

//我们自己封装的同名系统API实现
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen){
	printf("connect\n");
	//调用真正的系统API(已经通过dlsym函数把系统函数改名了)
	return connect_f(sockfd, addr, addrlen);
}

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen){
	printf("accept\n");
	return accept_f(sockfd, addr, addrlen);
}

ssize_t read(int fd, void *buf, size_t count){
	printf("read\n");
	return read_f(fd, buf, count);
}

ssize_t write(int fd, const void *buf, size_t count){
	printf("write\n");
	return write_f(fd, buf, count);
}

ssize_t send(int sockfd, const void *buf, size_t len, int flags){
	printf("send\n");
	return send_f(sockfd, buf, len, flags);
}

ssize_t recv(int sockfd, void *buf, size_t len, int flags){
	printf("recv\n");
	return recv_f(sockfd, buf, len, flags);
}

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen){
	printf("sendto\n");
	return sendto_f(sockfd, buf, len, flags, dest_addr, addrlen);
}

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen){
	printf("recvfrom\n");
	return recvfrom_f(sockfd, buf, len, flags, src_addr, addrlen);
}

//设置系统API调用时候hook真正执行的函数
static int init_hook(){
	//把原生系统API的名字更换 如把"connect"换成"connect_f"
	connect_f = dlsym(RTLD_NEXT, "connect");
	accept_f = dlsym(RTLD_NEXT, "accept");

	read_f = dlsym(RTLD_NEXT, "read");
	write_f = dlsym(RTLD_NEXT, "write");

	send_f = dlsym(RTLD_NEXT, "send");
	recv_f = dlsym(RTLD_NEXT, "recv");
	
	sendto_f = dlsym(RTLD_NEXT, "sendto");
	recvfrom_f = dlsym(RTLD_NEXT, "recvfrom");
}

int main(){

	init_hook();

	MYSQL* m_mysql = mysql_init(NULL);
	if(!m_mysql){
		printf("mysql_init failed\n");
		return 0;
	}

	if(!mysql_real_connect(m_mysql, 
				"192.168.184.131", "admin", "123456", 
				"TEST_DB", 3306, NULL, CLIENT_FOUND_ROWS)){
		printf("mysql_real_connect failed:%s\n", mysql_error(m_mysql));
		return 0;
	} else {
		printf("mysql_real_connect success\n");	
	}

}

编译运行

# gcc -o sys_func_hook sys_func_hook.c -L/usr/lib64/mysql -lmysqlclient -I/usr/include/mysql/ -ldl
# ./sys_func_hook
connect
recv
read
send
recv
mysql_real_connect success

为什么要将系统API换成hook同名的自己的API

答:如上面例子,我们使用的第三方动态库mysqlclient,里面本来是调用系统阻塞API(如connect、read、recv、send等),如果我们想在不修改第三方源码的情况下,使得第三方库调用我们封装的系统API,那么就可以使用上面例子的方法,hook系统API到我们对应同名的API。

使用场景还包括,比如替换系统函数malloc和free,增加打印,哪里调用了malloc,却没有调用free造成内存泄漏。jemalloc和tcmalloc就是利用的这个原理,替换了系统函数malloc,当我们调用malloc其实是调用了jemalloc提供的函数。

5. 多进程+协程 和 多线程+协程

如何充分利用多核?

  1. 每个核绑定一个进程运行(设置进程与核的亲缘性)
  2. 每个核绑定一个线程运行(设置线程与核的亲缘性)

协程充分利用多核方式

  1. 一个进程+一个协程调度器。n个进程对应n个调度器。这种情况下,不需要加锁(因为一个进程只有一个主线程,对调度器操作不涉及线程竞争共享资源问题)。
  2. 多个线程+一个协程调度器 。这种情况下,需要对协程进行状态切换的时候加锁(涉及多线程对一个协程调度器队列进行存取)。

二、IO异步操作与协程结合的组件-NtyCo

现在有很多C/C++实现的IO异步与协程结合的库,如libgo/libco,生产环境建议使用libco,libco由腾讯微信团队开发的开源项目。

由于学习需要下面介绍比较简单的一个IO异步操作与协程结合的开源项目-NtyCo

1. NtyCo介绍

NtyCo实现了一个 IO 异步操作与协程结合的组件https://github.com/wangbojing/NtyCo

NtyCo 封装出来了若干接口,一类是协程本身的,二类是 posix 的异步封装协程 API:

  1. 协程创建
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg)
  1. 协程调度器的运行
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)

接口格式与 POSIX 标准的函数定义一致。

2. 协程的实现之工作流程
1. 创建协程

当我们需要异步调用的时候,我们会创建一个协程。比如 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,协程的子过程。当协程被调度的时候,就会执行该函数。
参数 3void *arg,需要传入到新协程中的参数。

协程不存在亲属关系,都是一致的调度关系,接受调度器的调度。调用 create API就会创建一个新协程,新协程就会加入到调度器的就绪队列中。

2. 实现 IO 异步操作

先来看一下一段代码:

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_ctl 的 del 操作,将相应的 sockfd 从epfd中删除掉,在执行完 IO 操作(recv,send)再进行 epoll_ctl 的 add 的动作。这段代码看起来似乎好像没有什么作用。

如果是在多个上下文中,这样的做法就很有意义了。能够保证 sockfd 只在一个上下文中能够操作 IO 的。不会出现在多个上下文同时对一个 IO 进行操作的。协程的 IO 异步操作正式是采用此模式进行的

把单一协程的工作与调度器的工作的划分清楚,先引入两个原语操作 resume,yield 会在《协程的实现之原语操作》来讲解协程所有原语操作的实现,yield 就是让出运行,resume就是恢复运行。调度器与协程的上下文切换如下图所示
在这里插入图片描述
在协程的上下文 IO 异步操作(nty_recv,nty_send)函数,步骤如下:

  1. 将 sockfd 添加到 epoll 管理中。
  2. 进行上下文环境切换,由协程上下文 yield 到调度器的上下文。
  3. 调度器获取下一个协程上下文。Resume 新的协程

IO 异步操作的上下文切换的时序图如下:
在这里插入图片描述

3. 回调协程的子过程

在 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
}
3. 协程的实现之原语操作

协程的核心原语操作:create, resume, yield。

协程的原语操作有create 怎么没有 exit?以 NtyCo 为例,协程一旦创建就不能有用户自己销毁,必须得以子过程执行结束,就会自动销毁协程的上下文数据。

以_exec 执行入口函数返回而销毁协程的上下文与相关信息。co->func(co->arg) 是子过程,若用户需要长久运行协程,就必须要在 func 函数里面写入循环等操作。所以 NtyCo 里面没有实现 exit 的原语操作。

create:创建一个协程。
  1. 调度器是否存在,不存在也创建。调度器作为全局的单例。将调度器的实例存储在线程的私有空间 pthread_setspecific。
  2. 分配一个 coroutine 的内存空间,分别设置 coroutine 的数据项,栈空间,栈大小,初始状态,创建时间,子过程回调函数,子过程的调用参数。
  3. 将新分配协程添加到就绪队列 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 是两个可逆过程的原子操作。

调用nty_coroutine_yield的主要地方

  1. 插入epoll_ctl之前。
//添加到epoll管理
epoll_ctl(add)
//直接让出CPU
nty_coroutine_yield();
  1. 插入准备队列之前。
//插如准备就绪队列
TAILQ_INSERT_TAIL(&nty_coroutine_get_sched()->ready, co, ready_next);
//直接让出CPU
nty_coroutine_yield();
resume:恢复协程的运行权
int nty_coroutine_resume(nty_coroutine *co)
参数:需要恢复运行的协程实例

调用后该函数也不会立即返回,而是切换到运行协程实例的 yield 的位置。返回是在等协程相应事务处理完成后,主动 yield 会返回到 resume 的地方。

调用nty_coroutine_resume的主要地方

//协程调度器死循环
void nty_schedule_run(void) {
	while (1) {
		//1 超时队列
		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_resume(co);
		}
		
		//3 io完成队列
		nty_schedule_epoll(sched);
		while (sched->num_new_events) {
			nty_coroutine_resume(co);
		}
	}
}
4. 协程的实现之切换

问题:协程的上下文如何切换?切换代码如何实现?

首先来回顾一下 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 用作数据存储,就是使用前要先保存原值。

上下文切换,就是将 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 的实现代码:

0: __asm__ (
1: " .text \n"
2: " .p2align 4,,15 \n"
3: ".globl _switch \n"
4: ".globl __switch \n"
5: "_switch: \n"
6: "__switch: \n"
7: " movq %rsp, 0(%rsi) # save stack_pointer \n"
8: " movq %rbp, 8(%rsi) # save frame_pointer \n"
9: " movq (%rsp), %rax # save insn_pointer \n"
10: " movq %rax, 16(%rsi) \n"
11: " movq %rbx, 24(%rsi) # save rbx,r12-r15 \n"
12: " movq %r12, 32(%rsi) \n"
13: " movq %r13, 40(%rsi) \n"
14: " movq %r14, 48(%rsi) \n"
15: " movq %r15, 56(%rsi) \n"
16: " movq 56(%rdi), %r15 \n"
17: " movq 48(%rdi), %r14 \n"
18: " movq 40(%rdi), %r13 # restore rbx,r12-r15 \n"
19: " movq 32(%rdi), %r12 \n"
20: " movq 24(%rdi), %rbx \n"
21: " movq 8(%rdi), %rbp # restore frame_pointer \n"
22: " movq 0(%rdi), %rsp # restore stack_pointer \n"
23: " movq 16(%rdi), %rax # restore insn_pointer \n"
24: " movq %rax, (%rsp) \n"
25: " ret \n"
26: );

按照 x86_64 的寄存器定义,%rdi 保存第一个参数的值,即 new_ctx 的值,%rsi 保存第二个参数的值,即保存 cur_ctx 的值。X86_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 指向的指令。
上下文环境的切换完成。

5. 协程的实现之定义

问题:协程如何定义? 调度器如何定义?

先来一道设计题:
设计一个协程的运行体 R 与运行体调度器 S 的结构体

  1. 运行体 R:包含运行状态{就绪,睡眠,等待},运行体回调函数,
    回调参数,栈指针,栈大小,当前运行体
  2. 调度器 S:包含执行集合{就绪,睡眠,等待}

这道设计题拆分两个个问题,一个运行体如何高效地在多种状态集合更换。调度器与运行体的功能界限。

运行体如何高效地在多种状态集合更换

新创建的协程,创建完成后,加入到就绪集合,等待调度器的调度;协程在运行完成后,进行 IO 操作,此时 IO 并未准备好,进入等待状态集合;IO 准备就绪,协程开始运行,后续进行 sleep 操作,此时进入到睡眠状态集合。

就绪(ready),睡眠(sleep),等待(wait)集合该采用如何数据结构来存储?

  • 就绪(ready)集合并不没有设置优先级的选型,所有在协程优先级一致,所以可以使用队列来存储就绪的协程,简称为就绪队列(ready_queue)。
  • 睡眠(sleep)集合需要按照睡眠时长进行排序,采用红黑树来存储,简称睡眠树(sleep_tree)红黑树在工程实用为<key, value>, key 为睡眠时长,value 为对应的协程结点。
  • 等待(wait)集合,其功能是在等待 IO 准备就绪,等待 IO 也是有时长的,所以等待(wait)集合采用红黑树的来存储,简称等待树(wait_tree),此处借鉴 nginx 的设计。

数据结构如下图所示:
在这里插入图片描述
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;
	
	struct {
		void *buf;
		size_t nbytes;
		int fd;
		int ret;
		int err;
	} io;

	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;
6.协程的实现之调度器

问题:协程如何被调度?

调度器的实现,有两种方案,一种是生产者消费者模式,另一种多状态运行。

1. 生产者消费者模式

在这里插入图片描述
逻辑代码如下:

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);
 	}
}
2. 多状态运行

在这里插入图片描述
实现逻辑代码如下:

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);
 	}
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值