首页 > 代码库 > I/O多路复用之epoll

I/O多路复用之epoll

介绍epoll的文章大多都详细介绍了epoll模型和select/poll模型之间的比较,这里就不再详细列举,只总结一下我对这两个模型的看法:

1.要监视的文件句柄非常多,而且每次完成操作的句柄非常少,使用epoll模型效率比select/poll模型要高许多;2.取决于文静句柄的响应速度,在LAN环境中这几个模型的效率差不多;WAN环境中epoll的效率最高;

1.Linux2.6内核epoll介绍
    先介绍2本书《The Linux Networking Architecture--Design and Implementation of Network Protocols in the Linux Kernel》,以2.4内核讲解Linux TCP/IP实现,相当不错.作为一个现实世界中的实现,很多时候你必须作很多权衡,这时候参考一个久经考验的系统更有实际意义。举个例子,linux内核中sk_buff结构为了追求速度和安全,牺牲了部分内存,所以在发送TCP包的时候,无论应用层数据多大,sk_buff最小也有272的字节.其实对于socket应用层程序来说,另外一本书《UNIX Network Programming Volume 1》意义更大一点.2003年的时候,这本书出了最新的第3版本,不过主要还是修订第2版本。其中第6章《I/O Multiplexing》是最重要的。Stevens给出了网络IO的基本模型。在这里最重要的莫过于select模型和Asynchronous I/O模型.从理论上说,AIO似乎是最高效的,你的IO操作可以立即返回,然后等待os告诉你IO操作完成。但是一直以来,如何实现就没有一个完美的方案。最著名的windows完成端口实现的AIO,实际上也是内部用线程池实现的罢了,最后的结果是IO有个线程池,你应用也需要一个线程池...... 很多文档其实已经指出了这带来的线程context-switch带来的代价。在linux 平台上,关于网络AIO一直是改动最多的地方,2.4的年代就有很多AIO内核patch,最著名的应该算是SGI那个。但是一直到2.6内核发布,网络模块的AIO一直没有进入稳定内核版本(大部分都是使用用户线程模拟方法,在使用了NPTL的linux上面其实和windows的完成端口基本上差不多了)。2.6内核所支持的AIO特指磁盘的AIO---支持io_submit(),io_getevents()以及对Direct IO的支持(就是绕过VFS系统buffer直接写硬盘,对于流服务器在内存平稳性上有相当帮助)。
    所以,剩下的select模型基本上就是我们在linux上面的唯一选择,其实,如果加上no-block socket的配置,可以完成一个"伪"AIO的实现,只不过推动力在于你而不是os而已。不过传统的select/poll函数有着一些无法忍受的缺点,所以改进一直是2.4-2.5开发版本内核的任务,包括/dev/poll,realtime signal等等。最终,Davide Libenzi开发的epoll进入2.6内核成为正式的解决方案

2、epoll的优点
<1>支持一个进程打开大数目的socket描述符(FD)
    select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

<2>IO效率不随FD数目增加而线性下降
    传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行操作---这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的---比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。

 

3、epoll的使用方法

 

[cpp] view plaincopy
  1. //epoll的接口非常简单,一共就三个函数:  
  2. 1. int epoll_create(int size);  
  3. 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。  
  4.   
  5.   
  6. 2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
  7. epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:  
  8. EPOLL_CTL_ADD:注册新的fd到epfd中;  
  9. EPOLL_CTL_MOD:修改已经注册的fd的监听事件;  
  10. EPOLL_CTL_DEL:从epfd中删除一个fd;  
  11. 第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:  
  12. struct epoll_event   
  13. {  
  14.   __uint32_t events;  /* Epoll events */  
  15.   epoll_data_t data;  /* User data variable */  
  16. };  
  17.   
  18. events可以是以下几个宏的集合:  
  19. EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);  
  20. EPOLLOUT:表示对应的文件描述符可以写;  
  21. EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);  
  22. EPOLLERR:表示对应的文件描述符发生错误;  
  23. EPOLLHUP:表示对应的文件描述符被挂断;  
  24. EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。  
  25. EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里  
  26.   
  27.   
  28. 3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);  
  29. 等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。  
 
    首先通过create_epoll(int maxfds)来创建一个epoll的句柄,其中maxfds为你epoll所支持的最大句柄数。这个函数会返回一个新的epoll句柄,之后的所有操作将通过这个句柄来进行操作。在用完之后,记得用close()来关闭这个创建出来的epoll句柄。之后在你的网络主循环里面,每一帧的调用epoll_wait(int epfd, epoll_event events, int max events, int timeout)来查询所有的网络接口,看哪一个可以读,哪一个可以写了。基本的语法为:
nfds = epoll_wait(kdpfd, events, maxevents, -1);
其中kdpfd为用epoll_create创建之后的句柄,events是一个epoll_event*的指针,当epoll_wait这个函数操作成功之后,epoll_events里面将储存所有的读写事件。max_events是当前需要监听的所有socket句柄数。最后一个timeout是 epoll_wait的超时,为0的时候表示马上返回,为-1的时候表示一直等下去,直到有事件范围,为任意正整数的时候表示等这么长的时间,如果一直没有事件,则范围。一般如果网络主循环是单独的线程的话,可以用-1来等,这样可以保证一些效率,如果是和主逻辑在同一个线程的话,则可以用0来保证主循环的效率。

