Linux NIO 系列(04-3) epoll
Linux NIO 系列(04-3) epoll
Netty 系列目录(https://www.cnblogs.com/binarylei/p/10117436.html)
一、why epoll
1.1 select 模型的缺点
-
句柄限制:单个进程能够监视的文件描述符的数量存在最大限制,通常是 1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在 linux 内核头文件中,有这样的定义:
#define __FD_SETSIZE 1024
) -
数据拷贝:内核 / 用户空间内存拷贝问题,select 需要复制大量的句柄数据结构,产生巨大的开销;
-
轮询机制:select 返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
select 的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行 IO 操作,那么之后每次 select 调用还是会将这些文件描述符通知进程。
设想一下如下场景:有 100 万个客户端同时与一个服务器进程保持着 TCP 连接。而每一时刻,通常只有几百上千个 TCP 连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
粗略计算一下,一个进程最多有 1024 个文件描述符,那么我们需要开 1000 个进程来处理 100 万个客户连接。如果我们使用 select 模型,这 1000 个进程里某一段时间内只有数个客户连接需要数据的接收,那么我们就不得不轮询 1024 个文件描述符以确定究竟是哪个客户有数据可读,想想如果 1000 个进程都有类似的行为,那系统资源消耗可有多大啊!
针对 select 模型的缺点,epoll 模型被提出来了!
1.2 epoll 模型优点
- 支持一个进程打开大数目的 socket 描述符
- IO 效率不随 FD 数目增加而线性下降
- 使用 mmap 加速内核与用户空间的消息传递
- epoll 支持水平触发和边沿触发两种工作模式
二、epoll API
epoll 在 Linux2.6 内核正式提出,是基于事件驱动的 I/O 方式,相对于 select 来说,epoll 没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。
Linux 中提供的 epoll 相关函数如下:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
2.1 epoll_create
函数创建一个 epoll 句柄,参数 size 表明内核要监听的描述符数量。调用成功时返回一个 epoll 句柄描述符,失败时返回 -1。
2.2 epoll_ctl
函数注册要监听的事件类型。四个参数解释如下:
-
epfd 表示 epoll 句柄
-
op 表示 fd 操作类型,有如下 3 种
EPOLL_CTL_ADD 注册新的fd到epfd中 EPOLL_CTL_MOD 修改已注册的fd的监听事件 EPOLL_CTL_DEL 从epfd中删除一个fd
-
fd 是要监听的描述符
-
event 表示要监听的事件。epoll_event 结构体定义如下:
struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; typedef union epoll_data { void *ptr; int fd; __uint32_t u32; __uint64_t u64; } epoll_data_t;
2.3 epoll_wait
函数等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。
- epfd 是 epoll 句柄
- events 表示从内核得到的就绪事件集合
- maxevents 告诉内核 events 的大小
- timeout 表示等待的超时事件
epoll 是 Linux 内核为处理大批量文件描述符而作了改进的 poll,是 Linux 下多路复用 IO 接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率。原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核 IO 事件异步唤醒而加入 Ready 队列的描述符集合就行了。
三、epoll 工作模式
epoll 除了提供 select/poll 那种 IO 事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存 IO 状态,减少 epoll_wait/epoll_pwait 的调用,提高应用程序效率。
-
水平触发(LT):默认工作模式,即当 epoll_wait 检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用 epoll_wait 时,会再次通知此事件。
水平触发同时支持 block 和 non-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。比如内核通知你其中一个fd可以读数据了,你赶紧去读。你还是懒懒散散,不去读这个数据,下一次循环的时候内核发现你还没读刚才的数据,就又通知你赶紧把刚才的数据读了。这种机制可以比较好的保证每个数据用户都处理掉了。
-
边缘触发(ET): 当 epoll_wait 检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。
边缘触发是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,等到下次有新的数据进来的时候才会再次出发就绪事件。简而言之,就是内核通知过的事情不会再说第二遍,数据错过没读,你自己负责。这种机制确实速度提高了,但是风险相伴而行。
LT 和 ET 原本应该是用于脉冲信号的,可能用它来解释更加形象。Level 和 Edge 指的就是触发点,Level 为只要处于水平,那么就一直触发,而 Edge 则为上升沿和下降沿的时候触发。比如:0->1 就是 Edge,1->1 就是 Level。
ET 模式很大程度上减少了 epoll 事件的触发次数,因此效率比 LT 模式下高。
附1:epoll 网络编程
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define SERVER_PORT 8888
#define OPEN_MAX 3000
#define BACKLOG 10
#define BUF_SIZE 1024
int main() {
int ret, i;
int listenfd, connfd, epollfd;
int nready;
int recvbytes, sendbytes;
char* recv_buf;
struct epoll_event ev;
struct epoll_event* ep;
ep = (struct epoll_event*) malloc(sizeof(struct epoll_event) * OPEN_MAX);
recv_buf = (char*) malloc(sizeof(char) * BUF_SIZE);
struct sockaddr_in seraddr;
struct sockaddr_in cliaddr;
int addr_len;
memset(&seraddr, 0, sizeof(seraddr));
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(SERVER_PORT);
seraddr.sin_addr.s_addr = htonl(INADDR_ANY);
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(listenfd == -1) {
perror("create socket failed.\n");
return 1;
}
ret = bind(listenfd, (struct sockaddr*)&seraddr, sizeof(seraddr));
if(ret == -1) {
perror("bind failed.\n");
return 1;
}
ret = listen(listenfd, BACKLOG);
if(ret == -1) {
perror("listen failed.\n");
return 1;
}
epollfd = epoll_create(1);
if(epollfd == -1) {
perror("epoll_create failed.\n");
return 1;
}
ev.events = EPOLLIN;
ev.data.fd = listenfd;
ret = epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev);
if(ret == -1) {
perror("epoll_ctl failed.\n");
return 1;
}
while(1) {
nready = epoll_wait(epollfd, ep, OPEN_MAX, -1);
if(nready == -1) {
perror("epoll_wait failed.\n");
return 1;
}
for(i = 0; i < nready; i++) {
if(ep[i].data.fd == listenfd) {
addr_len = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr*) &cliaddr, &addr_len);
printf("client IP: %s\t PORT : %d\n", inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));
if(connfd == -1) {
perror("accept failed.\n");
return 1;
}
ev.events = EPOLLIN;
ev.data.fd = connfd;
ret = epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &ev);
if(ret == -1) {
perror("epoll_ctl failed.\n");
return 1;
}
}
else {
recvbytes = recv(ep[i].data.fd, recv_buf, BUF_SIZE, 0);
if(recvbytes <= 0) {
close(ep[i].data.fd);
epoll_ctl(epollfd, EPOLL_CTL_DEL, ep[i].data.fd, &ev);
continue;
}
printf("receive %s\n", recv_buf);
sendbytes = send(ep[i].data.fd, recv_buf, (size_t)recvbytes, 0);
if(sendbytes == -1) {
perror("send failed.\n");
}
}
} // for each ev
} // while(1)
close(epollfd);
close(listenfd);
free(ep);
free(recv_buf);
return 0;
}
参考:
每天用心记录一点点。内容也许不重要,但习惯很重要!