Linux c/c++协程设计原理

在C++20中引入了协程新特性,能够实现异步操作并且以可读,可维护的方式编写代码,并且提供更高的性能,因为减少了线程切换和上下文切换的开销。
下面通过9个问题来理解协程

  1. 为什么要有协程
  • 同步的编程方式,异步的性能:以dns为例,对于同步方式指第一个请求发送到服务器,并且服务器返回结果后,再进行第二个请求,发送请求和等待结果是同步的,是一种串行的方式;对于异步方式指不等待第一个请求返回结果,直接发送第二个请求,并且在请求返回后在回调函数中执行结果,发送请求和等待结果是异步的,是一种并行的方式。
  • 对于io多路复用,将检测io是否就绪的过程(epoll_wait)和操作io(recv/send)解耦,检测io就绪后,在线程池中对具体的io进行操作,即异步;如果检测io就绪后,在同一个线程直接对io进行操作,即同步。
  • 异步缺点:对于io密集型的场景,例如网页中的内容密集请求,异步性能对同步性能提升很大,但代码容易出现回调嵌套,相较于同步方式,代码难以理解。
  • 协程能够实现同步式编程,但具有异步性能。
  1. 协程实现过程,有哪些原语操作
  • 对于依次调用send/recv操作,协程使用异步的方式实现了在操作io前判断io是否可读/可写,如果未就绪,则让出资源进而跳转至下一个io判断,以此实现async_send()/async_recv(),对于应用层与同步的调用方式相同;对于协程,关键步骤之一是如何实现函数的跳转。
    • 跳转方式1:setjmp/longjmp,使用setjmp保存调用环境,再通过longjmp跳转至保存的环境处,相比于goto,能够实现跨函数的跳转;
    • 跳转方式2:ucontext,makecontext保存上下文,swapcontext切换上下文,为实现代码和逻辑的可控,通常需要抽象出调度器来负责运行的让出和恢复,即每次执行协程都从调度器中切换。
    • 跳转方式3:汇编,通过cpu保存上下文,通过一系列mov操作保存和加载协程
  • 原语操作1:create创建一个协程。int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg)
    • 调度器是否存在不存在也创建 。调度器作为全局的单例。将调度器的实例存储在线程的私有空间pthread_setspecific。
    • 分配一个coroutine的内存空间,分别设置 coroutine 的数据项,栈空间,栈大小,初始状态,创建时间,子过程回调函数(协程入口函数),子过程的调用参数。
    • 将新分配协程添加到就绪队列ready _queue。
  • 原语操作2:yield让出 CPU。void nty_coroutine_yield(nty_coroutine *co)
    • 参数:当前运行的协程实例,调用后该函数不会立即返回,而是切换到最近执行resume的上下文。该函数返回是在执行resume 的时候,会由调度器统一选择resume的,然后再次调用yield的。resume与yield是两个可逆过程的原子操作 。
  • 原语操作3:resume恢复协程的运行权。int nty_coroutine_resume(nty_coroutine *co)
    • 参数:需要恢复运行的协程实例,调用后该函数也不会立即返回,而是切换到运行协程实例的 yield 的位置。返回是在等协程相应事务处理完成后主动yield 会返回到resume的地方。
  1. 协程与调度器关系
    每一协程都需要使用的 而且可能会不同属性的,就是协程属性。每一协程
    都需要的而且数据一致的,就是调度器的属性。 比如栈大小的数值,每个协程
    都一样的后不做更改可以作为调度器的属性,如果每个协程大小不一致,则可
    以作为协程的属性。
    用来管理所有协程的属性 作为调度器的属性。比如 epoll 用来管理每一
    个协程对应的 IO,是需要作为调度器属性。
    调度器是管理所有协程运行的组件,调度器的属性,需要有保存 CPU 的寄存器上下文 ctx ,可以从协程运行状态yield 到调度器运行的。从协程到调度器用 yield ,从调度器到协程用 resume。协程与调度器的运行关系如下图:
    调度器与协程的让出和恢复
  2. 协程结构体设计
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;							//协程id
	void *stack;							//协程自身的栈空间
	RB_ENTRY(_nty_coroutine) sleep_node;	//当前处于sleep状态的节点
	RB_ENTRY(_nty_coroutine) wait_node;		//当前处于wait状态的节点
	TAILQ_ENTRY(_nty_coroutine)	ready_next;	//当前处于ready状态的节点
} nty_coroutine;
  1. 调度器结构体设计
typedef struct _nty_schedule
{
	uint64_t birth;										//创建时间
	nty_cpu_ctx ctx;									//调度器上下文
	struct _nty_coroutine *curr_thread;					//当前执行的协程
	int poller_fd;										//调度器用于事件循环的epoll_fd
	int eventfd;										//由eventfd创建的用于系统事件通知的fd
	struct epoll_event eventlist[NTY_CO_MAX_EVENTS];	//就绪的事件数组
	int num_new_events;									//就绪的事件个数
	nty_coroutine_queue ready;							//处于ready状态的协程队列
	nty_coroutine_rbtree_sleep sleeping;				//处于sleep状态的协程rbtree
	nty_coroutine_rbtree_wait waiting;					//处于wait状态的rbtree
} nty_schedule;
  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);
	}
}
  • 多状态运行模式
    在这里插入图片描述
    逻辑代码如下
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);
	}
}
  1. 使用协程如何与posix api一致
    使用hook技术将原本的send()/recv()替换为用户态实现的send和recv,使用dlsym函数通过句柄和连接符名称获取函数名或者变量名
    dlsym实现hook代码如下:
typedef ssize_t (*read_t)(int fd, void *buf, size_t count);
read_t read_f = NULL;

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

ssize_t read(int fd, void *buf, size_t count) {
	struct pollfd fds[1] = {0};
	fds[0].fd = fd;
	fds[0].events = POLLIN;
	int res = poll(fds, 1, 0);
	if (res <= 0) { //
		// fd --> epoll_ctl();
		// swapcontext(); // fd --> ctx
	}
	// io ready
	ssize_t ret = read_f(fd, buf, count);
	printf("read: %s\n", (char *)buf);
	return ret;
}

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

void init_hook(void) {
	if (!read_f) {
		read_f = dlsym(RTLD_NEXT, "read");
	}
	if (!write_f) {
		write_f = dlsym(RTLD_NEXT, "write");
	}
}

以此能够在不更改原有代码的情况下,将使用的函数替换为用户态的实现。

  1. 协程的执行流程
  • 创建第一个协程时,创建调度器,协程创建完成后,加入到调度器的就绪集合,等待调度器的调度;对于调度器中就绪集合的协程,调度器恢复到协程中,进行io操作,如果io并未就绪,则加入调度器的等待状态集合,并将对应的fd加入到epoll中管理,再让出到调度器的执行;在调度器调度等待状态集合时,从epoll_wait中获取到就绪的fd,再通过fd查找到对应的协程,恢复到协程运行。
  1. 协程的多核模式
    由于是在用户态实现的协程,所以对比多线程和多进程无法做到与cpu亲缘性。多线程需要对调度器的三个集合进行加锁,所以性能上多进程会更好,能够做到多个调度器互不干涉。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值