优于select的epoll

之前谈到了select,我们认为它的性能容易受影响的原因在于调用select后常见的针对所有文件描述符的循环语句和每次都需要向select函数传递需要监视的对象信息。那么有没有更好的IO复用函数能解决这个问题提高效率呢?

答案就是epoll

epoll不需要针对所有的fd做循环,它可以直接返回有事件发生的fd。epoll自己维持着一个fd,我们仅仅需要用它来监视所有的文件描述符,不需要每次都给内核传递需要监视的fd集合。对于epoll这个技术,linux给出了3个函数来处理。

#include <sys/epoll.h>
int epoll_create(int size);//失败返回-1,成功返回一个文件描述符

这个函数返回一个文件描述符标识着epoll句柄。调用这个函数,内核内部产生了很多必须的数据结构和资源,然后返回一个文件描述符来标识。参数size在linux2.6.8以上版本就ignored了,但是必须传递一个大于0的值,这个参数是对操作系统用这个epoll_fd监视多少个socket的建议。

既然创建了epoll_fd,那么就需要往这个epoll_fd中添加需要监视的socket了

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
成功返回0,失败返回-1
epfd是epoll_create返回的epoll_fd
op表示在epoll_fd这个句柄中做添加,删除,修改socket的操作
fd表示需要监视的socket
epoll_event是一个结构体
struct epoll_event
{
    __uint32_t events;//需要监视的socket上发生的事件
    epoll_data_t data;//用户自定义数据,可以传递fd进去,也可以绑定一些数据和fd一起传递进去,这就给了很多扩展的空间。如果这个结构体不挂着socket,那么你从epoll_wait得到了结果也无法使用
};

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

//举一个代码的实例
int ret = epoll_ctl(A, EPOLL_CTL_ADD, B, C);
这表示在epoll实例A中注册文件描述符B,主要是监视B上是否发生C事件
int ret = epoll_ctl(A, EPOLL_CTL_DEL, B, NULL);
在epoll实例A中删除文件描述符B,此时最后一个参数没必要了
int ret = epoll_ctl(A, EPOLL_CTL_MOD, B, D);
这表示在epoll实例A中修改文件描述符B,监视B上是否发生D事件(原来是C事件)
    
//疑问:如果想A中连续注册两次B的C事件,会有什么效果?先注册B的C事件,在注册B的D事件?

下面是一个事件的集合

在这里插入图片描述

上面两个函数主要是为了做准备,那么最后一个函数就与select有相同的功能了

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
失败返回-1, 成功返回有io事件产生的socket的个数。0表示超时,大于0表示有多少个socket上的事件发生了
很好理解它的各个参数的意义
epfd和epoll_ctl中的epfd一样
events是一个有maxevents个epoll_event大小的内存块的指针
timeout表示超时事件,-1表示一直等

这个函数如果返回一个大于0的值,那么意味着有多少个socket上有io事件发生,它把之前epoll_ctl你传递socket上挂着的epoll_event返回给你(如果这个socket上有事件发生),并保存在events上

疑问:我们传events,是传递一个尽量大的,还是传递一个小的?

通过这三个函数做一个对比,我们可以看到,在epoll_fd中,内核自动维持着一个socket监视队列,所以无需每次像select一样需要我们传递sockets进去。在epoll_wait返回后,我们得到的是一个已经发生了io事件的socket上挂着的epoll_event的集合。我们无需遍历所有的socket来找到发生io事件socket。这里在应用层面上就体现了epoll的高效的秘密。在内核实现上,epoll_fd里面,内核维持着一个红黑树,socket是key,socket上的epoll_event就是value,大致是这个意思。具体的后面讨论。

介绍了这三个函数,关于这三个函数还有一些疑问

疑问:如果想A中连续注册两次B的C事件,会有什么效果?先注册B的C事件,在注册B的D事件?写代码看看有啥效果

int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1)
        exit(0);
    struct sockaddr_in addr;
    bzero(&addr, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(5050);
    if (1 != inet_pton(AF_INET, "192.168.196.130", &addr.sin_addr))
        exit(0);
    if (0 != ::bind(listen_fd, (const sockaddr*)&addr, sizeof(addr)))
        exit(0);
    if (0 != listen(listen_fd, 5))
        exit(0);

    int epoll_fd = epoll_create(1024);
    struct epoll_event listen_event;
    listen_event.data.fd = listen_fd;
    listen_event.events |= EPOLLIN;
    int ret = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &listen_event);
    ret = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &listen_event);
    if (ret != 0)
    {
        printf("error : %s\n", strerror(errno));
    }

得到的结果是:error : File exists。这个socket已经存在了。那么我们在看一下它的事件是否做了修改

