网络编程(四):NtyCo协程框架实现


前言

1.为什么要有协程?
2.协程实现过程,原语操作(哪些?)
3.协程如何定义
4.调度器如何定义
5.调度器的执行策略
6.posix api做到一致
7.协程的执行流程
8.协程的多核模式
9.协程的性能如何测试

一、协程设计原理

1. 什么是协程,协程解决了什么问题 ?

  • 网络IO优化
    业务处理其实是由 网络IO时间 + 业务处理时间 组成,不同的业务,其业务处理时间是不同的,所以对于业务处理时间的优化,要根据业务场景来优化。而协程模式可以提升recv和send的性能,优化网络IO时间。

  • 以同步的编程方式,实现异步的性能
    IO同步操作,写代码逻辑清晰,但是效率低;而IO异步操作,fd管理复杂,但是效率高。协程解决了IO同步效率低,IO异步fd管理发杂的问题,协程结合两者的优点,实现了以同步的编程方式,实现异步的性能

Q: 为什么协程的效率会更高?单线程reactor用的是epoll_wait, 协程调度也是epoll_wait,从整体上来看不是一样吗?

  • 在没有加入业务解析的情况下,协程性能与单线程reactor是差不多的 ,但是编程会容易很多。首先,用协程业务代码会比较简单,一个协程对应一个fd,业务逻辑都在协程内部;而reactor提供的recv_cb和send_cb是所有业务流程的。

Q: 有了业务解析,效率不也一样吗?

  • 业务部分,比如数据库操作的,是比较耗时的阻塞IO,而协程可以通过hook,把recv和send变成异步,把数据库IO阻塞的时间,切换到别的协程上运行,所有阻塞等待的地方,都会引起切换。而同步reactor在业务部分数据库IO就只能干等着。

IO同步/异步/协程比较 (协程结合IO同异之所长)

  • 对比项IO 同步操作IO 异步操作协程
    Sockfd 管理管理方便多个线程共同管理,需要避免一个fd被多个线程操作的情况发生管理方便
    代码逻辑程序整体逻辑清晰同步:检测IO 与 读写IO 在同一个流程里子模块逻辑清晰, 异步:检测IO 与 读写IO 不在同一个流程里程序整体逻辑清晰
    程序性能响应时间长,性能差响应时间短,并行性能高响应时间短,性能好

IO同步/异步逻辑伪代码 (响应time异步 << 同步)

2. 协程的设计思路

  • 把线程换成协程,线程API的思维来使用协程
    在网络IO编程的时候,如果每次accept返回的时候,为新来的fd单独分配一个线程,这一个fd对应一个线程,就不会存在多个线程共用一个fd的问题了,虽然这样代码逻辑清晰易读,但是线程创建与线程调度的代价是很大的。
1. send函数不阻塞,通过switch不断切换来实现并发
2. async_xxx()是重构后的网络io,当检测到相应事件时会切换不同的执行函数

线程思维
在这里插入图片描述
协程思维
在这里插入图片描述


NtyCo协程封装两类API (自身api + Posix api)

//一类是协程本身的api
1.创建协程
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg);
2.调度器运行
void nty_schedule_run(void);

//一类是posix api的异步封装协程api
//POSIX 异步封装 API
int nty_socket(int domain, int type, int protocol);

int nty_accept(int fd, struct sockaddr *addr, socklen_t *len);

ssize_t nty_recv(int fd, void *buf, size_t len, int flags);

ssize_t nty_send(int fd, const void *buf, size_t len, int flags);

int nty_close(int fd);

int nty_connect(int fd, struct sockaddr *name, socklen_t len);

