Linux下的socket编程实践(九) epoll实现高并发的原理及其使用

在 linux 没有实现 epoll 事件驱动机制之前,我们一般选择用 selec t或者 poll IO多路复用的方法来实现并发服务程序(详见此链接)。在大数据、高并发、集群等一些名词唱得火热之年代,select 和 poll的用武之地越来越有限,风头已经被 epoll 占尽。 

本文便来介绍 epoll 的实现机制,并通过对比其不同的实现机制,真正理解为何 epoll 能实现高并发。

Epoll相对select/poll的优势:

1. Epoll 没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数字一般远大于 2048, 一般来说这个数目和系统内存关系很大 ,具体数目可以 cat /proc/sys/fs/file-max[599534] ,并且现在服务器的内存都很大,所以这个不是问题。

2. 效率提升,epoll对于句柄事件的选择不是遍历的,是事件响应的,就是句柄上事件来就马上选择出来,不需要遍历整个句柄链表,因此效率非常高,内核将句柄用红黑树保存的,IO效率不随FD数目增加而线性下降。

3. 内存拷贝, select让内核把 FD 消息通知给用户空间的时候使用了内存拷贝的方式,开销较大,但是Epoll 在这点上使用了共享内存的方式,这个内存拷贝也省略了。

epoll的使用

[cpp]  view plain copy
  1. int epoll_create(int size);    
  2. int epoll_create1(int flags);    
  3. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);    
  4. int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);  

 1. 对于epoll_create1 的flag参数: 可以设置为0 或EPOLL_CLOEXEC,为0时函数表现与epoll_create一致, EPOLL_CLOEXEC标志与open 时的O_CLOEXEC 标志类似,即进程被替换时会关闭打开的文件描述符(需要注意的是,epoll_create与epoll_create1当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc//fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽)。

 2. 对于epoll_ctl, op参数表示动作,用三个宏来表示:

EPOLL_CTL_ADD

注册新的fd到epfd中

EPOLL_CTL_DEL

从epfd中删除一个fd

EPOLL_CTL_MOD

修改已经注册的fd的监听事件

 3. 对于epoll_wait:

    events:结构体指针, 一般是一个数组

    maxevents:事件的最大个数, 或者说是数组的大小

    timeout:超时时间, 含义与poll的timeout参数相同,设为-1表示永不超时;

 4. epoll_event结构体

[cpp]  view plain copy
  1. struct epoll_event  
  2. {  
  3.     uint32_t     events;      /* Epoll events */  
  4.     epoll_data_t data;        /* User data variable */  
  5. };  
  6. typedef union epoll_data  
  7. {  
  8.     void        *ptr;  
  9.     int          fd;  
  10.     uint32_t     u32;  
  11.     uint64_t     u64;  
  12. } epoll_data_t;  

一般data 共同体我们设置其成员fd即可,也就是epoll_ctl 函数的第三个参数。

events集合

EPOLLIN

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

EPOLLOUT

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

EPOLLPRI

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

EPOLLERR

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

EPOLLHUP

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

EPOLLET

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

EPOLLONESHOT

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


epoll IO多路复用模型实现机制

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃(事实上大部分场景都是这种情况)。如何实现这样的高并发?

select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统把原先的select/poll调用分成了3个部分:

1)调用epoll_create()建立一个epoll对象(epoll文件系统中为这个句柄对象分配资源)

2)调用epoll_ctlepoll对象中添加这100万个连接的套接字

3)调用epoll_wait收集发生的事件的连接 

如此一来,要实现上面说的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。

下面来看看Linux内核具体的epoll机制实现思路。 

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:

[cpp]  view plain copy
  1. struct eventpoll{  
  2.     ....  
  3.     /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/  
  4.     struct rb_root  rbr;  
  5.     /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/  
  6.     struct list_head rdlist;  
  7.     ....  
  8. };  

 每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:

[cpp]  view plain copy
  1. struct epitem{  
  2.     struct rb_node  rbn;//红黑树节点  
  3.     struct list_head    rdllink;//双向链表节点  
  4.     struct epoll_filefd  ffd;  //事件句柄信息  
  5.     struct eventpoll *ep;    //指向其所属的eventpoll对象  
  6.     struct epoll_event event; //期待发生的事件类型  
  7. }  

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

Linux下的socket编程实践(九) epoll实现高并发的原理及其使用_第1张图片


                        epoll数据结构示意图

