漫谈NIO(1)之计算机IO实现

1.前言

    此系列将尽可能详细介绍断更博客半年以来个人的一个成长,主要是对Netty的源码的一个解读记录,将从整个计算机宏观IO体系上,到Java的原生NIO例子最后到Netty的源码解读。不求完全掌握,但求知道前因后果,设计思路,来检验半年所学(之前是懒,水平不够,现在写博客查漏补缺)。介绍过程中所涉及的知识点可能有错,请各位指教,相互学习。

2.为什么关注IO

    简而言之,就是为了程序执行快,当然现代程序的瓶颈IO只占了其中一部分。IO为什么成为了系统瓶颈呢,引用网上一篇博客(这里)对CPU、内存和磁盘等存储介质的运行比较,这里简要说明一下:

    CPU的运行速度快的离谱,大部分简单指令只要一个时钟周期(操作系统是按时间片(几十毫秒,不固定)运行程序的,这样单核CPU就可以实现大的时间尺度上多个程序同时运行的假象),一个时间周期1/3纳秒,足以见CPU速度之快。把一个时间周期看做1秒,从主存中读取数据就花了4分钟,果断不能忍啊,所以现代计算机在主存和CPU之间会构建多级高速缓存,L1、L2、L3等。CPU尽可能从L1获取数据,只要3秒,L1尽可能从L2获取数据,只要14秒,同样L2->L3,L3->内存。这样可以在有限的成本增加,大幅度提高CPU和主存的数据交互带来的性能浪费。当然这会带来而外的问题,比如什么数据应该放在L1、L2、L3中,另外一个带来的问题就是内存可见性问题,Java中多线程volatile字段的含义(多线程中线程A、B持有同一个变量,A修改了这个变量,确实在主存中该变量被修改了,但是B修改的时候会产生线程安全问题,因为由于高速缓存的存在,B读取该变量不是从主存中读取的,而是从高速缓存中读取,此时这个值并没有更新,导致A的操作对B线程而言是不可见的。volatile字段的作用就在于强制从主存中获取该变量的值,而不是高速缓存。当然这样做只保证了可见性,即A线程操作对B线程可见,并不能保证线程安全,还需要保证操作的原子性,这里就不再多解释)。

    上述扯远了,再来看内存获取磁盘数据的速度,接上面的例子,大概是1年零3个月。纳尼!!!1年零3个月获取数据,CPU就工作了1秒?这就是IO对程序运行带来的可怕的性能制约。阻塞式IO必须等待执行完IO操作,才能运行后面的操作,这对CPU性能是极大的浪费。虽然在时间片消耗完后CPU会切换到其它程序运行相关代码,但是阻塞式IO带来的性能浪费并没有得到解决。所以我们才需要关注非阻塞式IO,让程序继续执行其它逻辑,等到IO操作完成后再执行相应的操作。IO的速度慢在寻道操作,即磁头要先移动到正确的磁道上,然后磁盘旋转到正确的位置上,读取指定扇区的数据。RPM就是衡量磁盘的旋转速度,越快耗时越少。另一个方式就是将文件连续存储在磁盘上,这样也就能节省寻道时间了,但实际上这种方式带来的效果并不明显,主要关注的还是单位时间上寻道和随机IO操作次数,固态硬盘SSD在这方面做的就很好。还有一种方式就是硬盘的cache机制,其将一组零散的写入操作合并成一个,磁盘控制写入顺序,进而减少寻道次数。一系列读取操作也可以重组减少耗时。

3.现代IO操作模型

    上面扯了那么多,说明了IO对CPU的性能的浪费,NIO的主要目的是减少这部分无效损耗,并没有对IO所需要的耗时有太多作用。NIO的意思是非阻塞IO,其实现方式有多种,下面准备介绍一下目前IO的主要模型。

    在介绍IO模型之前,首先要明白几个概念,才会清楚为什么NIO可以减少CPU的性能浪费。现代操作系统为了保证系统的正确执行,分成了两种操作模式:用户模式和监督程序模式(内核模式)。这么做的原因是为了保护操作系统和用户程序不受其它程序的影响,实现方法是:将能引起损害的机器指令作为特权指令,用户执行特权指令的时候,硬件不会执行该指令,而是认为该指令是非法指令,会以陷阱的方式通知操作系统,由操作系统内核处理,最后转交给用户程序,IO操作就是一个特权指令。操作系统进程有其独享的内核空间,用户程序数据及相关内容放在用户空间中,用户发起一个IO请求,首先就会切换到内核模式,将数据读取到内核空间中去,之后将这部分数据拷贝到用户空间中,切换到用户模式继续执行用户程序,这就是一个基本的IO流程。这里插入一段文件网络传输零拷贝的知识:将文件通过网络传输涉及了两次IO,一次磁盘IO,一次网络IO,由于上述所讲的操作系统的安全策略,内核会先读取文件数据到内核空间,再拷贝到用户空间,传输到网络中去的时候,又将数据从用户空间拷贝到了内核空间,这造成了多次拷贝的浪费,实际上我们只需要将数据读取到内核空间,直接传输到网络中去就可以了。零拷贝的做法就是将文件描述符和网络描述符同时交给内核,指示内核将文件描述符的内容全部传输给网络通道中,这样就达到了零拷贝的效果了。

    有了上诉对操作系统的执行方式的了解,再来看IO模型就会比较好理解阻塞式IO和非阻塞式IO的区别,以及非阻塞式IO的好处了。下面介绍目前5种IO模型:

