linux下I/O复用与epoll实际使用(一)

一、背景

I/O多路复用有很多种实现,在linux上,2.4内核前主要是select和poll,从2.6内核正式引入epoll以来。epoll已经成为目前实现高性能网络服务器的必备技术。尽管他们的使用方法不尽相同,但是从本质上却没有什么区别。

二、选择epoll的原因

select的缺陷
高并发的核心解决方案是1个线程处理所有连接的“等待消息准备好”,这一点上epoll和select是无争议的。但select在数十万并发连接存在时,可能每一毫秒只有数百个活跃的连接,同时其余数十万连接在这一毫秒是非活跃的,select的使用方法是这样的:
返回的活跃连接==select(全部待监控的连接)。
什么时候会调用select方法呢?在你认为需要找出有报文到达的活跃连接时,就应该调用。所以,调用,调用select在高并发时是会被频繁调用的。这样,这个频繁调用的方法,就很有必要看看它是否有效率,因为,它的的轻微效率损失都会被“频繁”二字放大。它有效率损失吗?显而易见,全部带监控是数以十万计的,返回的知识数百个活跃连接,这本身就是无效率的表现。被放大后就会发现,处理并发上万个连接时,select就完全力不从心了。
此外,在linux内核中,select所用的FD_SET时有限的,即内个中有个参数__FD_SETSIZE定义了每个FD_SET的句柄个数

/linux/posix_types.h:
#define __FD_SETSIZE         1024

其次,内核中实现select是用轮训方法,即每次检测都会遍历所有FD_SET中的句柄,显然,select函数执行时间与FD_SET中的句柄个数有一个比例关系,即select要检测的句柄数越多就会越耗时间。而poll和select的内部机制方面并没有太大的差异。相比于select机制,poll取消了最大监控文件描述符数量的限制。但没有从根本上解决大并发情况下耗时严重的问题。

主流I/O复用机制的benchmark

三、epoll高效的奥秘
epoll精巧的使用了三个方法来实现select做的事:
1、新建epoll描述符epoll_create()
2、epoll_ctl(epoll描述符,添加或者删除所有待监控的的连接)
3、返回的活跃连接
epoll_wait(epoll描述符)
与select对比,epoll分清了频繁调用和不频繁调用的操作,例如,epoll_ctl是不太频繁调用的,而epoll_wait是非常频繁调用的。这时,epoll_wait却几乎没有入参,这笔select的效率高出一大截,而且,它也不会随着并发连接的增加使得入参越发多起来,导致内核执行效率的下降。
要想深刻理解epoll,首先得了解epoll的三大关键要素:mmap、红黑树、链表。
epoll是通过内核与用户控件mmap同一块内存实现的。mmap将用户空间的一块内存同时衍射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据转换。内核可以直接看到epoll监听的句柄,效率高。
红黑树将存储epoll所监听的套接字。上面mmap出来的内存如何保存epoll所监听的套接字,必然也得有一套数据结构,epoll在实现上采用红黑树去存储所有套接字,当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理,红黑树本身插入和删除性能比较好,时间复杂度O(logN)。
红黑树数据结构
下面几个关键数据结构的定义

1 struct epitem
 2 {
 3     struct rb_node rbn;            //用于主结构管理的红黑树
 4     struct list_head rdllink;       //事件就绪队列
 5     struct epitem *next;           //用于主结构体中的链表
 6     struct epoll_filefd ffd;         //每个fd生成的一个结构
 7     int nwait;                 
 8     struct list_head pwqlist;     //poll等待队列
 9     struct eventpoll *ep;          //该项属于哪个主结构体
10     struct list_head fllink;         //链接fd对应的file链表
11     struct epoll_event event;  //注册的感兴趣的事件,也就是用户空间的epoll_event
12  }

 1 struct eventpoll
 2 {
 3     spin_lock_t lock;            //对本数据结构的访问
 4     struct mutex mtx;            //防止使用时被删除
 5     wait_queue_head_t wq;        //sys_epoll_wait() 使用的等待队列
 6     wait_queue_head_t poll_wait; //file->poll()使用的等待队列
 7     struct list_head rdllist;    //事件满足条件的链表
 8     struct rb_root rbr;          //用于管理所有fd的红黑树
 9     struct epitem *ovflist;      //将事件到达的fd进行链接起来发送至用户空间
10 }

添加以及返回事件
通过epoll_ctl函数添加进来的事件都会被放在红黑树的某个节点内,所以,重复添加是没有用的。当把事件添加进来的时候时候会完成关键的一步,那就是该事件都会与相应的设备(网卡)驱动程序建立回调关系,当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为:ep_poll_callback,这个回调函数其实就所把这个事件添加到rdllist这个双向链表中。一旦有事件发生,epoll就会将该事件添加到双向链表中。那么当我们调用epoll_wait时,epoll_wait只需要检查rdlist双向链表中是否有存在注册的事件,效率非常可观。这里也需要将发生了的事件复制到用户态内存中即可。
红黑树链表

epoll_wait的工作流程:
1、epoll_wait调用ep_poll,当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒。
2、文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),导致相应fd上的回调函数ep_poll_callback()被调用。
3、ep_poll_callback将相应fd对应epitem加入rdlist,导致rdlist不空,进程被唤醒,epoll_wait得以继续执行。
4、ep_events_transfer函数将rdlist中的epitem拷贝到txlist中,并将rdlist清空。
5、ep_send_events函数(很关键),它扫描txlist中的每个epitem,调用其关联fd对用的poll方法。此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events和相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。
小结

select poll epoll比较
行文至此,想必各位都应该已经明了为什么epoll会成为Linux平台下实现高性能网络服务器的首选I/O复用调用。
需要注意的是:epoll并不是在所有的应用场景都会比select和poll高很多。尤其是当活动连接比较多的时候,回调函数被触发得过于频繁的时候,epoll的效率也会受到显著影响!所以,epoll特别适用于连接数量多,但活动连接较少的情况。
接下来,笔者将介绍一下epoll使用方式的注意点。
EPOLL的使用
文件描述符的创建

#include <sys/epoll.h>
int epoll_create ( int size );

在epoll早期的实现中,对于监控文件描述符的组织并不是使用红黑树,而是hash表。这里的size实际上已经没有意义。
注册监控事件

#include <sys/epoll.h>
int epoll_ctl ( int epfd, int op, int fd, struct epoll_event *event );

函数说明:
fd:要操作的文件描述符
op:指定操作类型
操作类型:
EPOLL_CTL_ADD:往事件表中注册fd上的事件
EPOLL_CTL_MOD:修改fd上的注册事件
EPOLL_CTL_DEL:删除fd上的注册事件
event:指定事件,它是epoll_event结构指针类型
epoll_event定义:

struct epoll_event
{
__unit32_t events;    // epoll事件
 epoll_data_t data;     // 用户数据 
}

结构体说明:
events:描述事件类型,和poll支持的事件类型基本相同(两个额外的事件:EPOLLET和EPOLLONESHOT,高效运作的关键)
data成员:存储用户数据

typedef union epoll_data
{
void* ptr;              //指定与fd相关的用户数据 
int fd;                 //指定事件所从属的目标文件描述符 
uint32_t u32;
uint64_t u64;
}epoll_data_t;

epoll_wait函数

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

函数说明:
返回:成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno
timeout:指定epoll的超时时间,单位是毫秒。当timeout为-1是,epoll_wait调用将永远阻塞,直到某个时间发生。当timeout为0时,epoll_wait调用将立即返回。
maxevents:指定最多监听多少个事件
events:检测到事件,将所有就绪的事件从内核事件表中复制到它的第二个参数events指向的数组中。
EPOLLONESHOT事件
使用场合:
一个线程在读取完某个socket上的数据后开始处理这些数据,而数据的处理过程中该socket又有新数据可读,此时另外一个线程被唤醒来读取这些新的数据。
于是,就出现了两个线程同时操作一个socket的局面。可以使用epoll的EPOLLONESHOT事件实现一个socket连接在任一时刻都被一个线程处理。
作用:
对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多出发其上注册的一个可读,可写或异常事件,且只能触发一次。
使用:
注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个sockt。
效果:
尽管一个socket在不同事件可能被不同的线程处理,但同一时刻肯定只有一个线程在为它服务,这就保证了连接的完整性,从而避免了很多可能的竞态条件。
LT与ET模式

   程序一:
/*********************
epoll 特性学习
date:2018/9/29
**************************/
/************************************************************************************************************
* struct epoll_event
* {
*	__unit32_t events;    // epoll事件
*	epoll_data_t data;     // 用户数据 	
* }
*结构体说明:
*	 events:描述事件类型,和poll支持的事件类型基本相同(两个额外的事件:EPOLLET和EPOLLONESHOT,高效运作的关键)
*    data成员:存储用户数据
*typedef union epoll_data
* {
*     void* ptr;              //指定与fd相关的用户数据 
*     int fd;                 //指定事件所从属的目标文件描述符 
*     uint32_t u32;
*     uint64_t u64;
* } epoll_data_t;
*
*总结:LT(Level_triggered)模式是水平触发方式:
*	   当被监控的文件描述符上有可读写事件发生时,
*      epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),
	   那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,
	   当然如果你一直不去读写,它会一直通知你!!!如果系统中有大量你不需要读写的就绪文件描述符,
	   而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!!!
	   
*	   ET(Edge_triggered)模式是边缘触发方式:
		当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。
		如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,
		也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!
		这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符!!!

****************************************************************************************************/
#include<stdio.h>
#include<unistd.h>
#include<sys/epoll.h>



#ifndef _EDGE_TRIGGERED
#define _EDGE_TRIGGERED
#endif
//#undef _EDGE_TRIGGERED

int main(void)
{
	int epfd,nfds;
	struct epoll_event ev;//注册事件
	struct epoll_event events[100];//用于返回要处理的事件
	int i;
	
	epfd = epoll_create(10);
	ev.data.fd=STDIN_FILENO;
#ifdef _EDGE_TRIGGERED
	ev.events=EPOLLIN|EPOLLET;//监听读状态同时设置ET模式
#else //LT 
	ev.events=EPOLLIN;
#endif
	epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
	while(1)
	{
		nfds=epoll_wait(epfd,events,5,-1);
		printf("nfds=%d\n",nfds);
		for(i=0;i<nfds;i++)
		{
			if(events[i].data.fd==STDIN_FILENO)
			{
				char buf[10]={0};
#ifdef _EDGE_TRIGGERED
				read(STDIN_FILENO,buf,sizeof(buf));
				ev.data.fd=STDIN_FILENO;
				ev.events=EPOLLIN|EPOLLET;
				epoll_ctl(epfd,EPOLL_CTL_MOD,STDIN_FILENO,&ev);
				printf("welcom to epoll's ET world!\n");
#else				
				read(STDIN_FILENO,buf,sizeof(buf));
				printf("welcom to epoll's LT world!\n");
#endif
				
			}
		}
	}
	return 0;
}

1、ET方式详解

将epoctl_ctl注掉
图1

在这里插入图片描述
图2
以上两张图呈现的是在边缘触发方式下,呈现出来的结果。
epoll_wait返回读状态就绪的时候我们都将buffer(缓冲)中的内容read出来,所以导致buffer中数据变化,但如图1所示,如果这次没有把数据全部读写完(如读写buf缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,会阻塞在那里。也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!如图2。

在这里插入图片描述
图3
如图三所示,在for循环内部加上epoll_ctl重置epoll事件。
程序依然使用ET,但是每次读就绪将buffer(缓冲)中的内容read出来后,都会主动的再次MOD IN事件,所以在buffer未读取完前每次都会返回读就绪。知道buffer数据读取完。下面看一下下面这个例子。
在这里插入图片描述
图4
如图4,我将代码中的read给注掉。
程序依然使用ET,但是每次读就绪后都主动的再次MOD IN事件,我们发现程序再次出现死循环,也就是每次返回读就绪。但是注意,如果我们将MOD改为ADD,将不会产生任何影响。别忘了每次ADD一个描述符都会在epitem组成的红黑树中添加一个项,我们之前已经ADD过一次,再次ADD将阻止添加,所以在次调用ADD IN事件不会有任何影响。如图5
在这里插入图片描述
图5

我们现在回到ET最本源的东西,如图6
在这里插入图片描述
图6
1、当用户输入一组字符,这组字符被送入buffer,字符停留在buffer中,又因为buffer由空变为不空,所以ET返回读就绪,输出”welcome to epoll’s ET world!”。
2、之后程序再次执行epoll_wait,此时虽然buffer中有内容可读,但是根据我们上节的分析,ET并不返回就绪,导致epoll_wait阻塞。(底层原因是ET下就绪fd的epitem只被放入rdlist一次)。
3、用户再次输入一组字符,导致buffer中的内容增多,根据我们上节的分析这将导致fd状态的改变,是对应的epitem再次加入rdlist,从而使epoll_wait返回读就绪,再次输出“Welcome to epoll’s world!”。

2、LT方式详解:
首先我们再复习下前面的内容:
LT当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),
那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,
当然如果你一直不去读写,它会一直通知你!!!如果系统中有大量你不需要读写的就绪文件描述符,
而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!!!

#include<stdio.h>
#include<unistd.h>
#include<sys/epoll.h>



#ifndef _EDGE_TRIGGERED
#define _EDGE_TRIGGERED
#endif
#undef _EDGE_TRIGGERED

int main(void)
{
	int epfd,nfds;
	struct epoll_event ev;//注册事件
	struct epoll_event events[100];//用于返回要处理的事件
	int i;
	
	epfd = epoll_create(10);
	ev.data.fd=STDIN_FILENO;
#ifdef _EDGE_TRIGGERED
	ev.events=EPOLLIN|EPOLLET;//监听读状态同时设置ET模式
#else //LT 
	ev.events=EPOLLIN;
#endif
	epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
	while(1)
	{
		nfds=epoll_wait(epfd,events,5,-1);
		printf("nfds=%d\n",nfds);
		for(i=0;i<nfds;i++)
		{
			if(events[i].data.fd==STDIN_FILENO)
			{
				char buf[10]={0};
#ifdef _EDGE_TRIGGERED
				read(STDIN_FILENO,buf,sizeof(buf));
				ev.data.fd=STDIN_FILENO;
				ev.events=EPOLLIN|EPOLLET;
				epoll_ctl(epfd,EPOLL_CTL_MOD,STDIN_FILENO,&ev);
				printf("welcom to epoll's ET world!\n");
#else				
				read(STDIN_FILENO,buf,sizeof(buf));
				printf("welcom to epoll's LT world!\n");
#endif
				
			}
		}
	}
	return 0;
}

在这里插入图片描述
图7
本程序使用LT模式,对于前两次输入,epoll_wait返回读就绪的时候我们都将buffer(缓冲)中的内容read出来,所以导致buffer再次数据减少,下次调用epoll_wait就会处于读状态。所以能够实现我们所想要的功能——当用户从控制台有任何输入操作时,输出”welcome to epoll’s LT world!”知道buffer中的数据读取完毕。而对于第三次输入,buffer中的内容小于buf的大小,故buffer被清空,下次调用epoll_wait就会阻塞。所以能够实现我们所想要的功能——当用户从控制台有任何输入操作时,输出”welcome to epoll’s LT world!”

结语:
本文大部分内容参考自junren https://www.cnblogs.com/lojunren/p/3856290.html

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值