基于 select
的 I/O 复用技术的优缺点分析
(一)缺点
之前已经在 TCP/IP 网络编程(十一)实现过基于 select
的 I/O 复用服务器端,很容易从代码中分析出不合理的设计,最主要的两点如下:
① 调用 select
函数后常见的针对所有文件描述符的循环语句。
② 每次调用 select
函数都需要向该函数传递监视对象信息。
其中 每次都需要传递监视对象信息 是提高性能的更大障碍。因为 每次调用 select
函数时是向操作系统传递监视对象信息。应用程序向操作系统传递数据将对程序造成很大负担,而且无法通过优化代码解决,因此称为性能上的致命弱点。
“为什么需要把监视对象传递给操作系统呢?”
因为 select
函数是监视套接字变化的函数。而套接字是由操作系统管理的,所以 select
函数绝对需要借助于操作系统才能完成功能。
(二)优点
select
函数具有兼容性,大部分操作系统都支持 select
函数。只要满足或要求如下两个条件,select
函数也是很好的选择:
① 服务器端接入者少。
② 程序应具有兼容性。
实现 epoll 时必要的函数和结构体
能够克服 select
函数缺点的 epoll
函数具有如下优点,这些优点正好与之前的 select
函数缺点相反:
① 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
② 调用对应于 select
函数的 epoll_wait
函数时无需每次传递监视对象信息。
(一)epoll_create
函数
select
方式中为了保存监视对象文件描述符,直接声明了 fd_set
变量。但 epoll
方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时用的函数就是 epoll_create
。
#include <sys/epoll.h>
// 成功时返回 epoll 文件描述符,失败时返回-1
int epoll_create(int size);
size
: 最初的epoll_create
版本需要传递一个整数size
,这个参数最初被用于告知内核预计需要监视的文件描述符数量,只是对操作系统的建议,并不能决定最终大小。然而在现代 Linux 内核中,这个参数实际上已经被忽略了。
现代版本的 Linux 内核中已经用 epoll_create1
替代了 epoll_create
,epoll_create1
的函数原型如下:
#include <sys/epoll.h>
// 成功时返回 epoll 文件描述符,失败时返回-1
int epoll_create1(int flags);
-
flags
: 该参数允许设置额外的选项,通常使用以下标志:EPOLL_CLOEXEC
: 创建的文件描述符将会设置close-on-exec
标志,这意味着当调用exec()
系列函数时,这个文件描述符将自动关闭。这个标志提高了程序的安全性,避免文件描述符在不小心的情况下泄漏到子进程中。
这两个函数创建的资源与套接字相同,也由操作系统管理。该函数返回的文件描述符主要用于区分 epoll 例程,需要终止时,与其他文件描述符相同,也要调用 close
函数。
(二)epoll_ctl
函数
生成 epoll 例程后,应在其内部注册监视对象文件描述符,此时使用 epoll_ctl
函数:
#include <sys/epoll.h>
// 成功时返回0,失败时返回-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epfd
: 由epoll_create
或epoll_create1
返回的epoll
实例的文件描述符。 -
op
: 要执行的操作类型,它可以是以下三种之一:EPOLL_CTL_ADD
: 向epoll
实例中添加一个新的文件描述符。EPOLL_CTL_MOD
: 修改已存在于epoll
实例中的文件描述符的事件。EPOLL_CTL_DEL
: 从epoll
实例中删除一个文件描述符。
-
fd
: 要添加、修改或删除的目标文件描述符。 -
event
: 指向epoll_event
结构体的指针,该结构体指定要监视的事件类型及相关数据。对于EPOLL_CTL_DEL
操作,该参数可以为NULL
。 -
epoll_event
结构体struct epoll_event { uint32_t events; /* epoll 事件掩码 */ epoll_data_t data; /* 用户数据 */ };
-
events
: 指定感兴趣的事件类型,可以是以下常见事件的组合:EPOLLIN
: 表示对应的文件描述符可以读(需要读取数据的情况)。EPOLLOUT
: 表示对应的文件描述符可以写(输出缓冲为空,可以立即发送数据的情况)。EPOLLRDHUP
: 表示对端关闭连接或半关闭。EPOLLPRI
: 表示有紧急数据可读(通常是带外数据)。EPOLLERR
: 表示对应的文件描述符发生错误。EPOLLHUP
: 表示对应的文件描述符挂起。EPOLLET
:表示以边缘触发的方式得到事件通知。EPOLLONESHOT
:发生一次事件后,相应文件描述符不再接收到事件通知。因此需要向epoll_ctl
函数的第二个参数传递EPOLL_CTL_MOD
,再次设置事件。
-
data
结构体: 用于存储用户数据,通常是文件描述符fd
,也可以是其他指针或整数数据。它通过epoll_data_t
联合体表示。typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
fd
: 一个整数,用于存储文件描述符。通常这是最常用的成员,用来标识与epoll
事件相关的文件描述符。
-
举例理解 epoll_ctl
函数的调用:
epoll_ctl(A, EPOLL_CTL_ADD, B, C);
表示 “epoll 例程 A 中注册文件描述符 B,主要目的是监视参数 C 中的事件。”
epoll_ctl(A, EPOLL_CTL_DEL, B, NULL);
表示 “从 epoll 例程 A 中删除文件描述符 B。”
(三)epoll_wait
函数
#include <sys/epoll.h>
// 成功时返回发生事件的文件描述符数,失败时返回-1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
-
epfd
: 由epoll_create
或epoll_create1
返回的epoll
实例的文件描述符。 -
events
: 指向epoll_event
结构体的数组的指针,该数组用于存储已经准备好的事件。调用成功后,该数组会被填充有待处理的事件信息。 -
maxevents
:events
数组的大小,指定本次调用最多能处理的事件数量。maxevents
必须大于 0。 -
timeout
: 超时值,以毫秒为单位。它可以是以下几种情况:timeout > 0
: 阻塞调用,最长等待timeout
毫秒。timeout = 0
: 非阻塞调用,立即返回。timeout = -1
: 无限期阻塞,直到有事件发生。
该函数调用方式如下。需要注意的是,第二个参数所指缓冲需要动态分配。
int event_cnt;
struct epoll_event* ep_events;
...
// EPOLL_SIZE 是宏常量
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE;
...
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
调用函数后,返回发生事件的文件描述符数,同时在第二个参数所指向的内存中保存发生事件的事件信息。
基于 epoll 的回声服务器端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define BUF_SIZE 100
#define EPOLL_SIZE 50
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];
struct epoll_event *ep_events;
struct epoll_event event;
int epfd, event_cnt;
if(argc!=2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_adr.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr))==-1)
error_handling("bind() error");
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
epfd=epoll_create(EPOLL_SIZE);
ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
event.events=EPOLLIN;
event.data.fd=serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
while(1)
{
event_cnt=epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if(event_cnt==-1)
{
puts("epoll_wait() error");
break;
}
for(i=0; i<event_cnt; i++)
{
if(ep_events[i].data.fd==serv_sock)
{
adr_sz=sizeof(clnt_adr);
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &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
{
str_len=read(ep_events[i].data.fd, buf, BUF_SIZE);
if(str_len==0) // close request!
{
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
{
write(ep_events[i].data.fd, buf, str_len); // echo!
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
-
epoll
实例创建:epoll_create()
创建一个epoll
实例,并返回一个文件描述符epfd
,用于管理epoll
事件。- 使用
malloc()
动态分配用于存储epoll
事件的内存。
-
将服务器套接字添加到
epoll
中:- 将服务器套接字
serv_sock
添加到epoll
实例中,用于监视其上的连接请求事件 (EPOLLIN
)。
- 将服务器套接字
-
事件循环:
- 进入一个无限循环,调用
epoll_wait()
来等待事件的发生。epoll_wait()
返回触发事件的文件描述符数量,并将事件存储在ep_events
数组中。 - 如果发生错误 (
epoll_wait()
返回-1
),则打印错误信息并退出循环。
- 进入一个无限循环,调用
-
处理事件:
- 如果是服务器套接字上的事件,表示有新的客户端连接请求,使用
accept()
接受连接,并将新的客户端套接字添加到epoll
实例中。 - 如果是客户端套接字上的事件,表示有数据可读。使用
read()
读取数据并回显给客户端。如果读取到的字节数为 0,则表示客户端关闭连接,调用epoll_ctl()
将客户端套接字从epoll
中移除,并关闭该套接字。
- 如果是服务器套接字上的事件,表示有新的客户端连接请求,使用
-
关闭套接字和清理资源:
- 在程序结束时,关闭服务器套接字和
epoll
实例文件描述符,释放动态分配的内存。
- 在程序结束时,关闭服务器套接字和
条件触发和边缘触发
(一)条件触发(水平触发,Level-Triggered)
(1)定义
在水平触发模式下,只要某个文件描述符上有数据可读或可写,epoll
就会不断触发事件,直到该文件描述符的条件被处理为止。
(2)行为
- 当
epoll_wait
检测到一个文件描述符上有数据可读(或可以写),它将返回这个文件描述符。 - 如果你在处理事件后没有完全消耗数据(例如,未读取所有数据),
epoll
会在下一次epoll_wait
调用时再次返回这个文件描述符,直到所有数据都被处理。
(3)优点
- 实现简单,适用于大多数场景,特别是当你不需要处理大量的并发连接时。
- 适合阻塞 I/O 操作和简单的事件处理逻辑。
(4)缺点
- 可能导致“虚假唤醒”,即文件描述符的事件处理没有被完全完成时,它会继续返回,导致不必要的多次调用
epoll_wait
,降低效率。
(5)验证条件触发的特性
echo_EPLTserv.c
:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define BUF_SIZE 4
#define EPOLL_SIZE 50
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];
struct epoll_event *ep_events;
struct epoll_event event;
int epfd, event_cnt;
if(argc!=2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_adr.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr))==-1)
error_handling("bind() error");
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
epfd=epoll_create(EPOLL_SIZE);
ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
event.events=EPOLLIN;
event.data.fd=serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
while(1)
{
event_cnt=epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if(event_cnt==-1)
{
puts("epoll_wait() error");
break;
}
puts("return epoll_wait");
for(i=0; i<event_cnt; i++)
{
if(ep_events[i].data.fd==serv_sock)
{
adr_sz=sizeof(clnt_adr);
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &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
{
str_len=read(ep_events[i].data.fd, buf, BUF_SIZE);
if(str_len==0) // close request!
{
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
{
write(ep_events[i].data.fd, buf, str_len); // echo!
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
- 第10行将调用
read
函数时使用的缓冲大小设置为4个字节,是为了阻止服务器端一次性读取接收的的数据,换言之,调用read
函数后,输入缓冲中仍有数据需要读取。 - 第58行为验证
epoll_wait
函数调用次数的语句。
该服务器端可与 TCP/IP 网络编程(四) 中的 echo_client.c
结合运行。
运行结果:
(二)边缘触发(Edge-Triggered)
(1)定义
在边缘触发模式下,epoll
仅在文件描述符的状态从非准备状态变为准备状态时触发一次事件。事件只在状态改变时触发,而不是持续触发。
(2)行为
- 当
epoll_wait
检测到文件描述符的状态发生变化(如数据变为可读或可写),它将返回该文件描述符。 - 一旦事件被触发,你必须一次性处理所有的数据。如果不完全处理,
epoll
不会再次触发相同的事件,直到状态发生变化。
(3)优点
- 高效,因为事件只在状态发生变化时触发,避免了多次触发同一个事件,适合高性能的 I/O 操作和大规模并发连接的场景。
- 可以减少不必要的系统调用,提高性能。
(4)缺点
- 实现复杂,需要在每次事件触发后确保处理所有可能的数据。通常需要使用非阻塞 I/O 操作和循环读取数据。
(5)边缘触发服务器实现实现的要点
下面两点是实现边缘触发的必知内容:
① 通过 error 变量验证错误原因。
在边缘触发模式下,epoll
不会持续提醒你某个文件描述符仍然可读或可写。当你处理事件时,如果一次性没有读取或写入所有数据,epoll
可能不会再触发该事件。这就需要通过检查 read
或 write
函数的返回值和 errno
错误码来确保所有数据都已被处理完毕。
例如,当 read
或 write
函数返回 -1
时,应该检查 errno
的值:
- 如果
errno
是EAGAIN
或EWOULDBLOCK
,这意味着数据已经被读完或写完,没有更多的数据需要处理。此时应该返回并等待下一次事件触发。 - 如果是其他错误码,则需要进行相应的错误处理。
② 为了完成非阻塞(Non-blocking)I/O,更改套接字特性。
“什么是非阻塞模式?”
在非阻塞模式下,如果你调用 read
或 write
,但操作无法立即完成(例如,数据还没有准备好),这些函数会立即返回,而不会让程序停下来等待。这让你的程序可以继续执行其他逻辑,或者再一次尝试读取或写入。
“为什么边缘触发需要将套接字设置为非阻塞模式?”
边缘触方式下,以阻塞方式工作的 read
和 write
函数有可能引起服务器端的长时间停顿。所以边缘触发方式中一定要采用非阻塞 read
和 write
函数。
“如何将套接字改为非阻塞模式?”
要将套接字设置为非阻塞模式,可以使用 fcntl
函数:
int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag | O_NONBLOCK);
-
fcntl(fd, F_GETFL, 0);
-
功能: 获取文件描述符
fd
的当前状态标志(file status flags)。 -
参数说明:
fd
:要操作的文件描述符。F_GETFL
:这是一个命令,用于获取文件描述符的状态标志。0
:此参数在使用F_GETFL
命令时不需要,用作占位。
-
返回值: 返回当前的文件状态标志,如果出现错误,则返回
-1
。
-
-
fcntl(fd, F_SETFL, flag | O_NONBLOCK);
-
功能: 将文件描述符
fd
的状态标志设置为非阻塞模式。 -
参数说明:
fd
:要操作的文件描述符。F_SETFL
:这是一个命令,用于设置文件描述符的状态标志。flag | O_NONBLOCK
:将原有的标志位flag
与O_NONBLOCK
进行按位或操作,保留原有标志的同时,添加非阻塞标志。
-
O_NONBLOCK
: 是一个标志,用于将文件描述符设置为非阻塞模式。
-
(6)实现边缘触发的回声服务器端
echo_EPETserv.c
:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define BUF_SIZE 4
#define EPOLL_SIZE 50
void setnonblockingmode(int fd);
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];
struct epoll_event *ep_events;
struct epoll_event event;
int epfd, event_cnt;
if(argc!=2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_adr.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr))==-1)
error_handling("bind() error");
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
epfd=epoll_create(EPOLL_SIZE);
ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
setnonblockingmode(serv_sock);
event.events=EPOLLIN;
event.data.fd=serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
while(1)
{
event_cnt=epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if(event_cnt==-1)
{
puts("epoll_wait() error");
break;
}
puts("return epoll_wait");
for(i=0; i<event_cnt; i++)
{
if(ep_events[i].data.fd==serv_sock)
{
adr_sz=sizeof(clnt_adr);
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
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
{
while(1)
{
str_len=read(ep_events[i].data.fd, buf, BUF_SIZE);
if(str_len==0) // close request!
{
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;
}
else if(str_len<0)
{
if(errno==EAGAIN)
break;
}
else
{
write(ep_events[i].data.fd, buf, str_len); // echo!
}
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
void setnonblockingmode(int fd)
{
int flag=fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag|O_NONBLOCK);
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
- 第11行:为了验证边缘触发的工作方式,将缓冲设置为4字节。
- 第61行:为观察事件发生数而添加的输出字符串的语句。
- 第68行:将
accept
函数创建的套接字改为非阻塞模式。 - 第69行:向
EPOLLIN
添加EPOLLET
标志,将套接字事件注册方式改为边缘触发。 - 第78行:边缘触发方式中,发生事件时需要读取输入缓冲中的所有数据,因此需要循环调用
read
函数。 - 第86行:当
read
函数返回 -1 且error
值为EAGAIN
时,意味着读取了输入缓冲中的全部数据,因此需要通过break
语句跳出循环。
问答
(一)采用边缘触发时可以分离数据的接收和处理时间点,说明其原因及优点。
采用边缘触发(Edge-Triggered, EPOLLET)模式时,数据的接收和处理时间点可以分离,这是因为边缘触发仅在事件状态发生变化时通知程序。例如,当数据到达套接字缓冲区并从无数据变为有数据时,边缘触发会通知程序,而不会重复通知。因此,程序可以选择何时实际处理这些数据。
原因
-
事件通知的方式:
- 边缘触发模式只在事件发生时通知程序,而不会在数据仍然可读的情况下重复触发通知。这意味着程序在接收到事件通知后,可以选择立即处理数据,也可以将处理推迟到合适的时间点。
-
非阻塞 I/O 的支持:
- 边缘触发通常配合非阻塞 I/O 使用。程序在接收到事件后,可以快速地检查并接收数据,然后将数据的处理推迟到稍后完成。通过非阻塞操作,程序在读取数据时不会被阻塞,这就允许程序在稍后合适的时间点处理这些数据。
优点
-
提高并发处理效率:
- 边缘触发模式下,程序可以优先接收所有数据,然后集中在某个时间点处理。这可以减少上下文切换,提高 CPU 利用率,因为程序可以批量处理数据而不是频繁地在处理和等待之间切换。
-
减少事件通知的开销:
- 边缘触发模式仅在事件发生时通知程序,避免了重复的事件通知,减少了系统调用的开销。对于高并发服务器,这种机制能够显著降低系统负担。
-
灵活性:
- 分离接收和处理数据的时间点使得程序有更大的灵活性。例如,程序可以在高优先级任务完成后再处理数据,或者在负载较低时批量处理积累的数据。
-
更好地管理系统资源:
- 在高负载情况下,边缘触发模式允许程序集中处理积累的数据,而不是频繁地响应事件。这种集中处理方式可以减少内存和 CPU 资源的消耗。
举例
假设一个服务器使用边缘触发模式处理多个客户端的连接。在某个时刻,多个客户端可能同时发送数据。服务器在接收到事件通知后,可以先接收这些数据并将其暂存起来,然后在空闲时逐一处理这些数据。这样,服务器不会因为处理某个客户端的数据而影响到其他客户端的连接处理,确保了系统的响应速度和稳定性。