[cpp] view plaincopy
  1. //epoll_wait范围之后应该是一个循环,遍利所有的事件:  
  2. for(n = 0; n < nfds; ++n)   
  3. {  
  4.     if(events[n].data.fd == listener)   
  5.     { //如果是主socket的事件的话,则表示有新连接进入了,进行新连接的处理。  
  6.         client = accept(listener, (struct sockaddr *) &local,  &addrlen);  
  7.        if(client < 0)  
  8.        {  
  9.            perror("accept");  
  10.            continue;  
  11.        }  
  12.        setnonblocking(client); // 将新连接置于非阻塞模式  
  13.          /*并且将新连接也加入EPOLL的监听队列。 
  14.          注意,这里的参数EPOLLIN | EPOLLET并没有设置对写socket的监听, 
  15.          如果有写操作的话,这个时候epoll是不会返回事件的, 
  16.          如果要对写操作也监听的话,应该是EPOLLIN | EPOLLOUT | EPOLLET*/  
  17.         ev.events = EPOLLIN | EPOLLET;  
  18.        ev.data.fd = client;  
  19.        /*设置好event之后,将这个新的event通过epoll_ctl,加入到epoll的监听队列里面,这里用EPOLL_CTL_ADD 
  20.        来加一个新的 epoll事件,通过EPOLL_CTL_DEL来减少一个epoll事件,通过EPOLL_CTL_MOD来改变一个事件的监听方式。*/  
  21.   
  22.       if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0)   
  23.       {    
  24.            fprintf(stderr, "epoll set insertion error: fd=%d0, client);  
  25.          return -1;  
  26.       }  
  27.     }    
  28.     else // 如果不是主socket的事件的话,则代表是一个用户socket的事件,  
  29.          do_use_fd(events[n].data.fd);//则来处理这个用户socket的事情,比如说read(fd,xxx)之类的,或者一些其他的处理。  
  30. }  

 

4.Linux下EPOll编程实例

[cpp] view plaincopy
  1. while (TRUE)   
  2. {  
  3.   int nfds = epoll_wait (m_epoll_fd, m_events, MAX_EVENTS, EPOLL_TIME_OUT);//等待EPOLL时间的发生,相当于监听,  
  4.                                                                              //至于相关的端口,需要在初始化EPOLL的时候绑定。  
  5.   if (nfds <= 0)  
  6.     continue;  
  7.   m_bOnTimeChecking = FALSE;  
  8.   G_CurTime = time(NULL);  
  9.   for (int i=0; i<nfds; i++)   
  10.   {  
  11.     try   
  12.     {  
  13.        if (m_events[i].data.fd == m_listen_sock_fd)//如果新监测到一个SOCKET用户连接到了绑定的SOCKET端口,建立新的连接。  
  14.         {  
  15.           OnAcceptSockEpoll ();  
  16.        }   
  17.        else if (m_events[i].events & EPOLLIN)//如果是已经连接的用户,并且收到数据,那么进行读入。  
  18.         {  
  19.           OnReadEpoll (i);  
  20.        }  
  21.       OnWriteEpoll (i);//查看当前的活动连接是否有需要写出的数据。  
  22.     }   
  23.     catch (int)   
  24.     {  
  25.       PRINTF ("CATCH捕获错误/n");  
  26.       continue;  
  27.     }  
  28.   }  
  29.   m_bOnTimeChecking = TRUE;  
  30.   OnTimer ();//进行一些定时的操作,主要就是删除一些短线用户等。  
  31. }  


Epoll模型主要负责对大量并发用户的请求进行及时处理,完成服务器与客户端的数据交互。其具体的实现步骤如下:
(a) 使用epoll_create()函数创建文件描述,设定将可管理的最大socket描述符数目。
(b) 创建与epoll关联的接收线程,应用程序可以创建多个接收线程来处理epoll上的读通知事件,线程的数量依赖于程序的具体需要。
(c) 创建一个侦听socket描述符ListenSock;将该描述符设定为非阻塞模式,调用Listen()函数在套接字上侦听有无新的连接请求,在epoll_event结构中设置要处理的事件类型EPOLLIN,工作方式为 epoll_ET,以提高工作效率,同时使用epoll_ctl()注册事件,最后启动网络监视线程。
(d) 网络监视线程启动循环,epoll_wait()等待epoll事件发生。
(e) 如果epoll事件表明有新的连接请求,则调用accept()函数,将用户socket描述符添加到epoll_data联合体,同时设定该描述符为非阻塞,并在epoll_event结构中设置要处理的事件类型为读和写,工作方式为epoll_ET.
(f) 如果epoll事件表明socket描述符上有数据可读,则将该socket描述符加入可读队列,通知接收线程读入数据,并将接收到的数据放入到接收数据的链表中,经逻辑处理后,将反馈的数据包放入到发送数据链表中,等待由发送线程发送。

I/O多路复用之epoll