ssize_t nty_recvfrom(int fd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

ssize_t nty_sendto(int fd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

协程工作流程

  1. 创建协程

    int nty_coroutine_create(&co, server, port);
    + (协程对象,调度入口函数,传入到入口函数中的参数)
    +  协程不存在亲属关系,都是一致的调度关系,接受调度器的调度。调用 create API就会创建一个新协程,新协程就会加入到调度器的就绪队列中
    
  2. 回调协程的子过程

    在 create 协程后, 可以把回调函数的地址存储到 EIP (CPU的EIP寄存器就是存储cpu下一条指令的地址)中。这样在resume回复协程之后,就会执行协程的子过程

  3. 协程封装posix api异步原理

    在进行 IO 操作(recv,send)之前,先执行了 epoll_ctl 的 del 操作,将相应的 sockfd 从 epfd中删除掉,在执行完 IO 操作(recv,send)再进行 epoll_ctl 的 add 的动作。如果是在多个上下文中, 能够保证 sockfd 只在一个上下文中能够操作 IO 的。不会出现在多个上下文同时对一个 IO 进行操作的, 协程的 IO 异步操作正式是采用此模式进行的


  • coroutine协程定义: struct _nty_coroutine
    在这里插入图片描述
//set
  RB_ENTRY(_nty_coroutine) sleep_node;
  RB_ENTRY(_nty_coroutine) wait_node;
  TAILQ_ENTRY(_nty_coroutine) ready_node;
  + [就绪集合] 没有设置优先级,所以在就绪集合里面的协程优先级一样,那么就可以用[队列]来存储,先进先出
  + [等待集合] 就是等待IO准备就绪,这个等待IO是有时间长短的,这里用[红黑树]来存储
  + [睡眠集合] 需要按照睡眠时间的长短进行唤醒,所以也用[红黑树]存储,key为睡眠时长
  • schedule调度器定义: struct _nty_schedule
    用来管理所有协程的属性,作为调度器的属性。调度器的属性,需要有保存 CPU 的寄存器上下文 ctx,可以从协程运行状态yield 到调度器运行的。从协程到调度器用 yield,从调度器到协程用 resume。

实现协程的原语(switch/yield/resume)

  • switch() 开关原语
    切换协程/进程/线程

  • yield() 让出
    yield的含义,让出将当前的执行流程,让出给schedule调度器
    那么什么时候需要yield让出呢?很明显在recv之前,send之前,也就是在io之前,因为我们不知道io是否准备就绪了,所以我们先将fd加入epoll中,然后yield让出,将执行流程给调度器运行。

    schedule调度器做什么事情呢?调度器就是io检测,调度器就是不断的调用epoll_wait,来检测哪些fd准备就绪了,然后就恢复相应fd的执行流程执行现场。**注意schedule不是原语,schedule是调度器。

  • resume() 恢复
    从上面我们得知恢复是被schedule恢复的,那么现在恢复到了原来流程的哪里呢?其实是恢复到了yield的下一条代码处*。通常下面的代码都会将fd从epoll中移除,然后执行recv或send操作,因为一旦被resume,就说明肯定是准备就绪的。

协程的IO异步操作原理 (底层实现是异步/应用层效果是同步)

如何以同步的编程方式来实现异步的性能(代码+图 进行理解)
在这里插入图片描述在这里插入图片描述


3. 协程原语操作实现的3种方式

(1)setjmp/longjmp方式 (实现较复杂,但跨平台性好是标准C库)

eg: 通过控制arg的值来运行不同的func

#include <setjmp.h>

jmp_buf env;

void func(int arg) {
	printf("func: %d\n", arg);
	longjmp(env, ++arg);
}

int main() {
	int ret = setjmp(env);
	if (ret == 0) {
		func(ret);
	} else if (ret == 1) {
		func(ret);
	} else if (ret == 2) {
		func(ret);
	} else if (ret == 3) {
		func(ret);
	}
	return 0;
}

(2)ucontext 方式 (实现简单,跨平台性一般推荐这个)

通过ucontext来实现函数跳转,需要注意的是第一次切换的时候上下文是对应的函数开始,之后就是直接跳转的对应的语句。

#include <ucontext.h>

ucontext_t ctx[2];
ucontext_t main_ctx;

int count = 0;

// coroutine1
void func1(void) {
	while (count ++ < 20) {
		printf("1\n");
		swapcontext(&ctx[0], &ctx[1]);
		// swapcontext(&ctx[0], &main_ctx);
		printf("4\n");
	}
}
// coroutine2
void func2(void) {
	while (count ++ < 20) {
		printf("2\n");
		swapcontext(&ctx[1], &ctx[0]);
		// swapcontext(&ctx[1], &main_ctx);
		printf("5\n");
	}
}

// scheduler
int main(void) {
    char stack1[2048] = {0};
    char stack2[2048] = {0};
    
	getcontext(&ctx[0]);
	ctx[0].uc_stack.ss_sp = stack1;
	ctx[0].uc_stack.ss_size = sizeof(stack1);
	ctx[0].uc_link = &main_ctx;
	makecontext(&ctx[0], func1, 0);

	getcontext(&ctx[1]);
	ctx[1].uc_stack.ss_sp = stack2;
	ctx[1].uc_stack.ss_size = sizeof(stack2);
	ctx[1].uc_link = &main_ctx;
	makecontext(&ctx[1], func2, 0);

	printf("swapcontext\n");
	swapcontext(&main_ctx, &ctx[0]);
	printf("\n");

    return 0;
}

实现打印顺序自己来推理一遍 + 理解api初始化和跳转步骤
在这里插入图片描述

(3)asm汇编实现切换 (受限制芯片类型影响,平台指令不同统一)

store:
mov eax(co_a.a);
mov ebx(co_a.b);

load:
mov (co_b.a) eax;
mov (co_b.b) ebx;

上下文切换的理解:
就是将 CPU 的寄存器暂时保存,再将即将运行的协程的上下文寄存器,分别mov 到相对应的寄存器上。此时上下文完成切换。
在这里插入图片描述


4. hook钩子重构网络IO

  • 所有对io的操作为什么不能直接用posix api,而是要再去封装一次?

    比如我们调用recv的时候,如果我们调用系统的,那么这个fd怎么yield到调度器上呢,所以我们需要在posix api的基础上封装,当然有些接口需要封装,有些不需要。

  • 两种策略封装API

    1. 第一种就是定义Nty_XXX(),框架独立定义一套标准接口出来
      但是这种方法,如果跟mysql,redis建立连接,但是不去修改它们提供的客户端源码开发包的时候,就会发现连不上去,因为其源码用的是posix api,recv和send。而协程用的是nty_recv()和nty_send()。两者之间没有关联。
    2. 第二种就是使用hook,当我们做成跟系统调用(posix api)一样的重名函数接口时,那么一样的接口名就会引起冲突。这个冲突我们就使用hook(映射)来解决。
      在这里插入图片描述
      hook提供了两个接口:1. dlsym()是针对系统原始的api;2. dlopen()是针对第三方库

二、NtyCo协程源码

执行指令参考MD文档: GitHub - wangbojing/NtyCo


优秀笔记:
1. 协程性能测试步骤
2. 采用协程重构网络io
参考学习: https://github.com/0voice

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值