协程设计与原理(二)

简介: 协程设计与原理(二)

1 协程的起源

协程存在的原因?协程能够解决那些问题?

在我们现在cs,bs开发模式下,服务器的吞吐量是一个很重要的参数。其实吞吐量是IO处理时间加上业务处理。为了简单起见,比如,客户端与服务器之间是长连接的,客户端定期给服务器发送心跳包数据。客户端发送一次心跳包到服务器,服务器更新该客户端状态的。心跳包发送的过程,业务处理时长等于IO读取(recv系统调用)加上业务处理(更新客户状态)。吞吐量等于1s业务处理次数。

业务处理(更新客户端状态)时间,业务不一样的,处理时间不一样,我们就不做讨论。

那如何提升recv的性能。若只有一个客户端,recv的性能也没有必要提升,也不能提升。若有百万级的客户端长连接的情况下,我们改如何提升。以linux为例,这里需要介绍网红epoll,服务器使用epoll管理百万级的客户端长连接,代码框架如下

 ![在这里插入图片描述](https://img-blog.csdnimg.cn/323c192cc2ed40f28b4004664e01fb00.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cH

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

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

第一种,handle(sockfd)函数内部对sockfd进行读写操作。代码如下

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

优点:
1 sockfd管理方便。
2 操作逻辑清晰。
缺点:
1 服务器程序依赖epol_wait的循环响应速度慢。
2 程序性能差

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

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

优点:
1 子模块好规划
2 程序性能高
缺点:
正因为子模块好规划,使得模块之间的sockfd的管理异常麻烦。每一个子线程都需要管理好sockfd,避免io操作的时候,sockfd出现关闭或者其他异常。

对比IO同步操作与IO异步操作:

#if 1 打开的时候,为IO异步操作,关闭的时候为同步操作

接下来把我测试接入量的结果粘贴处理。

IO异步操作,每1000个连接接入的服务器响应事件(900ms)

IO同步操作,每1000个连接接入的服务器响应事件(6500ms).

IO异步操作与IO同步操作

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

基于这种想法,目前开源组件有libgo(采用golang思想),libco(微信开源),自己实现(汇编实现)。

2 协程的案例

问题:协程如何使用?与线程使用有何区别?

 在做网络编程IO的时候,有一个非常理想的情况,就是每次accept返回的时候,就为新来的客户端分配一个线程,这样一个客户端对应一个线程。就不会有多个线程共用一个sockfd。每请求每线程的方式,并且代码逻辑非常易读。但是这只是理想,线程创建代价,调度代价就呵呵了。

先来看一下每请求每线程的代码如下:

这种的做法,写完放在生成环境,就是个傻屌。

如果我们又协程,我们就可以这样用

这样的代码是完全可以放在生成环境中,性能是很高的。

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

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

1 协程创建

2 协程调度器的运行

3 POSIX异步封装API

格式与POSIX标准的函数定义一致可以使用hook技术。

3 协程的实现之工作流程

问题:协程内部是如何工作呢?

主要比较难理解的工作流程。第一个协程的创建;第二个io异步操作;第三个协程子过程回调

3.1 创建协程

当我们需要异步调用的时候,我们会创建一个协程。比如accept返回一个新的sockfd,创建一个客户端处理的子过程。在比如需要监听多个端口的时候,创建一个server的子过程,这样多个端口同时工作的,是符合微服务的架构的。

创建协程的时候,进行了如何的工作?创建api如下:

参数1:nty_coroutine **new_co,需要传入空的协程的对象,这个对象是由内部创建的,并且在函数返回的时候,会返回一个内部创建的协程对象。

参数2:proc_coroutine func,协程的子过程。当协程被调度的时候,就会执行该函数。

参数3:void *arg需要传入到新协程中的参数。

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

3.2 实现IO异步操作

大部分朋友会关心IO异步操作如何实现,在send和recv调用的时候,如何实现异步操作的。

先看一段代码:

在进行IO操作(recv,send)之前,先执行了epoll_ctl的del操作,将对应的sockfd从epfd中删除掉,在执行完IO操作(recv,send)在进行epoll_ctl的add动作。这段代码看起来好像没有什么作用。

如果是在多个上下文,能保证sockfd只在一个上下文中能够操作IO的。不会出现在多个上下文同时同一个io进行操作的,协程的IO异步操作正式采用此模式

把单一协程的工作与调度器的工作划分清楚,先引入两个原语操作yield就是让出运行,resume就是恢复运行

调度器与协程的上下文切换关系如下:

在协程的上下文IO异步操作(nty_recv,nty_send)函数,如下流程:
1 将sockfd添加到epoll管理中。
2 进行上下文环境切换,由协程上下文yield到调度器的上下文。
3 调度器获取下一个协程上下文。Resume新的协程
IO异步操作的上下文切换的时序图如下:

3.3 回调协程的子过程

在创建协程后,何时回调子过程?何种方式回调子过程?

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有一个非常重要的寄存器叫做EIP,用来存储CPU运行下一条指令的地址。我们可以把回调函数的地址存储到EIP中,将响应的参数存储到响应的参数寄存器中。
实现子过程调用的逻辑代码如下:

4 协程的实现之原语操作

协程的内部原语操作有哪些?分包如何实现的?

协程的核心原语操作:create,resume,yield。协程的原语操作有create,怎么没有exit。协程一旦创建就不能有用户自己销毁,必须得以子过程执行结束,就会自动销毁协程的上下文数据。以_exec执行入口函数返回而销毁协程的上下文与相关信息。co->func(co->arg)是子过程,若用户需要长久运行协程,就必须要在func函数里面写入循环等操作。所以无exit原语操作。

1 create:创建一个协程

1 调度器是否存在,不存在也创建。调度器作为全局的单例。将调度器的实例存储在线程的私有空间pthread_setspecific.

2 分配一个coroutine的内存空间,分包设置coroutine的数据项,栈空间,栈大小,初始状态,创建时间,子过程回调函数,子过程的调用参数。

3 将新分配协程添加到就绪队列ready_queue中。

实现代码:

2 yield 让出cpu

void nty_coroutine_yield(nty_coroutine *co) 

参数:当前运行的协程实例。调用后该函数不会立即返回,而是切换到最近执行resume的上下文。该函数返回在执行resume的时候,会有调度器同意选择resume的,然后再次调用yield的。resume与yield是两个不逆过程的原子操作。

3 resume 恢复协程的运行全

参数:需要恢复运行的协程实例

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

5 协程的实现之切换

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

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的寄存器暂时保存,将运行的协程的上下文寄存器,分别到响应的寄存器上。此时上下文完成切换,如下图:

对于yield和resume两个原语操作底层都是调用的_swithc()函数

int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);

参数1:即将运行协程的上下文,寄存器列表

参数2:正在运行协程的上下文,寄存器列表

结构体定义为了兼容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返回后,执行即将运行协程的上下文。是实现上下文的切换

代码如下:

__asm__ (
"    .text                                  \n"
"       .p2align 4,,15                                   \n"
".globl _switch                                          \n"
".globl __switch                                         \n"
"_switch:                                                \n"
"__switch:                                               \n"
"       movq %rsp, 0(%rsi)      # save stack_pointer     \n"
"       movq %rbp, 8(%rsi)      # save frame_pointer     \n"
"       movq (%rsp), %rax       # save insn_pointer      \n"
"       movq %rax, 16(%rsi)                              \n"
"       movq %rbx, 24(%rsi)     # save rbx,r12-r15       \n"
"       movq %r12, 32(%rsi)                              \n"
"       movq %r13, 40(%rsi)                              \n"
"       movq %r14, 48(%rsi)                              \n"
"       movq %r15, 56(%rsi)                              \n"
"       movq 56(%rdi), %r15                              \n"
"       movq 48(%rdi), %r14                              \n"
"       movq 40(%rdi), %r13     # restore rbx,r12-r15    \n"
"       movq 32(%rdi), %r12                              \n"
"       movq 24(%rdi), %rbx                              \n"
"       movq 8(%rdi), %rbp      # restore frame_pointer  \n"
"       movq 0(%rdi), %rsp      # restore stack_pointer  \n"
"       movq 16(%rdi), %rax     # restore insn_pointer   \n"
"       movq %rax, (%rsp)                                \n"
"       ret                                              \n"
);

 %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指向的指令。 上下文环境的切换完成。

6 协程的实现之定义

协程如何定义?调度器如何定义?

先来设计一道设计题

设计一个协程的运行体R和运行体调度器S的结构体

1 运行体R:包含运行状态(就绪,睡眠,等待),运行体回调函数,回调参数,栈指针,栈大小,当前运行体

2 调度器S:包含执行集合(就绪,睡眠,等待)

主要拆分为两个问题:1 一个运行体如何高效的在多种状态集合更换;2 调度器与运行体的功能界限

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

新创建的协程,创建完成后,加入到就绪集合,等待调度器的调度;

协程在运行完成后,进行IO操作,此时IO并未准备好,进入等待状态集合;

IO准备就绪,协程开始运行,后续进行sleep操作,此时进入到水命状态集合。

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

就绪集合:并没有优先级的类型,所有的在协程优先级一直,所以可使用队列来存储就绪协程,ready_queue就绪队列

睡眠集合: 需要按照睡眠时长进行排序,采用红黑树来存储,key为睡眠时长,value为对于协程结点,睡眠树

等待集合: 功能等待IO准备就绪,等待IO也是有时间长度的,采用红黑树来存储 等待树

数据结构如下图:

 coroutine就是协程的相应属性,status表示协程的运行状态。sleep与wait两个红黑树,ready使用的队列,比如某协程调用sleephanshu  ,加入到睡眠树(sleep tree),status |=  S即可。比如某协程在等待树(wait_tree)中,而IO准备就绪放入ready队列中,只需要移除等待树(wait_tree),状态更改statue  &= ~W即可。有一个前提条件就是不管何种运行状态的协程,都在就绪队列中,只是同事包含有其它的运行状态。

