纯c实现的协程框架NtyCo


layout: post
title: 纯c实现的协程框架NtyCo
description: 纯c实现的协程框架NtyCo
tag: 项目实战


前言

记录在网上学习的一个基于协程实现的百万并发TCPsever的项目-NtyCo
项目链接

1、为什么有协程,协程解决了什么问题?

网络IO优化

    在CS,BS的开发模式下,服务器的吞吐量是一个受关注的参数。吞吐量等于1秒内业务处理的次数,那么这个业务处理其实是由网络IO时间 + 业务处理时间组成的。
    不同的业务,其业务处理时间是不同的,所以对于业务处理时间的优化,要根据业务场景来优化。而网络IO时间是可以通过采用统一的措施来进行优化的。
    所谓的提高IO处理性能,即对数据的recv和send进行优化,对于基于epoll实现的响应式服务器来说,所有客户端的操作都是源于这个下边这个大循环:
1、获取epoll中的就绪事件,遍历就绪事件;
2、如果就绪事件fd与监听fd一致:有新的连接需要接待;
3、否则说明是IO事件(需要执行读或者写),统一用handle()表示IO的处理。
在这里插入图片描述
  对于服务器处理网络IO,handle(sockfd) 的实现有两种方式。第一种,IO同步;第二种,IO异步。

IO同步

handle(sockfd)函数内部对 sockfd 进行读写动作。代码如下:
在这里插入图片描述
  handle 的 io 操作(send,recv)与 epoll_wait 是在同一个处理流程里面的。这就是 IO 同步操作。
IO同步的优点:

  • 1、sockfd管理方便
  • 2、操作逻辑更符合人的思路,逻辑清晰

IO同步的缺点:

  • 多个需要处理的事件串行,前一个需要等待的事件影响后边的事件;服务器程序响应速度依赖epoll_wait的循环响应,速度慢,性能低。

IO异步

handle(sockfd)函数内部将 sockfd 的操作,push 到线程池中,代码
如下:
在这里插入图片描述
Handle 函数是将 sockfd 处理方式放到另一个已经其他的线程中运行,如此做法,将 io 操作(recv,send)与 epoll_wait 不在一个处理流程里面,使得 io操作(recv,send)与 epoll_wait 实现解耦。这就叫做 IO 异步操作。
IO异步的优点:

  • 1、划分为子模块,降低了程序的耦合性
  • 2、大循环中将待处理的sockfd交给新开辟的线程处理后,无需等待即可进入下一轮循环处理,提高了程序的响应速度。

缺点:

  • 1、模块之间的sockfd管理异常麻烦,每一个子线程都需要管理好sockfd,避免sockfd出现异常或其他关闭
  • 2、服务器能开辟的线程有限,无法处理大规模IO事件

两种处理方式对比:
在这里插入图片描述
IO处理是一种高频,但是CPU开销和内存占用都比较小的处理操作,使用多线程异步处理解决了高频的响应速度问题,但是创建线程的开销对于一个IO处理而言又太大了,因此有了协程的概念——一种用户态的轻量级线程

2、协程

有没有一种方式,有异步性能,同步的代码逻辑。来方便编程人员对 IO 操作的组件呢? 有,采用一种轻量级的线程——协程来实现。
协程对IO处理的方案如下:
1、首先也是将子任务recv和send模块化,各个模块的执行由调度器决定:
在这里插入图片描述
2、使用调度器管理协程,每当需要处理IO时,resume(恢复)到协程中,处理完毕,协程再yield(让出)处理。

在协程的上下文 IO 异步操作(nty_recv,nty_send)函数,步骤如下:

  1. 将 sockfd 添加到 epoll 管理中。
  2. 进行上下文环境切换,由协程上下文 yield 到调度器的上下文。
  3. 调度器获取下一个协程上下文。resume 新的协程
    IO 异步操作的上下文切换的时序图如下:
    在这里插入图片描述

在这里插入图片描述

协程的原语

yield()

先来理解yield的含义,让出,将当前的执行流程让出,让出给调度器。

schedule()

schedule调度器做什么事情呢?

  调度器就是io检测,调度器就是不断的调用epoll_wait,来检测哪些fd准备就绪了,然后就恢复相应fd的执行流程执行现场。

注意schedule不是原语,schedule是调度器。

resume()

从上边知道,resume()是被schedule执行的,即从schedule流程,恢复到协程中。

那么恢复到原来流程的哪里呢?

其实就是恢复到yield()的下一条代码处。

由于我们有多个协程,每次执行resume后,恢复到的可能不是刚刚让出执行权给schedule的那个协程,但是由于schedule要管理所有的协程,总会在未来某个时刻轮到让出的协程。

在这里插入图片描述

yield和resume的实现方法

  yield和resume实际上类似于操作系统中对于中断与恢复的处理,要保存中断现场,执行中断处理,再恢复现场。

以下3种方式都可以实现上边的功能:
1、setjmp/longjmp:C语言中保存环境现场的库函数

  • 使用setjmp保存当前执行环境到jmp_buf,然后默认返回0。
  • 程序继续执行,到某个地方调用longjmp,传入上面保存的jmp_buf,以及另一个值。
  • 此时执行点又回到调用setjmp的返回处,且返回值变成longjmp设置的值。

2、ucontext:C语言中用于用户态上下文切换的另一个函数。

3、用汇编代码自己实现切换:即直接使用setjmp的底层源码实现

下面介绍具体原理:

首先:yield()和resume()可以由上下文切换的函数_swich()简化为:

  • yield = _swich(coprogress,schedule)
  • resume = _swich(schedule,coprogress)
//new_ctx[%rdi]:即将运行协程的上下文寄存器列表; cur_ctx[%rsi]:正在运行协程的上下文寄存器列表
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);

// yield让出
void nty_coroutine_yield(nty_coroutine *co) {
    _switch(&co->sched->ctx, &co->ctx);
}

// resume协程恢复执行
int nty_coroutine_resume(nty_coroutine *co) {
	//...
    nty_schedule * sched = nty_coroutine_get_sched();
    sched->curr_thread = co;
    _switch(&co->ctx, &co->sched->ctx);
    //...
}

  如何从一个协程切换到另一个协程呢?我们只需要将当前协程的上下文从寄存器组中保存下来;将下一个要运行的协程的上下文放到寄存器组上去,即可实现协程的切换。

在这里插入图片描述

汇编实现切换

对于X86_64的寄存器

  • %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数…(这里我们只需关注%rdi和%rsi)
  • %rbx,%rbp,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改
  • new_ctx是一个指针,指向一块内存,它现在存在%rid里面,同理cur_ctx存在%rsi里面
  • %rsp代表栈顶,%rbp代表栈底,%eip代表cpu下一条待取指令的地址(这也就是为什么resume之后会接着运行代码流程的原因)
//寄存器 cpu上下文
typedef struct _nty_cpu_ctx {
    void *rsp;//栈顶
    void *rbp;//栈底
    void *eip;//CPU通过EIP寄存器读取即将要执行的指令
    void *edi;
    void *esi;
    void *rbx;
    void *r1;
    void *r2;
    void *r3;
    void *r4;
    void *r5;
} nty_cpu_ctx;

//new_ctx[%rdi]:即将运行协程的上下文寄存器列表; cur_ctx[%rsi]:正在运行协程的上下文寄存器列表
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);
//默认x86_64
__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)     # save eip               \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                              \n"
"       movq 32(%rdi), %r12                              \n"
"       movq 24(%rdi), %rbx     # restore rbx,r12-r15    \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)       # restore eip            \n"
"       ret                     # 出栈,回到栈指针,执行eip指向的指令。\n"
);

3、协程流程中的方法定义

NtyCo中封装了4类接口:

  • 1、POSIX API中的socket函数的异步封装
  • 2、epoll函数的异步封装
  • 3、协程处理函数:resume、yield、sleep等
  • 4、调度器处理函数:create、run等
//1、socket

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);

//2、nty_epoller

int nty_epoller_create(void);

int nty_epoller_wait(struct timespec t);

int nty_epoller_ev_register_trigger();

