写点什么

高性能网络设计秘笈:深入剖析 Linux 网络 IO 与 epoll

  • 2023-07-18
    广东
  • 本文字数:4124 字

    阅读完需:约 14 分钟

高性能网络设计秘笈:深入剖析Linux网络IO与epoll

本文分享自华为云社区《高性能网络设计秘笈:深入剖析Linux网络IO与epoll》,作者: Lion Long 。

一、epoll 简介


epoll 是 Linux 内核中一种可扩展的 IO 事件处理机制,可替代 select 和 poll 的系统调用。处理百万级并发访问性能更佳。

二、select 的局限性


(1) 文件描述符越多,性能越差。 单个进程中能够监视的文件描述符存在最大的数量,默认是 1024(在 linux 内核头文件中定义有 #define _FD_SETSIZE 1024),当然也可以修改,但是文件描述符数量越多,性能越差。


(2)开销巨大 ,select 需要复制大量的句柄数据结构,产生了巨大的开销(内核/用户空间内存拷贝问题)。


(3)select 需要遍历整个句柄数组才能知道哪些句柄有事件。


(4)如果没有完成对一个已经就绪的文件描述符的 IO 操作,那么每次调用 select 还是会将这些文件描述符通知进程,即水平触发。


(5)poll 使用链表保存监视的文件描述符,虽然没有了监视文件数量的限制,但是其他缺点依旧存在。

由于以上缺点,基于 select 模型的服务器程序,要达到十万以上的并发访问,是很难完成的。因此,epoll 出场了。

三、epoll 的优点


(1)不需要轮询所有的文件描述符


(2)每次取就绪集合,都在固定位置


(3)事件的就绪和 IO 触发可以异步解耦

四、epoll 函数原型

4.1、epoll_create(int size)


#include <sys/epoll.h>
int epoll_create(int size);
复制代码


功能:创建 epoll 的文件描述符。


参数说明:size 表示内核需要监控的最大数量,但是这个参数内核已经不会用到,只要传入一个大于 0 的值即可。 当 size<=0 时,会直接返回不可用,这是历史原因保留下来的,最早的 epoll_create 是需要定义一次性就绪的最大数量;后来使用了链表以便便维护和扩展,就不再需要使用传入的参数。


返回:返回该对象的描述符,注意要使用 close 关闭该描述符。

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 的红黑树节点进行操作,比如节点的增删改查。

参数说明:


4.2.1、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 成员:



data 成员:


data 成员时一个联合体类型,可以在调用 epoll_ctl 给 fd 添加/修改描述符监听的事件时携带一些数据,方便后面的 epoll_wait 可以取出信息使用。

4.2.2、扩展说明:SYSCALL_DEFINE 数字 的宏定义


跟着的数字代表函数需要的参数数量,比如 SYSCALL_DEFINE1 代表函数需要一个参数、SYSCALL_DEFINE4 代表函数需要 4 个参数。

4.2.3、注意


epoll_ctl 是非阻塞的,不会被挂起。

4.3、epoll_wait


函数原型


#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
复制代码


功能:阻塞一段时间,等待事件发生


返回:返回事件数量,事件集添加到 events 数组中。也就是遍历红黑树中的双向链表,把双向链表中的节点数据拷贝出来,拷贝完毕后把节点从双向链表中移除。


五、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);


}
}
}
复制代码

六、完整示例代码


#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;
}
复制代码

七、epoll 的缺点


读写使用相同的缓冲区。比如上述的示例中,wbuffer 和 rbuffer 是使用同一个缓冲区的,所以需要 rbuff[ret] = ‘\0’;去除杂数据。

八、水平触发(LT)与边沿触发(ET)

8.1、两者差异


1、水平触发可以一次 recv,边沿触发需要用循环来 recv;


2、水平触发可以使用阻塞模式,边沿模式不能


3、两者性能差异非常小,一般小数据使用水平触发 LT,大数据使用边沿触发 ET


4、listen fd 最好使用水平触发,尽量不要边沿触发


5、当当 recv 的 buffer 小于接受的数据时:


(1)水平触发是只要有数据就一直触发,直到数据读完;


(2)边沿触发是来一次连接触发一次,如果接受数据的 buffer 不够大,则数据会保留在缓冲区,下次触发继续从缓冲区读出来;


6、一般,水平触发只需要一个 recv,边沿触发需要搭配 while 从缓冲区读完数据

8.2、设置触发模式


默认是水平触发模式,在事件中设置中 | EPOLLET 就可以设置边沿触发,不设置则默认是水平触发。


例如:


ev.events=EPOLL_IN | EPOLLET
复制代码

九、常见疑惑问题

9.1、为什么提前先定义一个事件?


我们需要注册,内核才会有事件来的时候通知进程。比如生活中要退一个快递,那么我们需要注册一个快递公司的账户,然后发送一个退快递请求时快递公司才能找到你并取快递。

9.2、epoll events 超出 EVENTS_LENGTH?


epoll 会循环拷贝红黑树结构体中的双向链表节点,读取节点数据,直到没有事件。

9.3、缓冲区有多大空间时才返回可读/可写?


只要缓冲区有空间就返回可读、可写,不管空间多少。比如缓冲区是 1024,但是有 1023 有数据了,这种极端条件也会返回可读、可写。

9.4、recv 和 send 放在一起时,有什么问题?


发送给客户端数据很大的时候(大于内核缓冲区),就可能出现 send 不全,客户端 recv 不全,最好用 EPOLLOUT 单独处理发送数据事件。

总结


本文介绍了网络 IO 模型,引入了 epoll 作为 Linux 系统中高性能网络编程的核心工具。通过分析 epoll 的特点与优势,并给出使用 epoll 的注意事项和实践技巧,该文章为读者提供了宝贵的指导。通过掌握这些知识,读者能够构建高效、可扩展和稳定的网络应用,提供出色的用户体验。


点击关注,第一时间了解华为云新鲜技术~

发布于: 刚刚阅读数: 4
用户头像

提供全面深入的云计算技术干货 2020-07-14 加入

生于云,长于云,让开发者成为决定性力量

评论

发布
暂无评论
高性能网络设计秘笈:深入剖析Linux网络IO与epoll_后端_华为云开发者联盟_InfoQ写作社区