四种网络IO模型与高并发服务器实现

原创 2018年04月16日 12:13:22

同步:必须等待IO操作完成,控制权才返回给用户进程;

异步:无需等待IO操作完成,控制权便返回给用户进程。

当一个read操作发生时,它会经历两个阶段:1:等待数据准备(到内核)2:将数据从内核拷贝到用户进程中。

服务器端套接字的创建有两次:开始的时候创建一个用于监听;accept()的时候返回一个新的socket!

fctl()函数将套接字设置为非阻塞状态。

下边是多路复用io的几个模型

这个模型和阻塞IO模型其实并没有太大的不同,事实上还更差一些,但是它可以同时处理多个链接。

一:select()

使用select函数时,最关键的地方是如何维护select()的三个参数readfds,writefds和execptfds。

作为输入参数,readfds应该标记所有需要检测的“可读事件”的句柄,其中永远包括那个检测connect()的那个“母”句柄;

同时,writefs和execptfds应该标记所有需要检测的“可写事件”和“错误事件”的句柄。

作为输出参数,readfs,writefds和exceptfds中保存了保存了select()捕捉到的所有事件的句柄。程序猿需要检测所有的标记位,以确定到底哪些句柄发生了事件。

缺点:

1:当句柄较大时,select()接口需要消耗大量的时间去轮询各个句柄。

2:该模型将事件探测和事件响应夹在一起,一旦事件响应的执行体过于庞大,则对整个模型都是灾难性的。

   int select() 返回值:准备就绪的文件描述符数,超时返回0,错误返回-1;


服务端代码如下:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/wait.h>
#include <string.h>
#include <errno.h>
#define DEFAULT_PORT 6666
int main( int argc, char ** argv){
    int serverfd,acceptfd; /* 监听socket: serverfd,数据传输socket: acceptfd */
    struct sockaddr_in my_addr; /* 本机地址信息 */
    struct sockaddr_in their_addr; /* 客户地址信息 */
    unsigned int sin_size, myport=6666, lisnum=10;
    if ((serverfd = socket(AF_INET , SOCK_STREAM, 0)) == -1) {
       perror("socket" );
       return -1;
    }
    printf("socket ok \n");
    my_addr.sin_family=AF_INET;
    my_addr.sin_port=htons(DEFAULT_PORT);
    my_addr.sin_addr.s_addr = INADDR_ANY;
    bzero(&(my_addr.sin_zero), 0);
    if (bind(serverfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr )) == -1) {
        perror("bind" );
        return -2;
    }
    printf("bind ok \n");
    if (listen(serverfd, lisnum) == -1) {
        perror("listen" );
        return -3;
    }
    printf("listen ok \n");
	
	fd_set client_fdset;	/*监控文件描述符集合*/
	int maxsock;            /*监控文件描述符中最大的文件号*/
	struct timeval tv;		/*超时返回时间*/
	int client_sockfd[5];   /*存放活动的sockfd*/
	bzero((void*)client_sockfd,sizeof(client_sockfd));
	int conn_amount = 0;    /*用来记录描述符数量*/
	maxsock = serverfd;
	char buffer[1024];
	int ret=0;
	while(1){
		/*初始化文件描述符号到集合*/
		FD_ZERO(&client_fdset);
		/*加入服务器描述符*/
	FD_SET(serverfd,&client_fdset);
	/*设置超时时间*/
	tv.tv_sec = 30; /*30秒*/
	tv.tv_usec = 0;
	/*把活动的句柄加入到文件描述符中*/
	for(int i = 0; i < 5; ++i){
/*程序中Listen中参数设为5,故i必须小于5*/
	    if(client_sockfd[i] != 0){
	        FD_SET(client_sockfd[i], &client_fdset);
	    }
     }
	/*printf("put sockfd in fdset!\n");*/
	/*select函数*/
	ret = select(maxsock+1, &client_fdset, NULL, NULL, &tv);
	if(ret < 0){
	    perror("select error!\n");
	    break;
	}
	else if(ret == 0){
	    printf("timeout!\n");
	    continue;
	}
	/*轮询各个文件描述符*/
	for(int i = 0; i < conn_amount; ++i){
	/*FD_ISSET检查client_sockfd是否可读写,>0可读写*/
	    if(FD_ISSET(client_sockfd[i], &client_fdset)){
	        printf("start recv from client[%d]:\n",i);
	        ret = recv(client_sockfd[i], buffer, 1024, 0);
	        if(ret <= 0){
		    printf("client[%d] close\n", i);
		    close(client_sockfd[i]);
		    FD_CLR(client_sockfd[i], &client_fdset);
		    client_sockfd[i] = 0;
	        }
	        else{
		 printf("recv from client[%d] :%s\n", i, buffer);
	        }
	    }
	}   //可以看到把事件响应和事件探测写到了一块!
	/*检查是否有新的连接,如果收,接收连接,加入到client_sockfd中*/
	if(FD_ISSET(serverfd, &client_fdset)){
	    /*接受连接*/
	    struct sockaddr_in client_addr;
        size_t size = sizeof(struct sockaddr_in);
int sock_client = accept(serverfd, (struct sockaddr*)(&client_addr), (unsigned int*)(&size));
if(sock_client < 0){
	perror("accept error!\n");
	continue;
}
/*把连接加入到文件描述符集合中*/
if(conn_amount < 5){
	client_sockfd[conn_amount++] = sock_client;
	bzero(buffer,1024);
	strcpy(buffer, "this is server! welcome!\n");
	send(sock_client, buffer, 1024, 0);
	printf("new connection client[%d] %s:%d\n", conn_amount, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
	bzero(buffer,sizeof(buffer));
	ret = recv(sock_client, buffer, 1024, 0);
	if(ret < 0){
	    perror("recv error!\n");
	    close(serverfd);
	    return -1;
	}
	printf("recv : %s\n",buffer);
	if(sock_client > maxsock){
		maxsock = sock_client;
	}
	else{
	    printf("max connections!!!quit!!\n");
	    break;
	}
   }
  }
}
for(int i = 0; i < 5; ++i){
	if(client_sockfd[i] != 0){
	    close(client_sockfd[i]);
	}
}
close(serverfd);
return 0;	
}

客户端代码实现如下,比较简单

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#define DEFAULT_PORT 6666
int main( int argc, char * argv[]){
    int connfd = 0;
    int cLen = 0;
    struct sockaddr_in client;
    if(argc < 2){
        printf(" Uasge: clientent [server IP address]\n");
        return -1;
    }	
    client.sin_family = AF_INET;
    client.sin_port = htons(DEFAULT_PORT);
    client.sin_addr.s_addr = inet_addr(argv[1]);
    connfd = socket(AF_INET, SOCK_STREAM, 0);
    if(connfd < 0){
	    perror("socket" );
        return -1;
    }
    if(connect(connfd, (struct sockaddr*)&client, sizeof(client)) < 0){
 	    perror("connect" );
        return -1;
    }
	char buffer[1024];
	bzero(buffer,sizeof(buffer));
	recv(connfd, buffer, 1024, 0);
	printf("recv : %s\n", buffer);
	bzero(buffer,sizeof(buffer));
	strcpy(buffer,"this is client!\n");
	send(connfd, buffer, 1024, 0);
	while(1){
		bzero(buffer,sizeof(buffer));
		scanf("%s",buffer);
		int p = strlen(buffer);
		buffer[p] = '\0';
		send(connfd, buffer, 1024, 0);
		printf("i have send buffer\n");
	}
	close(connfd);
	return 0;
}

二:epoll

获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

问题的提出???????????????????

select为什么要在用户空间和内核空间之间重复拷贝fd,应该也可以像opoll那样只拷贝一次吧?

select 哪有拷贝 fd?拷贝的 fd 的 fd_set类型的对象,简单地理解为按bit位标记句柄的队列。epoll使用一个文件描述符管理多个文件描述符,将用户关系的文件描述符的事件放到一个事件表中,这样在用户空间和内核空间的数据拷贝只需要一次!也就是说提前在内核注册好了,无需从用户到内核的copy。

select原理概述

调用select时,会发生以下事情:

  1. 从用户空间拷贝fd_set到内核空间;
  2. 注册回调函数__pollwait;
  3. 遍历所有fd,对全部指定设备做一次poll(这里的poll是一个文件操作,它有两个参数,一个是文件fd本身,一个是当设备尚未就绪时调用的回调函数__pollwait,这个函数把设备自己特有的等待队列传给内核,让内核把当前的进程挂载到其中);
  4. 当设备就绪时,设备就会唤醒在自己特有等待队列中的【所有】节点,于是当前进程就获取到了完成的信号。poll文件操作返回的是一组标准的掩码,其中的各个位指示当前的不同的就绪状态(全0为没有任何事件触发),根据mask可对fd_set赋值;
  5. 如果所有设备返回的掩码都没有显示任何的事件触发,就去掉回调函数的函数指针,进入有限时的睡眠状态,再恢复和不断做poll,再作有限时的睡眠,直到其中一个设备有事件触发为止。
  6. 只要有事件触发,系统调用返回,将fd_set从内核空间拷贝到用户空间,回到用户态,用户就可以对相关的fd作进一步的读或者写操作了。
  1. epoll原理概述
  1. 调用epoll_create时,做了以下事情:

    1. 内核帮我们在epoll文件系统里建了个file结点;
    2. 在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket;
    3. 建立一个list链表,用于存储准备就绪的事件。

    调用epoll_ctl时,做了以下事情:

    1. 把socket放到epoll文件系统里file对象对应的红黑树上;
    2. 给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。

    调用epoll_wait时,做了以下事情:

    观察list链表里有没有数据。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。

以下是服务端的额程序

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <unistd.h>
#include <sys/types.h>

#define IPADDRESS   "127.0.0.1"
#define PORT        6666
#define MAXSIZE     1024
#define LISTENQ     5
#define FDSIZE      1000
#define EPOLLEVENTS 100

/*函数声明*/
/*创建套接字并进行绑定*/
int socket_bind(const char* ip,int port);
/*IO多路复用epoll*/
void do_epoll(int listenfd);
/*事件处理函数*/
void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf);
/*处理接收到的连接*/
void handle_accpet(int epollfd,int listenfd);
/*读处理*/
void do_read(int epollfd,int fd,char *buf);
/*写处理*/
void do_write(int epollfd,int fd,char *buf);
/*添加事件*/
void add_event(int epollfd,int fd,int state);
/*修改事件*/
void modify_event(int epollfd,int fd,int state);
/*删除事件*/
void delete_event(int epollfd,int fd,int state);
//把main()函数写到额很小,值得学习
int main(int argc,char *argv[]){
    int  listenfd;
    listenfd = socket_bind(IPADDRESS,PORT);
    listen(listenfd,LISTENQ);
    do_epoll(listenfd);
    return 0;
}

int socket_bind(const char* ip,int port){
    int  listenfd;
    struct sockaddr_in servaddr;
    listenfd = socket(AF_INET,SOCK_STREAM,0);
    if (listenfd == -1){
        perror("socket error:");
        exit(1);
    }
    bzero(&servaddr,sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    inet_pton(AF_INET,ip,&servaddr.sin_addr);
    servaddr.sin_port = htons(port);
    if (bind(listenfd,(struct sockaddr*)&servaddr,sizeof(servaddr)) == -1){
        perror("bind error: ");
        exit(1);
    }
    return listenfd;
}

void do_epoll(int listenfd){
    int epollfd;
    struct epoll_event events[EPOLLEVENTS];
    int ret;
    char buf[MAXSIZE];
    memset(buf,0,MAXSIZE);
    /*创建一个描述符*/
    epollfd = epoll_create(FDSIZE);
    /*添加监听描述符事件*/
    add_event(epollfd,listenfd,EPOLLIN);
    while(1){
        /*获取已经准备好的描述符事件*/ ret返回的是有时间的个数
        ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
        handle_events(epollfd,events,ret,listenfd,buf);
    }
    close(epollfd);
}

void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf){
    int i;
    int fd;
    /*进行选好遍历*/
    for (i = 0;i < num;i++){
        fd = events[i].data.fd;
        /*根据描述符的类型和事件类型进行处理*/
        if ((fd == listenfd) &&(events[i].events & EPOLLIN))
            handle_accpet(epollfd,listenfd);
        else if (events[i].events & EPOLLIN)
            do_read(epollfd,fd,buf);
        else if (events[i].events & EPOLLOUT)
            do_write(epollfd,fd,buf);
    }
}

void handle_accpet(int epollfd,int listenfd){
    int clifd;
    struct sockaddr_in cliaddr;
    socklen_t  cliaddrlen;
    clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);
    if (clifd == -1)
        perror("accpet error:");
    else{
        printf("accept a new client: %s:%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port);
        /*添加一个客户描述符和事件*/
        add_event(epollfd,clifd,EPOLLIN);
    }
}

