目录
1. 深入剖析 Linux 网络 IO 与 epoll
【摘要】 本文介绍了网络 IO 模型, 引入了 epoll 作为 Linux 系统中高性能网络编程的核心工具。通过分析 epoll 的特点与优势, 并给出使用 epoll 的注意事项和实践技巧, 该文章为读者提供了宝贵的指导。通过掌握这些知识, 读者能够构建高效、可扩展和稳定的网络应用, 提供出色的用户体验。
1.1. epoll 简介
epoll 是 Linux 内核中一种可扩展的 IO 事件处理机制, 可替代 select 和 poll 的系统调用。处理百万级并发访问性能更佳。
1.2. select 的局限性
- 文件描述符越多, 性能越差。 单个进程中能够监视的文件描述符存在最大的数量, 默认是 1024(在 linux 内核头文件中定义有 #define _FD_SETSIZE 1024), 当然也可以修改, 但是文件描述符数量越多, 性能越差。
- 开销巨大, select 需要复制大量的句柄数据结构, 产生了巨大的开销(内核/用户空间内存拷贝问题)。
- select 需要遍历整个句柄数组才能知道哪些句柄有事件。
- 如果没有完成对一个已经就绪的文件描述符的 IO 操作, 那么每次调用 select 还是会将这些文件描述符通知进程, 即水平触发。
- poll 使用链表保存监视的文件描述符, 虽然没有了监视文件数量的限制, 但是其他缺点依旧存在。
由于以上缺点, 基于 select 模型的服务器程序, 要达到十万以上的并发访问, 是很难完成的。因此, epoll 出场了。
1.3. epoll 的优点
- 不需要轮询所有的文件描述符
- 每次取就绪集合, 都在固定位置
- 事件的就绪和 IO 触发可以异步解耦
1.4. epoll 函数原型
1.4.1. epoll_create(int size)
#include <sys/epoll.h>
int epoll_create(int size);
- 功能: 创建 epoll 的文件描述符。
- 参数说明: size 表示内核需要监控的最大数量, 但是这个参数内核已经不会用到, 只要传入一个大于 0 的值即可。 当 size<=0 时, 会直接返回不可用, 这是历史原因保留下来的, 最早的 epoll_create 是需要定义一次性就绪的最大数量; 后来使用了链表以便便维护和扩展, 就不再需要使用传入的参数。
- 返回: 返回该对象的描述符, 注意要使用 close 关闭该描述符。
1.4.2. epoll_ctl
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epoll_ctl 对应系统调用 sys_epoll_ctl
- 功能: 操作 epoll 的文件描述符, 主要是对 epoll 的红黑树节点进行操作, 比如节点的增删改查。
- 参数说明:
参数 | 含义 |
---|---|
epfd | 通过 epoll_create 创建的文件描述符 |
op | 对红黑树的操作, 比如节点的增加、修改、删除, 分别对应 EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL |
fd | 需要添加监听的文件描述符 |
event | 事件信息 |
event 参数说明
struct epoll_event 结构体原型
typedef union epoll_data{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64
};
struct epoll_event{
uint32_t events;
epoll_data_t data;
}
events 成员代表要监听的 epoll 事件类型
events 成员:
成员变量 | 含义 |
---|---|
EPOLLIN | 监听 fd 的读事件 |
EPOLLOUT | 监听 fd 的写事件 |
EPOLLRI | 监听紧急数据可读事件(带外数据到来) |
EPOLLRDHUP | 监听套接字关闭或半关闭事件 |
EPOLLET | 将 EPOLL 设为边缘触发 (Edge Triggered) 模式 |
data 成员:
data 成员时一个联合体类型, 可以在调用 epoll_ctl
给 fd
添加/修改描述符监听的事件时携带一些数据, 方便后面的 epoll_wait
可以取出信息使用。
扩展说明: SYSCALL_DEFINE 数字 的宏定义
跟着的数字代表函数需要的参数数量, 比如 SYSCALL_DEFINE1
代表函数需要一个参数、SYSCALL_DEFINE4
代表函数需要 4 个参数。
注意
epoll_ctl
是非阻塞的, 不会被挂起。
1.4.3. epoll_wait
函数原型
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 功能: 阻塞一段时间, 等待事件发生
- 返回: 返回事件数量, 事件集添加到 events 数组中。也就是遍历红黑树中的双向链表, 把双向链表中的节点数据拷贝出来, 拷贝完毕后把节点从双向链表中移除。
返回值 | 含义 |
---|---|
大于 0 | 事件个数 |
等于 0 | 超时时间 timeout 到了 |
小于 0 | 出错, 可通过 errno 查看出错原因 |
参数说明:
参数 | 含义 |
---|---|
epfd | 通过 epoll_create 创建的文件描述符 |
events | 存放就绪的事件集合, 是输出参数 |
maxevents | 最大可存放事件数量, events 数组大小 |
timeout | 阻塞等待的时间长短, 单位是毫秒, -1 表示一直阻塞等待 |
1.5. epoll 使用步骤
step 1: 创建 epoll 文件描述符
int epfd = epoll_create(1);
step 2: 创建 struct epoll_event 结构体
struct epoll_event ev;
ev.data.fd=listenfd;//保存监听的 fd, 以便 epoll_wait 的后续操作
ev.events=EPOLLIN;//设置监听 fd 的可读事件
step 3: 添加事件监听
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
step 4: 等待事件
struct epoll_event events[EVENTS_LENGTH];
char rbuffer[MAX_BUFF]={ 0 };
char wbuffer[MAX_BUFF]={ 0 };
while(1)
{
int nready = epoll_wait(epfd,events,EVENTS_LENGTH,-1);//-1 表示阻塞等待
int i=0;
for(i=0;i<nready;i++)
{
int clientfd=events[i].data.fd;
if(clientfd==listenfd)
{
struct sockaddr_in client;
int len=sizeof(client);
int confd=accept(listenfd,(struct sockaddr*)&client,&len);
//step 2: 创建 struct epoll_event 结构体
struct epoll_event evt;
evt.data.fd=confd;//保存监听的 fd, 以便 epoll_wait 的后续操作
evt.events=EPOLLIN;//设置监听 fd 的可读事件
// step 3: 添加事件监听
epoll_ctl(epfd,EPOLL_CTL_ADD,confd,&evt);
}
else if(events[i].events &EPOLLIN)
{
int ret = recv(clientfd,rbuffer,MAX_BUFF,0);
if(ret>0)
{
rbuffer[ret]='\0';//剔除干扰数据
printf("recv: %s\n",rbuffer);
memcpy(wbuffer,rbuffer,MAX_BUFF);//拷贝数据, 做回传示例
//step 2: 创建 struct epoll_event 结构体
struct epoll_event evt;
evt.data.fd=clientfd;//保存监听的 fd, 以便 epoll_wait 的后续操作
evt.events=EPOLLOUT;//设置监听 fd 的可写事件
// step 3: 修改事件监听
epoll_ctl(epfd,EPOLL_CTL_MOD,clientfd,&evt);
}
}
else if(events[i].events &EPOLLOUT)
{
int ret = send(clientfd,wbuffer,MAX_BUFF,0);
printf("send: %s\n",wbuffer);
//step 2: 创建 struct epoll_event 结构体
struct epoll_event evt;
evt.data.fd=clientfd;//保存监听的 fd, 以便 epoll_wait 的后续操作
evt.events=EPOLLIN;//设置监听 fd 的可读事件
// step 3: 修改事件监听
epoll_ctl(epfd,EPOLL_CTL_MOD,clientfd,&evt);
}
}
}
1.6. 完整示例代码
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/epoll.h>
#include <string.h>
#define BUFFER_LENGTH 128
#define EVENTS_LENGTH 128
char rbuff[BUFFER_LENGTH] = { 0 };
char wbuff[BUFFER_LENGTH] = { 0 };
int main() {
// block
int listenfd = socket(AF_INET, SOCK_STREAM, 0); //
if (listenfd == -1) return -1;
// listenfd
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);
if (-1 == bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr))) {
return -2;
}
#if 0 // nonblock
int flag = fcntl(listenfd, F_GETFL, 0);
flag |= O_NONBLOCK;
fcntl(listenfd, F_SETFL, flag);
#endif
listen(listenfd, 10);
int epfd = epoll_create(1);
struct epoll_event ev, events[EVENTS_LENGTH];
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
printf("epfd : %d\n", epfd);
while (1)
{
int nready = epoll_wait(epfd, events, EVENTS_LENGTH, -1);
printf("nready --> %d\n",nready);
int i;
for (i = 0; i < nready;i++)
{
int clientfd = events[i].data.fd;
if (listenfd == clientfd)
{
// accept
struct sockaddr_in client;
int len = sizeof(client);
int conffd = accept(clientfd, (struct sockaddr*)&client,&len);
printf("conffd --> %d\n",conffd);
ev.events = EPOLLIN;
ev.data.fd = conffd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conffd, &ev);
}
else if(events[i].events & EPOLLIN)//client
{
int ret=recv(clientfd, rbuff, BUFFER_LENGTH, 0);
if (ret > 0)
{
rbuff[ret] = '\0';
printf("recv buffer: %s\n", rbuff);
/*
int j;
for (j = 0; j < BUFFER_LENGTH;j++)
{
buff[j] = 'a' + (j % 26);
}
send(clientfd, buff, BUFFER_LENGTH, 0);
*/
memcpy(wbuff, rbuff, BUFFER_LENGTH);
ev.events = EPOLLOUT;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, clientfd, &ev);
}
}
else if (events[i].events & EPOLLOUT)
{
send(clientfd, wbuff, BUFFER_LENGTH, 0);
printf("send --> %s\n",wbuff);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, clientfd, &ev);
}
}
}
return 0;
}
1.7. epoll 的缺点
读写使用相同的缓冲区。比如上述的示例中, wbuffer
和 rbuffer
是使用同一个缓冲区的, 所以需要 rbuff[ret] = ‘\0’;
去除杂数据。
1.8. 水平触发(LT)与边沿触发 (ET)
1.8.1. 两者差异
- 水平触发可以一次 recv, 边沿触发需要用循环来 recv;
- 水平触发可以使用阻塞模式, 边沿模式不能
- 两者性能差异非常小, 一般小数据使用水平触发 LT, 大数据使用边沿触发 ET
- listen fd 最好使用水平触发, 尽量不要边沿触发
- 当当 recv 的 buffer 小于接受的数据时:
- 水平触发是只要有数据就一直触发, 直到数据读完;
- 边沿触发是来一次连接触发一次, 如果接受数据的 buffer 不够大, 则数据会保留在缓冲区, 下次触发继续从缓冲区读出来;
- 一般, 水平触发只需要一个 recv, 边沿触发需要搭配 while 从缓冲区读完数据
1.8.2. 设置触发模式
默认是水平触发模式, 在事件中设置中 | EPOLLET
就可以设置边沿触发, 不设置则默认是水平触发。
例如:
ev.events=EPOLL_IN | EPOLLET
1.9. 常见疑惑问题
1.9.1. 为什么提前先定义一个事件?
我们需要注册, 内核才会有事件来的时候通知进程。比如生活中要退一个快递, 那么我们需要注册一个快递公司的账户, 然后发送一个退快递请求时快递公司才能找到你并取快递。
1.9.2. epoll events 超出 EVENTS_LENGTH?
epoll 会循环拷贝红黑树结构体中的双向链表节点, 读取节点数据, 直到没有事件。
1.9.3. 缓冲区有多大空间时才返回可读/可写?
只要缓冲区有空间就返回可读、可写, 不管空间多少。比如缓冲区是 1024, 但是有 1023 有数据了, 这种极端条件也会返回可读、可写。
1.9.4. recv 和 send 放在一起时, 有什么问题?
发送给客户端数据很大的时候(大于内核缓冲区), 就可能出现 send 不全, 客户端 recv 不全, 最好用 EPOLLOUT 单独处理发送数据事件。
1.10. 总结
本文介绍了网络 IO 模型, 引入了 epoll 作为 Linux 系统中高性能网络编程的核心工具。通过分析 epoll 的特点与优势, 并给出使用 epoll 的注意事项和实践技巧, 该文章为读者提供了宝贵的指导。通过掌握这些知识, 读者能够构建高效、可扩展和稳定的网络应用, 提供出色的用户体验。