void catstatus(int status);

//3、nty_coroutine

int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg);

void nty_coroutine_free(nty_coroutine *co);

void nty_coroutine_yield(nty_coroutine *co);

int nty_coroutine_resume(nty_coroutine *co);

void nty_coroutine_sleep(uint64_t msecs);

//4、nty_schedule

int nty_schedule_create(int stack_size);

void nty_schedule_free(nty_schedule *sched);

void nty_schedule_sched_wait(nty_coroutine *co, int fd, uint32_t events, uint64_t timeout);

nty_coroutine *nty_schedule_desched_wait(int fd);

void nty_schedule_sched_sleepdown(nty_coroutine *co, uint64_t msecs);

void nty_schedule_desched_sleepdown(nty_coroutine *co);

void nty_schedule_run(void);

nty_coroutine *nty_schedule_search_wait(int fd);

4、协程与调度器结构体定义

协程

一个协程会有哪些状态呢?如果协程sleep了,那么就是睡眠状态,如果协程刚创建出来,那它肯定是就绪状态,如果协程在等待数据的到来,那就是等待状态。这里这里定义协程的三个运行状态{就绪,睡眠,等待}。

  • 新创建的协程,加入就绪集合等待调度

  • IO未就绪的协程,加入等待集合等待epoll_wait

  • 有sleep操作的协程,加入睡眠集合

  • 就绪集合没有设置优先级,所以在就绪集合里面的协程优先级一样,那么就可以用队列来存储,先进先出

  • 等待集合就是等待IO准备就绪,这个等待IO是有时间长短的,需要按顺序存储,这里用红黑树来存储

  • 睡眠集合需要按照睡眠时间的长短进行唤醒,也要保证有序,所以也用红黑树存储,key为睡眠时长

  我们描述了每一个协程有自己的上下文环境,需要保存 CPU 的寄存器 ctx;需要有子过程的回调函数 func;需要有子过程回调函数的参数 arg;需要定义自己的栈空stack;需要有自己栈空间的大小 stack_size;需要定义协程的创建时间birth;需要定义协程当前的运行状态 status;需要定当前运行状态的结点(ready_next, wait_node, sleep_node);需要定义协程 id;需要定义调度器的全局对象 sched。

typedef struct _nty_coroutine {
    //cpu ctx
    nty_cpu_ctx ctx;
    // func
    proc_coroutine func;
    void *arg;
    // create time
    uint64_t birth;
    //stack
    void *stack;
    size_t stack_size;
    size_t last_stack_size;
    //status
    nty_coroutine_status status;
    //root
    nty_schedule *sched;
    //co id
    uint64_t id;
    //fd event
    int fd;
    uint16_t events;
    //sleep time
    uint64_t sleep_usecs;
    //set
    RB_ENTRY(_nty_coroutine) sleep_node;
    RB_ENTRY(_nty_coroutine) wait_node;
    TAILQ_ENTRY(_nty_coroutine) ready_node;
} nty_coroutine;

调度器

调度器是全局唯一的,使用单例模式实现:
Ntyco中使用pthread_once函数来实现的单例模式。

    //单例获取全局唯一的sched
    assert(pthread_once(&sched_key_once, nty_coroutine_sched_key_creator) == 0);

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

typedef struct _nty_schedule {
    // create time
    uint64_t birth;
    //cpu ctx
    nty_cpu_ctx ctx;
    //stack_size
    size_t stack_size;
    //coroutine num
    int spawned_coroutines;
    //default_timeout
    uint64_t default_timeout;
    //当前调度的协程
    struct _nty_coroutine *curr_thread;
    //页大小
    int page_size;
    //epoll fd
    int epfd;
    //线程通知相关,暂未实现
    int eventfd;
    //events
    struct epoll_event eventlist[NTY_CO_MAX_EVENTS];
    int num_new_events;
    //set
    nty_coroutine_queue ready;
    nty_coroutine_rbtree_sleep sleeping;
    nty_coroutine_rbtree_wait waiting;
} nty_schedule;

5、调度器的调度策略

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

  • 生产者消费者模式
    在这里插入图片描述
    代码实现逻辑:

在这里插入图片描述

  • 多状态运行
    在这里插入图片描述

*
NtyCo中借鉴 了nginx 的设计,是多状态运行的:
数据结构如下图所示:
在这里插入图片描述
Coroutine 就是协程的相应属性,status 表示协程的运行状态。sleep 与wait 两颗红黑树,ready 使用的队列,比如某协程调用 sleep 函数,加入睡眠树(sleep_tree),status |= S 即可。比如某协程在等待树(wait_tree)中,而 IO 准备就绪放入 ready 队列中,只需要移出等待树(wait_tree),状态更改 status &= ~W 即可。有一个前提条件就是不管何种运行状态的协程,都在就绪队列中,只是同时包含有其他的运行状态。

6、hook封装

  在上边,我们使用Nty_XXX()对原生的POSIX API进行了封装,但是如果跟mysql,redis建立连接,但是不去修改它们提供的客户端源码开发包的时候,就会发现连不上去,因为其源码用的是posix 原生api,recv和send。而协程用的是nty_recv()和nty_send()。两者之间没有关联。

  使用hook封装可以解决上述问题。
  hook提供了两个接口:

  • 1、dlsym()是针对系统的,系统原始的api
  • 2、dlopen()是针对第三方的库

使用dlsym()函数可以对系统原生API的调用进行拦截,转而执行我们给定的函数。
比如经过下面的语句后:

connect_f = dlsym(RTLD_NEXT, "connect");

原来的系统调用connect,被改名为connect_f, 所以原来的名字connect就空出来了,交由我们用户实现。
在这里插入图片描述

#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <dlfcn.h>
#include<mysql/mysql.h>
typedef int (*connect_t)(int, struct sockaddr *, socklen_t);

connect_t connect_f;

typedef ssize_t (*recv_t)(int, void *buf, size_t, int);

recv_t recv_f;

typedef ssize_t (*send_t)(int, const void *buf, size_t, int);

send_t send_f;

typedef ssize_t (*read_t)(int, void *buf, size_t);

read_t read_f;

typedef ssize_t (*write_t)(int, const void *buf, size_t);

write_t write_f;

int connect(int fd, struct sockaddr *name, socklen_t len) {
    printf("in connect\n");
    return connect_f(fd, name, len);
}

ssize_t recv(int fd, void *buf, size_t len, int flags) {
    printf("in recv\n");
    return recv_f(fd, buf, len, flags);
}

ssize_t send(int fd, const void *buf, size_t len, int flags) {
    printf("in send\n");
    return send_f(fd, buf, len, flags);
}
ssize_t read(int fd, void *buf, size_t len) {
    printf("in read\n");
    return read_f(fd, buf, len);
}

ssize_t write(int fd, const void *buf, size_t len) {
    printf("in write\n");
    return write_f(fd, buf, len);
}

// 拦截原生api,替换为我们指定的
static int init_hook() {
    connect_f = dlsym(RTLD_NEXT, "connect");
    recv_f = dlsym(RTLD_NEXT, "recv");
    send_f = dlsym(RTLD_NEXT, "send");
    read_f = dlsym(RTLD_NEXT, "read");
    write_f = dlsym(RTLD_NEXT, "write");
}

void main() {
    init_hook();
    MYSQL *m_mysql = mysql_init(NULL);
    if (!m_mysql) {
        printf("mysql_init failed\n");
        return;
    }
    if (!mysql_real_connect(m_mysql, "192.168.109.1", "root", "123456", "cdb", 3306, NULL, 0)) {
        printf("mysql_real_connect failed\n");
        return;
    }
    else {
        printf("mysql_real_connect success\n");
    }
}
//gcc -o hook hook.c -lmysqlclient -I /usr/include/mysql/ -ldl

如果我们的协程ntyco要和mysql做在一起,我们在不修改mysql-dev的前提下,只要把原生的POSIX API做成hook即可。

7、性能测试

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

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

为了达到百万连接的TCP,根据连接的五元组:
1、客户端ip地址
2、客户端端口号
3、协议
4、服务端端口号
5、服务端ip地址

