我们聊了聊select和poll知道:
- 它们都是采取轮询的方式查找是否有就绪描述符。
- 都有数据结构从用户态拷贝到内核态,内核态拷贝到用户态这个过程。
为了针对许多大量连接,高并发的的场景下大量的资源消耗,效率低的问题,这一节就浅浅来聊一下epoll,epoll是之前的select和poll的增强版本,是linux操作系统独有的I/O复用技术。
对于epoll来说他更灵活,解决了select和poll的弊端,使用起来也更加方便顺手,他不像select和poll那样只提供了一个方法,epoll提供了一组方法。
本节呢就是聊聊epoll的使用和一些优点,对于epoll的两种触犯机制ET和LT的探讨会放在下一节去聊聊,注意select和poll只是LT。
好了~言归正传
目录
epoll的工作原理:
epoll的API:
epoll的特点:
用epoll实现tcp服务器的并发
epoll的工作原理:
- 1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
- 2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
- 3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
用大白话去理解就是,比如你是老师,你给同学们布置了作业,你在班里要检查作业的时候 不是你一个个去问,“啊,你写完了没有啊?”,而是你坐在讲台上,谁写完了,谁把作业拿上来给你,比方说有三个人写完了,这三个人就把作业交给你,你就会知道“哦,有三个人写完了”,至于剩下没交给你的那就是没写完的,你也不需要去问~
大概就是这样了,关键就是不用你去一个个问,而是谁完了,谁通知你
前面说了epoll提供了一组api,方便我们去使用,下来我们看看~
epoll的API:
epoll的核心是3个API,核心数据结构是:1个红黑树和1个链表组成。
【1. 创建内核事件表:】
函数原型为:
#include <sys/epoll.h>
int epoll_create(int size)
//成功返回内核事件表的标识符,失败返回-1
功能:该函数 创建内核事件表用于存放描述符和关注的事件。调用这个函数的时候,在内核cache里建立了红黑树struct rb_root用于存储以后epoll_ctl传来的socket,也就是内核事件表;还建立了一个双向链表struct list_headrdllidt用于存储就绪事件。 当epoll_wait调用时,仅仅观察这个双向链表里有没有数据即可,有数据表示有就绪事件,直接返回。
size参数:表示创建的事件表需要多大,记住最后要close关闭,如果不关闭,那么就会导致fd被耗尽
注意:
size参数告诉内核这个epoll对象会处理的事件⼤致数量,⽽不是能够处理的事件的最⼤数(同时,size不要传0,会报invalid argument错误)。
在现在linux版本中,这个size参数已经没有意义了;
返回: epoll对象句柄;之后针对该epoll的操作需要通过该句柄来标识该epoll对象;
【2. 管理内核事件表:】
可以对要监听的事件进行操作:注册,删除,修改;
返回: 0表示成功, -1表示错误,根据errno错误码判断错误类型。
原型如下:
#include <sys/epoll.h>
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);
//成功返回0,失败返回-1.
参数:
- epfd:内核事件表的标识符。
- op:标识操作,有:添加,删除,修改。不用自己写了,根据第二个op参数来选择:
EPOLL_CTL_ADD //注册新的文件描述符到内核事件表epfd中
EPOLL_CTL_DEL //从内核事件表中删除文件描述符
EPOLL_CTL_MOD //修改文件描述符
- fd:要操作的文件描述符。
- events:保存事件类型,用户填充,告诉内核要对哪种事件进行操作。所以需要先定义event结构体,然后再进行操作,表示对何种事件类型进行何种操作
struct epoll_event
{
short events;//事件类型:在每一个poll的事件类型标识前加个‘E’
Union epoll_data_t data(联合体,用到其中的fd成员即文件描述符,其他的不用)
}
typedef union epoll_data {
void *ptr; /* 指向用户自定义数据 */
int fd; /* 注册的文件描述符 */
uint32_t u32; /* 32-bit integer */
uint64_t u64; /* 64-bit integer */
} epoll_data_t;
event.events 取值:
EPOLLIN 表示该连接上有数据可读(tcp连接远端主动关闭连接,也是可读事件,因为需要处理发送来的FIN包; FIN包就是read 返回 0)
EPOLLOUT 表示该连接上可写发送(主动向上游服务器发起⾮阻塞tcp连接,连接建⽴成功事件相当于可写事件)
EPOLLRDHUP 表示tcp连接的远端关闭或半关闭连接
EPOLLPRI 表示连接上有紧急数据需要读
EPOLLERR 表示连接发⽣错误
EPOLLHUP 表示连接被挂起
EPOLLET 将触发⽅式设置为边缘触发,系统默认为⽔平触发
EPOLLONESHOT 表示该事件只处理⼀次,下次需要处理时需重新加⼊epoll
假如现在我们将监听文件描述符添加到内核事件表中:
struct epoll_event event;
event.events=EPOLLIN;//监听读事件
event.data.fd=listenfd;//被监听的文件描述符
int res=epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&event);//将event结构体中存储的事件类型添加到内核事件表中。
【3. 开始监听内核事件表的事件】
int epoll_wait(int epfd,struct epoll_event events[],int maxevents,int timeout)
// 成功返回就绪个数,失败-1,超时0
参数:
- epfd:内核事件表;
- events[]:events的数据是 由内核在epoll_wait返回时填充的,有事件就绪的文件描述符和就绪的事件类型;
- maxevents:数组的长度,表示一次epoll_wait最多返回多少个就绪的文件描述符。
- timeout:监听时间。
收集 epoll 监控的事件中已经发⽣的事件,如果 epoll 中没有任何⼀个事件发⽣,则最多等待timeout 毫秒后返回。
返回:表示当前发⽣的事件个数
返回0表示本次没有事件发⽣;
返回-1表示出现错误,需要检查errno错误码判断错误类型。
epoll的特点:
【1. 速度快:】
- select中为fd_set,poll 为 struct pollfd fds[],它们都是用户创建的,用户态存在,每调用一次select或者poll,都会存在两次用户态->内核,内核->用户的数据拷贝,一次是在调用时,一次是在返回时。这样数据结构越大,则越慢。
- epoll直接就在内核中记录,将其都记录在内核事件表上,在监听时不需要进行拷贝,所以速度比他俩快.
【 2. 查找时间复杂度低:】
- select,poll返回就绪事件的个数,但不知道在哪,所以要轮询找,每次用户检索就绪事件的时间复杂度为O(N)。
- epoll直接返回就绪事件链表,链表中有值就有就绪事件,所以每次都是就绪的不用找,每次用户检索就绪事件的时间复杂度为O(1)。
【3. 采取回调函数方式处理就绪事件】
- select,poll采用轮询方式检测是否有事件发生,循环检测是否有事件就绪,直到找不到。适合关注的文件描述每次都有很大的机率就绪,1000个有900多个就绪,那么就很适合,但是1000个只有一个事件发生,还是要进行轮询1000多次,函数栈帧空间频繁,影响效果;
- epoll的内核采用的是回调的方式检测文件描述符是否有事件发生。假设1000个,把文件描述符添加到事件表上,文件描述符在红黑树上挂载,除了值,事件类型,还带有一个回调函数,如果文件描述符有事件发生,它就调用回调函数,回调函数的作用是有事件就绪时就把文件描述符添加到双向链表中,返回时把链表的值拷贝到用户态。1000个一个事件发生只会触发一次回调,不用多次循环。适合很多文件描述符,就绪事件描述符少。
用epoll实现tcp服务器的并发
因为epoll封装了很多函数,所以操作起来比起selet和poll代码简洁了很多
服务器代码如下:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
//网络头文件
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<sys/epoll.h>
#define MAX 10//定义最大连接数为10个
int InitSocket()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd == -1) return -1;
struct sockaddr_in ser;//指明地址信息,是一种通用的套接字地址
memset(&ser,0,sizeof(ser));
ser.sin_family = AF_INET;//设置地址家族
ser.sin_port = htons(6000);//设置端口
ser.sin_addr.s_addr = inet_addr("127.0.0.1");//设置地址
int res = bind(sockfd,(struct sockaddr*)&ser,sizeof(ser));//绑定端口号和地址
if(res == -1) return -1;
res = listen(sockfd,5);
if(res == -1) return -1;
return sockfd;
}
void epoll_add(int epfd,int fd)
{
struct epoll_event ev;
ev.events=EPOLLIN;//读
ev.data.fd=fd;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev)==-1)
{
printf("epoll add failed\n");
}
}
void epoll_del(int epfd,int fd)
{
if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL)==-1)
{
printf("epoll del failed\n");
}
}
void accept_client(int epfd,int sockfd)
{
struct sockaddr_in caddr;
int len=sizeof(caddr);
int c=accept(sockfd,(struct sockaddr*)&caddr,&len);
if(c<0)
{
return ;
}
printf("accpet c=%d ip=%s\n",c,inet_ntoa(caddr.sin_addr));
epoll_add(epfd,c);
}
void recv_data(int epfd,int c)
{
char buff[128]={0};
int num=recv(c,buff,127,0);
if(num<=0)//如果num==0说明客户端结束了描述符号
{
epoll_del(epfd,c);//移除改客户端对应的描述符
close(c);
printf("client close\n");
return ;
}
printf("buff (%d)=%s\n",c,buff);
send(c,"ok",2,0);
}
int main()
{
int sockfd = InitSocket();//监听套接字,有客户端链接时就会触发读事件。
assert(sockfd != -1);
//创建内核事件表
int epfd=epoll_create(MAX);//底层,红黑树
if(epfd==-1)
{
exit(1);
}
epoll_add(epfd,sockfd);//将监听套接子添加到内核事件表
struct epoll_event evs[MAX];//用来接收就绪的文件描述符
while(1)
{
int n=epoll_wait(epfd,evs,MAX,5000);
if(n==-1)
{
printf("err\n");
}
else if(n==0)
{
printf("time out\n");
}
else
{//前n个元素是数据就绪的
for(int i=0;i<n;++i)
{
int fd=evs[i].data.fd;
if(evs[i].events&EPOLLIN)//看读事件是不是就绪
{
if(fd==sockfd)
{
accept_client(epfd,sockfd);
}
else
{
recv_data(epfd,fd);
}
}
//if(evs[i].events&EPOLLOUT)
}
}
}
close(sockfd);
}
客户端的代码都是一样的,这里我就不粘了
ヾ(◍°∇°◍)ノ゙