前言
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);
协程工作流程
-
创建协程
int nty_coroutine_create(&co, server, port); + (协程对象,调度入口函数,传入到入口函数中的参数) + 协程不存在亲属关系,都是一致的调度关系,接受调度器的调度。调用 create API就会创建一个新协程,新协程就会加入到调度器的就绪队列中
-
回调协程的子过程
在 create 协程后, 可以把回调函数的地址存储到 EIP (CPU的EIP寄存器就是存储cpu下一条指令的地址)中。这样在resume回复协程之后,就会执行协程的子过程
-
协程封装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
- 第一种就是定义Nty_XXX(),框架独立定义一套标准接口出来
但是这种方法,如果跟mysql,redis建立连接,但是不去修改它们提供的客户端源码开发包的时候,就会发现连不上去,因为其源码用的是posix api,recv和send。而协程用的是nty_recv()和nty_send()。两者之间没有关联。 - 第二种就是使用hook,当我们做成跟系统调用(posix api)一样的重名函数接口时,那么一样的接口名就会引起冲突。这个冲突我们就使用hook(映射)来解决。
hook提供了两个接口:1. dlsym()是针对系统原始的api;2. dlopen()是针对第三方库
- 第一种就是定义Nty_XXX(),框架独立定义一套标准接口出来
二、NtyCo协程源码
执行指令参考MD文档: GitHub - wangbojing/NtyCo
优秀笔记:
1. 协程性能测试步骤
2. 采用协程重构网络io
参考学习: https://github.com/0voice