文章目录
上网一搜epoll,基本是这样的结果出来:《多路转接I/O – epoll模型》,万变不离这个标题。
但是呢,不变的事物,我们就更应该抓出其中的重点了。
多路、转接、I/O、模型。
别急,先记住这几个词,我比较喜欢你们看我文章的时候带着问题。
记住我,CSDN搜“看,未来”~~
https://blog.csdn.net/qq_43762191
什么是epoll?或者说,它和select有什么判别?
什么是select
有的朋友可能对select也不是很了解啊,我这里稍微科普一下:网络连接,服务器也是通过文件描述符来管理这些连接上来的客户端,既然是供连接的服务器,那就免不了要接收来自客户端的消息。那么多台客户端,消息那么的多,要是漏了一条两条重要消息,那也不要用TCP了,那怎么办?
前辈们就是有办法,轮询,轮询每个客户端文件描述符,查看他们是否带着消息,如果带着,那就处理一下;如果没带着,那就一边等着去。这就是select,轮询,颇有点领导下基层的那种感觉哈。
但是这个select的轮询呐,会有个问题,明眼人一下就能想到,那即是耗费资源啊,耗费什么资源,时间呐,慢呐(其实也挺快了,不过相对epoll来说就是慢)。
再认真想一下,还浪费什么资源,系统资源。有的客户端呐,占着那啥玩意儿不干那啥事儿,这种客户端呐,还不少。这也怪不得人家,哪儿有客户端时时刻刻在发消息,要是有,那就要小心是不是恶意***了。那把这么一堆偶尔动一下的客户端的文件描述符一直攥手里,累不累?能一次攥多少个?就像一个老板,一直想着下去巡视,那他可以去当车间组长了哈哈哈。
所以,select的默认上限一般是1024(FD_SETSIZE),当然我们可以手动去改,但是人家给个1024自然有人家的道理,改太大的话系统在这一块的负载就大了。
那句话怎么说的来着,你每次对系统的索取,其实都早已明码标价!哈哈哈。。。
所以,我们选用epoll模型。
什么是epoll
epoll接口是为解决Linux内核处理大量文件描述符而提出的方案。该接口属于Linux下多路I/O复用接口中select/poll的增强。其经常应用于Linux下高并发服务型程序,特别是在大量并发连接中只有少部分连接处于活跃下的情况 (通常是这种情况),在该情况下能显著的提高程序的CPU利用率。
前面说,select就像亲自下基层视察的老板,那么epoll这个老板就要显得精明的多了。他可不亲自下基层,他找了个美女秘书,他只要盯着他的秘书看就行了,呸,他只需要听取他的秘书的汇报就行了。汇报啥呢?基层有任何消息,跟秘书说,秘书汇总之后一次性交给老板来处理。这样老板的时间不就大大的提高了嘛。
如果你学过设计模式,这就是典型的“命令模式”,非常符合“依赖倒置原则”,这是一个非常美妙的模式,这个原则也是我最喜欢的一个原则,将高层实现与低层实现解耦合,从而可以各自开发,只要接口一致便可,这个接口,就是秘书。
扯远了,如果对“设计模式”有兴趣,可以找我的专栏。
好,言归正传哈哈哈。
epoll设计思路简介
- (1)epoll在Linux内核中构建了一个文件系统,该文件系统采用红黑树来构建,红黑树在增加和删除上面的效率极高,因此是epoll高效的原因之一。有兴趣可以百度红黑树了解,但在这里你只需知道其算法效率超高即可。
- (2)epoll提供了两种触发模式,水平触发(LT)和边沿触发(ET)。当然,涉及到I/O操作也必然会有阻塞和非阻塞两种方案。目前效率相对较高的是 epoll+ET+非阻塞I/O 模型,在具体情况下应该合理选用当前情形中最优的搭配方案。
- (3)epoll所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于1024,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以下面语句查看,一般来说这个数目和系统内存关系很大。
系统最大打开文件描述符数
cat /proc/sys/fs/file-max
进程最大打开文件描述符数
ulimit -n
修改这个配置:
sudo vi /etc/security/limits.conf
写入以下配置,soft软限制,hard硬限制
* soft nofile 65536
* hard nofile 100000
那个65536,改一下,不碍事。
边缘触发与水平触发,阻塞I/O与非阻塞I/O
阻塞I/O与非阻塞I/O
为了方便理解后面的内容,我们先看几张图,关于阻塞与非阻塞I/O的。
阻塞式文件I/O
非阻塞式文件I/O
多路复用I/O
好,有了上面这几张图垫着,咱来看看边缘触发和水平触发。
ET V/S LT
EPOLL 事件有两种模型:
Edge Triggered (ET) 边缘触发 只有新数据到来,才触发,不管缓存区中是否还有数据。
Level Triggered (LT) 水平触发 只要有数据都会触发,不管数据是哪里的。
(这样表述会不会好理解一些)
LT(level triggered) 是 缺省 的工作方式 ,并且同时支持 block 和 no-block socket. 在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的 select/poll 都是这种模型的代表.
ET(edge-triggered) 是高速工作方式 ,只支持 no-block socket 。在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了 ( 比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个 EWOULDBLOCK 错误)。但是请注意,如果一直不对这个 fd 作 IO 操作 ( 从而导致它再次变成未就绪 ) ,内核不会发送更多的通知 (only once), 不过在 TCP 协议中, ET 模式的加速效用仍需要更多的 benchmark 确认。
epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读 / 阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用 ET 模式的 epoll 接口,在后面会介绍避免可能的缺陷。
- 基于非阻塞文件句柄
- 只有当 read(2) 或者 write(2) 返回 EAGAIN 时才需要挂起,等待。但这并不是说每次 read() 时都需要循环读,直到读到产生一个 EAGAIN 才认为此次事件处理完成,当 read() 返回的读到的数据长度小于请求的数据长度时,就可以确定此时缓冲中已没有数据了,也就可以认为此事读事件已处理完成。
epoll API
讲了这么多概念的东西,来看看API吧。
头文件
#include<sys/epoll.h>
创建句柄
int epoll_create(int size);
创建一个epoll句柄,参数size用于告诉内核监听的文件描述符个数,跟内存大小有关。
返回epoll 文件描述符
epoll控制函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event ); // 成功,返0,失败返-1
控制某个epoll监控的文件描述符上的事件:注册,修改,删除
参数释义:
epfd:为epoll的句柄
op:表示动作,用3个宏来表示
··· EPOLL_CTL_ADD(注册新的 fd 到epfd)
··· EPOLL_CTL_DEL(从 epfd 中删除一个 fd)
··· EPOLL_CTL_MOD(修改已经注册的 fd 监听事件)
event:告诉内核需要监听的事件
typedef union epoll_data
{
void* ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t; /* 保存触发事件的某个文件描述符相关的数据 */
struct epoll_event
{
__uint32_t events; /* epoll event */
epoll_data_t data; /* User data variable */
};
/* epoll_event.events:
EPOLLIN 表示对应的文件描述符可以读
EPOLLOUT 表示对应的文件描述符可以写
EPOLLPRI 表示对应的文件描述符有紧急的数据可读
EPOLLERR 表示对应的文件描述符发生错误
EPOLLHUP 表示对应的文件描述符被挂断
EPOLLET 表示对应的文件描述符有事件发生
*/
epoll消息读取
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待所监控文件描述符上有事件的产生
参数释义:
events:用来从内核得到事件的集合
maxevent:用于告诉内核这个event有多大,这个maxevent不能大于创建句柄时的size
timeout:超时时间
··· -1:阻塞
··· 0:立即返回
···>0:指定微秒
成功返回有多少个文件描述符准备就绪,时间到返回0,出错返回-1.
代码示例
这里我要声明一下,如果有疑义,可以在下面评论,因为我之前初学的时候那篇epoll被人口吐芬芳哈哈哈。
下面这个代码,可以实现万级并发。
/*server.c*/
#include<stdio.h>
#include<string.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/epoll.h>
#include<errno.h>
#define MAXLINE 80
#define SERV_PORT 8000
#include OPEN_MAX 1024
int main(void)
{
struct sockaddr_in servaddr,cliaddr;
socklen_t cliaddr_len;
int i,j,maxi,listenfd,connfd,sockfd;
int nready,efd,res,client[OPEN_MAX];
ssize_t n;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
struct epoll_event tep,ep[OPEN_MAX];
listenfd = socket(AF_INET,SOCK_STREAM,0);
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htonl(SERV_PORT);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr));
listen(listenfd,20);
maxfd = listenfd; //初始化
maxi = -1; //client[]的下标
for(i = 0 ; i < OPEN_MAX ; i++ )
client[i] = -1; //用-1初始化client
//套路开始
efd = epoll_create(OPEN_MAX); //创建句柄
if(efd == -1)
perrno("epoll_create");
tep.events = EPOLLIN; //设置读事件
tep.data.fd = listenfd; //套接socket文件描述符
res = epoll_ctl(efd,EPOLL_CTL_ADD,listened,&tep); //将listenfd加入监听文件表,监听listenfd的读取内容
if(res == -1)
perrno("epoll_ctl");
while(1)
{
nready = epoll_wait(efd,ep,OPEN_MAX,-1); //阻塞监听
if(nready == -1)
perrno("epoll_wait error:");
for(i == 0; i<nready; i++)
{
if(!ep[i].event & EPOLLIN)
continue;
if(ep[i].data.fd == listenfd)
{
//开始接收数据了
printf("Accepting connections··· \n"); //写完一定要来检查一下这个换行,一不小心就忘记了
cliaddr_len = sizeof(cliaddr); //这得实时更新
connfd = accept(listenfd,(struct sockaddr *)&cliaddr,&cliaddr_len); //接收连接
printf("Read from %s at port %d \n",inet_ntop(AF_INET,&cliaddr.sin_addr,str,sizeof(str)),ntohs(cliaddr.sin_port));
/*将客户端的地址读取到str里面然后打印*/ /*将端口号转换成整形数输出*/
for(j = 0;j < OPEN_MAX; j++)
{
if(client[j] < 0)
{
client[j] = connfd; //保存accept返回的文件描述符到client【】里
break;
}
if( j == OPEN_MAX )
{
printf("Too many clients \n",stderr);
exit(-1);
}
if(j > maxi)
maxi = j;
}
tep.events = EPOLLIN;
tep.data.fd = connfd;
res = epoll_ctl(efd,EPOLL_CTL_ADD,connfd,&tep); //将connfd加入监听文件表,监听connfd的读取内容
if(res == -1)
perrno("epoll_ctl");
else
{
sockfd = ep[i].data.fd;
n = read(sockfd,buf,MAXLINE);
if(n == 0)
{
for(j = 0 ; j <= maxi ; j++)
{
if(client[i] == sockfd)
{
client[j] = -1;
break;
}
}
res = epoll_ctl(efd,EPOLL_CTL_ADD,sockfd,&tep); //将sockfd加入监听文件表,监听sockfd的读取内容
if(res == -1)
perrno("epoll_ctl")
close(sockfd);
printf("Client[%d] closed connetion \n",j);
}
else
{
for(j = 0; j<n; j++)
buf[j] = toupper(buf[j]);
write(sockfd,buf,n);
}
}
}
}
}
close(listenfd);
close(efd)
return 0;;
}
如果有需要,在下面评论讨论,我的项目用的是C++,不是C,这套代码是用作测试端与服务器速度比对的。
番外:高效的并发方式
并发编程的目的是让程序”同时”执行多个任务。如果程序是计算密集型的,并发编程并没有什么优势,反而由于任务的切换使效率降低。但如果程序是I/O密集型的,那就不同了。
并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法,服务器主要有两种并发编程模式:半同步/半异步(half-sync/half-async)模式和领导者/追随者(Leader/Followers)模式。
这里讲一个“半同步/半异步”。
下面的内容需要有一定的基础了,小白可以收藏一下以后变强了再看。
半同步/半异步模式
在半同步/半异步模式中,同步线程用于处理客户逻辑,异步线程用于处理I/O事件。异步线程监听到客户请求之后就将其封装成请求对象并插入到请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
半同步/半反应堆模式(half-sync/half-reactive模式)
半同步/半反应堆模式是半同步/半异步模式的一种变体。
其结构如下图:
在上图中,异步线程只有一个,由主线程充当,负责监听socket上的事件。如果监听socket上有新的连接请求到来,主线程就接受新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入到请求队列中,所有工作线程都睡眠在请求队列上,当有任务到来时,他们通过竞争来获取任务的接管权。
由于主线程插入请求队列中的任务是就绪的连接socket,所以该半同步/半反应堆模式所采用的事件处理模式是Reactor模式,即工作线程要自己从socket上读写数据。当然,半同步/半反应堆模式也可以用模拟的Proactor事件处理模式,即由主线程来完成数据的读写操作,此时主线程将应用程序数据、任务类型等信息封装为一个任务对象,然后将其插入到请求队列。
半同步/半反应堆模式的缺点:
主线程和工作线程共享请求队列,因而请求队列是临界资源,所以对请求队列操作的时候需要加锁保护。
每个工作线程在同一时间只能处理一个客户请求。如果客户数量增多,则请求队列中堆积任务太多,客户端的响应会越来越慢。如果增多工作线程的话,则线程的切花也将消耗大量的CPU时间。
高效的半同步/半异步模式
在半同步/半反应堆模式中,每个工作线程同时只能处理一个客户请求,如果并发量大的话,客户端响应会很慢。如果每个工作线程都能同时处理多个客户链接,则就能改善这种情况,所以就有了高效的半同步/半异步模式。
其结构如图:
主线程只管监听socket,当有新的连接socket到来时,主线程就接受连接并返回新的连接socket给某个工作线程。此后该新连接socket上的任何I/O操作都由被选中的工作线程来处理,直到客户端关闭连接。当工作线程检测到有新的连接socket到来时,就把该新的连接socket的读写事件注册到自己的epoll内核事件表中。
主线程和工作线程都维持自己的事件循环,他们各自独立的监听不同事件。因此在这种高效的半同步/半异步模式中,每个线程都工作在异步模式中,所以它并非严格意义上的半同步/半异步模式。