🐱作者:一只大喵咪1201
🐱专栏:《网络》
🔥格言:你只管努力,剩下的交给时间!
书接上文五种IO模型 | select。
poll | epoll
- 🍧poll
- 🧁认识接口
- 🧁简易poll服务器
- 🧁poll的特点
- 🍧epoll
- 🧁认识接口
- 🧁epoll原理
- 🧁简易epoll服务器
- 🧁epoll的特点
- 🧁epoll的工作方式
- 🍧总结
🍧poll
poll
也是一种多路转接的方案,它专门用来解决select
的两个问题:
- 等待fd有上限的问题。
- 每次调用都需要重新设置
fd_set
的问题。
🧁认识接口
如上图所示便是poll
系统调用的声明,它有三个参数。
struct pollfd* fds
:用来设置需要等待的fd以及事件
如上图所示,struct pollfd
结构体中存在三个成员变量,第一个是fd,表示需要操作系统等待的文件描述符。第二个是short events
,表示需要操作系统等待该fd的事件类型。第三个是short revents
,操作系统告诉用户层该fd的哪个事件就绪了。
此时的文件描述符fd直接设置到struct pollfd
结构中即可,需要设置哪个就设置哪个,不用再去寻找对应的位图。
告诉操作系统需要等待的事件时,只需要直接设置short events
即可,不用将不同的事件类型放在不同的位图中。
当指定文件描述符fd的就绪时,操作系统会设置对应short revents
,用户层直接读取fds
中的这个字段便可知道是哪个事件就绪了。
struct pollfd
结构体将用户和操作系统设置的字段分开了,所以就不存在相互干扰的问题。
events和revents的取值:
如上图所示便是用户层以及操作系统可以设置的事件类型,这些同样是一些宏定义,常用的就是POLLIN
数据可读,以及POLLOUT
数据可写。
假设fds
结构体中,events
的值是POLLIN
,此时操作系统就关注指定文件描述符的读事件是否就绪,如果就绪,就将revents
的值也设置成POLLIN
,用户层读取到该值后就知道文件可读了。
nfds_t nfds
:需要poll
等待的文件描述符fd的个数。
如上图所示,在内核中,nfds_t
类型本质上是一个unsigned long int
类型,也是一个整形。
第二个参数nfds
就是用来设定需要poll
等待文件描述符的个数的。用户层和操作系统同时维护一个元素为struct pollfd
类型的数组,这个数组中有多少个元素,用户层需要让操作系统等待的文件描述符就有多少个,变量nfds
就表示数组的大小。
- 这个数组就类似用户层和操作系统之间的“临界资源”,双方都能看到,而且都可以访问,由于访问的位置不同,所以不会出现干扰。
由于nfds
的值是由用户层设定的,所以poll
可同时等待的文件描述符数量并没有上限,unsigned long int
的最大值非常大,远大于一个系统能打开的文件个数,所以可以理解为没有上限。
int timeout
:阻塞等待的时间
和select
中的struct timeval
变量的作用类似,但是这里的timeout
是一个int类型的变量,它的单位是1ms。并且它不是一个输入输出型参数,只需要定义一次即可。
timeout>0
表示在timout
时间以内阻塞等待,超出这个时间就超时返回,如该值是1000就表示阻塞等待1s。
timeout ==0
表示非阻塞等待。timeout < 0
表示阻塞等待。
- 返回值:就绪事件的个数。
和select
的返回值意义一样,本喵就不再解释了。
🧁简易poll服务器
下面本喵用poll
实现一下上面select
所实现的服务器,大部分代码都一样,本喵仅讲解不一样的部分:
如上图所示,poll
服务器中,成员变量只是将原本的int* _fdarry
类型数组变成了struct pollfd* _rfds
类型的数组,其他成员保持不变。
- initServer():初始化服务器
如上图所示,初始化服务器中,在堆区开辟的数组变成了struct pollfd
类型的数组,大小是由户自定义的。
在初始化这个数组的时候,需要初始化struct pollfd
中的三个字段,其中fd仍然是-1,revents
和events
都是0,本喵将初始化字段放在了一个函数中。
同样需要将数组中第一个需要等待的文件设置成监听套接字,事件设置成POLLIN
,表示需要操作系统等待监听套接字中的读事件。
- Start():启动服务器
如上图所示,可以看到此时Start
函数比之前简洁了许多,因为不用每次轮询的时候都重新设置一遍fd_set
了,直接调用poll
即可,然后根据返回值做具体的处理。
同时阻塞事件也不用再重新设置了,因为并不是一个输入输出型参数,操作系统并不会改变timeout
只需要设置一次即可。
HandlerReadEvent():处理事件函数。
如上图所示,当事件就绪时,仍然需要调用HandlerReadEvent
函数来处理事件,而且仍然需要遍历数组来判断具体是哪个文件描述符的哪个事件就绪了,然后再看是调用Accept
还是Recver
。
- 此时遍历判断的时候,判断的是
struct pollfd
中的fd字段,之前所有判断fd的语句中,都需要变成_rfds[i].fd
。- 在判断是否是
POLLIN
事件就绪时,需要将revents
的值与POLLIN
进行按位与,如果结果大于0则说明该事件就绪了。
其他部分代码本喵就不讲解了,因为和select
中的代码一样,只是遍历判断时由直接的_fdarry[i]
变成了_rfds[i].fd
,包括pollServer.cpp
中的代码都不用作任何修改。
如上图,服务器运行起来后,由于没有新连接到来,所以监听套接字每隔5s就超时返回一次。
同样为了避免干扰,将poll
设置成阻塞等待方式:
如上图所示,将timeout
的值设置成-1,表示让poll
阻塞等待,可以看到,运行起来后,由于没有新连接到来,程序阻塞不动了。
如上图所示,使用两个telnet
客户端连接客户端后,现象和selsect
一样,也是一个服务端进程可以同时和两个客户端进行通信。
🧁poll的特点
优点:
struct pollfd
结构包含了要监视的event
和发生的revent
,不再使用select
“参数-值”传递的方式,接口使用比select更方便。poll
并没有最大等待文件描述符数量限制 (但是数量过大后性能也是会下降)。
缺点:
- 和
select
一样,poll
返回后,需要轮询struct pollfd
数组来获取就绪的描述符。 - 每次调用
poll
都需要把大量的struct pollfd
结构从用户层拷贝到内核中。 - 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长, 其效率也会线性下降。
🍧epoll
epoll
是基于poll
的基础上改进的,它不仅克服了select
的缺点,而且解决了poll
遍历成本,是效率最高的多路转接模式,但是它也是最复杂的一种模式。
🧁认识接口
epoll_create
如上图所示的epoll_create
系统调用是用来创建epoll
句柄的。
int size
:自Linux2.6.8以后,该参数是被忽略的,不起实际作用,但是必须是大于0的一个值。- 返回值:返回的也是一个文件描述符fd。
epoll
句柄在内核中也是一个结构体,类似于struct file
,而Linux下一切皆文件,所以返回的也是一个文件描述符,拿着这个文件描述符可以访问到这个epoll
句柄。
epoll_ctl
如上图所示的epoll_ctl
系统调用是用来修改创建的epoll
句柄属性的。
-
int epfd
:该值就是epoll_create
的返回值,用来指示哪个epoll
句柄。 -
int op
:是修改句柄属性的选项,有增,删,改三个选项: -
EPOLL_CTL_ADD
:向句柄中增加要等待的文件描述符。 -
EPOLL_CTL_MOD
:修改句柄中指定的文件描述符。 -
EPOLL_CTL_DEL
:从句柄中删除指定的文件描述符。 -
int fd
:要进行操作的文件描述符。 -
struct epoll_event* event
:用来指定要等待的事件。
如上图所示便是内核中struct epoll_event
结构体的定义,它有两个成员变量。
第一个成员是uint32_t events
,用来设置需要等待的事件,其值也是有几个宏组成的集合:
值 | 意义 |
---|---|
EPOLLIN | 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭) |
EPOLLOUT | 表示对应的文件描述符可以写 |
EPOLLPRI | 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来) |
EPOLLERR | 表示对应的文件描述符发生错误 |
EPOLLHUP | 表示对应的文件描述符被挂断 |
EPOLLET | 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的 |
EPOLLONESHOT | 只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里 |
第二个参数是一个联合体epoll_data_t data
,可以看到有四个成员共用这个联合体,后面本喵会讲解它每个变量的妙用。
- 返回值:调用成功返回0,调用失败返回-1,并且设置相应的错误码。
epoll_wait
如上图所示的epoll_wait
系统调用是用来从操作系统中获取被等待文件描述符的状态的。第一个参数不做解释。
-
struct epoll_event* events
:是一个数组,操作系统将就绪的文件描述符放入这个数组中供用户层读取。 -
int maxevents
:该值就是events
数组的大小,是用户层用来告诉内核这个数组有多大的,这个值不能大于epoll_create
时的size
。 -
int timeout
:和poll
中是一样的,不再解释。 -
返回值: 也是和
poll
的返回值以及select
代表的意义一样,大于0表示就绪的文件描述符个数,等于0表示超时返回,小于0表示调用失败。
以上三个系统调用是epoll
模型的核心调用,epoll_ctl
是用户层用来告诉内核自己的需求的,epoll_wait
是内核用来告诉用户层哪些文件描述符的什么事件就绪的。
现在知道了接口的使用,但是仍然并不清除为什么epoll
模型能够解决poll
和select
存在的问题,所以我们需要大概知道epoll
模型的底层原理。
🧁epoll原理
网络通信过程中,接收端将数据从网卡(硬件层)开始逐层向上交付,最后给到应用层,那么接收端是如何知道网卡上有数据到来的?也就是操作系统是怎么感知到数据来了呢?
如上图所示,本喵将计算机体系结构,冯诺依曼体系结构,以及中断向量表放在了一起来讲解。
当网卡接收到数据后,输入外设(网卡)会自己产生一个控制信号直接给CPU中的控制器,表示此时网卡中有数据到来,可以读了。
- 冯诺依曼体系中,外设的数据信号不能直接和CPU传递,如上图中红色线条,必须经过存储器。
- 外设的控制信号可以直接传递给CPU的控制器,如上图黑色线条。
- 外设给CPU发送一个信号表示数据到来,这叫做中断事件发生。
CPU根据中断信号的编号,去操作系统维护的中断向量表中找到对应的中断服务函数并且执行。
中断服务函数中会调用网卡接收数据的驱动程序,将数据读取并且向上层交付,如上图绿色线条所示。
- 在这里要重点关注中断服务函数,从网卡中接收数据是从它开始的。
epoll
是一个模型,这个模型包含多个数据结构,而前面所讲的句柄,可以理解为是这个模型标志,通过句柄可以找到这个模型,并且使用它。
如上图所示是整个epoll模型理论图,包含计算机体系结构中的驱动层,操作系统,系统调用三层。
在调用epoll_create
创建模型后,会返回一个文件描述符fd,这个fd同样放在服务器进程PCB所维护的进程描述符表中,通过fd这个句柄就可以找到对应的epoll
模型。
- epoll模型同样是一个大的结构体,只是这个结构体更加复杂,在Linux眼中,都是
struct file
,所以创建模型后返回的也是一个文件描述符。
上图中操作系统中黑色框内的部分就是epoll
模型,包含一个红黑树和一个就绪队列。
以增加需要操作系统等待的文件描述符为例,调用epoll_ctl
,将fd以及需要等待的事件构建成struct epoll_event
变量插入到红黑树中,操作系统会遍历红黑树中所有节点。
- 红黑树节点中包含很多成员变量,如上图左下角所示,这其中必然有文件描述符fd,需要等待的事件
event
,左右字节的指针。- 还包括
next
和prev
指针。
如果是删除或者修改等操作,同样是在修改这颗红黑树,而红黑树查找效率非常高,所以对应的操作也会很高效。
当操作系统发现红黑树中有节点的事件就绪后,就会将该节点放入到就绪队列中,就绪队列是一个双向循环链表。
将节点从红黑树放入到就绪队列中并没有发生拷贝,秘密就在next
和prev
指针上。当网卡中有数据到来时,通过中断函数最终调用了网卡驱动程序,在驱动程序中有一个回调函数void* private_data
,这是由操作系统提供的。
private_data
回调函数会将红黑树节点中的next
和prev
指针的指向关系做对应的修改,让该节点链入到就绪队列中去。
- 红黑树的一个节点,它不只属于红黑树,还可能属于就绪队列。
- 如上图所示,红黑树中的节点和就绪队列中的节点地址都是
0x11223344
。
本喵画的是逻辑图,所以将就绪队列和红黑树分开了。
就绪队列中必然也包括就绪文件的文件描述符,以及就绪的事件,如上图所示的struct epoll_event
结构。
- 所以,凡是处于就绪队列中的节点必然已经就绪。
用户层在调用epoll_wait
后,获取的就是内核中就绪队列中的内容,所以获取到的全部都是就绪的事件,所以用户层的struct epoll_event
类型数组中,全部都是就绪的事件。
epoll_wait
将所有就绪的事件,按照顺序放入到用户层传入的数组中。
此时从内核到用户层虽然也需要遍历,但是此时是遍历拷贝,而不需要遍历检测,所以时间复杂度相当于从之前的O(N)
变成了O(1)
,效率提升的不是一点半点。
🧁简易epoll服务器
和poll
服务器一样,本喵仅讲解不一样的地方,其他和select
服务器中有详细讲解。
如上图所示是epoll
服务器类的基本组成,相比poll
服务器有三个变化的成员变量,_epfd
是epoll
模型句柄文件描述符,_revs
是用户层获取就绪文件描述符的数组,_num
是该数组的大小。
还包含几个全局的默认值,size
是调用epoll_create
是的参数,要大于0并且大于用户层数组的大小。defaultnum
是用户层数组的默认大小。
- initServer: 初始化服务器
如上图所示初始化服务器代码,创建epoll
模型,并且将监听套接字的读事件让操作系统去等待,加入到红黑树中,最后就是在堆区开辟获取就绪事件所用的数组。
在设置struct epoll_event
结构体变量的时候,暂时先给data
联合体的fd赋值,虽然epoll_ctl
的第三个参数才是真正指定要等待的文件描述符。
由于用户层在设置的时候设置的是联合体的fd字段,所以当该文件的事件就绪时,就绪队列中该文件的联合体中仍然是fd字段。
- Start: 启动服务器
如上图代码所示,Start
成员函数的实现更加简单,仅仅是调用了epoll_wait
函数,当有事件就绪时,调用HandlerReadEvent
时需要传入就绪事件的个数,方便遍历拷贝。
HandlerReadEvent
如上图所示,可以看到,此时遍历拷贝的元素全部是已经就绪的事件,而不需要再挨个检测,所以效率非常高。
Accept
如上图代码所示,监听到的新连接同样需要交给操作系统去等待,所以需要将新连接的文件描述符添加到红黑树中。
Recver
如上图所示,接收数据的代码和之前是一模一样的,不同的是,当新连接出现异常关闭后,需要将新连接的文件描述符从红黑树中删除,让操作系统不用再等待该文件描述符了。
epollServer.cpp
源文件同样不需要做修改,直接用之前的就可以。
如上图运行结果所示,epoll
模型创建好以后,句柄的值是4,监听套接字的文件描述符是3,两个telnet
客户端的连接文件描述符是5和6,足以说明该句柄就是一个文件描述符,其他效果和之前的一样。
🧁epoll的特点
- 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开。
- 数据拷贝轻量:只在合适的时候调用
epoll_ctl
将文件描述符结构拷贝到内核中,这个操作并不频繁(而select/poll
是每次循环都要进行拷贝)。 - 事件回调机制:避免使用遍历检测,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。
epoll_wait
返回直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度是O(1)
,即使文件描述符数目很多, 效率也不会受到影响。 - 没有数量限制:文件描述符数目无上限。
虽然epoll
的机制更复杂,但是它用起来更方便也更高效。
🧁epoll的工作方式
epoll
主要解决的是多路转接中,进行IO的时候等的这一环节,当操作系统所监管的事件就绪了,就会通知用户层来处理事件,这个通知有两种方式:
- 水平触发(Level Triggered)工作模式:简称LT。
- 边缘触发(Edge Triggered)工作模式:简称ET。
来举一个生活中的例子,假设你正在打王者荣耀,正要推对方水晶的时候,你妈喊你吃饭,此时就存在两种方式:
- 如果喊你一次你没动,那么就会继续喊第二次,第三次…,直到你去吃饭,这种方式就是水平触发。
- 如果喊你一次你没动,之后就不再喊你了,这种方式就是边沿触发。
放在多路转接中就是,事件就绪时,操作系统通知用户层后,用户层没有读取数据或者没有读取完毕,如果操作系统继续通知就是LT模式,如果没有继续通知就是ET模式。
epoll
默认状态下就是LT工作模式。- LT模式下,事件未被用户层处理完毕,每调用一次
epoll_wait
就会返回一个大于0的值。- ET模式下,事件未被用户处理完毕,只有第一次调用
epoll_wait
才会返回大于0的值,之后不再返回,并且将事件设置为未就绪状态,除非该套接字中数据增加,才会再返回一次大于0的值。
在调用epoll_ctl
的时候,将struct epoll_event
中的uint32_t events
字段设置成EPOLLET
,此时该文件描述符就变成了ET模式,并没有设置LT模式的方法,因为默认就是LT模式。
使用ET模式
能够减少epoll
触发的次数,但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完,如果不处理完,剩下的数据就有可能被覆盖,后果由程序猿自己承担。
相当于一个文件描述符就绪之后,不会反复被提示就绪,所以就比 LT 更高效一些。
ET模式的高效是建立在程序员的痛苦之上的,由于它只通知用户层一次,如果不一次处理完数据就没机会再处理了,但是,用户层是怎么知道数据有没有读取完毕呢?
- 答案是:循环读取,直到读不到数据了,就证明读完了。
如上图所示,此时就存在一个问题,客户端发送了10K的数据给服务端,服务端收到了epoll
的通知后,用户层调用recv
进行读取,但是一次没有读取完毕,只读取了1K的数据。
由于此时epoll
是ET模式,所以操作系统认为事件已经被处理了,就又将读事件设置成了未就绪的状态,再次读取时recv
就会阻塞不动,整个进程就阻塞了,如下面伪代码:
while(1)
{
int ret = recv(sock,buffer,sizeof(buffer)-1,0);
//第二次读取就会阻塞
}
由于epoll_wait
不会再次返回,剩下的9K数据会一直在缓冲区中,直到下一次客户端再给服务器写数据,操作系统再次将读事件设置成就绪状态,才能再次recv
。
- 服务端只有将10k数据完全读取完,才会给客户端一个确认应答。
- 客户端收到服务端的确认应答后才会发送下一个请求。
- 客户端发送下一个请求,
epoll_wait
才会返回,才会将读事件设置未继续,服务端才能再次去缓冲区中读取。
服务端无法读取剩余的数据,也就不会发出响应,客户端无法收到响应,也就不会再次发送请求,服务端无法收到再次的请求,就无法再次读取缓冲区中剩余的数据。
时间一长,就会触发TCP的超时重传机制,导致数据被覆盖甚至丢失等问题。
- 为了解决这个问题,文件描述符对应的缓冲区必须设置成非阻塞 IO方式,本喵在上篇文章中讲解过,使用
fcntl
设置。- 只有非阻塞方式,才能用轮询的方式不断读取缓冲区中的数据,直到读取完毕。
如果是LT模式就不用设置成非阻塞模式,因为数据没有读取完毕,epoll_wait
会持续返回,而事件也被保持就绪状态,recv
就可以持续读取数据,直到将数据读取完毕。
select
和poll
是采用LT模式的,和epoll
的默认方式一样,那么如果将文件描述符设置成非阻塞方式,仍然使用LT模式不是更方便吗?既能循环读取,又能让epoll
持续返回,也能提高效率啊,为什么仍然要多此一举设计一个ET模式呢?
- ET模式的高效不仅仅体现在通知机制上,减少通知次数,降低系统调用的开销。
- ET模式的高效还体现在增加底层网络的吞吐量上。
ET模式表面上看是在倒逼程序员将本轮就绪的数据全部读走,深入网络底层TCP协议去看,服务端由于一次将数据全部读走了,从而能给客户端应答一个更大的窗口值。
客户端就能更新出一个更大的滑动窗口,增加一次发送的数据量,从而提高底层数据发送的效率,更好的利用诸如TCP延迟应答等策略,提高整个网络通信的吞吐量。
- 所以说,ET模式在压榨程序员的基础上,提高了整个网络通信的效率。
🍧总结
虽然介绍了poll
和epoll
两种方式,但是epoll
不仅解决了poll
方式的问题,而且还带来了其他优势,比如使用简单,遍历成本低等优势,以及ET模式对于通信效率的提升,虽然epoll
的机制更复杂,但是它带来了更好的效果,利远大于弊。
epoll
的高性能是有一定的特定场景的,如果场景选择的不适宜,epoll
的性能可能适得其反。
- 对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用epoll。
例如,一个需要处理上万个客户端的服务器,例如各种互联网APP的入口服务器,这样的服务器就很适合epoll
。
如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用epoll
就并不合适,具体要根据需求和场景特点来决定使用哪种模型。