3.1 阻塞IO

block

    阻塞式IO就是用户发起IO请求->内核等待数据加载->内核加载数据完毕->拷贝内核空间数据到用户空间->提醒用户程序完成IO读取操作。

    整个过程种,用户发起请求到最后一直都在等待内核处理好数据,之前也说过这段时间相对于CPU而言,执行是非常慢的,所以这段时间整个都是浪费了,一直处理等待之中。这就是阻塞式IO。

3.2 非阻塞IO

noblock

    阻塞式IO的特点就是等待数据和拷贝数据两段都被阻塞了,后面就产生了上图这种非阻塞式IO了。这种思路就是不断的询问内核是否把数据准备好了,如果没好就继续询问,好了就开始拷贝数据。这种方式拷贝数据阶段也式阻塞的,但是毕竟是内存操作,不会很慢,但是关键问题在于前面的轮询,这个过程也是非常消耗CPU的,并没有达到多好的效果。

3.3 多路复用IO

select

    IO multiplexing就是我们比较熟悉的select/epoll,也被称为事件驱动IO。其可以在单个process中处理多个网络连接。有一个专门的任务负责轮询所有的socket,只要有一个socket有数据达到,就会通知用户进程。当用户进程调用了select,整个进程就会被block,内核会监视所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回,用户调用read操作,数据就会从内核拷贝到用户进程。

    该方法和阻塞式IO没有太大不同,可能更差,这里有两个系统调用,但是其优势就在于能够处理多个连接,所以说大量连接此种方式更加,少量连接可能多线程+阻塞式IO要更出色。这种方式用户进程其实也是一直被block的,不过是被select阻塞而不是socketIO。因此select()与非阻塞IO类似。

    实际上这个模型也有很多问题。如果连接数真的过多,select会消耗大量时间去轮询各个句柄。epoll方式虽然能更高效,但是epoll方式受制于操作系统,跨平台困难。另外该模型将事件探测和事件响应混在了一起,如果事件响应执行过长,整个响应会延迟。通常使用事件驱动库解决该问题libevent等。

    Java的NIO就是基于select实现的。

3.4 信号驱动IO

sigio

    信号驱动IO会注册一个信号处理程序给内核,内核处理好了就会触发该信号通知程序执行,最终处理数据。该方式真正做到第一阶段的非阻塞,但是其不适合TCP协议。因为无法判断该信号的具体含义,该信号产生的非常频繁,UDP传输采取的较多。

3.5 异步IO

asy

    异步IO可以称得上全程无阻塞,直到数据全部准备完成才会通知用户执行后续操作。但是在netty上并没有采取该种方式,其一是在linux系统上运行速度并没有比epoll方式更快,其二是与Netty精心设计的线程模型相悖。windows系统实现了IOCP方式的异步IO,其是proactor模型。

3.6 总结

all

    上诉5种IO模型,只有异步IO方式达到了完全的非阻塞,阻塞式IO则是完全阻塞。但是常用的还是复用IO的方式,设计的好足以媲美aio,而且aio在某些情况性能不如epoll方式,和其具体实现有关。select/epoll和aio造就了两种设计模式,前者是reactor,后者就是proactor。

    顺带一提,epoll就是用于解决select在大量连接时遍历连接效率低下的问题,其是基于事件驱动,fd的限制数也远大于1024。epoll_wait只会返回准备就绪的fd,使用nmap内存映射技术避免了内存复制的开销。epoll还有个知识点就是edge-triggered和level-triggered,边缘触发和水平触发。水平触发指的是当准备就绪的fd没有被用户进程处理,下一次查询仍会返回,这是select poll的触发方式。边缘触发指的是无论准备好的fd有没有被处理,下一次都不再返回。理论上边缘触发性能更高,但是实现非常复杂,任何意外的丢失事件都会造成请求处理错误。epoll默认使用水平触发。

4.后记

    本节粗略介绍一下IO的相关概念,以便更好理解NIO,下一章介绍JAVA的NIO实现,这里指的就是select poll方式,结合代码,更易理解多路信号复用IO。select poll的真正含义。

posted @ 2018-04-22 20:31  dark_saber  阅读(1490)  评论(0编辑  收藏