2 调度器与协程的功能界限

每一协程都需要使用的而且可能会不同属性的,就是协程属性。每一协程都需要的而且数据一致的,就是调度器的熟悉比如栈大小的数值,每个协程都一样的后不做更改可以作为调度器的属性,如果每个协程大小不一致,则可以作为协程的属性。

用来管理所有协程的属性,作为调度器的属性比如epoll用来管理每一个协程对应的IO,是需要作为调度器属性。

协程结构体:

调度器是管理所有协程运行的组件,协程与调度器运行关系。

 ![在这里插入图片描述](https://img-blog.csdnimg.cn/3e0bdee038d54f1d87e617bb37e58fb5.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTE3NDQ4ND

调度器的属性,需要有保存cpu的寄存器上下文ctx,可以从协程运行状态到调度器运行的。从协程到调度器用yield,从调度器到协程用resume,一下是协程的定义:

7协程的实现之调度器

协程如何被调度?

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

1 生产者消费者模式

逻辑代码如下:

2 多状态运行

实现逻辑代码如下:

8 协程性能测试

测试环境:4台VMWare 虚拟机 1台服务器 6G内存,4核CPU 3台客户端 2G内存,2核CPU 操作系统:ubuntu 14.04

按照每一个连接启动一个协程来测试。每一个协程栈空间 4096byte 6G内存 –> 测试协程数量100W无异常。并且能够正常收发数据

9 协程多核模式

解决方式有三种:1 多进程 2多线程 3 x86指令实现 多进程实现起来方便,一个进程(单线程)一个调度器,协程代码本身不需要修改,多线程方式就很复杂。 多核与多进程多线程关系:每个线程或每个进程作为轻原型,绑定一个cpu核,也可以采用x86指令实现。如果采用多线程实现需要加锁:就绪队列叫spinlock,红黑树就加mutex锁。

目录
相关文章
|
7月前
|
API 调度
2.3.1 协程设计原理与汇编实现
2.3.1 协程设计原理与汇编实现
|
4月前
|
存储 Linux 调度
协程(coroutine)的原理和使用
协程(coroutine)的原理和使用
|
1月前
|
存储 安全 测试技术
GoLang协程Goroutiney原理与GMP模型详解
本文详细介绍了Go语言中的Goroutine及其背后的GMP模型。Goroutine是Go语言中的一种轻量级线程,由Go运行时管理,支持高效的并发编程。文章讲解了Goroutine的创建、调度、上下文切换和栈管理等核心机制,并通过示例代码展示了如何使用Goroutine。GMP模型(Goroutine、Processor、Machine)是Go运行时调度Goroutine的基础,通过合理的调度策略,实现了高并发和高性能的程序执行。
103 29
|
1月前
|
负载均衡 算法 Go
GoLang协程Goroutiney原理与GMP模型详解
【11月更文挑战第4天】Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理,创建和销毁开销小,适合高并发场景。其调度采用非抢占式和协作式多任务处理结合的方式。GMP 模型包括 G(Goroutine)、M(系统线程)和 P(逻辑处理器),通过工作窃取算法实现负载均衡,确保高效利用系统资源。
|
3月前
|
调度 Python
揭秘Python并发编程核心:深入理解协程与异步函数的工作原理
在Python异步编程领域,协程与异步函数成为处理并发任务的关键工具。协程(微线程)比操作系统线程更轻量级,通过`async def`定义并在遇到`await`表达式时暂停执行。异步函数利用`await`实现任务间的切换。事件循环作为异步编程的核心,负责调度任务;`asyncio`库提供了事件循环的管理。Future对象则优雅地处理异步结果。掌握这些概念,可使代码更高效、简洁且易于维护。
31 1
|
2月前
|
数据采集 调度 Python
Python编程异步爬虫——协程的基本原理(一)
Python编程异步爬虫——协程的基本原理(一)
22 0
|
2月前
|
数据采集 Python
Python编程异步爬虫——协程的基本原理(二)
Python编程异步爬虫——协程的基本原理(二)
25 0
|
2月前
|
Java Linux Go
协程的设计原理(一)
协程的设计原理(一)
34 0
|
5月前
|
调度 Python
揭秘Python并发编程核心:深入理解协程与异步函数的工作原理
【7月更文挑战第15天】Python异步编程借助协程和async/await提升并发性能,减少资源消耗。协程(async def)轻量级、用户态,便于控制。事件循环,如`asyncio.get_event_loop()`,调度任务执行。异步函数内的await关键词用于协程间切换。回调和Future对象简化异步结果处理。理解这些概念能写出高效、易维护的异步代码。
63 2
|
7月前
|
存储 关系型数据库 MySQL
纯c协程框架NtyCo实现与原理
纯c协程框架NtyCo实现与原理
160 1
下一篇
DataWorks