1 epoll理解及应用
select复用方法由来已久,由于无法同时接入上百个客户端,所以并不适合以Web服务端开发为主流的现代开发环境,所以需要linux平台下的epoll。
1.1 基于select的I/O复用技术速度慢的原因
主要有两点原因:
- 调用select函数后常见的针对所有文件描述符的循环语句
- 每次调用select函数时都需要向该函数传递监视对象信息
前面,调用select函数后并不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的fd_set变量的变化,找出发生变化的文件描述符,因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的fd_set变量会发生变化,所以调用select函数之前应复制并保存原有信息,并且每次调用select时传递新的监视对象。
尤其是后者导致的性能问题无法避免,因为每次调用select函数时要向操作系统传递监视对象信息,会经历用户空间到内核空间的复制(套接字是操作系统管理的),循环遍历时,还要将整个变量从内核控件复制到用户空间遍历。
如果只复制一次给操作系统,操作系统监视,然后通知发送变化的事项就好了,所以Linux有了epoll。
1.2 select优点
大部分操作系统都支持select函数,而epoll仅仅在Linux。所以当有如下情况是,请考虑select。
- 服务器端接入者少
- 程序应具有兼容性
1.3 实现epoll时必要的函数和结构体
能够克服select
函数缺点的epoll
函数具有以下优点,恰好与之前的select
函数缺点相反。
- 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
- 调用对应于
select
函数的epoll_wait
函数时无需每次传递监视对象信息。
下面时实现epoll服务端需要的3个函数,结合epoll的优点理解这些函数的功能。
epoll_create
:创建保存epoll文件描述符的空间。epoll_ctl
:向空间注册并注销文件描述符。epoll_wait
:与select函数类似,等待文件描述符发生变化。
select
方式中为了保存监视对象文件描述符,直接声明了fd_set
变量。但epoll
方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述的空间,此时使用的函数就是epoll_create
。
此外,为了添加和删除文件描述符,直接声明了fd_set
变量。但epoll
方式下,通过epoll_ctl
函数请求操作系统完成。最后,select
方式下调用select
函数等待文件描述符的变化,而epoll
中调用epoll_wait
函数。还有,select方式中通过fd_set
查看监视对象的状态变化(事件发生与否),而epoll方式下通过如下结构体epoll_event
将发生变化的文件描述符单独集中到一起。
struct epoll_event
{
__uint32_t events;
epoll_data_t data;
}
typedef union epoll_data
{
void* ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
}epoll_data_t;
声明足够大的epoll_even幽构体数组后,传递给epoll_wait函数时,发生变化的文件描述符信息将被填入该数组。 因此,无需像select函数那样针对所有文件描述符进行循环。
1.4 epoll_create
epoll是从Linux的2.5.44版本内核引入的,可以使用cat /proc/sys/kernel/osrelease
查看版本,不过基本不用担心,ubuntu18、20、22肯定是满足的。
#include <sys/epoll.h>
//成功时返回epoll文件描述符,失败返回-1
//2.6.8之后这个size参数没用,例程的大小由操作系统自行决定
int epoll_create(int size);
epoll_ create
函数创建的资源与套接字相同,也由操作系统管理。 因此,该函数和创建套接字的情况相同,也会返回文件描述符。 也就是说,该函数返回的文件描述符主要用与于区分epoll
例程。 需要终止时与其他文件描述符相同,也要调用close
函数。
1.5 epoll_ctl
生成epoll的例程后,接下来就是在其内部注册监视对象文件描述符。
#include <sys/epoll.h>
//成功返回0,失败返回-1
//epfd:用于注册监视对象的epoll例程的文件描述符
//op:用于指定监视对象的添加、删除或更改等操作
//fd:需要注册的监视对象文件描述符
//event:监视对象的事件类型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
- EPOLL_CTL_ADD:将文件描述符添加到epoll例程
- EPOLL_CTL_DEL:删除
- EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况。
struct epoll_event event;
....
event.events = EPOLLIN;
event.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
- EPOLLIN:需要读取数据的情况
- EPOLLOUT:输出缓冲为空,可以立即发送数据的情况
- EPOLLPRI:收到OOB数据的情况
- EPOLLRDHUB:断开连接或者半关闭的情况,这在边缘触发方式下非常有用
- EPOLLERR:发生错误的情况
- EPOLLET:以边缘触发方式得到事件通知
- EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向epoll_ctl函数的第二个参数传递EPOLL_CTL_MOD,再设置事件。
1.6 epoll_wait
#include <sys/epoll.h>
//成功是返回发生事件的文件描述符数,失败返回-1
//epfd:表示事件发生监视范围的epoll例程的文件描述符
//events:保存发生事件的文件描述符集合的结构体地址
//maxevents:第二个参数中可以保存的最大事件数
//timeout:以1/1000秒为单位的等待时间,传递-1时,一直等待到发生事件
int epoll_wait(int dpfd, struct epoll_event* events, int maxevents, int timeout);
int event_cnt;
struct epoll_event* ep_event;
....
ep_event = malloc(sizeif(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量
....
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
1.7 基于epoll的回声服务端
通过修改select示例说明二者差异:
客户端使用任意的回声客户端即可
#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 BUFF_SIZE 100
#define EPOLL_SIZE 100
void ErrorHandling(char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char* argv[]) {
if (argc != 2) {
printf("Usage : %s <port> \n", argv[0]);
exit(1);
}
int serv_sock = socket(PF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_adr;
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) {
ErrorHandling("bind error");
}
if (listen(serv_sock, 5) == -1) {
ErrorHandling("listen error");
}
struct epoll_event* ep_events;
struct epoll_event event;
int epfd, event_cnt;
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);
int clnt_sock;
struct sockaddr_in clnt_adr;
socklen_t adr_sz;
char buf[BUFF_SIZE];
for (;;) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
fputs("epoll_wait() error", stderr);
break;
}
for (int 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("connect client: %d \n", clnt_sock);
} else {
int str_len = read(ep_events[i].data.fd, buf, BUFF_SIZE);
if (str_len == 0) {
//连接关闭
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("close client: %d\n", ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len);
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
2 条件触发和边缘触发
2.1 条件触发和边缘触发的区别在于发生事件的时间点
水平触发
儿子:妈妈,我收到了500元的压岁钱。
妈妈:嗯,省着点花。
儿子:妈妈,我今天花了200元买了个变形金刚。
妈妈:以后不要乱花钱。
儿子:妈妈,我今天买了好多好吃的,还剩下100元。
妈妈:用完了这些钱,我可不会再给你钱了。
儿子:妈妈,那100元我没花,我攒起来了
妈妈:这才是明智的做法!
儿子:妈妈,那100元我还没花,我还有钱的。
妈妈:嗯,继续保持。
儿子:妈妈,我还有100元钱。
妈妈:…
接下来的情形就是没完没了了:只要儿子一直有钱,他就一直会向他的妈妈汇报。LT模式下,只要内核缓冲区中还有未读数据,就会一直返回描述符的就绪状态,即不断地唤醒应用进程。在上面的例子中,儿子是缓冲区,钱是数据,妈妈则是应用进程了解儿子的压岁钱状况(读操作)。
边缘触发
儿子:妈妈,我收到了500元的压岁钱。
妈妈:嗯,省着点花。
(儿子使用压岁钱购买了变形金刚和零食。)
儿子:
妈妈:儿子你倒是说话啊?压岁钱呢?
这个就是ET模式,儿子只在第一次收到压岁钱时通知妈妈,接下来儿子怎么把压岁钱花掉并没有通知妈妈。即儿子从没钱变成有钱,需要通知妈妈,接下来钱变少了,则不会再通知妈妈了。在ET模式下,缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程 。
2.2 掌握条件触发的事件特性
可以看到一句话触发了四次事件才读完
#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 BUFF_SIZE 4
#define EPOLL_SIZE 50
void ErrorHandling(char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char* argv[]) {
if (argc != 2) {
printf("Usage : %s <port> \n", argv[0]);
exit(1);
}
int serv_sock = socket(PF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_adr;
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) {
ErrorHandling("bind error");
}
if (listen(serv_sock, 5) == -1) {
ErrorHandling("listen error");
}
struct epoll_event* ep_events;
struct epoll_event event;
int epfd, event_cnt;
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);
int clnt_sock;
struct sockaddr_in clnt_adr;
socklen_t adr_sz;
char buf[BUFF_SIZE];
for (;;) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
fputs("epoll_wait() error", stderr);
break;
}
puts("epoll_wait");
for (int 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("connect client: %d \n", clnt_sock);
} else {
int str_len = read(ep_events[i].data.fd, buf, BUFF_SIZE);
if (str_len == 0) {
//请求关闭
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("close client: %d\n", ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len);
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
通过修改
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
event.events = EPOLLIN | EPOLLET;
就可以改成边缘触发,可以验证效果,发现epoll_wait只会输出一次。
但是,结果可能会有一点出乎意料,即没有回声,因为只读一次不满足str_len==0,无法进入else。
即边缘触发只会发送一次信号,如果不读完,那么上次剩余的数据会导致本次读取发生错误。
2.3 边缘触发的服务端的实现中必知的两点
- 通过errno变量验证错误
- 为了完成非阻塞I/O,更换套接字特性
套接字相关函数失败时一般会返回-1,但是无法得知失败的具体原因。因此,为了提供错误发生时的额外信息,Linux声明了errno
全局变量。
为了使用errno
,需要包含头文件error.h
,这里有errno
的extern声明。
Linux提供了更改或读取文件属性的如下方法:
#include <fcntl.h>
int fcntl(int filedes, int cmd, ...);
修改为非阻塞模式,需要如下2条语句:
int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag | O_NONBLOCK);
2.4 实现边缘触发的回声服务端
ET要搭配while使用才可以保证在一次触发的前提下一次性读完/写完一个事件的数据;若现在这个事件的数据有9个字节,buffer有5个字节,ET是使用的阻塞I/O,则在while中不停地调用read时,第三次read会阻塞住,然后程序就卡在那里,直到有新数据进入buffer;若ET使用的是非阻塞I/O,则会在第三次read的时候返回error,然后跳出while。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
//减小缓冲大小,防止一次性读完
#define BUFF_SIZE 4
#define EPOLL_SIZE 50
void setNonBlockMode(int fd) {
int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}
void ErrorHandling(char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char* argv[]) {
if (argc != 2) {
printf("Usage : %s <port> \n", argv[0]);
exit(1);
}
int serv_sock = socket(PF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (const void*)&opt, sizeof(opt));
setNonBlockMode(serv_sock);
struct sockaddr_in serv_adr;
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) {
ErrorHandling("bind error");
}
if (listen(serv_sock, 5) == -1) {
ErrorHandling("listen error");
}
struct epoll_event* ep_events;
struct epoll_event event;
int epfd, event_cnt;
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);
int clnt_sock;
struct sockaddr_in clnt_adr;
socklen_t adr_sz;
char buf[BUFF_SIZE];
for (;;) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
fputs("epoll_wait() error", stderr);
break;
}
puts("epoll_wait");
for (int 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);
setNonBlockMode(clnt_sock);
event.events = EPOLLIN | EPOLLET;
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connect client: %d \n", clnt_sock);
} else {
for (;;) {
int str_len = read(ep_events[i].data.fd, buf, BUFF_SIZE);
if (str_len == 0) {
//连接关闭,对于socket来说,EOF就是关闭连接
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("close client: %d\n", ep_events[i].data.fd);
break;
} else if (str_len < 0){
if (errno == EAGAIN) {
printf("读取了全部数据了\n");
break; //缓冲区为空
}
} else {
write(ep_events[i].data.fd, buf, str_len);
}
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
2.5 LT和ET的优劣
边缘触发优点:可以分离接收数据和处理数据的时间点。
试想如果数据量很大,不能一次读取完,则需要产生很多次事件,服务端压力会很大。