1、用户空间和内核空间
本质在说用户态和内核态
任何Linux发行版,其系统内核都是Linux。我们的应用都需要通过Linux内核与硬件交互。
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:
1、进程的寻址空间会划分为两部分:内核空间、用户空间
2、用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
3、内核空间可以执行特权命令(Ring0),调用一切系统资源
Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
1、写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
2、读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
2、阻塞IO
2.1概述
在《UNIX网络编程》一书中,总结归纳了5种IO模型:
1、阻塞IO(Blocking IO)
2、非阻塞IO(Nonblocking IO)
3、IO多路复用(IO Multiplexing)
4、信号驱动IO(Signal Driven IO)
5、异步IO(Asynchronous IO)
2.2、什么是阻塞IO
顾名思义,阻塞IO就是两个阶段都必须阻塞等待:
阶段一:
①用户进程尝试读取数据(比如网卡数据)
②此时数据尚未到达,内核需要等待数据
③此时用户进程也处于阻塞状态
阶段二:
①数据到达并拷贝到内核缓冲区,代表已就绪
②将内核数据拷贝到用户缓冲区
③拷贝过程中,用户进程依然阻塞等待
④拷贝完成,用户进程解除阻塞,处理数据
可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。
3、非阻塞IO
顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。
阶段一:
阶段二:
可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
4、IO多路复用
4.1、为什么需要IO多路复用
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:
1、如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
2、如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据
而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。
就比如服务员给顾客点餐,分两步:
要提高效率有几种办法?
那么问题来了:用户进程如何知道内核中数据是否就绪呢?
4.2、引入IO多路复用
文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。(也就是说一个符号,代表一个文件,一个文件对应一个东西,比如网络套接字,这也说明了linux的万物皆文件的道理,而FD则是从0 开始的无符号整数构成的一个数字)
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
阶段一:
阶段二:
上图解释
1、select函数调用,会同时监听多个sockets也就是多个FD,而调用recvfrom函数则只会针对一个socket也就是一个FD
2、当我调用了select函数后,内核态就会检查这些select对应的FD集合,如果有就绪的,就会返回给用户态,(如果内核态查看一个都没有就绪,那么就是真的阻塞了)
3、随后直接调用recvfrom,此时直接从内核态拷贝数据到用户态。因为select函数监听多个FD,所以会重复的调用recvfrom函数。
4、然后在进行下一批的select函数调用重复上面操作。
4.3、IO多路复用的三种实现方式
IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:
1、select
2、poll
3、epoll
差异:
1、select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认
2、epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间
4.4、IO多路复用之select
4.4.1、源码展示
select是Linux最早是由的I/O多路复用技术:
1、int nfds:表示FD的最大的FD+1,主要方便遍历
2、fd_set *readfds:表示要监听读事件的FD集合
3、 fd_set *writefds:表示 要监听写事件的FD集合
4、fd_set *exceptfds:表示要监听异常事件的FD集合
5、timeval *timeout:表示监听FD的超时时间,null-用不超时;0-不阻塞等待;大于0-固定等待时间
6、fd_set:表示一个结构体,就是FD集合,最大可以容纳1024个FD
4.4.2、动画展示
1、创建fd_set集合用于存放多个FD,设置读监听的FD集合,这里以8个集合为例
2、假设监听FD 有 1,2,5对应的FD集合编号变为1
3、执行select函数,传递参数
3.1、5+1:表示最大的FD是5在加一,和上面源码相对应
3.2、rfds: 表示的是读事件
3.3、后面的两个null分别对应写事件和异常事件
3.4、超时时间为3秒
1、在执行select的那一刻,就需要用户态的fd_set集合拷贝到内核态,让其内核态进行监听
1.开始对其内核态的fd_set进行遍历,遍历的位置是从最大的FD+1的位置开始遍历,查看有没有就绪的
1、可以看到没有就绪的那么就需要等待,休眠
1、等待被唤醒,只要有任意一个FD被唤醒
1、正如下图,有一个FD=1可读了,此时内核就会将结果写到对应fd_set集合中去,还是一样先遍历,找到已经标记的和现在的FD进行一个比较。就绪的保留,未就绪的删除,和上图对比可以看到fd_set只剩下一个,并且select函数会返回有几个就绪了,但是不知道是谁就绪了
所以背地会有一个隐含的操作,会把内核态的fd_set再拷贝到用户态,覆盖掉之前的,但是还是不知道谁是就绪的
1、紧接着,找不到谁是就绪的,所以用户态只能进行遍历,找到就绪的fd,读取其中的数据
4.4.3、总结
select模式存在的问题:
1、需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
2、select无法得知具体是哪个fd就绪,需要遍历整个fd_set
3、fd_set监听的fd数量不能超过1024
4.5、IO多路复用之poll
4.5.1、概述
poll模式对select模式做了简单改进,但性能提升不明显,部分关键代码如下:
IO流程:
①创建pollfd数组,向其中添加关注的fd信息(也就是添加FD到pollfd数组中),数组大小自定义
②调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
③内核遍历fd,判断是否就绪
④数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
⑤用户进程判断n是否大于0
⑥大于0则遍历pollfd数组,找到就绪的fd
与select对比:
1、select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
2、监听FD越多,每次遍历消耗时间也越久,性能反而会下降
4.5.2、源码
1、 struct pollfd *fds, // pollfd数组,可以自定义大小,用于装FD集合的
2、nfds_t nfds, // 数组元素个数
3、int timeout // 超时时间
4、 int fd; /* 要监听的fd 直接监听值,相对于select来说而是使用二进制数进行保存的 */
5、short int events; /* 要监听的事件类型:读、写、异常 */
6、short int revents;/* 实际发生的事件类型 */
4.6、IO多路复用之epoll
4.6.1、三个函数
epoll模式是对select和poll的改进,它提供了三个函数:epoll_create,epoll_ctl,epoll_wait,
1、epoll_create:创建epoll实例
内部构造有一个eventpoll,而eventpoll有两部分组成,一个是红黑树用于记录所有监听的FD,一个是链表,用于记录就绪的FD
下面的就是开始调用epoll_create,每次调用该函数,就会创建eventpoll,并且返回一个标识epfd,一个epfd对应一个eventpoll.
2、epoll_ctl:添加FD到红黑树结构中
1、epoll_ctl相当于select函数的其中一个环节
2、 int epfd, // epoll实例的句柄,也就epoll_create函数创建的实例对象
3、 int op, // 要执行的操作,包括:ADD、MOD、DEL,对其FD进行什么操作添加,删除等
4、 int fd, // 要监听的FD
5、 struct epoll_event *event // 要监听的事件类型:读、写、异常等,对FD进行读还是写
会给每一个监听的FD和对应的事件类型添加ep_poll_callback函数,当callback触发时,就把对应的FD加入到rdlist这个就绪列表中,也就是list_head中
3、epoll_wait:等待FD就绪
1、 int epfd, // epoll实例的句柄,也就epoll_create函数创建的实例对象
2、 struct epoll_event *events // 空event数组,用于接收就绪的FD
3、 int maxevents // events数组的最大长度
4、 int timeout // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
5、epoll_wait返回值是FD就绪的数量
epoll_wait函数返回的是就绪的数量,但是该函数有一个数组,专门存放list_head中就绪的FD,list_head将其就绪的FD,从内核态复制到用户态,那这个数组全部都是就绪的FD,相对于select函数和poll函数,就好了很多,不用全部复制所有的FD,里面还包含未就绪的
4.6.2、总结
1、select模式存在的三个问题:
1、能监听的 FD 个数最大不 超过10242、 每次 select 都需 要把所有要监听的FD 都 拷贝 到 内核空间
3、每次都要遍历所有FD来判断就绪状态
2、poll模式的问题:
1、poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降
3、epoll模式中如何解决这些问题的?
1、基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高2、每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
3、利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降
4.7、IO多路复用-事件通知机制
4.7.1、概述
当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:
1、LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
2、EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。只会通知一次,不管数据是否处理完成
举列子:
①假设一个客户端socket对应的FD已经注册到了epoll实例中
②客户端socket发送了2kb的数据
③服务端调用epoll_wait,得到通知说FD就绪
④服务端从FD读取了1kb数据
⑤回到步骤3(再次调用epoll_wait,形成循环)
两种模式对应结果如下:
1、如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知2、如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。
4.7.2、总结
1、LT:事件通知频率较高,会有重复通知,影响性能
2、ET(推荐使用):仅通知一次,效率高。可以基于非阻塞IO循环读取解决数据读取不完整问题
select和poll仅支持LT模式,epoll可以自由选择LT和ET两种模式
4.8、IO多路复用-web服务流程
基于epoll模式的web服务的基本流程如图:
1、调用epoll_create函数创建红黑树和链表
2、创建ServserSocket,也就是对应FD,通过函数epoll_ctl向其红黑树中进行注册
3、给在红黑树中的每一个监听的FD和对应的事件类型添加ep_poll_callback函数,如果FD就绪,就将其记录到list_head链表中
4、调用epoll_wait函数,判断list_head链表中有没有就绪的FD
5、判断没有重新进行等待判断list_head中有没有就绪的FD
6、找到对应的ServerSocket的FD就绪,判断FD是什么事件类型,是不是可读的,是可读,再看看对应的ServerSocket的FD是不是可读,是的话进行accept()接收客户端socket,得到对应的客户端的FD,在通过epoll_ctl监听客户端的FD
7、如果对应的serverSocekt的FD不是可读的,可能是客户端的socket,也就是客户端FD可读,那么客户端继续读取数据,除此之外在判断事件类型的地方如果是异常事件,那么存在异常情况,随后将其异常情况写出响应数据。
5、信号驱动IO
5.1、概述
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
阶段一:
①用户进程调用sigaction,注册信号处理函数
②内核返回成功,开始监听FD
③用户进程不阻塞等待,可以执行其它业务
④当内核数据就绪后,回调用户进程的SIGIO处理函数
阶段二:
①收到SIGIO回调信号
②调用recvfrom,读取
③内核将数据拷贝到用户空间
④用户进程处理数据
上图解释:
1、用户进行首先调用sigaction函数,会向内核中指定一个FD,并且绑定一个SIGIO信号处理函数,随后立即结束不用阻塞等待,如果没有数据,内核会帮其监听,如果有数据,就会被唤醒,并递交一个SIGIO信号给用户进程,随后SIGIO信号处理函数处理这个SIGIO信号,说明有数据就绪了。
2、知道FD就绪了,就会调用recvfrom读取数据,从内核态拷贝数据到用户态
5.2、缺点:
当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
6、异步IO
6.1、概述
异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
阶段一:
①用户进程调用aio_read,创建信号回调函数
②内核等待数据就绪
③用户进程无需阻塞,可以做任何事情
阶段二:
①内核数据就绪
②内核数据拷贝到用户缓冲区
③拷贝完成,内核递交信号触发aio_read中的回调函数
④用户进程处理数据
上图说明:
1、用户态调用aio_read函数,告诉内核,想要哪一个FD,然后读到哪里去,就独自干其他的了,
内核会监听FD,等待就绪,直接把数据从内核拷贝到用户态, 在通知用户。
6.2、总结
1、虽然全程不需要用户态管理,但是对于高并发场景是存在问题的,导致内核部分承受压力过大,所以需要在用户态进行一个限流操作。
7、同步和异步
IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是同步还是异步: