socket网络通信模型之select与epoll

目录

1、select模型

1.1 简介

1.2 使用

1.3 注意事项

2、epoll模型

2.1 简介

2.2 使用

2.3 注意事项

3、select与epoll模型的比较

3.1 select与epoll的运行原理

3.2 select与epoll的优缺点比较

4、解决实际问题

5、总结


       本篇文章是针对linux系统中解决大量客户端并发需求中遇到的socket模型问题进行分析,对之前使用的select模型以及为解决问题使用的epoll模型,进行简单的介绍,并进行对比分析。

socket网络通信模型之select与epoll_第1张图片

1、select模型

1.1 简介

       所谓select模型核心在于系统API函数select函数上,它用于确定一个或多个套接口的状态,对每一个套接口,调用者可查询它的可读性、可写性及错误状态信息。用fd_set结构来表示一组等待检查的套接口,在调用返回时,这个结构存有满足一定条件的套接口组的子集,并且select()返回满足条件的套接口的数目。
       我们先来看select接口的声明:

int select (int maxfd ,					//指集合中所有文件描述符的范围,[0,maxfd)
fd_set *readset,				//可选,指向一组等待可读性检查的套接口
fd_set *writeset,				//可选,指向一组等待可写性检查的套接口
fd_set *exceptset,				//可选,指向一组等待错误检查的套接口
const struct timeval * timeout);	//最多等待时间,对阻塞操作则为NULL

其中,fd_set是一组文件描述字(fd)的集合,它用一位来表示一个fd。

typedef int32_t __fd_mask;
#define _NFDBITS (sizeof(__fd_mask) * 8)      /* 8 bits per byte */
#define __howmany(x,y)   (((x)+((y)-1))/(y))
typedef struct __fd_set 
{
    long fds_bits[__howmany(FD_SETSIZE, (sizeof(long) * 8))];
}fd_set;

       readsetwritesetexceptset指定我们要让内核测试读、写和异常条件的描述字。如果对某一个的条件不感兴趣,就可以把它设为NULL。

       timeout是一个超时时间值,类型是一个struct timeval结构的变量的指针,有三种情况:1:timeout=NULL(阻塞:直到有一个fd位被置为1函数才返回);2:timeout所指向的结构设为非零时间(等待固定时间:有一个fd位被置为1或者时间耗尽,函数均返回);3. timeout所指向的结构,时间设为0(非阻塞:函数检查完每个fd后立即返回)。

       select函数的返回值,大于0,表示某些文件可读写或出错的数目;等于-1表示出错;等于0则表示等待超时,没有可读写或错误的文件。

1.2 使用

       以下四个宏用来对fd_set结构体进行操作:

void FD_ZERO (fd_set *fdset); // clear all bits in fdset
#define FD_ZERO(p)    memset((void *)(p), (int) 0, sizeof(*(p)))

void FD_SET (int fd,fd_set *fdset); // turn on the bit for fd in fdset
#define FD_SET(n,p)   (((__fd_mask *)((p)->fds_bits))[(n)/_NFDBITS] |= (1 <<((n) % _NFDBITS)))

void FD_CLR (int fd,fd_set *fdset); // turn off the bit for fd in fdset
#define FD_CLR(n,p) 	(((__fd_mask *)((p)->fds_bits))[(n)/_NFDBITS] &= ~(1 <<((n) % _NFDBITS)))

int FD_ISSET(int fd,fd_set *fdset); // is the bit for fd on in fdset
#define FD_ISSET(n,p) (((__fd_mask *)((p)->fds_bits))[(n)/_NFDBITS] & (1 <<((n) % _NFDBITS)))

      使用select模型的示例:

fd_set readfds; //读集合,定义读、写或者异常的集合,不需要的就不用定义
FD_ZERO (&readfds); //对集合清空;

int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); //分配tcp类型套接字
FD_SET(fd, &readfds ); //使fd对应可读集合里的bit可用,即对此fd进行检测

While(ret = select (fd + 1, &readfds, NULL, NULL, NULL)) //对可读集合进行检测,阻塞等待;
{
    if(ret < 0) 
        printf (“select error\n”);
    else if (ret = = 0) 
        printf(“time out\n”);
    else {	
       if(FD_ISSET(fd, &rdfds)) //检查fd是否可读
       recv( ); 
    }//fd可读,读取内容

FD_SET(fd, &readfds );
} //重新把fd在set到集合中去

1.3 注意事项

       1)select的第一个参数为所有监视的文件描述符的最大值+1,而不是监视的文件描述符个数+1。在linux下是值而不是个数,fd_set集合存储描述符是以位形式存储,所以只能对最大FD_SETSIZE=1024个,即描述符值是0~1023的句柄进行检测,可以在头文件中修改这个值来改变select使用的文件描述符集的大小,但是必须重新编译内核才能使修改后的值有效;而在windows下其意义是个数,描述符值的大小不受限制,每次存入fd_set中的数组时,计数器count++。上面分别列出了fd_set以及相关的四个宏在linux下的定义,可以看出是以位存储,可以和在windows下的定义做下对比;

       2)select返回后,在fd_set集合中的文件描述符都会被清0,因此在select的循环中,每次进入都要重新设置我们所关注的文件描述符。

       3)如果select使用了超时操作,每次返回select都会修改计时器,将计时器设为余下的时间,因此如果使用了计时器,每次进入循环都要重置计时器。

2、epoll模型

2.1 简介

     epoll是linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用I/O接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

       epoll只有epoll_createepoll_ctlepoll_wait3个系统调用:

1)int epoll_create(int size); //创建一个epoll的句柄,size是epoll所支持的最大句柄数。自从linux2.6.8之后,size参数只要大于0的数值就行,内核自己动态分配。

2)int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //epoll的事件注册函数。

第一个参数是epoll_create()的返回值。

第二个参数表示动作,用三个宏来表示:EPOLL_CTL_ADD:注册新的fd到epfd中;EPOLL_CTL_MOD:修改已经注册的fd的监听事件;EPOLL_CTL_DEL:从epfd中删除一个fd;

第三个参数是需要监听的fd。

第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:

  typedef union epoll_data {   
     void *ptr;   
     int fd;   
     __uint32_t u32;   
     __uint64_t u64;   
  } epoll_data_t;   

struct epoll_event {   
      __uint32_t events; /* Epoll events */   
      epoll_data_t data; };/* User data variable */   

events可以是以下几个宏的集合:

EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);

EPOLLOUT:表示对应的文件描述符可以写;

EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);

EPOLLERR:表示对应的文件描述符发生错误;

EPOLLHUP:表示对应的文件描述符被挂断;

EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。

EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里;

3)int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); //收集在epoll监控的事件中已经发送的事件。

      参数events是分配好的epoll_event结构体数组,epoll将会把发生的事件赋值到events数组中(events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存)。

       maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size;

       参数timeout是超时时间(毫秒,0会立即返回,-1永久阻塞)。

       如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,返回0表示已超时。

2.2 使用

       使用epoll模型的示例:

int epfd= epoll_create();// 创建一个epoll的句柄,返回一个新的epoll句柄;
int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 分配tcp类型套接字
int nfds = 0; // 临时变量,有多少个socket有事件

struct epoll_event ev;    //事件临时变量
struct epoll_event events[FD_SETSIZE];    //监听事件数组

ev.data.fd = fd;     //设置与要处理的事件相关的文件描述符
ev.events = EPOLLIN|EPOLLET;   //设置要处理的事件类型 
if ( 0 != epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) )
 { 
    //注册epoll事件
    printf("epoll_ctl  failed!\n");
    close(fd);
    close(epfd); }  

while(条件){
nfds = epoll_wait(epfd, events, FD_SETSIZE, -1); //最大事件是FD_SETSIZE个,-1表示没有消息就一直等待
for (s32 i = 0; i < nfds; i++){ //循环读event里的内容
	if (fd== events[i].data.fd && events[i].events&EPOLLIN ) {//fd上有读事件
		recv();
        //重新设置fd
        ev.data.fd = fd;         
		ev.events = EPOLLIN|EPOLLET;  
		epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev) }}}

close(fd); 
close(epfd);

2.3 注意事项

       1)当创建好epoll句柄后,它会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则会导致句柄泄漏。

       2)调用epoll_wait后,如果fd上有对应的消息,处理后应再次把fd重新设置到epoll中去。因为epoll_wait运行的原理是:等侍注册在epfd上的socket fd的事件的发生,如果发生则将发生的sokct fd和事件类型放入到events数组中。并且将注册在epfd上的socket fd的事件类型给清空,所以如果下一个循环你还要关注这个socket fd的话,则需要用epoll_ctl(epfd,EPOLL_CTL_MOD, fd,&ev)来重新设置socket fd的事件类型。这时不用EPOLL_CTL_ADD,因为socket fd并未清空,只是事件类型被清空。

       3)尽量少使用epoll_ctl,过多的使用epoll_ctl系统调用,有一定的性能开销,可能成为这个系统的瓶颈

3、select与epoll模型的比较

3.1 select与epoll的运行原理

      select会循环遍历它所监测的fd_set内的所有文件描述符对应的驱动程序的poll函数。驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select 当前资源哪些可用。当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用,则select让该进程睡眠,一直等到有资源可用为止,进程被唤醒(或者timeout)继续往下执行。

       对于epoll,一棵红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

3.2 select与epoll的优缺点比较

3.2.1 Select优缺点
       优点:
1)在连接个数较小,连接的活跃度较高情况下,效率高于epoll;
2)select的跨平台做的很好,几乎每个平台都支持;

      缺点:
1)单个进程能够监视的文件描述符的数量存在最大限制;
2)select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增长,其在用户态和内核的地址空间的复制所引发的开销也会线性增长; 
3)由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()还是会对所有的socket进行一次线性扫描 ,会造成一定的开销;
4)每次在调用开始时,要把当前进程放入各个文件描述符的等待队列。在调用结束后,又把进程从各个等待队列中删除,影响效率;

3.2.1 Epoll优缺点

      优点:

1)支持的FD上限为系统定义的进程打开的最大FD个数,与系统内存相关;
2)IO效率不随FD数目增加而线性下降(FD中有大量的空闲连接),它只会对"活跃"的socket进行操作(根据每个fd上面的callback函数实现)。
3)mmap加速内核与用户空间的消息传递: 内核和用户空间mmap同一块内存实现的。
4)epoll返回时已经明确的知道哪个sokcet fd发生了事件,不用再一个个比对,提高了效率。

       缺点:

1)在连接数量较小,或者连接活跃度都比较高的情况下,epoll的效率可能比不上select。
2)epoll只有Linux2.6以上才有实现 ,而其他平台都没有,跨平台性存在缺陷。

4、解决实际问题

       老平台的需求是让最大并发客户端数由原来的256个扩大为512个,并且业务也要正常运作。在解决这个问题的时候遇到如下问题:

1)支持512个客户端并发失败,只能支持并发最多330左右;
2)并发时容易出现堵塞现象;

        每个并发任务需要建立两条连接,则需要占用至少两个socket描述符,512并发终端就要占用至少1024个+2个监听描述符(用于控制)=1026个;另外一个业务模块收、发分别用掉512个句柄。

        对于问题1,首先把协议栈的并发限制由之前的256改为512个后,使得协议栈可以为512个并发分配占用的资源。由背景知识知道512个并发所用的描述符个数会大于1024,所以把协议栈的FD_SETSIZE(系统头文件中定义的宏,可被其他地方重新定义)由之前定义的1024改为2048(并且把系统对每个进程可以打开的最大文件句柄数限制也扩大到5120,临时修改方法ulimit –n 5120,永久性修改请查阅相关资料),但是也只能并发330多个,并且有时候会出现堵塞(堵塞问题看问题2的分析)。

       由于当时只是模糊了解到select对超过1024个描述符的支持有问题,遂决定把协议栈的socket模型由selcet改为epoll。改之后测试,可以建立1024个连接了,即可以呼叫512个模拟客户端。但是有时候堵塞问题还是会出现。

       对于问题2,通过gdb查看栈调用,发现是堵塞在一个业务模块的PV操作的P(S)(S为创建socket的信号量)操作上。此操作是为远遥接收通道创建socket句柄,基本过程是:分配一个watchsocket的socket用来收消息(消息为创建一个新的socket描述符),起一个线程用来对此watchsocket进行select,有消息就处理。如果有需要创建,就给watchsocket发送udp消息,同时进行P(S)操作;watchsocket收到消息后则创建句柄并进行V(S)操作。

       添加打印发现,创建消息成功发送,进行了P(S)操作,但是在select那里并没有创建的消息被检测到。此模块对FD_SETSIZE之前的定义是256,被改为512以及改成后来的1024(每个呼叫都有可能打开一个远遥接收通道,512个呼叫就最多开512个远遥接收通道,就要占用至少512个socket描述符,但是加上watchsocket的占用总体不会超过1024)个后都还是有时候会堵塞,所以怀疑堵塞问题与FD_SETSIZE的设置关系不大。

       最后经过多次测试,发现堵塞规律:远遥接收socket句柄的创建在512个并发的中间任何时候都有可能才开始创建(并不是每个呼叫成功后就立马创建),打印创建的socket描述符的值,发现只要此值超过1024后进行远遥接收socket句柄的创建就会出现堵塞,而有的时候不堵塞是因为512个都呼叫完了还没有开始创建远遥接收socket。

        遂仔细搜集以及查看了selcet相关的资料,原来这个问题就是由于2.1.3的注意事项1中说的那样,FD_SETSIZE在linux下是值而不是简单的个数。之前理解不深刻,而且只看了windows下的实现,以为select的FD_SETSIZE都指的是个数,找到linux下的定义看过后就明了了。于是也将此模块的socket模型有select改为epoll后堵塞问题解决。

5、总结

       对select不熟悉,对其中的maxfd的意义理解不深刻导致问题一直未被彻底解决。解决问题1的时候,可能也看到了有资料说FD_SETSIZE在linux下是值而不是简单的个数,但是看的实现又是windows下的,导致记忆不深、混淆概念,乱打乱撞用了epoll。多亏问题2的一直出现,让对select以及epoll重新做了梳理,重新了解了它们在linux下的实现原理、过程以及注意事项。案例中只是对select和epoll的简单介绍,部分内容是根据网上资料总结得出而并没有亲自实践,如有不对请指正,更多信息请搜索其他资料。

       有时候,问题不是阻碍,而是推动你前进的动力和引导力!

你可能感兴趣的:(C/C++技术分享,socket,网络通信,select,epoll)