基础知识
I/O分类
常见的I/O主要分为以下几类,如下所示:
- 阻塞I/O(blocking I/O)
- 非阻塞I/O(non-blocking I/O)
- 同步I/O(sync I/O)
- 异步I/O(async I/O)
同步:调用端会一直等待服务端响应,直到返回结果
异步:调用端发起调用之后不会等待服务端响应。服务端通过某种通知机制或者回调函数来通知客户端
阻塞:服务端返回结果之前,客户端线程会被挂起,此时线程不可被CPU调度,线程暂停运行
非阻塞:在服务端返回前,函数不会阻塞调用端线程,而会立刻返回
用户态与内核态
用户态(User model)和内核态(Kernel model)是CPU的2种工作状态。
内核态下运行的必然是操作系统相关的代码,它允许直接操作硬件。
而用户态下运行的必然是应用程序相关的代码,用户态下不可以直接操纵底层硬件。
它必须通过操作系统调用才能间接的使用底层硬件,而应用程序的运行必然是要操纵底层硬件的,所以就必须让CPU不断的做2种状态的切换才行。
需要注意的是,核心态和内核态中所产生的数据是不允许直接交互的,而是只能通过一种映射的方式进行数据交互,可以理解为copy。
在CPU中有一个名叫psw的寄存器就是区分内核态和用户态的,它有2个状态位,当CPU指令集是0的时候对应到内核态,也就获取了所有的内存权限。
当指令集是1的时候对应到用户态,保留一部分内存不让访问。所以说真正的内存是不可划分的,都只是一个状态不同的问题。
当应用层面的程序被CPU执行时,那么可以肯定的是它的状态必定是1,限制了一些调度硬件的权限。
文件描述符
文件描述符简称为fd,全称为file descriptor。
Linux系统一切皆文件,因此文件描述符通常是用于描述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
当程序打开一个现有文件或者创建一个新文件时,内核都会向进程返回一个文件描述符。
在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于Linux这样的操作系统。
缓存I/O
缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存IO。
在Linux的缓存I/O机制中,操作系统会将 I/O的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存I/O的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
其实缓存I/O出现的原因还是用户态和内核态的内存不允许直接进行数据交互而产生的,必须拥有这样一个缓存机制。
event loop
event loop中文释义为事件循环,是一种常见的编程范式,常用于前端领域。
它是一种非线性的驱动模式,举个例子:
![img](https://i-blog.csdnimg.cn/blog_migrate/50a3afc8f3d3bdaeb4d80c6c05aa468e.png)
任何的UI编程都是基于事件循环的驱动模型来完成的,当我们的鼠标放在任何一段文字之上,它会根据文字不同而做出对应的不同反应。
并且,我们进入一个网页不仅仅可以用鼠标与网页产生交互,也可以使用键盘与网页产生交互,那么这里就会有很多很多种不同的选择,如果想尝试用传统的编程思想来解决识别用户的操作无疑效率是非常低下的。
传统编程思想解决方案:
-
死循环来不断的检测是否有鼠标点击,键盘按下,鼠标悬浮等等操作
-
通过阻塞的方式来等待用户的一次点击或者键盘按下或者鼠标悬浮的等等操作
这种解决方案看似十分完美,但是拥有很大的缺点:
- 死循环占用大量CPU资源,并且如果需要检测的事件太多势必会引发延迟问题
- 通过阻塞方式只能检测一种操作,并不能同时检测多种操作
为了解决这些缺点,故诞生了event loop,它的设计思路如下:
- 有一个事件(消息)队列,包括但不仅限于鼠标事件,键盘事件,悬浮事件等等
- 假设当鼠标按下,便往这个队列中增加一个点击事件(消息)
- 有一个循环,不断的从队列中取出事件,根据不同的事件调用不同的函数
- 事件(消息)一般都各自保存各自的处理函数指针,这样每个消息都有独立的处理函数
图解如下:
包含一个事件循环并且只有当外部事件发生时才使用回调机制来触发相应的处理。
也就是说程序运行的整个流程都是取决于用户触发的各种事件来决定的,开发者并不用关心大体流程,而只是需要做好每一个事件对应的处理方式即可。
Linux五大I/O模型
阻塞I/O模型
发起I/O系统调用后,进程会被阻塞,直至出现响应数据。
当响应数据出现后,系统会转到内核空间进行处理,将内核缓冲区的数据映射(或被称为拷贝)至应用程序中。
举例说明:
一个人去食堂买饭,他问了食堂大妈还有没有饭后就站在窗口原地的等,此时这个人什么也做不了。
非阻塞I/O模型
发起I/O系统调用后,进程不会阻塞,而是通过死循环不断的查看是否出现响应数据,如果响应数据未出现时就进行拷贝,则会引起异常。
当响应数据出现后,系统会转到内核空间进行处理,将内核缓冲区的数据映射(或被称为拷贝)至应用程序中。
举例说明:
一个人要去食堂买饭,他会先问食堂大妈有没有饭,食堂大妈说没有饭这个人就走开了,过一会又会过来问食堂大妈有没有饭,直至食堂大妈说饭好了后他才端上饭满意的离开。
I/O复用模型
I/O复用模型与事件驱动模型相似,它会先将I/O事件以及回调函数进行注册,然后再进行循环监听,一旦文件描述符状态发生改变后就会触发回调函数。
我们可以注册多个文件描述符,来达到同时监听多个I/O的目的,相较于前两种I/O模型,它拥有了监听机制,而正是因为有了这个机制,故并发量可以得到质的提升。
I/O复用模型拥有3种不同的监听机制,分别是select、poll以及epoll。
I/O复用模型的出现很大程度改变了一个进程多个I/O阻塞的问题,它能够实现一个线程下监听多个I/O的功能,极大的提升了程序的并发性,也是目前较为主流的一种解决方案。
举例说明:
一群人要买饭,于是托付给一个人去食堂,这个人去食堂后会先告诉食堂大妈,我这里有多少人要吃饭,这个人等待食堂大妈做好饭后会打电话通知某一个人过来拿饭,即一份饭做完之后立马让一个人来吃。
信号驱动式I/O模型
当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;
当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用I/O读取数据。
由于该种I/O模型的编码难度较大,故现在很少有应用程序使用这种模型进行编程了。
异步I/O模型
当进程发起一个I/O操作,进程返回(不阻塞),但也不能返回果结;
内核把整个I/O处理完后,会通知进程结果。
如果I/O操作成功则进程直接获取到数据。
举例说明:
一个人要去食堂买饭,他告诉食堂大妈,我要吃饭,饭好了你让人给我送过来,然后这个人就可以去做其他的事情了。
五种I/O模型对比
5种I/O模型中,异步I/O模型的性能最高,它全程无阻塞,以下是对比图示:
I/O复用select、poll、epoll简介
select
selelct监听模式一般应用在Windows平台上。
它会使用顺序表存储所有注册的I/O事件描述符。
支持最大同时监听1024(32位系统)或者2048(64位系统)个描述符。
同时,它会不断的去轮询查看所有描述符的状态是否发生改变,以便触发回调函数,所以他的性能有点低,当然这只是针对其他的监听模式而言。
当描述符状态发生改变后,会将内核缓冲区的数据映射到应用程序中,这相当于拷贝一次。
还是举个例子,一个班主任最多管理1024个学生,当班主任想知道谁没有交作业的时候他会对这1024个学生一个一个进行询问,学生只会被动的回答。
如,老师问第一个学生,你交作业了吗?学生说交了,老师再问第二个学生,你交作业了吗?以此类推…
总结:
- 支持最大监听的描述符数量:1024(32位系统)或者2048(64位系统)
- 描述符存储结构:顺序表
- 处理事件响应:轮询处理
- 消息传递方式:将内核缓冲区的数据映射到应用程序中,这相当于拷贝一次
poll
poll监听模式一般应用在Linux平台上。
它会使用链表存储所有注册的I/O事件描述符。
最大支持同时监听的描述符数量无上限。
他会采用轮询方式来处理事件响应。
当描述符状态发生改变后,会将内核缓冲区的数据映射到应用程序中,这相当于拷贝一次。
总结:
- 支持最大监听的描述符数量:无限制,2G内存就可存放20W个描述符
- 描述符存储结构:链表
- 处理事件响应:轮询处理
- 消息传递方式:将内核缓冲区的数据映射到应用程序中,这相当于拷贝一次
epoll
epoll监听模式一般应用在Linux平台上。
它会使用红黑树存储所有注册的I/O事件描述符。
最大支持同时监听的描述符数量无上限。
它会采用及时响应的方式来处理事件响应。
epoll监听模式下,内核缓冲区的数据并不需要映射到应用程序中,因为epoll通过内核与用户空间共享一块内存来实现免拷贝的过程。
举个例子,如果说select以及poll对处理事件响应的机制是一个一个问,那么poll就是举手。
老师想知道谁没交作业,只需要吼一声,谁还没交作业?此时立马就会有人举手,相比于轮询来说它的响应速度上快了很多。
总结:
- 支持最大监听的描述符数量:无限制,2G内存就可存放20W个描述符
- 描述符存储结构:红黑树
- 处理事件响应:主动响应
- 消息传递方式:通过内核与用户空间共享一块内存来实现免拷贝的过程
LT和ET
水平触发(level-trggered)和边缘触发(edge-triggered)是2种读取内核缓冲区数据的机制。
水平触发(level-trggered)
- 只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知,
- 当文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直发出可写信号进行通知
边缘触发(edge-triggered)
- 当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知,
- 当文件描述符关联的内核写缓冲区由满转化为不满的时候,则发出可写信号进行通知
水平触发是只要读缓冲区有数据,就会一直触发可读信号,而边缘触发仅仅在空变为非空的时候通知一次,举个例子:
- 读缓冲区刚开始是空的
- 读缓冲区写入2KB数据
- 水平触发和边缘触发模式此时都会发出可读信号
- 收到信号通知后,读取了1kb的数据,读缓冲区还剩余1KB数据
- 水平触发会再次进行通知,而边缘触发不会再进行通知
边缘触发的效率比水平触发的效率明显要高出许多,它减少了重复且无用的通知。
poll和epoll均支持ET,而select只支持LT,但是一定要注意,要想支持水平触发,I/O读取机制必须设置成非阻塞的。