由于我们只有4台机器,因此服务器ip地址数为1,客户端ip地址数为3;

Linux中有限定端口的使用范围:60999 - 32768 = 2.8w

因此一台客户机可开放2.8w个端口。

服务器端我们监听100个端口。

根据五元组有:3 × 2.8w×1×100*1 = 840w

即理论上能够达到840w的连接。实际测试中,如果服务器连接数到了100w我们主动暂停了。能否达到理论上的数值也与实际服务器和客户机的Linux内核中的各种参数设置有关,如:系统默认允许打开文件描述符数量个数、连接队列大小、IO缓存栈大小等相关。

下面是实操时的一些问题记录:

error:Too many open files

程序执行到一半:创建了1023个连接后,报错Too many open files

怀疑是文件系统默认允许打开的文件描述符个数(默认1024)的限制。
使用ulimt -a查看open files

open files:一个进程能够打开文件描述符的数量。
在这里插入图片描述
解决方法:
1、临时修改,只在当前会话有效:ulimit -n 1048576
2、永久修改,对所有会话有效。

  • vim /etc/security/limits.conf查看系统配置
  • 添加:

在这里插入图片描述
软限制:超出软限制会发出警告
硬限制:绝对限制,在任何情况下都不允许用户超过这个限制

  • reboot重启

error:Cannot assign requested address

客户端运行过程中报错,Cannot assign requested address,这代表着客户端端口耗尽。
在这里插入图片描述
我们看到大概创建了2.8w的fd , 可是我们知道端口一个有6w多个,也就是说有6w个端口,为什么我们只使用了2.8w个?

Linux中有限定端口的使用范围:60999 - 32768 = 2.8w ,与我们上面实验结果相符。

也就是说客户端可用端口耗尽,因此,后边我们在服务器端监听了100个端口,那么一个客户端的连接就可以理论上达到280w。

error : Connection timed out

我们将服务器端口开100个,按理说客户端可以连280w,但是现在只连接到13w就error : Connection timed out,与我们的预期不符
在这里插入图片描述
网卡接收的数据,会发送到协议栈里面,通过sk_buff将数据传到协议栈,协议栈处理完再交给应用程序。由于操作系统在使用的时候,为防止被攻击,在数据发送给协议栈之前进行一个过滤,在协议栈前面加了一个小组件:过滤器,叫做netfilter

netfilter主要是对网络数据包进行一个过滤,在netfilter的基础上我们就可以实现防火墙,在linux里面有一个就叫做iptables,iptables是基于netfilter做的,iptables分为两部分,一部分是内核实现的netfilter接口,一部分是应用程序提供给用户使用的。iptables真正实现的是netfilter提供的接口。
在这里插入图片描述

Connection timed out译为连接超时,也就是说,client发送的请求超时了,那么这个超时有两种情况,第一种:三次握手第一次的SYN没发出去,第二种:三次握手第二次ACK没收到。

在这里插入图片描述
netfilter不管对发送的数据,还是对接收的数据,都是可以过滤的。当连接数量达到一定数量的时候,netfilter就会不允许再对外发连接了。所以现在推测是情况1造成的,发送的SYN被netfilter拦截了。

事实是这样吗,我们来查看一下netfilter允许对外最大连接数量是多少。13w,与我们上面建立成功的数量一致,所以现在就可以确定是netfilter允许对外开放的最大连接数造成的了

slave1@ubuntu:cat /proc/sys/net/netfilter/nf_conntrack_max
131072

因此可以通过设置netfilter允许对外最大连接数量来解决该问题。

在这里插入图片描述

虚拟机内存不够用

连接跑着跑着vscode的ssh连接就断了,查看虚拟机发现内存爆掉了。

8、可以挖掘的问题

(1)、select、poll、epoll的区别

  • select使用fd_set存放fd,内核需要将消息传递到用户空间,都需要内核拷贝操作,需要维护一个用来存放大量fd的数据结构,使得用户空间和内核空间在传递该结构时的复制开销很大,每次调用select时,都需要把fd集合从用户态拷贝到内核态,主动轮询检测fd
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值