浅析IO

I/O

因为库文件的良好封装,我们在开发中可能不会直接使用IO读写,不过程序底层还是离不开IO。这里就来简单介绍下IO吧。
全称:Input/Output,顾名思义就是输入和输出。
场景:涉及到数据交换的地方,通常是磁盘读写、网络数据传输等。
磁盘读写场景:我们记录服务器的日志,最终都要写入文件中保存。如果我们想要分析这些日志,也需要重新从磁盘中读取这些文件。
网络传输场景:在浏览器中输入google.com这个地址,会向google的服务器发送一个请求,google服务器处理后传回google的首页,浏览器会把其中数据解析出来显示给用户。

挑战

I/O读写的核心逻辑很简单,主要是传输数据。但是在实际IO中还有一些挑战,就是我们需要花很多时间去等待IO传输。例如,我们浏览一个网页,需要花1s才能将服务端数据传到浏览器中,在浏览器中渲染和显示只需要0.01s。如果不做其他处理,从点击地址后一直等待页面显示,那就非常浪费时间。

出现这个现象的原因是:CPU和内存的速度远远高于外设传输数据的速度。
短期内这个矛盾没有办法从根源上来解决,但是聪明的程序员想出很多策略去优化I/O场景。

同步IO

原始场景

这是最原始的场景:我们只运行一个线程,服务端接收到浏览器请求后,等待IO读取结束再进行操作,中间无法处理任何响应。这样的体验一定是非常糟糕的,因为整个服务器无法处理其他请求。在刚才的例子中,点击网页后1s内都无法响应其他任何请求,这会让用户非常沮丧。

多线程

我们再稍微改善下,我们点击一个网页后,服务端新开一个线程去处理IO和其他操作。这时服务端还可以有其他线程去处理其他请求,这比原来好很多。

昂贵的线程

但是线程是一种非常昂贵的资源,有以下四个原因:
1.每个空线程就占512k~1M的内存,如果线程增加太多JVM压力会变大;
2.在Linux中线程就是进程,无论是新建和销毁都要调用重量级系统函数,非常占用CPU;
3.线程切换时,需要更新线程的上下文,原先线程缓存的指令和数据都需要重新加载。这会影响整个程序的运行效率,就像我们看英文文献时发现陌生单词后,打开字典去查,查询完毕后再继续阅读。需要重新回想之前前后文,影响阅读效率;
4.有可能会有大量请求同时返回,CPU占有率变得锯齿状,服务器不稳定。

线程池

了解到线程的昂贵开销后,我们考虑接入线程池,可以重复利用原有线程。例如实时性要求不高的情况下,服务端同时有100个请求,线程池有20个线程,用着20个线程反复利用处理请求。这样只需要创建和销毁20个线程。而不用线程池的情况下,需要创建和销毁100个线程。这样大大降低的线程的开销,但是还是不能根治问题。因为始终需要线程去解决问题,我们需要换一种新的策略去解决问题。

异步IO

异步IO,Asynchronous Input and Output,简称AIO
异步IO从新的角度解决问题,用户只发出IO请求,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果后,再通知用户进行处理。
举个例子,服务器接受到一个浏览器的请求,发送读取其请求的IO请求,但是并不等待IO结果,而是去处理其他操作。当IO请求返回结果后,服务端再去处理这段IO。

这样操作的好处非常明显:用户不需要新开线程去处理IO。而且用户不用维护数据读不齐时的逻辑,因为操作系统将处理好的数据交给用户。
按照上面说的种种好处,大家肯定会觉得现在主流处理的IO肯定是AIO。但是其实不然,因为一个很现实的原因,纯正支持AIO的操作系统比较少。

各种IO对比

上面是一张特别经典的IO对比图,上面将各种IO都列出来了。我们一个个分析:

阻塞IO

阻塞IO就是上面所说的同步IO,这里不继续讨论。
举个例子,我在奶茶店点了一杯奶茶,点好后就一直在柜台等着,直到奶茶做好。

非阻塞IO

非阻塞IO与阻塞IO不同,发起IO请求后,不需要一直等待,在拿到IO数据之前还可以做一些别的操作。那怎么知道何时能拿到IO数据呢?一般会采用轮询的方法。
轮询就是每隔一段时间去检查是否IO进入可读状态,如果发现数据不可读则立即返回不会阻塞。但是轮询浪费CPU资源,因为大部分时间是无数据可读,但是仍会不间断的反复地去查询。
举个例子,我在奶茶店点了一杯奶茶,没有选择一直等着,而是选择去边上的商店逛逛,每隔一分钟回奶茶店看看奶茶是否完成。但是这仍然会浪费你的时间,因为可能有多次查询的结果都是奶茶没完成。

IO复用

IO复用是基于非阻塞IO的,可以理解成多个IO请求委托给一个对象,让它去统一检查IO是否进入可读状态。这会带来效率的提升,因为不需要所有对象都去轮询。
把例子展开下,一个班级中每个同学都在奶茶店点了一杯奶茶,全权委托班长去轮询奶茶是否已经做好。每一杯奶茶做好了,班长就去通知对应的同学。

信号驱动IO

信号驱动IO就比较好理解。IO进入可读状态后,系统直接通知用户。
对应的例子是,我在奶茶店点了一杯奶茶,而且给店员留了手机号码。然后我就去边上商店逛逛,店员在制作完奶茶后会打电话通知我。不用每隔一段时间再去查看(轮询),也不需要委托给其他人让他去查看(IO复用),非常开心。

异步IO

异步IO和信号驱动IO模式比较接近,但是告知的时间点不同。信号驱动IO在操作系统内核读取完数据后告知用户,用户接到通知后还需要从内核中读取到用户空间,之后才可以使用IO传输过的数据。而异步IO中,操作系统已经把上面的两步全部做完,放到用户空间后再告知用户,用户可以直接使用IO传输过的数据。
这两个IO的对比再举个例子,我们在网上点了一台冰箱,有以下两个不同的场景。
信号驱动IO:快递员送到小区门口,然后快递员给我们打电话(内核空间中数据读取完成,进入可读状态),我们还需要从小区门口搬到房间(将数据从内核空间中读取到用户空间),然后再安装冰箱(使用IO的接收到的数据)。
异步IO:快递员直接将冰箱送到家,这里包含了仓库到小区门口和小区门口到房间两步(包括读取到内核空间,内核空间到用户空间),送到房间门口时再通知我,我直接可以安装冰箱。

NIO

NIO全称为Non-blocking I/O(在Java领域,也称为New I/O)
与上面所说的IO复用模式相同,其中一个重要的角色就是监控管理IO是否可读的对象,在NIO中这个对象是Selector。Selector有一个重要方法select,select维护一张监听套接字的列表(使用前需要注册Selector上),一直阻塞直到其中有一个套接字有数据可供读写。

上图中一个Selector管理着多个Channel,一个Channel代表一个IO通道。

我们用一个交通管理系统去类比下NIO中的主要对象:
Channel:车辆
Selector:调度系统
Buffer:车辆上的货物
Stream:货物

我们再看下主干代码,注意其中的注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1.Channel注册到Selector中
channel.register(selector, opType);
// 2.Selector阻塞等待Channel变化
Set selectedKeys = selector.selectedKeys();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
keyIterator.remove();
// 3.按照类型做对应操作
if(key.isAcceptable()) {
// 注册新的Channel到Selector中
} else if (key.isReadable()) {
// 读操作
} else if (key.isWritable()) {
// 写操作
}
}
corresponding wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!