layout: post
title: 纯c实现的协程框架NtyCo
description: 纯c实现的协程框架NtyCo
tag: 项目实战
记录在网上学习的一个基于协程实现的百万并发TCPsever的项目-NtyCo
项目链接
在CS,BS的开发模式下,服务器的吞吐量是一个受关注的参数。吞吐量等于1秒内业务处理的次数,那么这个业务处理其实是由网络IO时间 + 业务处理时间组成的。
不同的业务,其业务处理时间是不同的,所以对于业务处理时间的优化,要根据业务场景来优化。而网络IO时间是可以通过采用统一的措施来进行优化的。
所谓的提高IO处理性能,即对数据的recv和send进行优化,对于基于epoll实现的响应式服务器来说,所有客户端的操作都是源于这个下边这个大循环:
1、获取epoll中的就绪事件,遍历就绪事件;
2、如果就绪事件fd与监听fd一致:有新的连接需要接待;
3、否则说明是IO事件(需要执行读或者写),统一用handle()表示IO的处理。
对于服务器处理网络IO,handle(sockfd) 的实现有两种方式。第一种,IO同步;第二种,IO异步。
handle(sockfd)函数内部对 sockfd 进行读写动作。代码如下:
handle 的 io 操作(send,recv)与 epoll_wait 是在同一个处理流程里面的。这就是 IO 同步操作。
IO同步的优点:
IO同步的缺点:
handle(sockfd)函数内部将 sockfd 的操作,push 到线程池中,代码
如下:
Handle 函数是将 sockfd 处理方式放到另一个已经其他的线程中运行,如此做法,将 io 操作(recv,send)与 epoll_wait 不在一个处理流程里面,使得 io操作(recv,send)与 epoll_wait 实现解耦。这就叫做 IO 异步操作。
IO异步的优点:
缺点:
两种处理方式对比:
IO处理是一种高频,但是CPU开销和内存占用都比较小的处理操作,使用多线程异步处理解决了高频的响应速度问题,但是创建线程的开销对于一个IO处理而言又太大了,因此有了协程的概念——一种用户态的轻量级线程
有没有一种方式,有异步性能,同步的代码逻辑。来方便编程人员对 IO 操作的组件呢? 有,采用一种轻量级的线程
——协程
来实现。
协程对IO处理的方案如下:
1、首先也是将子任务recv和send模块化,各个模块的执行由调度器决定:
2、使用调度器管理协程,每当需要处理IO时,resume(恢复)到协程中,处理完毕,协程再yield(让出)处理。
在协程的上下文 IO 异步操作(nty_recv,nty_send)函数,步骤如下:
先来理解yield的含义,让出,将当前的执行流程让出,让出给调度器。
schedule调度器做什么事情呢?
调度器就是io检测,调度器就是不断的调用epoll_wait,来检测哪些fd准备就绪了,然后就恢复相应fd的执行流程执行现场。
注意schedule不是原语,schedule是调度器。
从上边知道,resume()是被schedule执行的,即从schedule流程,恢复到协程中。
那么恢复到原来流程的哪里呢?
其实就是恢复到yield()的下一条代码处。
由于我们有多个协程,每次执行resume后,恢复到的可能不是刚刚让出执行权给schedule的那个协程,但是由于schedule要管理所有的协程,总会在未来某个时刻轮到让出的协程。
yield和resume实际上类似于操作系统中对于中断与恢复的处理,要保存中断现场,执行中断处理,再恢复现场。
以下3种方式都可以实现上边的功能:
1、setjmp/longjmp:C语言中保存环境现场的库函数
2、ucontext:C语言中用于用户态上下文切换的另一个函数。
3、用汇编代码自己实现切换:即直接使用setjmp的底层源码实现
下面介绍具体原理:
首先:yield()和resume()可以由上下文切换的函数_swich()简化为:
//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的寄存器
//寄存器 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"
);
NtyCo中封装了4类接口:
//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);
一个协程会有哪些状态呢?如果协程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;
调度器的实现,有两种方案,一种是生产者消费者模式,另一种多状态运行。
NtyCo中借鉴 了nginx 的设计,是多状态运行的:
数据结构如下图所示:
Coroutine 就是协程的相应属性,status 表示协程的运行状态。sleep 与wait 两颗红黑树,ready 使用的队列,比如某协程调用 sleep 函数,加入睡眠树(sleep_tree),status |= S 即可。比如某协程在等待树(wait_tree)中,而 IO 准备就绪放入 ready 队列中,只需要移出等待树(wait_tree),状态更改 status &= ~W 即可。有一个前提条件就是不管何种运行状态的协程,都在就绪队列中,只是同时包含有其他的运行状态。
在上边,我们使用Nty_XXX()对原生的POSIX API进行了封装,但是如果跟mysql,redis建立连接,但是不去修改它们提供的客户端源码开发包的时候,就会发现连不上去,因为其源码用的是posix 原生api,recv和send。而协程用的是nty_recv()和nty_send()。两者之间没有关联。
使用hook封装可以解决上述问题。
hook提供了两个接口:
使用dlsym()函数可以对系统原生API的调用进行拦截,转而执行我们给定的函数。
比如经过下面的语句后:
connect_f = dlsym(RTLD_NEXT, "connect");
原来的系统调用connect,被改名为connect_f, 所以原来的名字connect就空出来了,交由我们用户实现。
#define _GNU_SOURCE
#include
#include
#include
#include
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即可。
测试环境: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缓存栈大小等相关。
下面是实操时的一些问题记录:
程序执行到一半:创建了1023
个连接后,报错Too many open files
怀疑是文件系统默认允许打开的文件描述符个数(默认1024)的限制。
使用ulimt -a
查看open files
open files:一个进程能够打开文件描述符的数量。
解决方法:
1、临时修改,只在当前会话有效:ulimit -n 1048576
2、永久修改,对所有会话有效。
vim /etc/security/limits.conf
查看系统配置
软限制:超出软限制会发出警告
硬限制:绝对限制,在任何情况下都不允许用户超过这个限制
客户端运行过程中报错,Cannot assign requested address,这代表着客户端端口耗尽。
我们看到大概创建了2.8w的fd , 可是我们知道端口一个有6w多个,也就是说有6w个端口,为什么我们只使用了2.8w个?
Linux中有限定端口的使用范围:60999 - 32768 = 2.8w ,与我们上面实验结果相符。
也就是说客户端可用端口耗尽,因此,后边我们在服务器端监听了100个端口,那么一个客户端的连接就可以理论上达到280w。
我们将服务器端口开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连接就断了,查看虚拟机发现内存爆掉了。