void do_read(int epollfd,int fd,char *buf){
    int nread;
    nread = read(fd,buf,MAXSIZE);
    if (nread == -1){
        perror("read error:");
        close(fd);
        delete_event(epollfd,fd,EPOLLIN);
    }
    else if (nread == 0){
        fprintf(stderr,"client close.\n");
        close(fd);
        delete_event(epollfd,fd,EPOLLIN);
    }
    else{
        printf("read message is : %s",buf);
        /*修改描述符对应的事件,由读改为写*/
        modify_event(epollfd,fd,EPOLLOUT);
    }
}

void do_write(int epollfd,int fd,char *buf){
    int nwrite;
    nwrite = write(fd,buf,strlen(buf));
    if (nwrite == -1){
        perror("write error:");
        close(fd);
        delete_event(epollfd,fd,EPOLLOUT);
    }
    else
        modify_event(epollfd,fd,EPOLLIN);
    memset(buf,0,MAXSIZE);
}

void add_event(int epollfd,int fd,int state){
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev);
}

void delete_event(int epollfd,int fd,int state){
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev);
}

void modify_event(int epollfd,int fd,int state){
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev);
}

总结如下:

一颗红黑树,一张准备就绪句柄链表,少量的内核cache,解决了大并发下的socket处理问题。

执行epoll_create时,创建了红黑树和就绪链表; 
执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据; 
执行epoll_wait时立刻返回准备就绪链表里的数据即可。

两种模式的区别:

LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时重复返回这个句柄,而ET模式仅在第一次返回。

两种模式的实现:

当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait检查这些socket,如果是LT模式,并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表。所以,LT模式的句柄,只要它上面还有事件,epoll_wait每次都会返回。

对比

select缺点:

  1. 最大并发数限制:使用32个整数的32位,即32*32=1024来标识fd,虽然可修改,但是有以下第二点的瓶颈;
  2. 效率低:每次都会线性扫描整个fd_set,集合越大速度越慢;
  3. 内核/用户空间内存拷贝问题。

epoll的提升:

  1. 本身没有最大并发连接的限制,仅受系统中进程能打开的最大文件数目限制;
  2. 效率提升:只有活跃的socket才会主动的去调用callback函数;
  3. 省去不必要的内存拷贝:epoll通过内核与用户空间mmap同一块内存实现。

当然,以上的优缺点仅仅是特定场景下的情况:高并发,且任一时间只有少数socket是活跃的。

如果在并发量低,socket都比较活跃的情况下,select就不见得比epoll慢了(就像我们常常说快排比插入排序快,但是在特定情况下这并不成立)。

epoll机制实现分析

本文只介绍epoll的主要流程而不是分析源代码,如果需要了解更多的细节可以自己翻阅相关的内核源代码.

相关内核代码:

fs/eventpoll.c

判断一个tcp套接字上是否有激活事件:net/ipv4/tcp.c:tcp_poll函数


每个epollfd在内核中有一个对应的eventpoll结构对象.其中关键的成员是一个readylist(eventpoll:rdllist)

和一棵红黑树(eventpoll:rbr).

一个fd被添加到epoll中之后(EPOLL_ADD),内核会为它生成一个对应的epitem结构对象.epitem被添加到

eventpoll的红黑树中.红黑树的作用是使用者调用EPOLL_MOD的时候可以快速找到fd对应的epitem。

调用epoll_wait的时候,将readylist中的epitem出列,将触发的事件拷贝到用户空间.之后判断epitem是否需

要重新添加回readylist.


epitem重新添加到readylist必须满足下列条件:

1) epitem上有用户关注的事件触发.

2) epitem被设置为水平触发模式(如果一个epitem被设置为边界触发则这个epitem不会被重新添加到readylist

中,在什么时候重新添加到readylist请继续往下看).

注意,如果epitem被设置为EPOLLONESHOT模式,则当这个epitem上的事件拷贝到用户空间之后,会将

这个epitem上的关注事件清空(只是关注事件被清空,并没有从epoll中删除,要删除必须对那个描述符调用

EPOLL_DEL),也就是说即使这个epitem上有触发事件,但是因为没有用户关注的事件所以不会被重新添加到

readylist中.


epitem被添加到readylist中的各种情况(当一个epitem被添加到readylist如果有线程阻塞在epoll_wait中,那

个线程会被唤醒):

1)对一个fd调用EPOLL_ADD,如果这个fd上有用户关注的激活事件,则这个fd会被添加到readylist.

2)对一个fd调用EPOLL_MOD改变关注的事件,如果新增加了一个关注事件且对应的fd上有相应的事件激活,

则这个fd会被添加到readylist.

3)当一个fd上有事件触发时(例如一个socket上有外来的数据)会调用ep_poll_callback(见eventpoll::ep_ptable_queue_proc),

如果触发的事件是用户关注的事件,则这个fd会被添加到readylist中.

了解了epoll的执行过程之后,可以回答一个在使用边界触发时常见的疑问.在一个fd被设置为边界触发的情况下,

调用read/write,如何正确的判断那个fd已经没有数据可读/不再可写.epoll文档中的建议是直到触发EAGAIN

错误.而实际上只要你请求字节数小于read/write的返回值就可以确定那个fd上已经没有数据可读/不再可写.

最后用一个epollfd监听另一个epollfd也是合法的,epoll通过调用eventpoll::ep_eventpoll_poll来判断一个

epollfd上是否有触发的事件(只能是读事件).













(五十三)高并发服务器——多路IO转接机制Select模型

本文部分转载于: http://blog.csdn.net/wqx521/article/details/53782010 http://blog.csdn.net/orz415678659/ar...
  • FadeFarAway
  • FadeFarAway
  • 2017-02-03 00:51:30
  • 3731

高并发服务器开发--网络模型

libevent、memcached、多线程
  • pangyemeng
  • pangyemeng
  • 2015-12-11 12:00:44
  • 1537

并发: IO模型

并发: IO模型参考博客:聊聊并发,Part 1:IO模型1. 内核级别的IO1.1 同步IO vs 异步IO看过很多版本对于这些概念的描述,我觉得来自POSIX标准的这个判断准则最简单也最容易理解:...
  • wangyunzhong123
  • wangyunzhong123
  • 2017-08-07 23:52:18
  • 259

1高并发服务器:多路IO之select

 1 select A:select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开 的文件描述符个数并不能改变select监听文件个数 ...
  • toto1297488504
  • toto1297488504
  • 2014-09-30 22:35:30
  • 2000

Linux 网络编程——并发服务器的三种实现模型

服务器设计技术有很多,按使用的协议来分有 TCP 服务器和 UDP 服务器,按处理方式来分有循环服务器和并发服务器。循环服务器与并发服务器模型在网络程序里面,一般来说都是许多客户对应一个服务器(多对一...
  • tennysonsky
  • tennysonsky
  • 2015-05-12 17:40:08
  • 83843

四种IO模型的解释

同步阻塞 IO : 在此种方式下,用户进程在发起一个 IO 操作以后,必须等待 IO 操作的完成,只有当真正完成了 IO 操作以后,用户进程才能运行。 JAVA传统的 IO 模型属于此种方式! ...
  • wl6965307
  • wl6965307
  • 2016-04-28 18:57:28
  • 894

五种网络io模型

由于最近再看nginx,其采用了优于apache的select网络io模型,所以稍微了解了下网络io的模型的基础知识。 首先,介绍几种常见的I/O模型及其区别,如下: bloc...
  • lltaoyy
  • lltaoyy
  • 2017-02-04 11:07:55
  • 1881

几种典型的服务器网络编程模型归纳(select poll epoll)

1、同步阻塞迭代模型 同步阻塞迭代模型是最简单的一种IO模型。其核心代码如下:bind(srvfd); listen(srvfd); for(;;) { clifd = accept(srv...
  • drdairen
  • drdairen
  • 2016-12-17 10:52:17
  • 3468

tcpio5种io模型

传智扫地僧课程学习笔记。 阻塞IO, recv,接收数据,若没有,将阻塞, 当对方发数据来后,linux内核缓冲区得到数据, 内核数据复制到recv()调用所在的用户空间, 阻塞解除...
  • qq_18973645
  • qq_18973645
  • 2017-01-29 01:36:46
  • 363

几种服务器端IO模型的简单介绍及实现

一些概念: 同步和异步 同步和异步是针对应用程序和内核的交互而言的,同步指的是用户进程触发I/O操作并等待或者轮询的去查看I/O操作是否就绪,而异步是指用户进程触发I/O操作以后便开始做自...
  • xiaofei0859
  • xiaofei0859
  • 2016-03-31 00:03:49
  • 1071
收藏助手
不良信息举报
您举报文章:四种网络IO模型与高并发服务器实现
举报原因:
原因补充:

(最多只允许输入30个字)