代码改为:

	int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1)
        exit(0);
    struct sockaddr_in addr;
    bzero(&addr, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(5050);
    if (1 != inet_pton(AF_INET, "192.168.196.130", &addr.sin_addr))
        exit(0);
    if (0 != ::bind(listen_fd, (const sockaddr*)&addr, sizeof(addr)))
        exit(0);
    if (0 != listen(listen_fd, 5))
        exit(0);

    int epoll_fd = epoll_create(1024);
    struct epoll_event listen_event;
    listen_event.data.fd = listen_fd;
    listen_event.events |= EPOLLERR;
    int ret = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &listen_event);
    //listen_event.events = 0;
    //listen_event.events |= EPOLLIN;
    //ret = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &listen_event);
    if (ret != 0)
    {
        printf("error : %s\n", strerror(errno));
    }
    struct epoll_event events[1024];
    while (1)
    {
        memset(&events, 0, 1024 * sizeof(epoll_event));
        ret = epoll_wait(epoll_fd, events, 1024, -1);
        if (ret <= 0)
            continue;
        for (int  i = 0; i < ret; i++)
        {
            int fd = events[i].data.fd;//断点
            if (fd == listen_fd)
            {
                struct sockaddr_in client_addr;
                socklen_t addr_len = sizeof(client_addr);
                int client_fd = ::accept(fd, (struct sockaddr*)&client_addr, &addr_len);
                if (-1 == client_fd)
                {
                    exit(0);
                }
                char sz[] = "asfddsf";
                send(client_fd, sz, strlen(sz), 0);
                close(fd);
            }
        }
    }
    close(epoll_fd);

我们监视的是EPOLLERR消息,这个消息是收到OOB数据的情况,一般如果有人连接上的话,这个epoll_wait是不会管的。当把注释掉的三行代码解封,得到的结果还是当有连接来临,断点无法命中。说明我们第一次往A中添加B,并对C事件进行监视,后面再调用一次,无论继续监视什么事件,它始终监视C事件。如果需要修改,必须使用epoll_mod来修改。

第二个疑问

我们在epoll_wait中传events,是传递一个尽量大的结构体数组,还是传递一个小的?

操作系统会把发生io事件的socket上挂的epoll_event返回来,我们用一个内存块保存它。那么按照常理来讲,我们必须尽快获取所有的event来处理io事件。对吧!按照这个道理来说,我们做的是对的。那么我们需要传递多大的内存块,也就是说这个maxevents给多少合适?1024或者更多?

陈硕的muduo里面的EPollPoller内部维持着一个kInitEventListSize=16的初始值。

也有人建议这个 maxevents的值不能大于创建epoll_create()时给定的的size。

综上意见来说,用一个vector的events来表示,可以给一个初始值,当调用epoll_wait后,返回的socket_count的值如果等于events.size()。那么可以使用events.resize(socket_count * 2);动态分配双倍内存。这样子比较适合。

下面一段代码介绍了epoll_wait的常用做法

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中关于LT和ET的区别:

LT是水平触发,只要socket处于readable/writeable状态下,无论什么时候epoll_wait都会返回该socket。ET是边缘触发,只有socket从unreadable/unwriteable变为readable/writeable时,epoll_wait才会返回该socket,ET模式注重的是状态改变时才会触发。

在这里插入图片描述

ET模式下,在使用epoll_ctl注册socket时,必须将socket设置为非阻塞。原因:一般来说,我们设置ET模式,做数据读取写入的话,一般来说要将数据一次性的的读完或者写完,比如这样子

while (true)
{
	epoll_wait(epfd, events, max_events, timeout);
	//可读
	while (true)
	{
		int ret = recv(fd, ...);
		//直到返回0或者返回-1,,errno = EAGAIN/EWOULDBLOCK
	}
	//可写
	while (true)
	{
		int ret = send(fd, ...);
		//直到返回0或者返回-1,,errno = EAGAIN/EWOULDBLOCK
	}
    这样子下次调用时,才会继续返回该fd上信息
}

这样子的目的是为了让下一次调用epoll_wait时,还会返回有事件发生的socket。当然如果是LT就不需如此,但是et的性质是状态改变时才会触发,所以只能这样。如果,socket设置阻塞的话,得到的问题就会很大,比如recv倒数第二次的时候,socket可读缓冲区已经读完了,但是由于返回值是一个大于0的值,那么我们继续读取,但此时缓冲区是没有数据可以读的,那么就意味着recv会阻塞了。这样子效率太低了。所以一般来说ET模式配合非阻塞socket,效率很高。

关于epoll系列还有很多内部细节,比如内核是怎么实现epoll的?有什么高效率的办法?等等!这些问题留到后面继续研究

就不需如此,但是et的性质是状态改变时才会触发,所以只能这样。如果,socket设置阻塞的话,得到的问题就会很大,比如recv倒数第二次的时候,socket可读缓冲区已经读完了,但是由于返回值是一个大于0的值,那么我们继续读取,但此时缓冲区是没有数据可以读的,那么就意味着recv会阻塞了。这样子效率太低了。所以一般来说ET模式配合非阻塞socket,效率很高。

关于epoll系列还有很多内部细节,比如内核是怎么实现epoll的?有什么高效率的办法?等等!这些问题留到后面继续研究

在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章