文章目录
15 优于select的epoll
15.1 epoll理解及应用
select复用方法由来已久,由于无法同时接入上百个客户端,所以并不适合以Web服务端开发为主流的现代开发环境,所以需要linux平台下的epoll
15.1.1 基于select的I/O复用技术速度慢的原因
第10章曾实现过基于select的I/O复用服务器端,很容易从代码上分析出不合理的设计,主要以下两点:
- 调用select函数后常见的针对所有文件描述符的循环语句
- 每次调用select函数时都需要向该函数传递监视对象信息
调用 select 函数后,并不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的 fd_set 变量的变化,找出发生变化的文件描述符,因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的 fd_set 变量会发生变化,所以每次循环调用 select 时都要传进去新的 fd_set 变量。
后者导致的性能问题无法避免,因为每次调用 select 函数时要向操作系统传递监视对象信息,会经历用户空间到内核空间的复制(FD_ISSET由操作系统判断),循环遍历时,还要将整个变量从内核空间复制到用户空间。如果只复制一次给操作系统,由操作系统监视,然后通知发送变化的事项就好了,所以Linux有了epoll。
15.1.2 select的优点
大部分操作系统都支持select函数,只要满足或要求如下两个条件,即使在Linux平台也不应拘泥于epoll
- 服务器端接入者少
- 程序应具有兼容性
15.1.3 实现epoll时必要的函数和结构体
能够克服select函数缺点的epoll函数具有以下优点,恰好与之前的select函数缺点相反。
- 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
- 调用对应于select函数的epoll_wait函数时无需每次传递监视对象信息。
下面是实现epoll服务端需要的3个函数,结合epoll的优点理解这些函数的功能。
- epoll_create1/epoll_create:创建保存 epoll 文件描述符的空间(epoll例程)
- epoll_ctl:向空间注册并注销文件描述符。
- epoll_wait:与select函数类似,等待文件描述符发生变化。
特性/功能 | select方式 | epoll方式 |
---|---|---|
保存监视对象文件描述符 | 直接声明 fd_set 变量 | 使用 epoll_creat1/epoll_create 请求操作系统创建空间 |
添加/删除监视对象 | FD_SET 和 FD_CLR | 使用 epoll_ctl 函数请求操作系统完成 |
等待文件描述符变化 | 调用 select 函数 | 调用 epoll_wait 函数 |
查看状态变化 | 通过 fd_set 查看监视对象状态变化 | 通过 epoll_event结构体将发生变化的文件描述符单独集中到一起 |
事件处理方式 | 每次调用需遍历所有文件描述符 | 仅返回发生事件的文件描述符 |
适用场景 | 适合少量文件描述符的场景 | 适合大量文件描述符的高并发场景 |
struct epoll_event {
epoll_data_t data; //哪个文件描述符发生了变化(监视哪个文件描述符)
__uint32_t events; //出现了什么变化(监听文件描述符的什么事件)
/* (1)EPOLLIN:需要读取数据的情况(可读取的数据)
* (2)EPOLLOUT:输出缓冲为空,可以立即发送数据的情况
* (3)EPOLLPRI:收到OOB数据的情况
* (4)EPOLLRDHUB:断开连接或者半关闭的情况,这在边缘触发方式下非常有用
* (5)EPOLLERR:发生错误的情况
* (6)EPOLLET:以边缘触发方式得到事件通知
* (7)EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。
* 因此需要向epoll_ctl函数的第二个参数传递EPOLL_CTL_MOD,再设置事件
*/
}
typedef union epoll_data
{
void* ptr;
int fd; //监视的文件描述符(通常只关注该成员)
__uint32_t u32;
__uint64_t u64;
}epoll_data_t;
15.1.4 epoll_creat1
#include <sys/epoll.h>
int epoll_create1(int flag);
/*
* flag:如果填0,直接创建epoll,也可以标记获得不同的行为
* return:成功时返回epoll文件描述符,失败时返回-1
*/
调用 epoll_create1 函数时创建的文件描述符保存空间称为 "epoll例程"。epoll_ create1 函数创建的资源与套接字相同,也由操作系统管理。 因此,该函数也会返回文件描述符。 也就是说,该函数返回的文件描述符主要用与于区分 epoll 例程。 需要终止时与其他文件描述符相同,也要调用close函数。
15.1.5 epoll_ctl
生成 epoll 的例程后,接下来就是在其内部注册监视对象文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
/*
* epfd:epoll实例的文件描述符(需要监听的epoll池)
* op:用于指定监视对象的添加、删除或更改等操作
* (1)EPOLL_CTL_ADD:将文件描述符添加到epoll例程
* (2)EPOLL_CTL_DEL:删除
* (3)EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况。
* fd:需要注册的监视对象文件描述符(需要监听的文件描述符)
* event:保存发生事件的文件描述符集合的结构体地址
* return:成功返回0,失败返回-1
*/
例如 epoll_ctl(A,EPOLL_CTL_ADD,B,C); 意思是 “在例程A中注册文件描述符B,主要目的是监视参数C中的事件”
15.1.6 epoll_wait
声明足够大的 epoll_even 结构体数组后,传递给 epoll_wait 函数时,发生变化的文件描述符信息将被填入该数组。 因此,无需像 select 函数那样针对所有文件描述符进行循环。
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
/*
* epfd:epoll实例的文件描述符(需要监听的epoll池)
* events:提供给内核,用于返回已就绪的文件描述符的信息,空间需要动态分配
* maxevents:可以返回最大文件描述符数量
* timeout:超时时间,指明epoll_wait()在事件触发前阻塞等待最大秒数
* -1表示一直等待至有事件发生,0表示无论是否有事件发生立即返回
* return:成功时返回发生变化的文件描述符数量,超时仍没有就绪事件返回0,失败返回-1
*/
15.1.7 基于epoll的回声服务器端
基于第10章I/O复用服务器端实现了该服务器端实例,客户端代码与第三章回声客户端代码相同。
#define BUF_SIZE 1024
#define EPOLL_SIZE 50
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char *argv[])
{
int serv_sock,clnt_sock;
struct sockaddr_in serv_addr,clnt_addr;
//epoll_ctl的参数
struct epoll_event event;
//epoll_wait的参数,因为需要动态创建所以声明成指针形式
struct epoll_event* ep_events;
char* buf = malloc(sizeof(char)*BUF_SIZE);
memset(&serv_addr,0,sizeof(serv_addr));
memset(&clnt_addr,0,sizeof(clnt_addr));
if(argc != 2) {
printf("Usage:%s <port>\n",argv[0]);
exit(1);
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(atoi(argv[1]));
inet_pton(AF_INET,"0.0.0.0",&serv_addr.sin_addr);
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
//设置地址再分配
int option = 1;
setsockopt(serv_sock,SOL_SOCKET,SO_REUSEADDR,&option,sizeof(option));
printf("服务端的文件描述符是:%d\n",serv_sock);
if(bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");
//创建例程
int epfd = epoll_create1(0);
//为ep_events动态创建大小
ep_events = malloc(sizeof(struct epoll_event*)&EPOLL_SIZE);
//注册服务端文件描述符
event.events = EPOLLIN;
event.data.fd = serv_sock;
//为例程epfd注册文件描述符serv_sock,主要目的是监视serv_sock中的EPOLLIN事件
epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event);
while(1) {
//等待文件描述符发生变化,成功则返回发生事件的文件描述符数
int event_cnt = epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);
if(event_cnt==-1) {
puts("epoll_wait() error");
break;
}
//遍历发生变化的文件描述符集合
for(int i = 0;i < event_cnt;i++) {
//如果发生变化的是服务端文件描述符,则接受连接
if(ep_events[i].data.fd == serv_sock) {
socklen_t adr_sz = sizeof(clnt_addr);
int clnt_sock = accept(serv_sock,(struct sockaddr*)&clnt_addr,&adr_sz);
//注册客户端文件描述符
event.events = EPOLLIN;
event.data.fd = clnt_sock;
epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sock,&event);
printf("connected client:%d\n",clnt_sock);
}
//如果发生变化的是客户端文件描述符,收发消息
else {
//有可能与多个客户端相连并且客户端同时发送消息,所以可能同时变换很多个文件描述符
//i指的是当前发起请求的客户端
int str_len = recv(ep_events[i].data.fd,buf,BUF_SIZE,0);
//连接正常关闭
if(str_len==0) {
//将该文件描述符从例程中移除
epoll_ctl(epfd,EPOLL_CTL_DEL,ep_events[i].data.fd,NULL);
close(ep_events[i].data.fd);
printf("closed client:%d\n",ep_events[i].data.fd);
}
else {
send(ep_events[i].data.fd,buf,str_len,0);
}
}
}
}
close(serv_sock);
close(epfd);
free(buf);
return 0;
}
15.2 条件触发和边缘触发
15.2.1 条件触发和边缘触发的区别在于发生事件的时间点
条件触发中,只要输入缓冲中有数据就会一直通知该事件。例如,服务器端输入缓冲收到50字节数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但服务器端读取20字节后还剩30字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩余数据,就将以事件方式再次注册。
边缘触发中输入缓冲收到数据时仅注册1次该事件。即使输入缓冲中还留有数据,也不会再进行注册。
15.2.2 掌握条件触发的事件特性
epoll 默认以条件触发方式工作,因此,通过修改15.1.7小节服务器端代码验证条件触发特性
- 将调用recv函数时使用的缓冲大小 BUF_SIZE 缩减为512字节
- 在 epoll_wait 函数下方插入验证该函数调用次数的语句
减少缓冲大小是为了阻止服务器端一次性读取接收的数据。换言之,调用 recv 函数后,输人缓冲中仍有数据需要读取,因此会注册新的事件并从 epoll_wait 函数返回。
对于采用条件触发工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。当应用程序下一次调用 epoll_wait 时,epoll_wai还会再次向应用程序通告此事件,直到此事件被处理。(意思是:这次没读完,下次接着读)
从运行结果中看,每当收到客户端数据时,应用程序没有立即完成对事件的处理(没有一次读完数据),因此,会将未完成事件重新注册到 “例程” 中,epoll_wait 重新监测到该事件并通告该事件让应用程序继续处理。下面将上述示例改成边缘触发方式,只需要在条件触发代码基础上修改如下代码即可
//注册客户端文件描述符
event.events = EPOLLIN|EPOLLET;
更改代码后,从客户端接收数据,仅输出1次 "return epoll_wait" 字符串,这意味着仅注册1次事件。但是客户端运行时发生错误,下一小节对错误进行分析。
15.2.3 边缘触发的服务器端实现中必知的两点
如下两点是实现边缘触发的必知内容
- 通过errno变量验证错误原因
- 为了完成非阻塞I/O,更改套接字特性
套接字相关函数在失败时通常会返回 -1,但这并不能直接提供失败的具体原因。为了提供额外的错误信息,Linux 使用了全局变量 errno。为了访问该变量,需要包含头文件 <errno.h>。每个函数在发生错误时,会将不同的错误代码保存到 errno 变量中,具体的错误代码含义可以通过查阅相关资料得知,通常无需记住所有可能的错误值。
Linux提供了更改或读取文件属性的如下方法
#include <fcntl.h>
int fcntl(int filedes, int cmd, ...);
/*
* filedes:属性更改目标的文件描述符
* cmd:执行的操作,常用的有F_GETFL和F_SETFL
* F_GETFL:返回文件描述符的权限模式和状态标记,不需要额外参数
* F_SETFL:返回文件的状态标记设置为第三个参数指定的值
* ...(可变参数):cmd需要的参数,可以没有
* return:成功时返回cmd参数相关值。失败返回-1
*/
如希望将文件修改为非阻塞模式,需要如下2条语句
int flag = fcntl(fd, F_GETFL, 0); //获取当前文件的状态标志
fcntl(fd, F_SETFL, flag | O_NONBLOCK); //设置文件状态标志
通过第一条语句获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞
O_NONBLOCK标志。调用read&write函数时,无论是否存在数据,都会形成非阻塞文件(套接
字)。
15.2.4 实现边缘触发的回声服务器端
(1)为什么需要errno确认错误原因?
“边缘触发方式中,接收数据时仅注册1次该事件” 就因为这种特点,一旦发生输入相关事件,就应该读取输入缓冲中全部数据,因此需要验证输入缓冲是否为空。
(2)为社么需要将套接字变成非阻塞模式?
边缘触发方式下,以阻塞方式工作的 recv&send 函数有可能引起服务器端的长时间停顿。因此,边缘触发方式中一定要采用非阻塞 recv&send 函数。
(3)以边缘触发方式工作的回声服务器端示例
#define BUF_SIZE 4
#define EPOLL_SIZE 50 //例程大小
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
void setnonblockingmode(int fd) {
int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}
int main(int argc, char *argv[])
{
int serv_sock,clnt_sock;
struct sockaddr_in serv_addr,clnt_addr;
//epoll_ctl的参数
struct epoll_event event;
//epoll_wait的参数,因为需要动态创建所以声明成指针形式
struct epoll_event* ep_events;
char* buf = malloc(sizeof(char)*BUF_SIZE);
memset(&serv_addr,0,sizeof(serv_addr));
memset(&clnt_addr,0,sizeof(clnt_addr));
if(argc != 2) {
printf("Usage:%s <port>\n",argv[0]);
exit(1);
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(atoi(argv[1]));
inet_pton(AF_INET,"0.0.0.0",&serv_addr.sin_addr);
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
//设置地址再分配
int option = 1;
setsockopt(serv_sock,SOL_SOCKET,SO_REUSEADDR,(void*)&option,sizeof(option));
printf("服务端的文件描述符是:%d\n",serv_sock);
if(bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");
//将serv_sock更改为非阻塞
setnonblockingmode(serv_sock);
//创建例程
int epfd = epoll_create1(0);
//为ep_events动态创建大小
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
//注册服务端文件描述符
event.events = EPOLLIN;
event.data.fd = serv_sock;
//为例程epfd注册文件描述符serv_sock,主要目的是监视serv_sock中的EPOLLIN事件
epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event);
while(1) {
//等待文件描述符发生变化,成功则返回发生事件的文件描述符数
int event_cnt = epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);
if(event_cnt==-1) {
puts("epoll_wait() error");
break;
}
//遍历发生变化的文件描述符集合
puts("return epoll wait");
for(int i = 0;i < event_cnt;i++) {
//如果发生变化的是服务端文件描述符,则接受连接
if(ep_events[i].data.fd == serv_sock) {
socklen_t adr_sz = sizeof(clnt_addr);
int clnt_sock = accept(serv_sock,(struct sockaddr*)&clnt_addr,&adr_sz);
//将clnt_sock更改为非阻塞
setnonblockingmode(clnt_sock);
//注册客户端文件描述符
event.events = EPOLLIN|EPOLLET;
event.data.fd = clnt_sock;
epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sock,&event);
printf("connected client:%d\n",clnt_sock);
}
//如果发生变化的是客户端文件描述符,收发消息
else {
//边缘触发方式中,发生事件时需要读取缓冲中所有数据,因此需要循环调用recv函数
while(1) {
memset(buf,0,BUF_SIZE);
//有可能与多个客户端相连并且客户端同时发送消息,所以可能同时变换很多个文件描述符
//i指的是当前发起请求的客户端
int str_len = recv(ep_events[i].data.fd,buf,BUF_SIZE,0);
printf("str_len=%d\n",str_len);
if(str_len==0) {
//将该文件描述符从例程中移除
epoll_ctl(epfd,EPOLL_CTL_DEL,ep_events[i].data.fd,NULL);
close(ep_events[i].data.fd);
printf("closed client:%d\n",ep_events[i].data.fd);
break;
}
//发生错误且errno值为EAGAIN时,说明没有数据可读
else if(str_len<0){
if(errno==EAGAIN) {
printf("读取了全部数据\n");
break;
}
}
else {
send(ep_events[i].data.fd,buf,str_len,0);
//printf("发送了数据:%s\n",buf);
}
}
}
}
}
close(serv_sock);
close(epfd);
free(buf);
return 0;
}
注:条件触发回声服务器中,在 recv 接收数据时没有使用 while 循环读取数据。而在边缘触发方式中,发生事件时需要读取缓冲中所有数据,因此需要while循环读取缓冲区中的全部数据
15.2.5 边缘触发和条件触发孰优孰劣
边缘触发优点:可以分离接收数据和处理数据的时间点。
试想如果数据量很大,不能一次读取完,则需要产生很多次事件,服务端压力会很大。
条件触发和边缘触发的区别主要应该从服务器端实现模型的角度谈论,因此并不存在“谁更快?快多少?”的问题。