您的位置:首页 > 运维架构 > Linux

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

2018-01-22 17:17 741 查看
在 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
plaincopy

int epoll_create(int size);    

int epoll_create1(int flags);    

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);    

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/<pid>/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
plaincopy





struct epoll_event  

{  

    uint32_t     events;      /* Epoll events */  

    epoll_data_t data;        /* User data variable */  

};  

typedef union epoll_data  

{  

    void        *ptr;  

    int          fd;  

    uint32_t     u32;  

    uint64_t     u64;  

} 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_ctl向epoll对象中添加这100万个连接的套接字

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

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

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

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

[cpp] view
plaincopy

struct eventpoll{  

    ....  

    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/  

    struct rb_root  rbr;  

    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/  

    struct list_head rdlist;  

    ....  

};  

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

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

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

[cpp] view
plaincopy

struct epitem{  

    struct rb_node  rbn;//红黑树节点  

    struct list_head    rdllink;//双向链表节点  

    struct epoll_filefd  ffd;  //事件句柄信息  

    struct eventpoll *ep;    //指向其所属的eventpoll对象  

    struct epoll_event event; //期待发生的事件类型  

}  

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



                        epoll数据结构示意图

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

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

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

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

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

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

[cpp] view
plaincopy

for( ; ; )  

    {  

        nfds = epoll_wait(epfd,events,20,500);  

        for(i=0;i<nfds;++i)  

        {  

            if(events[i].data.fd==listenfd) //如果是主socket的事件,则表示有新的连接  

            {  

                connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接  

                ev.data.fd=connfd;  

                ev.events=EPOLLIN|EPOLLET;  

                epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中  

            }  

            else if( events[i].events&EPOLLIN ) //接收到数据,读socket  

            {  

  

            if ( (sockfd = events[i].data.fd) < 0) continue;  

                n = read(sockfd, line, MAXLINE)) < 0    //读  

                ev.data.ptr = md;     //md为自定义类型,添加数据  

                ev.events=EPOLLOUT|EPOLLET;  

                epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓  

            }  

            else if(events[i].events&EPOLLOUT) //有数据待发送,写socket  

            {  

                struct myepoll_data* md = (myepoll_data*)events[i].data.ptr;    //取数据  

                sockfd = md->fd;  

                send( sockfd, md->ptr, strlen((char*)md->ptr), 0 );        //发送数据  

                ev.data.fd=sockfd;  

                ev.events=EPOLLIN|EPOLLET;  

                epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据  

            }  

            else  

            {  

                //其他情况的处理  

            }  

        }  

    }  

首先,通过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

版权声明:本文为博主原创文章,未经博主允许不得转载。

本文已收录于以下专栏:
Programming
int the Linux environment
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: