参考《Linux高性能服务器编程》第9章
使用I/O复用方法的目的:
I/O复用函数可以同时检测多个文件描述符上有没有用户所关心的事件产生。
使用I/O复用方法的意义:
可以使服务器在不引入多进程或多线程的情况下同时处理多个连接(文件描述符)
一:select
1.API
参数列表:
- nfds:描述符的最大值加一;
集合共有1024个位,但每一次不可能把1024个位使用完,因此只需要找到描述符最大值加1,就可以只检测这么多位,可以降低时间复杂度。- readfds:读事件的集合
对于连接套接字: 对方有发数据过来,放到了接收缓冲区,本地有数据可以读了,这就叫读事件
对于监听套接字:socket() 如果客户端connect连接我们了,那么socketfd上就会有一个读事件- writefds:写事件的集合
发送缓冲区只要还有空间,send()就可以写入数据,那么写事件就是就绪的。对于套接字来讲,一开始读事件是没有产生的,但是写事件一开始就是就绪的- exceptfds:异常事件的集合
- timeout:超时时间,如果不填或者给一个NULL就会永久阻塞,直到描述符上有数据就会返回
- 返回值:集合中就绪的描述符数目
2.场景一:检测单描述符
使用I/O函数检测键盘,超时时间为5s,5s内键盘输入数据就读取打印到屏幕;若超时仍无数据,则打印timeout
单描述符实现步骤
- 1.定义文件描述符。由于此处需要检测的是键盘(标准输入),所以文件描述符是0,区别于赋值为0。
- 2.定义一个收纳文件描述符的集合 : fdset。
- 3.循环检测,设置超时时间
- 4.清空集合里面的每一个位
- 5.将需要检测的文件描述符添加到集合中。由于此次只有一个文件描述符,所以只加一次。真实情况应该是添加多个文件描述符,所以此次应该是一个循环
- 6.接收select返回的就绪文件描述符个数
- 7.遍历集合寻找被修改的文件描述符,并进行处理响应、
3.场景二:适用与tcp服务器端
处理流程
-
定义一个数组记录所有的文件描述符
-
服务器端一开始只有一个文件描述符:sockfd;因此需要准备一个集合将sockfd添加进去,检测其是否有客户端连接服务器端
-
刚开始select()检测到有就绪描述符则必定是sockfd上,所以需要调用accept()接收连接,接收连接会产生新的文件描述符,因此需要将新的文件描述符添加到集合。
-
重新使用select检测。若检测到连接套接字 ’ c '上有数据则需要调用 recv()接收数据并send()数据;若检测到监听套接字sockfd上有数据则需要调用accept()接收连接
-
注意细节:recv()返回值为0表明客户端关闭,所以服务器端也应该关闭文件描述符
4.select下何时有读事件就绪
select 返回值 n 大于0时有多种情况
- 1.sockfd 上有事件可读:有客户端连接服务器,需要接收
- 2.客户端连接上,向连接套接字上发送数据,有读事件:recv接收数据
- 3.客户端连接上,关闭客户端。虽然没有发送数据,但是select也会有读事件产生,recv返回值为0
5.select的缺点
- select检测的文件描述符的上限只有1024
因此poll就解决了描述符太多的场景,只要能申请到足够的内存,poll就可以检测所有文件描述符 - 遍历寻找就绪文件描述符效率低下
二:poll:加强版select
1.poll的特点
- 支持比select更多的文件描述符
- 支持比select更多的事件类型
- 内核实现上和select一样:都是采用轮询方式,挨个去检查。时间复杂度为 O(n)
2.API
参数意义:
- fds:结构体数组
数组定义多大就可以关注多少个元素,因此就可以使其超过1024的限制。因此对于select()来说集合大小固定为1024,但是对于自定义结构体数组可以随心所欲- nfds:结构体数组的元素个数
- timeout:超时时间
三:epoll(Linux平台特有)
1.select和poll的痛点
明明已经有select和poll方法了为什么Linux平台还需要自己实现epoll方法?
- 1.select和poll都需要在用户空间创建集合或结构体数组来维护描述符
每一轮调用select或poll方法都需要将数据结构当做参数传递进去,因此每一轮都需要拷贝大量的数据到内核空间进行系统调用- 2.将数据拷贝给内核空间。内核空间的实现方式是轮询(一个一个去检测),因此时间复杂度O(n)。
- 3.select和poll每一轮返回值并不清楚哪些描述符上有数据就绪。因为select和poll的返回值都只是仅仅标识有多少描述符上有事件就绪的,并没有告诉具体是谁。因此用户空间需要自己实现方法去找到具体哪一个描述符上有事件就绪,因此时间复杂度O(n)。因此当处理大量描述符时就会造成性能不佳
使用epoll的目的就是为了解决客户端数目非常多的情况 并且会解决以上select和poll的痛点。
2.使用epoll的目的
1.对于每一个描述符在其生命周期中只需要向内核添加一次。因此就解决了第一个问题(每一次调用select/poll都将数据结构作为参数去传递)
2.epoll的内核实现是:注册回调函数的方式实现。哪一个描述符上有事件产生就调用回调函数告诉epoll,因此时间复杂度O(1)
3.epoll_wait()获取就绪描述符并返回,因此epoll_wait()的返回值就是实际的就绪描述符数目,就绪的描述符就会被填充到一个数组中,因此用户只需要处理该数组前n个元素就可以,避免了用户自己去寻找就绪的描述符,用户找到就绪描述符的时间复杂度O(1)
3.epoll函数
(1)epoll_create():创建内核事件表,存放描述符和事件
将需要检测的文件描述符存放在内核空间,这样一来每一次循环就不需要向内核空间传递文件描述符
内核实现是一棵红黑树
- size:不起作用。因为内核事件表的底层实现是红黑树,类似于链表,不需要初始化大小。但是如果size传递-2表明内核事件表大小为-2显然不合适,因此传递的参数必须大于0
- 返回值:内核事件表对应的文件描述符
(2)epoll_ctl():向内核事件表中添加一个文件描述符;修改描述符上的事件;移除描述符
- epfd:epoll_creat()的返回值(内核事件表对应的文件描述符)
- op:对应的操作类型
- fd:需要添加的文件描述符
- event:事件的地址
- 返回值:添加成功返回0;失败返回-1
(3)epoll_wait():从内核事件表的就绪队列获取有就绪事件的描述符
- epfd:epoll_creat()的返回值(内核事件表对应的文件描述符)
- events:有多少个描述符上有事件就绪就向其中添加多少个元素
- maxevents:数组大小,最大就绪的描述符数目。防止添加越界
- timeout:超时时间
四:LT 模式
水平触发
select / poll /epoll 都具有该模式:每一次客户端发送数据,服务器接收数据,即使第一次没有接收完毕,后面还会提醒对其进行接收,直到接收缓冲区没有数据
五:ET 模式(高效模式:epoll独有)
边沿触发
当客户端送过来一波数据,就会触发该事件,告诉epoll该文件描述符上有数据,但是服务器一次没有接收完,第二次就不再提醒了。简单来说ET模式只会提醒一次该文件描述符上有数据,由于是TCP连接第一次接收不完数据就存放在接收缓冲区。缓冲区有数据但是已经不提醒了,下一次从接收缓冲区读数据依然会将剩余的数据进行读取。
简单来讲对方送过来一波数据只有一次机会去读取,若一次没有读完,只有等到下一次触发读事件才会继续进行读取
如何做可以将缓冲区数据读完
永远不清楚客户端会发送多少数据,因此必定会出现一次将数据接收不完的情况
对于客户端送过来的数据服务器一次只读取一个,那么剩余的数据必定存放在读取缓冲区,如何处理可以使得数据能读完呢?
矛盾点:
客户端发送一波数据服务器一次读不完,那么服务器就应该循环去读,但是ET模式不会循环去提醒,等到第二次提醒就已经是第二波数据过来了。因此服务器想要将其读完就需要循环recv,如果第一次就把数据读完了,第二次循环recv时就会发送阻塞;如果不循环读取只读一次又可能出现数据读不完。因此该如何解决这个矛盾的地方?
解决方法:
将该文件描述符设置成非阻塞模式,循环读取
当有数据就会返回一个大于0的值
没有数据返回-1,表明将数据读完了
如果对方关闭返回值为0
1.设置非阻塞模式
2.循环读取
ET模式的作用
ET是一种高效模式,该模式下可以明显的减少I/O函数被触发的次数。减少epoll_wait()返回的次数。如果数据之间有一定的联系,那么采用这种方法就可以大大缩短接收时间。