从上面的讲解可知:通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效

OK,讲解完了Epoll的机理,我们便能很容易掌握epoll的用法了。一句话描述就是:三步曲。

第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。

第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。

第三部:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。

下面提供一个epoll的使用框架:

[cpp]  view plain copy
  1. for( ; ; )  
  2.     {  
  3.         nfds = epoll_wait(epfd,events,20,500);  
  4.         for(i=0;i
  5.         {  
  6.             if(events[i].data.fd==listenfd) //如果是主socket的事件,则表示有新的连接  
  7.             {  
  8.                 connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接  
  9.                 ev.data.fd=connfd;  
  10.                 ev.events=EPOLLIN|EPOLLET;  
  11.                 epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中  
  12.             }  
  13.             else if( events[i].events&EPOLLIN ) //接收到数据,读socket  
  14.             {  
  15.   
  16.             if ( (sockfd = events[i].data.fd) < 0) continue;  
  17.                 n = read(sockfd, line, MAXLINE)) < 0    //读  
  18.                 ev.data.ptr = md;     //md为自定义类型,添加数据  
  19.                 ev.events=EPOLLOUT|EPOLLET;  
  20.                 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓  
  21.             }  
  22.             else if(events[i].events&EPOLLOUT) //有数据待发送,写socket  
  23.             {  
  24.                 struct myepoll_data* md = (myepoll_data*)events[i].data.ptr;    //取数据  
  25.                 sockfd = md->fd;  
  26.                 send( sockfd, md->ptr, strlen((char*)md->ptr), 0 );        //发送数据  
  27.                 ev.data.fd=sockfd;  
  28.                 ev.events=EPOLLIN|EPOLLET;  
  29.                 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据  
  30.             }  
  31.             else  
  32.             {  
  33.                 //其他情况的处理  
  34.             }  
  35.         }  
  36.     }  

首先,通过epoll_create(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这个函数操作成功之后, events里面将储存所有的读写事件。max_events是当前需要监听的所有socket句柄数。最后一个timeout是 epoll_wait的超时,为0的时候表示马上返回,为-1的时候表示一直等下去,直到有事件范围,为任意正整数的时候表示等这么长的时间,如果一直没有事件,则返回。一般如果网络主循环是单独的线程的话,可以用-1来等,这样可以保证一些效率,如果是和主逻辑在同一个线程的话,则可以用0来保证主循环的效率。

接下来,epoll_wait范围之后应该是一个循环,遍历所有的事件。

ET/LT模式

1、EPOLLLT:完全靠Linux-kernel-epoll驱动,应用程序只需要处理从epoll_wait返回的fds, 这些fds我们认为它们处于就绪状态。此时epoll可以认为是更快速的poll。

2、EPOLLET:此模式下,系统仅仅通知应用程序哪些fds变成了就绪状态,一旦fd变成就绪状态,epoll将不再关注这个fd的任何状态信息(从epoll队列移除), 直到应用程序通过读写操作(非阻塞)触发EAGAIN状态,epoll认为这个fd又变为空闲状态,那么epoll又重新关注这个fd的状态变化(重新加入epoll队列)。 随着epoll_wait的返回,队列中的fds是在减少的,所以在大并发的系统中,EPOLLET更有优势,但是对程序员的要求也更高,因为有可能会出现数据读取不完整的问题,举例如下:

   假设现在对方发送了2k的数据,而我们先读取了1k,然后这时调用了epoll_wait,如果是边沿触发ET,那么这个fd变成就绪状态就会从epoll 队列移除,则epoll_wait 会一直阻塞,忽略尚未读取的1k数据; 而如果是水平触发LT,那么epoll_wait 还会检测到可读事件而返回,我们可以继续读取剩下的1k 数据。

   因此总结来说: LT模式可能触发的次数更多, 一旦触发的次数多, 也就意味着效率会下降; 但这样也不能就说LT模式就比ET模式效率更低, 因为ET的使用对编程人员提出了更高更精细的要求,一旦使用者编程水平不够, 那ET模式还不如LT模式;


最后附上一篇很优秀的文章: Apache和Nginx网络模型  来加深理解。


参考博客:http://www.open-open.com/lib/view/open1410403215664.html

         http://www.cnblogs.com/panfeng412/articles/2229095.html

你可能感兴趣的:(Linux基础及编程,Programming,int,the,Linux,environment)