1 I/O
I/O(Input/Outpu),即输入/输出。
1.1 计算机结构视角理解I/O
从计算机结构的角度来看 I/O,冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
1.2 应用程序视角理解I/O
从应用程序的角度来看 I/O。一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space )。平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间。从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
在平时的开发任务中,接触最多的应该是磁盘 IO(读写文件)和网络 IO(网络请求和响应)。
当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据(数据准备)
- 内核将数据从内核空间拷贝到用户空间(数据复制)
以网络 IO 为例,数据准备可能是客户端还有部分数据还没有发送、或者正在发送的途中,当前内核 Buffer 中的数据并不完整;而数据复制则是将内核态 Buffer 中的数据复制到用户态的 Buffer 中去。
2 常见的 IO 模型分类
UNIX 系统下, IO 模型一共有 5 种:
- 同步阻塞 I/O
- 同步非阻塞 I/O
- I/O 多路复用
- 信号驱动 I/O
- 异步 I/O
3 同步阻塞I/O,又称BIO(Blocking IO)
同步阻塞 IO 模型中,应用程序发起 read 调用后,数据准备和数据复制两个阶段都会一直阻塞,直到内核把数据拷贝到用户空间。
在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
4 同步非阻塞,又称NIO(Non-Blocking IO)
Java 中的 nio包 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。 java的nio包 和这里的NIO模型是两个东西。nio包 本质是 New IO的翻译。而NIO模型则是一种IO模型。 java的nio包 对应的IO模型是IO多路复用。下一小节会提到这个。
NIO的过程如下:
- 数据准备阶段。此时用户线程发起 read 系统调用,此时内核会立即返回一个错误,告诉用户态数据还没有 Read,然后用户线程不停地发起请求,询问内核当前数据的状态。
- 数据复制阶段。此时用户线程还在不断的发起请求,但是当数据 Ready 之后,用户线程就会陷入阻塞,直到数据从内核态复制到用户态。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
存在问题:应用程序不断进行 I/O 系统调用轮询数据,会占用大量的 CPU 资源,降低效率。
5 IO 多路复用
Java 中的 nio包 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。其对应的IO模型是IO多路复用。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。。在之前的 BIO 和 NIO 中只涉及到一种系统调用——read ,在 IO 多路复用中要引入新的系统调用——select 。
在 IO 多路复用中调用了 select 之后,只要数据没有准备好,用户线程就会阻塞住,避免了频繁的轮询当前的 IO 状态。
目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select系统调用,目前几乎在所有的操作系统上都有支持。
- select 调用 :内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
- epoll 调用 :linux 2.6 内核(linux特有),属于 select 调用的增强版本,优化了 IO 的执行效率。
Java 中的nio包 ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
6 异步IO,又称AIO(Asynchronous I/O)
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。AIO的实现最重要的一点是需要OS支持。
7 IO多用复用详解
以网络IO为例,IO多路复用是最常用的一种。i/o多路复用的主要应用场景如下:
- 服务器需要同时处理多个处于监听状态的或者多个连接状态的套接字
- 服务器需要同时处理多种网络协议的套接字
要理解IO多路复用,必须了解select、poll和epoll之间的区别。
- select:时间复杂度O(n)
select只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。 - poll:时间复杂度O(n)
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的。 - epoll:时间复杂度O(1)
epoll可以理解为event poll,不同于忙轮询和无差别轮询,它是事件驱动(每个事件关联上fd)的。epoll会把哪个流发生了怎样的I/O事件通知我们。
7.1 select
select本质上是通过轮询检查存放fd标志位的数据结构来进行下一步处理。
缺点:
- 单个进程可监视的fd数量被限制,即能监听端口的大小有限。具体数目可以cat /proc/sys/fs/file-max察看。一般32位机默认是1024个。64位机默认是2048。
- 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。
- 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
7.2 poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历。如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
poll没有最大连接数的限制,原因是它是基于链表来存储的。
缺点:
- 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
- poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
7.3 epoll
epoll有EPOLLLT(水平触发)和EPOLLET(边缘触发)两种触发模式,分别简称LT模式和ET模式。
- LT(水平触发模式):只要fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作。
- ET(边缘触发模式):只会提示一次,直到下次再有数据流入之前都不会再提示了,无 论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读取完。
epoll使用“事件”的就绪通知方式,通过epoll_ctl()注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait()便可以收到通知。
如果采用LT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait()都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。而采用ET模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。一般来说,边缘触发比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
epoll的优点:
- 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)
- 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数。即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
- 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;本质上是epoll通过内核和用户空间共享一块内存的,即通过内核和用户空间mmap(映射)同一块内存来实现的。
7.4 select、poll和epoll三者简单对比总结
1 支持一个进程所能打开的最大连接数:
- select:一般32位机默认是1024个。64位机默认是2048。
- poll:没有最大连接数的限制,原因是它是基于链表来存储的。
- epoll:虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接。
2 FD剧增后带来的IO效率问题
- select:因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。
- poll:同上
- epoll:因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
3 消息传递方式
- select:内核需要将消息传递到用户空间,都需要内核拷贝动作。且同时需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
- poll:需要将用户传入的数组拷贝到内核空间。
- epoll:通过内核和用户空间mmap(映射)同一块内存来实现。避免了不必要的内存复制过程。
8 面试题提升:Redis了解吗?说说为什么单线程的Redis可以支持高并发访问?
- redis的自身特性:从Redis自身特性来说,Redis是基于内存的数据库,所以数据处理速度非常快。另外它的底层使用了很多效率很高的数据结构,如哈希表和跳表等。
- 单线程:Redis从狭义上面来说他是单线程的,网络请求解析与数据读写都是由主线程完成。因此它内部就省去了很多多线程访问共享数据资源的繁琐设计,同时也避免了频繁的线程上下文切换因此减少了多线程的系统开销。
- IO模型:Redis使用的是IO多路复用模型,使得它可以在网络IO操作并发处理数十万的客户端网络连接,实现非常高的网络吞吐率。这也是Redis可以实现高并发访问的最主要的原因。
9 面试题提升:详细说下Redis的IO多路复用的原理?
在传统的网络IO操作中,accept() 和 recv()函数都是阻塞型的,一旦发生阻塞,影响其他网络连接。但是在多路复用IO模型中,可以实现同时存在多个socket,内核监听socket中的是否有数据请求或者连接请求,如果有请求,那么内核就会交给Redis进行处理,因此Redis的主线程,也就是单线程的Redis可以处理多个IO连接。
整个过程涉及到epoll_create、epoll_ctl以及epoll_wait三个系统调用,具体的过程大致是这样的:
- epoll_create():当Redis启动的时候,会调用内核的epoll_create创建epoll对象,在这个过程中包含初始化红黑树cache以及双向链表,红黑树中主要存储了需要进行状态监控的FD,实际就是epitem结构体,双向链表中存储了需要返回给用户已经处于就绪状态的事件。
- epoll_ctl():通过epoll_ctl注册要监听的事件类型,将客户端FD以及需要监听的事件添加到红黑树cache中,添加时进行检查,如果已存在则返回,如果不存在则添加到节点当中,同时注册相应的事件回调函数,如果存在连接事件或者读写事件,那么就会通过回调函数将就绪的事件加入到双向链表中,实际就是红黑树的节点。
- epoll_wait():Redis通过调用epoll_wait获取已经就绪的事件的fired数组,fire数组的事件中存储了就绪的FD以及事件类型,遍历数组中的事件,根据事件类型处理函数继续后续的处理。如果是读事件那就调用读事件处理函数进行处理。对于Redis来说它只要关注链表中有没有数据就好,有数据就会进行读取,没有数据则阻塞超过timeout之后再进行调用。在大多数情况下,返回的数组中包含的事件并不多。通过这样的设计,Redis不需要一直轮训检查到底有没有实际的请求发生,避免了CPU资源的浪费。因此及时是单线程的Redis,借助于epoll机制,它也可以实现数十万连接的并发处理。
|