reactor模式代码实现
这是对之前文章实现的服务器的一个完整的封装,之前我们的实现过程并没有用到这些回调函数,就是没有接入具体的业务逻辑。于是我们将整个代码再进行一个改良,使之能够简单的接入各种类型的业务。
typedef struct sock_item{ //conn_item
int fd;//句柄
char *rbuffer;
int rlength;
char *wbuffer;
int wlength;
int event; //事件
//回调函数
// void (*recv_cb)(int fd,char *buffer,int length );
// void (*send_cb)(int fd,char *buffer,int length );
// void (*accept_cb)(int fd,char *buffer,int length);
}sock_item;
1 代码
完整的代码我还是放在github,这里讲解具体的实现逻辑
2 如何实现
2.1 变量定义部分
我们首先定义一些用到的常量
#define BUFFER_LENGTH 1024 //读写缓冲区大小
#define MAX_EPOLL_EVENTS 1024 //epoll池大小
#define SERVER_PORT 8888 //默认监听端口
#define PORT_COUNT 1 //默认监听端口的数量
typedef int NCALLBACK(int,int,void*); //回调函数形式
然后定义几个要用到的结构体
fd对应的控制块
以fd为索引,存有事件信息,fd对应的缓冲区和fd对应的回调函数
struct ntyevent
{
/* data */
int fd;
int events; //事件
void *arg; //回调函数参数
int (*callback)(int fd,int events,void *arg);
int status; //ntyevent是否是第一次添加在epoll池中
char rbuffer[BUFFER_LENGTH];
char wbuffer[BUFFER_LENGTH];
int rlength;
int wlength;
//http
};
eventblock这个结构体用来动态扩容,可以看作储存了大量ntyevent的数组,同时有指向下一个eventblock的指针
struct eventblock
{
/* data */
struct eventblock *next;
struct ntyevent *events;
};
ntyreactor 这个结构体对应一个reactor,存储了一个 eventblock链表,一个epoll池
struct ntyreactor {
int epfd;
int blkcnt;
struct eventblock *evblks;
};
2.2 epoll池中fd的状态控制部分
因为我们在epoll事件循环过程中需要不断改变每个fd的状态信息,比如监听fd的读还是写事件,或者将fd加入或者移除epoll池中,我们将这些操作封装成函数。
实现nty_event_set(), nty_event_add(),nty_event_del() 三个函数。
int nty_event_add(int epfd,int events,struct ntyevent *ev){
struct epoll_event ep_ev ={0,{0}}; //结构体初始化
ep_ev.data.ptr=ev; //在epoll_event中储存控制块的指针
ep_ev.events=ev->events=events; //修改epoll和我们定义的两个控制块的事件
int op;
if(ev->status==1){ //刚开始是0 加入epoll池中变为1
op=EPOLL_CTL_MOD;
}else{ //是0就加入
op=EPOLL_CTL_ADD;
ev->status=1;
}
//调用epoll_ctl
if(epoll_ctl(epfd,op,ev->fd,&ep_ev)<0){
printf("event add failed [fd=%d], events[%d]\n", ev->fd, events);
return -1;
}
return 0;
}
先来看看add函数的实现,将对应fd加入到epoll池中,也可能是修改fd的状态,ntyevent中的status用来标识fd是否在epoll池中,用来判断是对fd进行ADD还是MOD修改,在del函数中我们会将status复位。
int nty_event_del(int epfd, struct ntyevent *ev){
struct epoll_event ep_ev ={0,{0}}; //结构体初始化
if(ev->status!=1){ //对应fd不在epoll池中
return -1;
}
ep_ev.data.ptr=ev; //在epoll_event中储存控制块的指针
ev->status=0;
epoll_ctl(epfd,EPOLL_CTL_DEL,ev->fd,&ep_ev);
return 0;
}
del函数,将fd从epoll池中删除,也没什么要讲的。
void nty_event_set(struct ntyevent *ev,int fd,NCALLBACK callback,void *arg){
if(ev!=NULL){
ev->fd = fd;
ev->callback = callback;
ev->events = 0;
ev->arg = arg;
}
return ;
}
set函数,给fd对应的控制块设置一些信息和回调。这里 ev->events =0,没有传入事件,因为这里还没有去监听fd的状态,也就是还没有将fd放入epoll池中进行监听。
2.2 动态扩容相关
//动态扩容
int ntyreactor_alloc(struct ntyreactor *reactor) {
if (reactor == NULL) return -1;
if (reactor->evblks == NULL) return -1;
struct eventblock *blk = reactor->evblks;
while (blk->next != NULL) {
blk = blk->next;
}
struct ntyevent* evs = (struct ntyevent*)malloc((MAX_EPOLL_EVENTS) * sizeof(struct ntyevent));
if (evs == NULL) {
printf("ntyreactor_alloc ntyevent failed\n");
return -2;
}
memset(evs, 0, (MAX_EPOLL_EVENTS) * sizeof(struct ntyevent));
struct eventblock *block = malloc(sizeof(struct eventblock));
if (block == NULL) {
printf("ntyreactor_alloc eventblock failed\n");
return -3;
}
block->events = evs;
block->next = NULL;
blk->next = block;
reactor->blkcnt ++;
return 0;
}
//通过fd找到对应的ntyevent
struct ntyevent *ntyreactor_idx(struct ntyreactor *reactor, int sockfd) {
if (reactor == NULL) return NULL;
if (reactor->evblks == NULL) return NULL;
int blkidx = sockfd / MAX_EPOLL_EVENTS;
while (blkidx >= reactor->blkcnt) {
ntyreactor_alloc(reactor);
}
int i = 0;
struct eventblock *blk = reactor->evblks;
while (i++ != blkidx && blk != NULL) {
blk = blk->next;
}
return &blk->events[sockfd % MAX_EPOLL_EVENTS];
}
动态扩容相关的两个函数,一个是用来在reactor中扩充一个eventblock节点,一个是用来在一个reactor的eventblock链表中通过fd找到对应的控制块,这里两个函数在前面都有讲过,这里代码没什么变化不再展开。可以看下面这个图,调用一次ntyreactor_alloc函数我们就在链表中分配一个eventblock节点的空间。
在通过ntyreactor_idx查找对应fd出现越界时,我们就调用ntyreactor_alloc函数进行扩容。
2.3 reactor运行的几个核心函数
主要是 ntyreactor_init, ntyreactor_destory,ntyreactor_addlistener,ntyreactor_run 四个函数。我们可以先来看main函数的代码。
ntyreactor_init 主要负责初始化一个reactor,就是创建和分配资源
ntyreactor_destory 负责在最后对reactor的资源进行回收
ntyreactor_addlistener 负责给fd注册回调事件,主要是找到对应fd的控制块并且设置回调函数和参数。
ntyreactor_run就是启动reactor的事件循环,开始监听
int main(int argc,char * argv[]){
//先创建reactor
struct ntyreactor *reactor=(struct ntyreactor *) malloc(sizeof (struct ntyreactor));
ntyreactor_init(reactor);
unsigned short port =SERVER_PORT;
if(argc==2){ //手动指定了端口
port=atoi(argv[1]);
}
//创建监听的socket
int i=0;
int sockfds[PORT_COUNT]={0};
for(int i=0;i<PORT_COUNT;i++){
sockfds[i]=init_sock(port+i);
//绑定监听事件 将fd加入epoll池
ntyreactor_addlistener(reactor,sockfds[i],EPOLLIN,accept_cb);
}
ntyreactor_run(reactor);
ntyreactor_destory(reactor);
//释放资源
for(int i=0;i<PORT_COUNT;i++){
close(sockfds[i]);
}
free(reactor);
}
通过main函数我们能够看清整个运行的逻辑。
初始化reactor和listenfd,然后我们给最初的listenfd注册监听回调accept_cb,然后启动reactor的事件循环,最后释放资源。
注意ntyreactor_addlistener只是将回调函数放入了fd的控制块中,并不立刻执行,在ntyreactor_run中才会真正执行listenfd的accept函数。
//初始化reactor
int ntyreactor_init(struct ntyreactor *reactor) {
//需要初始化的东西 epfd
if (reactor == NULL) return -1;
memset(reactor, 0, sizeof(struct ntyreactor));
//epfd;
reactor->epfd = epoll_create(1);
if (reactor->epfd <= 0) {
printf("create epfd in %s err %s\n", __func__, strerror(errno));
return -2;
}
//申请空间 感觉不需要申请
//需要申请 不然影响后面的函数
struct ntyevent* evs = (struct ntyevent*)malloc((MAX_EPOLL_EVENTS) * sizeof(struct ntyevent));
if (evs == NULL) {
printf("create epfd in %s err %s\n", __func__, strerror(errno));
close(reactor->epfd);
return -3;
}
memset(evs, 0, (MAX_EPOLL_EVENTS) * sizeof(struct ntyevent));
struct eventblock *block = malloc(sizeof(struct eventblock));
if (block == NULL) {
free(evs);
close(reactor->epfd);
return -3;
}
block->events = evs;
block->next = NULL;
reactor->evblks = block;
reactor->blkcnt = 1;
return 0;
}
这里主要就是做两件事,一个是创建epoll池,一个是申请一个初始的eventblock。
//主要是释放资源
int ntyreactor_destory(struct ntyreactor *reactor) {
close(reactor->epfd);
struct eventblock * blk=reactor->evblks;
struct eventblock * next;
while(blk!=NULL){
next=blk->next;
free(blk->events);
free(blk);
blk=next;
}
return 0;
}
对应的这里主要也是做两件事,一个是回收epoll池的空间,一个是回收整个eventblock链表的空间。
这里跟前面的实现有区别的是。
注意:
我们的ntyevent结构体中的缓冲区是直接数组来表示。这和直接用指针来表示有什么区别呢,直接用数组的话,分配ntyevent结构体的空间时,缓冲区的空间也被分配了,之前的代码中我是用的指针,那么我们就还需要先为缓冲区分配一次空间,再分配ntyevent结构体的空间。这里我们没有这么做。
所以注意ntyreactor_destory函数中我们只需要 free(blk->events); 直接销毁单个eventblock中的整个ntyevent数组,再销毁单个eventblock。
如果是用指针的形式,我们需要先遍历单个eventblock中的整个ntyevent数组,分别销毁每个ntyevent中的读写缓冲区空间。
现在的实现
struct ntyevent
{
...
char rbuffer[BUFFER_LENGTH];
char wbuffer[BUFFER_LENGTH];
...
};
之前的实现
struct ntyevent
{
...
char rbuffer *;
char wbuffer *;
...
};
接下来看看设置监听函数的实现
//传入fd 监听事件 监听回调 设置监听
int ntyreactor_addlistener(
struct ntyreactor *reactor, int sockfd,int events,NCALLBACK *acceptor) {
if (reactor == NULL) return -1;
if (reactor->evblks == NULL) return -1;
//查找到对应的event控制块
struct ntyevent *event = ntyreactor_idx(reactor, sockfd);
//设置回调 set方法并没有传入监听事件
nty_event_set(event,sockfd,acceptor,reactor);
//传入监听事件 调用epoll_ctl
nty_event_add(reactor->epfd,events,event);
return 0;
}
其实就是通过fd,找到对应的fd控制块,然后我们通过前面设置的set和add方法为控制块设置回调,然后将fd加入epoll监听池中。
然后是整个核心循环流程
//启动reactor
int ntyreactor_run(struct ntyreactor *reactor) {
if (reactor == NULL) return -1;
if (reactor->epfd < 0) return -1;
struct epoll_event events[MAX_EPOLL_EVENTS];
int i;
while(1){
int nready =epoll_wait(reactor->epfd,events,MAX_EPOLL_EVENTS,1000);
if(nready<0){
printf("epoll_wait error, exit\n");
continue;
}
//循环处理事件
for(i=0;i<nready;i++){
//直接从epoll_event中拿到对应的控制块
struct ntyevent *ev=(struct ntyevent*)events[i].data.ptr;
// 什么时候处理
if((events[i].events&EPOLLIN)&&(ev->events&EPOLLIN)||
(events[i].events&EPOLLOUT)&&(ev->events&EPOLLOUT)
){
ev->callback(ev->fd,events[i].events,ev->arg);
}
}
}
return 0;
}
这里和之前的实现大同小异,在while循环中调用epoll_wait进行IO多路复用,然后循环处理就绪事件。
唯一的区别是我们在循环内不用分别对于不同的处理实现不同的代码,或者说对于fd的不同处理应该由用户来决定实现,这体现在fd控制块的回调函数中,我们在循环内只负责查找控制块,调用控制块的回调函数,而不关心回调函数如何实现。
核心是这一句代码,实现了对于每种fd我们调用不同的回调函数。
ev->callback(ev->fd,events[i].events,ev->arg);
2.4 回调函数的实现
这里实现了三个回调函数,recv_cb,send_cb,accept_cb,很容易理解就是读写事件的回调和接收连接的回调,这里只写了三个,但是基于不同的任务我们可以增加多种回调。
这里我们主要是将每个fd的处理抽离出来,变成一个个不同的回调函数。其他的部分我们都交给epoll池去管理,我们这里只需要关注当事件就绪时我们如何实现回调函数。
//接收客户端连接的回调
int accept_cb(int fd, int events, void *arg) {
//先判断
struct ntyreactor *reactor =(struct ntyreactor *)arg;
if (reactor == NULL) return -1;
//然后接受连接 拿到用户端fd
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int clientfd;
if ((clientfd = accept(fd, (struct sockaddr*)&client_addr, &len)) == -1) {
if (errno != EAGAIN && errno != EINTR) {
}
printf("accept: %s\n", strerror(errno));
return -1;
}
printf("client %d join\n",clientfd);
fflush(stdout);
//接着改为非阻塞IO
int flag =0;
if((flag=fcntl(clientfd,F_SETFL,O_NONBLOCK))<0){
printf("%s: fcntl nonblocking failed, %d\n", __func__, MAX_EPOLL_EVENTS);
return -1;
}
//最后将客户端的fd加入poll池 且初始化fd的控制块
//先查找到控制块
struct ntyevent *event = ntyreactor_idx(reactor, clientfd);
if (event == NULL) return -1;
nty_event_set(event,clientfd,recv_cb,reactor);
nty_event_add(reactor->epfd,EPOLLIN,event);
//下面是打印时间的操作
if (curfds++ % 1000 == 999) {//每1000个打印一次 记录一下连接1000个客户端所需要的时间
struct timeval tv_cur;
memcpy(&tv_cur, &tv_bgin, sizeof(struct timeval));
gettimeofday(&tv_bgin, NULL);
int time_used = TIME_SUB_MS(tv_bgin, tv_cur);
printf("connections: %d, sockfd:%d, time_used:%d\n", curfds, clientfd, time_used);
}
}
这个回调函数在listenfd接收到了客户端的请求时被调用。
实现的逻辑是,分配socket资源,创建fd的控制块,然后将fd的读事件注册进入epoll池,并且设置好fd的读回调函数。
最后我们实现了一个打印时间的操作,与核心功能无关,是为了后续测试连接1000个客户端所需要的时间,来判断服务器的承载能力。
//接收数据的函数
int recv_cb(int fd, int events, void *arg) {
//通过fd找到对应的控制块
struct ntyreactor *reactor = (struct ntyreactor*)arg;
struct ntyevent *ev = ntyreactor_idx(reactor, fd);
if (ev == NULL) return -1;
//调用resv函数
int len = recv(fd, ev->rbuffer, BUFFER_LENGTH, 0);
nty_event_del(reactor->epfd, ev);//为什么先要移除监听?不是直接可以修改
if(len>0){
ev->rlength = len;
ev->rbuffer[len] = '\0'; //设置结束符号
printf("resv from %d %s",fd,ev->rbuffer);
fflush(stdout);
nty_event_set(ev, fd, send_cb, reactor); //设置发送数据的回调
nty_event_add(reactor->epfd, EPOLLOUT, ev); //监听发送
}else if(len==0){
nty_event_del(reactor->epfd, ev); //移除监听
close(ev->fd); //释放连接
}else{ //出错
if (errno == EAGAIN && errno == EWOULDBLOCK) { //
//表示资源暂时不可用。在非阻塞套接字上调用 recv 函数时,
//如果没有数据可读,recv 函数会返回 EAGAIN 或 EWOULDBLOCK 错误码,
//表示暂时没有数据可用。这并不表示发生了错误,而是需要稍后再次尝试读取数据。
} else if (errno == ECONNRESET){
//:这个错误码表示连接被对方重置(reset)。
//在网络编程中,当远程主机(对方)意外关闭连接或发生连接中断时,
//本地套接字可能会接收到 ECONNRESET 错误码。这通常表示连接的一方意外
//终止了连接,可能是由于网络故障、超时、或对方进程崩溃等原因
nty_event_del(reactor->epfd, ev); //移除监听
close(ev->fd); //释放连接
}
}
return len;
}
这个回调函数在服务器接收到了fd的可读信号时被调用。
核心是调用了recv函数将数据从协议栈中拿到缓冲区,并且打印出来。然后将fd的监听修改为写事件,并且注册写事件的回调函数。
len==0的判断是为了判断客户端是否断开连接。如果断开连接我们将fd移除epoll池,并且调用close释放连接,否则会导致大量close_wait状态的产生。
//发送数据的函数
int send_cb(int fd, int events, void *arg) {
//先拿到控制块
struct ntyreactor *reactor = (struct ntyreactor*)arg;
struct ntyevent *ev = ntyreactor_idx(reactor, fd);
if (ev == NULL) return -1;
printf("buffer %s length %d\n",ev->rbuffer,ev->rlength);
int len = send(fd, ev->rbuffer, ev->rlength, 0);
printf("send to %d %d byte",fd,len);
fflush(stdout);
if(len>0){
nty_event_del(reactor->epfd, ev);
nty_event_set(ev, fd, recv_cb, reactor);
nty_event_add(reactor->epfd, EPOLLIN, ev);
}else{//出错 关闭连接
nty_event_del(reactor->epfd, ev);
close(ev->fd);
}
return len;
}
这个回调函数在服务器接收到了fd的可写信号时被调用。
核心是调用了send函数将数据写入协议栈,然后将fd的监听修改为读事件,并且注册读事件的回调函数。
也加入了len小于0的出错判断。
2.5 主函数
最后我们再看一下main函数的流程,就比较清楚了。
int main(int argc,char * argv[]){
//先创建reactor
struct ntyreactor *reactor=(struct ntyreactor *) malloc(sizeof (struct ntyreactor));
ntyreactor_init(reactor);
unsigned short port =SERVER_PORT;
if(argc==2){ //手动指定了端口
port=atoi(argv[1]);
}
//创建监听的socket
int i=0;
int sockfds[PORT_COUNT]={0};
for(int i=0;i<PORT_COUNT;i++){
sockfds[i]=init_sock(port+i);
//绑定监听事件 将fd加入epoll池
ntyreactor_addlistener(reactor,sockfds[i],EPOLLIN,accept_cb);
}
ntyreactor_run(reactor);
ntyreactor_destory(reactor);
//释放资源
for(int i=0;i<PORT_COUNT;i++){
close(sockfds[i]);
}
free(reactor);
}
我们初始化reactor和listenfd,然后我们给最初的listenfd注册监听回调accept_cb,然后启动reactor的事件循环,最后释放资源。
2.6 实现百万连接
还需要提的是init_sock()这个函数
为什么会有这个函数,我们的初衷是为了实现百万连接的服务器。
先介绍一下四元组的概念。
TCP连接四元组是由源IP地址、源端口、目的IP地址和目的端口构成。如果四元组任何一个发生变化,那就是一条新的连接。
想详细了解的可以看这篇知乎一台主机上只能保持最多 65535 个 TCP 连接吗?
我们知道一台主机只有65535个端口,那如果我们让一个客户端的每一个端口都与我们的服务端建立socket连接,也只能产生6w条连接。假如我们只有一台客户端用来测试。如何测试百万的并发量。
那我们其实只需要改变目的端口就行了,所以我们让服务器开多个端口来监听,那么每个端口最多也能收到6w条连接,理论上来说,只要硬件设施够,开十多个端口就能够实现百万连接。
所以init_sock()实际上就是用来申请监听每个端口的listenfd的socket空间。打开一个本地端口。
init_sock
//监听服务端的一个本地端口
int init_sock(short port){
//先创建socket 然后bind绑定本地端口 ,然后调用listen
int fd=socket(AF_INET,SOCK_STREAM,0);
//服务端的accept阻不阻塞感觉没什么影响,因为主要是epoll_wait阻塞了,
//epoll_wait结束阻塞时直接调用accept接受连接就行
fcntl(fd, F_SETFL, O_NONBLOCK); //设置非阻塞型IO
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family=AF_INET;
server_addr.sin_addr.s_addr=htonl(INADDR_ANY);
server_addr.sin_port = htons(port);
//bind绑定
bind(fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
//listen
if(listen(fd,20)<0){
printf("listen failed : %s\n", strerror(errno));
return -1;
}
printf("listen server port : %d\n", port);
//记录当前时间
gettimeofday(&tv_bgin,NULL);
return fd;
}
3 接下来的工作
整个IO复用的框架我们也算是搭好了,接下来我们可以在这个基础上继续实现一些应用层协议。如http协议,websocket协议,kcp协议等等。