Linux网络编程 --- IO复用之epoll系统调用详解

 

 

epoll简介

epoll是Linux特有的I/O复用函数。它在实现上与select、poll有很大差异。它的提出是为了弥补select和poll对于描述符过多处理时时间效率过高的问题。epoll使用一组函数来完成任务,而不是单个函数。

epoll_create  

  • epoll_create(int size)//创建内核事件表
  • epoll_ctl();//向内核事件表中添加描述符和事件,修改,删除
  • epoll_wait();//获取有就绪事件的描述符

首先,其次,epoll把用户关心的文件描述符上的事件放在一个事件表中,从而无需向select和poll那样每次调用都要重复传入文件描述符集或事件集。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用epoll_create函数来创建:

#include<sys/epoll.h>
int epoll_create(int size);

size参数只是给内核一个提示,告诉它事件表需要多大。函数返回的文件描述符用作其他所有epoll系统调用的第一个参数,用来指定要访问的内核事件表。内核事件表底层由红黑树实现

epoll_ctl

epoll_ctl用来操作epoll的内核事件表,原型如下:

#include<sys/epoll.h>
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);
  • fd参数是要操作的文件描述符,op参数则指定操作类型。操作类型有如下三种:
  1. EPOLL_CTL_ADD,往事件表中注册fd上的事件
  2. EPOLL_CTL_MOD,修改fd上的注册事件
  3. EPOLL_CTL_DEL,删除fd上的注册事件
  • event参数指定事件,它是epoll_event结构指针类型。epoll_event的定义如下:
struct epoll_event
{
    _uint32_t events;//epoll事件
    epoll_data_t data;//用户数据
};

 events成员描述事件类型。epoll支持的事件类型和poll基本相同,表示epoll事件类型的宏是在poll对应的宏前加上"E",比如epoll的数据可读事件是EPOLLIN。但是epoll有两个额外的事件类型——EPOLLET和EPOLLONESHOT。

data成员用于存储用户数据,它的类型是一个联合体。原型如下:

typedef union epoll_data
{
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
};epoll_data_t;

fd成员指定事件所从属的目标文件描述符。ptr成员可用来指定与fd相关的用户数据。

epoll_ctl调用成功时返回0,失败时返回-1并设置errno。

epoll_wait

epoll系列系统调用的主要接口是epoll_wait函数。它在一段超时时间内等待一组文件描述符上的事件,其原型如下:

#include<sys/epoll.h>
int epoll_wait(int epfd,struct epoll_event* events,int maxevents,int timeout);

该函数成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno。

  • timeout参数的含义与poll接口的timeout参数相同。指定超时值,单位是毫秒。当timeout为-1时,epoll调用用于阻塞,直到某个时间的发生;当timeout为0时,epoll调用立即返回。
  • maxevents参数指定最多能监听多少个事件,必须大于0。

epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表中复制到events指向的数组中这个数组只用于输出epoll_wait检测到的就绪事件。而不像select和poll的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。

用epoll简单实现服务器端程序

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<unistd.h>
#include<sys/epoll.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>

#define MAXFD 10
int create_sockfd();

//将当前监听到的文件描述符加入内核事件表
void epoll_add(int epfd,int fd)
{

    struct epoll_event ev;
    ev.data.fd = fd;
    ev.events = EPOLLIN;//写事件
    if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev) == -1)//调用失败
    {
        perror("epoll_ctl error");
    }
}
//将文件描述符fd从内核事件表中删除
void epoll_del(int epfd,int fd)
{
    if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL) == -1)//指定的操作类型为删除fd上的注册事件。
    {
        perror("epoll_ctl del error");
    }
}

