🐱作者:一只大喵咪1201
🐱专栏:《网络》
🔥格言:你只管努力,剩下的交给时间!
五种IO模型 | select
- 🍧五种IO模型
- 🍧select
- 🧁认识接口
- 🧁简易select服务器
- 🧁select的特点
- 🍧总结
🍧五种IO模型
在学习系统部分的时候,本喵就讲解过IO,当时我们学习的IO就是从文件中读数据和写数据,到了后来学习网络的时候,我们知道,从网络中读取和写入数据也是IO,那么IO到底是什么呢?今天我们来更深刻的认识一下IO。
就拿读取数据来说,无论是调用read
还是recv
,在文件描述符所指向的struct file
中的接收缓冲区如果没有数据的时候,都会阻塞等待。
当缓冲区中有数据后,才会进行读取,所谓读取,本质就是在拷贝,就是将内核缓冲区中的数据拷贝到用户缓冲区中供用户去使用。
无论是等待还是拷贝,都是读取的过程,二者缺一不可。
- IO = 等 + 数据拷贝
那么什么是高效的IO呢?我们知道,IO过程中我们在意的是拷贝,而不是等待,由于各种技术的发展,拷贝所花费的时间几乎是固定的,是由电路或者系统等等机制来保证的,所以拷贝的效率已经很难再有提升了。
- 高效的IO = 减少等待的比重
IO的种类有五种,下面本喵给大家介绍一下:
- 阻塞IO:在内核将数据准备好之前,系统调用会一直等待,所有的套接字以及文件,默认都是阻塞方式。
如上图所示便是阻塞IO的示意图,在进程调用recvfrom
从内核缓冲区中读取数据时,如果数据没有准备好,进程就会阻塞在调用处等待,直到数据准备好,才会将内核缓冲区的数据拷贝到用户缓冲区,并且给进程返回值。
- 阻塞IO是最常见的IO模型,也是最简单的IO模型,我们之前写的所有IO都是阻塞式的。
- 非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回
EAGAN
或者EWOULDBLOCK
错误码。
如上图所示,进程调用recvfrom
从内核缓冲区中读取数据时,即使数据没有准备好,仍然会给进程一个返回一个EAGAN
或者EWOULDBLOCK
。
通常情况下使用这种IO方式时,如果返回EAGAN
或者EWOULDBLOCK
,说明数据没有准备好,就会再次调用recvfrom
去读取数据,如此反复,直到数据准备好并且完成拷贝,最后返回表示成的返回值。
- 非阻塞IO需要程序员以循环的方式反复尝试读写文件描述符,这个过程称为轮询。
- 这对CPU来说是较大的浪费,一般只有特定场景下才使用。
默认情况下,文件描述符fd指向的struct file
中的缓冲区的阻塞式IO,所以我们前面无论是在进行文件操纵还是使用套接字的时候,都是阻塞IO。
如上图所示系统调用fcntl
,可以使用它将fd所指向的文件改成非阻塞IO模式,它的参数是一个可变参数。
- int fd:要修改文件的文件描述符fd。
- 返回值fl:大于0表示当前文件的状态,小于0表示调用失败。
- int cmd:对fd所指向的文件要进行的操作,可以传递两个参数:
F_GETFL
:用来获取该文件当前的状态。F_SETFL
:用来设置该文件当前的状态。
- 可变参数部分:可以传递参数有O_RDONLY,O_WRONLY等等,最重要的是
O_NONBLOCK
非阻塞IO方式,但是在传参的时候,必须和fl
进行按位或,如fl | O_NONBLOCK
。
如上图所示代码是本喵写的一个工具函数,作用就是将指定fd
所代表的文件设置成非阻塞IO方式。
如上图,从内核中的代码可以看到,O_NONBLOCK
是一个宏,仅仅代表着一个比特位的状态,所以在传参时,需要使用fl | O_NONBLOCK
的方式,在文件原有的状态上增加非阻塞。
再重新来看一下read
系统调用:
如上图所示为man
手册中的描述,调用失败以后,返回-1,并且错误码被设置。
- 设置不同的错误码代表着不同的意义。
如上图所示,可以设置的错误码有这么多,虽然返回值是-1,但是不同的错误码代表着不同的情况,其中EAGAIN
或者EWOULDBLOCK
表示的就是数据没有准备好,需要稍后再读。
- 读取数据时,内核中缓冲区的数据没有准备好并没有错,这是很正常的情况。
- 所以错误码
EAGAIN
并不表示错误了,只是表示数据暂时没有准备好,需要稍后再来读取。
如上图所示,EAGAIN
和EWOULDBLOCK
都是宏,而且它们的值都是11,本质上是一个东西。
如上图代码所示,使用本喵写的工具serNonBlock
将标准输入,也就是文件描述符为0的文件设置成非阻塞IO模式,然后运行。
如上图所示,可以看到,输入提示符>>>
在不停打印,在本喵从键盘上输入的时候,仍然在打印,虽然输入的内容被>>>
分隔开了,但是输入完成以后,回显echo
的内容却是完整的,仍然是helloworld
。
- 输入和输出互不影响,因为有输入缓冲区和输出缓冲区,二者是相互独立的。
如上图所示便是默认情况下的阻塞IO,可以看到,输入和输出轮替进行着,在本喵没有输入的情况下,该进程是阻塞等待的。
如上图所示,可以在else
情况,也就是返回值s小于0的时候再进行具体的判断,如果错误码errno
的值是EAGAIN
说明只是数据没有准备好,并不是出错了,在等待数据就绪的过程中可以执行其他任务。
如上图所示,此时进程并没有阻塞,而是在轮询,数据没有准备好就执行其他任务来打发时间。
- 信号驱动IO:内核将数据准备好的时候,使用
SIGIO
信号通知应用程序进行IO操作。
如上图所示信号驱动IO模型,该模式调用recvfrom
并不是在主进程中调用,而且使用signal
注册信号处理函数,在信号处理函数中调用recvfrom
。
进程一直在正常运行,执行自己的逻辑,当内核接收缓冲区有数据到来时,进程会收到系统给发的信号SIGIO
,然后就会调用该信号注册的处理方式,在信号处理方式中调用recvfrom
读取缓冲区中的数据。
- 一旦进入信号处理函数中,说明内核缓冲区中的数据已经准备好了,在这里只需要读取,而不再需要等待。
- IO多路转接:虽然从流程图上看起来和阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。
如上图IO多路转接模式,该模式中,将IO的等待和拷贝两个步骤分开了。
进程调用select
系统调用来等待内核缓冲区中数据就绪,当数据就绪以后通知进程调用recvfrom
来将数据拷贝到用户缓冲区中。
这样看起来和信号驱动的IO模式没有什么区别,但是多路转接的优势在于可以同时等待多个文件描述符所指向的文件。
- IO多路转接模式下,可以同时等待多个文件,当一个或者多个文件的缓冲区中数据就绪时,就会通知上层用户来读取。
此时虽然也有等,但是等的比重就降低了,因为能够一次等待多个文件,进而让上层用户一次来读取多个缓冲区中的数据,拷贝的比重就增加了,从而提高了IO的效率。
- 异步IO:由内核在数据拷贝完成时,通知应用程序直接去用户缓冲区中使用数据。
- 同步和异步关注的是消息通信机制:
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了。
- 也就是由调用者主动等待这个调用的结果。
异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。
- 当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、信号等来通知调用者,或通过回调函数处理这个调用。
如上图所示异步IO模式示意图,进程调用aio_read
,将等待数据就绪和将数据拷贝到用户缓冲区两个步骤的工作全部交给操作系统来完成。
当操作系统完成两个步骤以后,通知上层用户直接去用户缓冲区中使用数据即可。
信号驱动是告诉进程可以从内核缓冲区中拷贝数据到用户缓冲区了,而异步IO连拷贝这一步也不用做了,直接使用数据。
- 另外,我们回忆在学习多进程多线程的时候,也提到同步和互斥,这里的同步通信和进程之间的同步是完全不同的概念。
- 进程/线程同步也是进程/线程之间直接的制约关系,是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系。尤其是在访问临界资源的时候。
比较这五种IO模式,阻塞IO模式肯定是效率最低的,非阻塞以及信号驱动的IO模式,同样需要参加IO的等待和拷贝两个过程,对于IO的效率是相同的。
异步IO模式也是同样的道理,虽然等待和拷贝不是由线程去做的,而是由操作系统在做,但是线程也得在等待和拷贝完成后才能使用数据,所以IO的效率还是没有提高的。
而多路转接不一样,虽然等待和拷贝两个过程都参与,但是等待时可以一次等待多个文件描述符,拷贝时也是可以拷贝多个文件描述符中的数据。
- 由于等待的文件描述符数量多,所以有数据就绪的概率就高,进行拷贝也更加频繁。
- 站在上帝视角来看,多路转接模式下,进程在单位时间内进行拷贝的次数要比其他几种模式多。
所以多路转接更加高效,而我们研究的重点也在多路转接模式上,主要有select
,poll
,epoll
三种方式。
🍧select
🧁认识接口
如上图所示,该系统调用有5个参数。
- int nfds:要等待的所有文件描述符中的最大
fd+1
。
假设现在要等待的文件有3个,文件描述符分别是3,4,7,则传参时就需要传7+1=8
给nfds。
- fd_set* reads:等待读取就绪文件描述符的位图。
select
等待的文件有不同的事件会就绪,比如读取就绪,写就绪,错误就绪等等。
如上图所示是fd_set
类型在内核中的定义,可以看到它就是一个位图。它有多个比特位,每一个比特位代表一个文件描述符,比特位的状态表示该比特位是否被监听。
如上图所示便是fd_set
位图示意图,其中比特位的顺序由低到高从左向右,比特位的下标就是代表文件描述符fd,比特位的内容表示状态,这张图中,文件描述符为1,3,1023的三个文件描述符需要被select
监视。
如上图所示,使用sizeof
得到fd_set
类型的大小是8字节,所以有1024个比特位,这意味着使用select
最多监视1024个文件描述符。
将接收缓冲区所在文件的文件描述符设置到readfds
中,当该缓冲区有数据到来时(读就绪),select
就会通知上层进程去读取该缓冲区中的数据。
- fd_set* writefds:等待写入就绪文件描述符所在位图。
该位图和readfds
一样,只是操作系统等待的事件由读就绪变成了写就绪,当发送缓冲区空了以后(写就绪),操作系统就会通知上层进程向该缓冲区中写入数据。
- fd_set* exceptfds:等待异常就绪文件描述符所在位图。
打开的文件,如TCP中的套接字,当对端关闭套接字以后,自己这边的套接字就会出现异常,此时操作系统就会通知上层进程处理该异常。
需要操作系统监视哪里事件就将对应的位图传给select
,待事件就绪后就会通知上层进程去处理,如果不需要监视直接设置成nullptr
就行。
- struct timeval* timeout:设置等待方式。
如上图所示struct timeval
类型的定义,有两个成员,第一个是秒,第二个是微秒。
该参数传入nullptr
的时候,select
是阻塞等待,只有当一个或者多个文件描述符就绪时才会通知上层进程去读取数据。
该参数如果是struct timeval timeout = {0, 0}
,时间设置成0,select
是非阻塞等待,就需要使用轮询的方式。
该参数如果设置了具体值,如struct timeval timeout = {5, 0}
,时间设置成5,select
在5秒内阻塞等待,如果在5秒内有文件描述符就绪,则通知上层,如果没有文件描述符就绪则超时返回。
- 返回值:
ret > 0
表示有ret个fd就绪了,ret == 0
表示超时返回,ret < 0
表示select
调用失败了。
为了使用方便,内核还提供了一组宏供用户去设置fd_set
位图,将对应的文件描述符设置进去,避免了我们自己使用按位或等运算给位图赋值的麻烦:
如上图所示,FD_CLR
是将位图中指定文件描述符fd所对应位的状态清零,表示不用操作系统再等待该文件。
FD_ISSET
是用来判断特定文件描述符fd是否被设置进了位图,如果设置返回1,没有则返回0。
FD_SET
是用来将指定文件描述符fd对应的位图设置为1,表示需要操作系统等待该文件。
FD_ZERO
是用来将整个位图清空的,此时操作系统不等待任何一个文件。
select
的五个参数中,后面四个都是输入输出型参数,都是传的指针,意味着操作系统和用户共用一个参数。
调用select
传参时,表示用户告诉内核,需要操作系统等待哪个文件,以及哪种事件。操作系统第一时间会将传入的位图参数清空,每就绪一个文件,传入位图相应比特位置一,然后传返回值给用户。
此时就是内核在告诉用户,你关心的多个fd,有哪些已经就绪了。
用户根据返回值来处理调用结果,如果是ret>0
,则判断自己当初设置进位图中的文件描述符是否被置一了,如果置一了,说明该文件所对应的事件就绪了,用户就可以进行进一步处理。
- 由于传入的参数是输入输出型的,操作系统等待后的结果也是在这个参数中,所以用户必须自己再维护一个数据结构来记录自己最初设置要等待的文件描述符。
- 根据
select
返回状态以及操作系统修改后的位图,与自己维护的记录结构作对比,得出自己当初要等待的文件是否就绪的结论。
无论是读取数据,还是写入数据,再或者是异常事件,都是采用这样的方式和机制。
struct timeval* timeout
表示的阻塞等待时间,操作系统内部也会进行修改,假设初值是{5, 0}
表示阻塞等待5s中,如果5s内没有事件就绪则select
返回0,表示超时返回,此时原本传入的time
的值也变成了{0, 0}
。
如果5s内有事件就绪,假设操作系统等待了3秒,selsect
返回值大于0,表示不是超时返回,有事件就绪,此时原本传入的time
的值也变成了{2, 0}
,表示设置的阻塞事件还剩两秒。
- 所以在轮询过程中,每循环一次就需要重新设置一下时间,否则第一次超时返回后,
time
的值就成了0,之后select
就成了非阻塞IO,与初衷不符。
- 输入输出型参数的作用就是让用户和内核之间相互沟通,互相知晓对方要关心的。
🧁简易select服务器
本喵将创建套接字的代码封装成了一个Sock
类,使用的时候直接调用即可,同样可以将其作为一个小组件。
如上图上,Sock
类中包含套接字的创建,绑定,监听,以及获取新连接等四个方法,这些函数中还包含日志信息,具体的日志代码本喵就不贴了,有兴趣的小伙伴自行去查阅日志功能。
如上图所示便是select
简易服务器的基本配置,成员变量包含监听套接字,端口号,用户维护的用于设置等待位图的数组_fdarry
,以及回调函数。
定义了几个全局默认变量,包括默认端口号,select
所能等待文件的上限1024
,以及用户维护数组的初始默认值。
- initServer():初始化服务器
如上图所示,第一步便是初始化服务器,在系统中创建套接字,进行绑定和监听,构建完整的会话层。之后将用户维护的用来设置等待位图的数组进行初始化,大小是1024个元素,初始值都是-1。
- 0,1,2文件描述符不用
select
等待,从listen
套接字开始进行等待。
- Start():启动服务器
如上图所示,服务器在启动后,是一个while(1)
的死循环,在这个循环中进行一遍又一遍的轮询。
由于select
的后面四个参数都是输入输出型参数,所以每一次轮询之前都需要重新设置一遍,否则就会因为操作系统的修改而出现问题。需要重新设置位图状态,重新设置等待时间等等。
- 每次轮询的时候,都需要将用户维护的数组中需要等待的文件描述符重新设置到
fd_set
位图中。
在将参数设置好以后便调用select
系统调用,用switch
将返回的情况分别处理,当n>0
时,说明有文件就绪,可以去读取,所以调用HandlerReadEvent
函数去处理该事件。
事件处理函数:
如上图所示,当执行到该函数的时候,必有套接字就绪,此时用户要做的就是判断到底是哪个套接字上事件就绪。
第一次执行该函数的时候,必然是监听套接字就绪,因为当前select
等待的只有这一个套接字,所以判断符合条件以后,去执行Accepter
函数来获取新连接。
- 监听套接字就绪也是属于读事件就绪,因为此时有新连接到来,需要用户去将新连接读取走。
如上图所示,当监听套接字就绪后,用户第一时间肯定就是读取监听到的新连接,并且获取客户端的IP地址以及端口号。
新连接获取成功后,由于此时没有客户端的数据到来,所以新连接套接字没有就绪。
- 此时必须将新连接套接字交给
select
去等待就绪,所以需要向用户层维护的数组中添加该套接字的文件描述符。- 如果此时直接读取新连接套接字的话,由于没有就绪,就会阻塞在这里,服务器无法继续执行下去,与我们的初衷就不符合了。
为了方便,本喵还增加了一个Print
函数,打印新增加的需要select
等待的文件描述符。
第一次之后调用Accepter
函数时,已经就绪的文件就不一定是监听套接字了,有可能是前面获取的新连接套接字,所以就需要进行具体判断到底是哪个套接字就绪了。当新连接套接字就绪后,就调用Recver
从套接字中读取客户端的请求。
如上图所示,当新连接套接字就绪以后,首先要进行的就是读取套接字中客户端发来的数据,使用recv
获取数据。
- 当前这种读取数据的方式是存在一定问题的,因为不能保证套接字中的数据是否被读完了。
但是此时并不会产生影响,所以暂且这样,后面本喵会制定具体的协议去处理它。
如果读取数据出现了问题,或者套接字出现异常,那么就需要关闭套接字,并且将文件描述符从用户维护的数组中清除。
读取成功后将请求处理,并且构建响应发送给客户端,当前的select
服务端的重点在于读取,所以写入本喵这里就不详细写了。
- selectServer.cpp
如上图所示就是selectServer.cpp
中的代码,服务器的回调函数中仅是将客户端发送来的数据原封不动的响应给客户端,没有做其他处理,其他部分代码本喵不再解释。
如上图所示,当服务器开始运行后,由于此时没有新连接到来,所以监听套接字一直处于未就绪的状态,前面在设置等待事件的时候设置了5s,所以每隔5s就超时返回一次。
- 可以看到当前最大文件描述符是3,也就是监听套接字的文件描述符。
如上图所示,为了避免超时返回的干扰,本喵将select
的最后一个参数也设置成nullptr
此时select
就是阻塞等待,根据运行结果也可以看成是阻塞不动的。
如上图所示,此时使用两个telnet
连接客户端,可以看到服务端提示有新事件就绪,此时是监听套接字监听到了两个客户端连接,所以显示两次,服务端每处理一次,用户层维护的数组中文件名描述符就会增加一个,所以最终是3,4,5
三个文件需要select
进行等待。
两个客户端向服务端发送消息的时候,服务端显示事件就绪,这是进行数据通信的套接字上有数据到来,提醒用户层去读取数据。
- 服务器上只有一个进程(线程),客户端有多个,此时服务端可以同时接收多个客户端的连接请求和数据。
- 多路转接实现了我们之前只能通过多进程或者多线程才能实现的功能,而且效率非常高。
🧁select的特点
select
能同时等待的文件fd是有上限的,除非重新修改内核,否则无法解决。
fd_set
位图大小只有1024个比特位,意味着select
同时最多只能等待1024个文件描述符。
可以看到,本喵的Linux机器上,最多可以打开100001个文件,远大于1024,所以说select
能够同时等待的文件数量在高访问量的服务器中是远远不够的。
- 必须借助第三方数组等结构来维护需要
select
等待的文件描述符fd。
由于select
的后四个参数都是输入输出型参数,操作系统也会修改这几个参数,这就导致用户层必须自己维护一个数组来记录自己曾经想要让select
等待的文件描述符。
并且每轮询一次就需要重新设置一次fd_set
位图,不仅繁琐,而且对于效率相对较低。
select
存在遍历成本。
在上面本喵实现的服务器代码中,对于用户层来说,存在多处遍历所维护的数组。
在将文件描述符设置到fd_set
位图中的时候,需要遍历数组中的所有元素来找到合法的fd设置到位图中。
在select
完成等待后,同样需要遍历一次数组,来确定是哪个fd的事件就绪了,然后再去处理。
- 内核也要进行遍历
select
的第一个参数传参时传入的是最大文件描述符fd + 1
,这个参数就是为了让内核确定遍历的范围,这个值之前的所有文件描述符,操作系统都会进行查看,看看是否有事件就绪。
- 内核和用户层来回拷贝的成本问题
select
采用的是位图来标记哪个文件的事件就绪,无论是用户层告诉内核,还是内核告诉用户层,都需要拷贝,这样来回进行数据拷贝也是有很大的成本。
🍧总结
多路转接是一个非常重要的IO模式,而select
方式只最基础的一种,它主要的作用是带领我们理解多路转接,后面本喵还会讲解poll
和epoll
方式。