协程
在了解协程前,我们需要先理清几个概念:同步,异步,阻塞,非阻塞
同步 vs 异步
同步和异步描述的是用户线程与内核的交互方式
- 同步:指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行
- 异步:指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程或者调用用户线程注册的回调函数
阻塞 vs 非阻塞
阻塞和非阻塞描述的是用户线程调用内核IO操作的方式,其中同步才区分阻塞和非阻塞,异步则一定是非阻塞
- 阻塞:指IO操作需要彻底完成后才返回到用户空间
- 非阻塞:指IO操作被调用后立即返回给用户一个状态值,无需等待IO操作彻底完成
在理清了同步,异步,阻塞,非阻塞的概念后,我们接下来对比看下IO同步和IO异步的处理流程
IO同步 vs IO异步
- IO同步:IO检测和IO读写在一个流程中,整个IO操作过程中,主进程是被阻塞的,不能处理其他业务,对CPU的资源利用率低
//同步处理伪代码
int mainloop() {
while(1) {
int nready = epoll_wait(...); //IO检测
for(i = 0; i < nready; i++) {
// IO读写
recv(sockfd, rbuffer, length, 0);
parser(rbuffer, length);
send(sockfd, sbuffer, length, 0);
}
}
}
- IO异步:IO检测和IO读写不在一个流程中,当检测有IO就绪时,创建线程进行IO读写,检测线程不会被阻塞,对CPU的资源利用率高
//异步处理伪代码
void thread_cb(int sockfd)
{
// 此函数是在线程池创建的线程中运行。
// 与 handle 不在一个线程上下文中运行
recv(sockfd, rbuffer, length, 0);
parse(rbuffer, length);
send(sockfd, sbuffer, length, 0);
}
int mainloop() {
while(1) {
int nready = epoll_wait(...); //检测IO
for(i = 0; i < nready; i++) {
push_thread(sockfd, thread_cb);
}
}
}
- 总结:
- 同步处理流程
- 优点:socket管理方便,程序逻辑清晰,符合人的思维
- 缺点:IO检测与IO读写在同一流程,响应时间长,程序性能低
- 异步处理流程
- 优点:子模块逻辑清晰,IO检测与IO读写分离,响应时间短,程序性能高
- 缺点:多线程共同管理fd容易造成读写异常,为了解决这种情况就必须在使用每个fd前加锁,但是这样做的效率较低
- 同步处理流程
IO同步和IO异步都有各自的优缺点,那么是否有解决方来使得代码既有同步的简单编程方式,又能实现异步的高性能?答案是有的,我们可以在同步的代码实现基础加上
跳转
操作,即当调用完阻塞式的IO操作后,我们可以使用跳转操作将CPU切换到其他IO就绪的子流程上执行,以提高CPU的利用率,使得同步做得跟异步差不多性能
Linux系统的跳转方法
- setjmp/longjmp:长跳,可以实现跨越函数栈间的跳转,但是只能在进程内部跳转,不能跨进程 (C接口实现)
- ucontext:可以实现进程内上下文间的跳转(linux系统提供的接口)
- 自己使用汇编实现:通过汇编指令操作CPU寄存器,来实现进程中上下文间的切换 (协程就是使用此方式)
单独调用跳转方法难度高且容易出错,是否可将跳转方法封装成代码框架,于是乎协程就产生了,在了解协程前,我们先看下协程的切换原理是如何的
协程切换原理
- 协程切换的原理:将CPU中当前运行协程的上下文寄存器值暂时保存,然后再将即将运行协程的上下文寄存器加载到CPU中相应的寄存器上,从而完成协程切换,如下图所示:
- 协程将切换的操作封装成两个原语操作
- yield:调用后该函数不会立即返回, 而是切换到最近执行 resume 的上下文
- resume:调用后该函数也不会立即返回,而是切换到运行协程实例的 yield 的位置
- resume 与 yield 是两个可逆过程的原子操作
- _switch操作:
yield
和resume
两个原语操作的内部实现都是通过 _switch 实现跳转, _switch函数是通过汇编实现的- new_ctx 对应的寄存器指针 rdi
- cut_ctx 对应的寄存器指针 rsi
协程的定义
协程的组成包含: 协程运行体,协程调度器
-
运行体的定义:(协程特有的属性定义在运行体中)
- 当前运行体上下文:cpu_ctx,用来切换协程用的,主要存储CPU寄存器的值
- 协程子流程的回调函数:func(),
- 回调函数参数:arg
- 栈空间:协程内部函数调用时压栈用的
- 栈空间大小:
- 协程创建的时间点
- 当前运行状态:
- 协程ID
- 调度器的全局对象
- 就绪状态节点:ready,就绪集合中的元素
- 等待状态节点:wait,等待集合中的元素
- 休眠状态节点:sleep,休眠集合中的元素
-
调度器的定义:(用来管理协程或者协程统一的属性定义在调度器中)
- CPU的寄存器上下文:
- 协程创建的时间点
- 当前运行的协程:方便yield操作 yield(sched->cur, sched->cur->next)
- epoll句柄 epfd: epoll是协程调度器的核心驱动
- epoll监听的事件集:epoll_events
- 就绪集合:由于协程优先级一致,所以使用队列进行存储
- 休眠集合:由于休眠集合需要按照睡眠时长进行排序,所以采用红黑树来存储,key为睡眠时长,value为对应的协程节点
- 等待集合:等待集合存储的是等待IO就绪,等待IO也是有时长的,所以也是采用红黑树来存储,key为等待时长,value为对应的协程节点
-
协程内部数据集合的关系
协程的工作流程
- 创建协程:
- coroutine_create() 创建协程
- 创建完后,加入就绪队列中
- IO异步操作:
- 将 sockfd 添加到 epoll 管理中
- 进行上下文环境切换, 由协程上下文 yield 到调度器的上下文
- 调度器获取下一个协程上下文, resume 到新的协程
- IO异步操作的上下文切换时序图如下
- 回调协程子过程:
- CPU有个非常重要的寄存器叫EIP,用来存储CPU下一条指令的地址
- 将回调函数的地址存储在EIP中,将相应的参数存储到相应的参数寄存器中,实现子过程调用的逻辑代码如下:
void _exec(nty_coroutine *co) {
co->func(co->arg); //子过程的回调函数
}
void coroutine_init(nty_coroutine *co) {
//ctx 就是协程的上下文
co->ctx.edi = (void*)co; //设置参数
co->ctx.eip = (void*)_exec; //设置回调函数入口
//当实现上下文切换的时候,就会执行入口函数_exec , _exec 调用子过程 func
}
协程的接口封装
协程的接口封装可分为两类:
-
类1;所有需要判断IO是否就绪的IO操作,将同步操作封装成异步操作
- 具体接口:
- connect();
- accept();
- send()/write()/sendto();
- recv()/read()/recvfrom();
- 封装样式如下:
nty_func() { epoll_ctl(add, fd); //fd先加入epoll //然后yield让出cpu,跳到调度器中,由调度器找询下一个执行的协程,然后调用resume跳到对应协程中 yield(); func(); }
- 封装方法:
- 可以给func加前缀
- 可以使用hook方法,截获并自定义系统接口
- 具体接口:
-
类2:协程执行流程的接口
- 具体接口:
- 创建协程: coroutine_create(…);
- 调度器调度:scheduler_loop(…);
- 具体接口:
协程的调度
- 协程的调度器实现方案有两种:一种是生产者消费者模式:,另一种是多状态模式
- 生产者消费者模式:
- 多状态模式:
协程的多核模式实现
- 多核的模式
- 线程的粘合
- 进程的粘合:将指定的进程绑定到指定的cpu核上执行,实现CPU的亲缘性
- 实现多核的方式
- 借助多线程
- 所有线程共用一个调度器
- 会出现线程间互跳
- 需对调度器需要加锁
- 每个线程对应一个调度器 (性能较优)
- 不需要加锁,但成本较大
- 适用场景:前后请求间存在共享资源或者依赖,则使用多线程模式。如即时聊天系统
- 所有线程共用一个调度器
- 借助进程
- 实现代码简单
- 每个进程对应一个调度器
- 适用场景:前后请求间无共享资源或者依赖,则使用多进程模式。 如nginx、http请求场景
- 用汇编实现:实现起来较复杂,此处不展开
- 借助多线程
协程的分类
- 有栈协程:每一个协程,有独立的栈
- 优点:实现容易,性能高
- 缺点:栈利用率不高
- 无栈协程:共享栈
- 优点:栈利用率高
- 缺点,实现复杂
协程库
- libgo/libco: c++实现的
- ntyco:c实现
epoll实现异步的两种方案对比
-
方案1:多线程(线程池)+ epoll
- 线程1:不停的提交请求,提交完请求的连接,加入到epoll中
- 线程2:由 epoll_wait 被动等待结果返回
-
方案二:协程 + epoll
- 提交请求,让出CPU,切换到另一个线程的epoll中
- 由epoll来检测IO是否有数据可读,若有则recv数据,然后切换回当前操作
-
对比:方案2会比方案1慢一些,慢主要来源于调度器,但方案2编程简单好维护
线程 vs 协程性能对比
- IO密集型场景:协程能代替线程
- 计算密集型场景:协程和线程无差别