int main()
{
    int sockfd = create_sockfd();
    assert(sockfd != -1);
    
    int epfd = epoll_create(MAXFD);//创建内核事件表
    assert(epfd != -1);

    epoll_add(epfd,sockfd);//将当前监听到的文件描述符加入内核事件表
    
    struct epoll_event events[MAXFD];//用来接收就绪事件

    while(1)
    {
        int n = epoll_wait(epfd,events,MAXFD,5000);//得到就绪的文件描述符的个数
        if(n == -1)//失败
        {
            perror("epoll_wait error");
        }
        else if(n == 0)//超时
        {
            printf("time out\n");
        }
        else
        {
            int i = 0;
            for(;i<n;i++)//通过epoll_wait得到了就绪文件描述符的个数,所以只需处理就绪个数就ok
            {
                int fd = events[i].data.fd;//得到当前的文件描述符
                if(events[i].events & EPOLLIN)//如果是写事件
                {
					//如果fd == sockfd,则说明监听队列中有连接待处理,建立连接
					//否则就是有客户端发来了数据
                    if(fd == sockfd)
                    {
                        //accept
                        struct sockaddr_in caddr;
                        int len = sizeof(caddr);

                        int c = accept(sockfd,(struct sockaddr*)&caddr,&len);//建立连接
                        if(c < 0)
                        {
                            continue;
                        }
                        printf("accept c = %d\n",c);
                        epoll_add(epfd,c);//将建立好连接返回的新的文件描述符也加入内核事件表
                    }
                    else
                    {
                        //recv
                        char buff[128] = {0};
                        int num = recv(fd,buff,1,0);//接收客户端发来的数据
                        if(num == 0)
                        {
                        //如果检测到有客户端要进行关闭连接时,应该先将它的文件描述符从内核事件表中删除,服务器端再关闭。
                        //如果先关闭再删除,会在运行时发现有错误。
                            epoll_del(epfd,fd);//从内核事件表中删除
                            close(fd);//关闭
                            printf("one client over\n");
                        }
                        else
                        {
                            printf("buff(%d) = %s\n",fd,buff);
                            send(fd,"ok",2,0);//回复反馈信息
                        }
                    }
                }
            }

        }
    }

}
int create_sockfd()
{
    int sockfd = socket(AF_INET,SOCK_STREAM,0);//监听套接字
    if(sockfd == -1)
    {
        return -1;
    }

    struct sockaddr_in saddr;
    memset(&saddr,0,sizeof(saddr));
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(6000);
    saddr.sin_addr.s_addr = inet_addr("127.0.0.1");

    int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));//绑定监听套接字的地址
    if(res == -1)
    {
        return -1;
    }

    listen(sockfd,5);//监听

    return sockfd;
}

正常运行结果:

如代码中所说,如果检测到有客户端先关闭连接,但是服务器端也先进行关闭套接字,然后再从内核事件表中删除文件描述符就会引起错误。

针对这种问题,GNU引入了一个针对poll和epoll系统调用中,如果TCP连接被对方关闭或者对方关闭了写操作时的一个事件类型POLLRDHUP。在遍历所有就绪文件描述符时,使用这个事件类型可以判断出客户端是不是因为这种原因关闭。代码如下

 if(events[i].events & EPOLLRDHUP)
{
    epoll_del(epfd,fd);
    close(fd);
    printf("one client hup over\n");
}

运行结果如下:

 LT和ET模式

epoll对文件描述符的操作有两种模式:

  • LT(Level Trigger,电平触发)
  • ET(Edge Trigger,边沿触发)

LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll的高效工作模式

LT模式和ET模式的区别:

  • LT模式当有数据就绪时,就会反复提醒读数据,直到数据被读完,LT阻塞模式和非阻塞模式都可以。
  • ET模式当数据就绪时,只提醒一次,且ET模式只能使用非阻塞模式。可以用fcntl设置为非阻塞模式,然后循环读数据。

ET阻塞模式下只提醒一次的情况:

void epoll_add(int epfd,int fd)
{

    struct epoll_event ev;
    ev.data.fd = fd;
    ev.events = EPOLLIN|EPOLLET;//开启ET模式
    if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev) == -1)
    {
        perror("epoll_ctl error");
    }
    setnonblock(fd);
}

int num = recv(fd,buff,1,0);//将每次接收的字符改为1

 

根据运行结果可以看出每个客户端给服务器发送数据时,会有自己的一个接收缓冲区。ET模式下,只提醒读一次数据。但是没有读完的话,它剩下的数据依然在缓冲区存放,可以使用netstat命令查看。所以在输入第二次的时候读取到的依然是第一次剩下没有读完的数据。这样的话效率就太低了,不符合epoll本该有的效率。所有可以用fcntl设置为非阻塞模式,然后循环读数据。

代码改进如下:

//设置为非阻塞模式
void setnonblock(int fd)
{
    int oldfl = fcntl(fd,F_GETFL);
    int newfl = oldfl | O_NONBLOCK;

    if(fcntl(fd,F_SETFL,newfl) == -1)
    {
        perror("fcntl error");
    }

}

在得到一个新的文件描述符时,并且它不是新连接时,则采用循环读的方式来读取所有缓冲区的数据。

while(1)//循环读只提醒一次接收到的数据
{
    char buff[128] = {0};
    int num = recv(fd,buff,1,0);
    if(num == 0)
    {
        epoll_del(epfd,fd);
        close(fd);
        printf("one client over\n");
        break;
    }
    else if(num == -1)//表示数据已被读完
    {
        send(fd,"ok",2,0);
        break;
    }
    else
    {
        printf("buff(%d) = %s\n",fd,buff);
    }
}

 根据运行结果可以看出来,虽然设置的仍然是每次只读一个字符,但是我们将每次输入的数据都得到了。

epoll总结 

epoll和select、poll比起来高效了很多,因为它的实现首先会创建一个内核事件表,内核事件表的底层又是由红黑树实现的,查询的效率非常高。而且在内核事件表中,每个描述符只会被添加一次。所以从内核事件表中得到所有就绪描述符的时间复杂度约为O(1),这个效率较select和poll来说非常高了。内核实现则是靠注册回调函数,通过回调函数将就绪的文件描述符添加到就绪队列

猜你喜欢

转载自blog.csdn.net/Disremembrance/article/details/89601608