目录
1.五种IO模型
- IO (Input/Output,输入/输出)即数据的读取(接收)或写入(发送)操作,通常用户进程中的一个完整IO分为两阶段:用户进程空间<–>内核空间、内核空间<–>设备空间(磁盘、网络等)。IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者。
- LINUX中进程无法直接操作I/O设备,其必须通过系统调用请求kernel来协助完成I/O动作;内核会为每个I/O设备维护一个缓冲区。
- 对于一个输入操作来说,进程IO系统调用后,内核会先看缓冲区中有没有相应的缓存数据,没有的话再到设备中读取,因为设备IO一般速度较慢,需要等待;内核缓冲区有数据则直接复制到进程空间。
- 一个网络输入操作通常包括两个不同阶段:
- 等待网络数据到达网卡→读取到内核缓冲区,数据准备好(等待阶段);
- 从内核缓冲区复制数据到进程空间(拷贝阶段)。
5种IO模型的区别就体现在上述两个等待数据和处理数据的阶段上:
- 阻塞IO:用户进程在两个阶段都阻塞;
- 非阻塞IO:用户进程在等待阶段单线程轮询监听数据状态,在拷贝阶段阻塞;
- 多路复用IO:用户进程等待阶段调用Select(由内核单线程监听)并阻塞,拷贝阶段也阻塞;
- 信号驱动IO:用户进程等待阶段调用信号函数监听数据状态而不阻塞,在在拷贝阶段阻塞;
- 异步IO:用户进程两个阶段都不阻塞;
1.1 同步阻塞IO模型(买票过去一直排队等)
recvfrom()
用来接收远程主机经指定的socket传来的数据,并把数据传到由参数buf指向的内存空间- 当用户空间的应用程序执行一个系统调用(recvfrom),导致应用程序被阻塞,从等待数据准备好到数据从内核复制到用户空间,整个进程都被阻塞。
优点:
- 数据一准备好就能及时返回,无延迟
- 对于内核开发者实现简单
缺点:
- 对于用户阻塞带来了性能的损耗
- 一种简单的改进方案是在服务端使用多线程,可以有多个连接线程同时等待数据准备,但当数据量大时,再多的线程也会使得整个进程完全阻塞。
1.2 同步非阻塞IO模型(买票每间隔一会去问一次售票员)
- 同步非阻塞就是 “每隔一会儿检查一次数据准备情况” 的轮询(polling)方式,若数据准备好了则进行下一步操作;若数据没准备好则返回一个错误代码,此时用户进程可以干一些其他工作后再发起recvfrom系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。
- 需要注意,从内核到用户空间拷贝数据的整个过程,进程仍然是属于阻塞的状态
优点:
- 轮询调用recvfrom,单个线程即完成了对所有连接数据的接受工作
- 等待阶段无需阻塞
缺点:
- 轮询调用的方式由用户进程发起,将大幅推高CPU占用率
- 轮询实际上起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如select()多路复用模式,可以一次检测多个连接是否活跃。
1.3 IO复用模型(买票看大厅屏幕信息)
如果轮询不是进程的用户态,而是有人帮忙就好了,这就是所谓的 “IO 多路复用”
- UNIX/Linux 下的 select、poll、epoll 就是干这个的(epoll 比 poll、select 效率高,做的事情是一样的)。使用select实现轮训机制时间复杂度是为 O(n),而且这种情况也会存在空轮训的情况,效率非常低、其次默认对我们的轮训有一定限制,所以这样的话很难支持上万tcp连接。所以在这时候linux操作就出现epoll实现事件驱动回调形式通知,不会存在空轮训的情况,只是对活跃的socket实现主动回调,这样的性能有很大的提升 所以时间复杂度为是O(1)。
- 这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。
IO复用模型优势:
1.可同时监听多个端口: select可以等待多个socket,能实现同时对多个IO端口进行监听。当其中任何一个socket的数据准好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。
2.监视发起者为kernel:select的监听由内核发起,非阻塞IO模型是用户发起的,降低了用户进程的开销。
3. 简单:相比其他模型,使用select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。
IO复用模型缺点
- 首先, select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll。
- 其次,该复用模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。
1.4 信号驱动IO模型(有票了售票员打电话告知后再去买)
信号驱动式I/O:首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。
1.5 异步IO模型(AIO:黄牛帮买)
相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。
- 用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
2.NIO核心概念
- 缓冲区:负责数据存取
- 通道:负责连接
- 选择器:多路复用器,用于监听Channel的状态
2.1 缓冲区(Buffer)
缓冲区在NIO中负责数据的存取,缓冲区就是数组,用于存储不同数据类型的数据。
Buffer四个核心属性:
- capacity:容量,表示缓冲区中最大存储数据的容量,一旦声明不能改变。
- limit:界限,缓冲区中可以操作数据的大小(limit后的数据不能进行读写)。
- position:位置,缓冲区中正在操作数据的位置。
- mark:标记,临时记录当前position的位置。可以通过
reset()
将position恢复到mark的位置。
mark < = position < = limit < = capacity
直接缓冲区与非直接缓冲区
- 非直接缓冲区:通过
allocate()
方法分配缓冲区,将缓冲区建立在JVM的堆内存中; - 直接缓冲区:通过
allocateDirect()
方法直接分配缓冲区,将缓冲区建立在物理内存中(堆外),可以提高效率。
- 由上图可知直接缓冲区通过建立物理内存映射文件,省去了非直接缓冲区读写过程中的copy操作,IO速度快效率高;
- 直接字节缓冲区可以通过调用此类的allocateDirect() 工厂方法来创建,由于引用指向堆外内存,当IO操作完成后,GC操作无法及时回收堆外资源,导致物理内存的浪费。
- 数据写入物理内存映射文件后,应用程序便无法干涉数据管理,只能由操作系统全权负责。
- 建议将直接缓冲区主要分配给数据长期存在于内存,或者大数据量的操作场景。
2.2 通道(Channel)
- Channel是专注于IO操作且完全独立的处理器,拥有自己的命令,不同于以往的向CPU申请的IO操作,Channel解放了CPU对IO的管理,相当于提高了CPU的利用率。
- 通道用于源节点和目标节点的连接,在NIO中负责数据的传输
- 通道本身不存储数据,需要配合缓冲区才能完成传输
java.nio.channels.Channel接口:
- FileChannel 用于读取、写入、映射和操作文件的通道
- SocketChannel 通过TCP 读写网络中的数据
- ServerSocketChannel 可以监听新进来的TCP 连接,对每一个新进来的连接都会创建一个SocketChannel。
- DatagramChannel 通过UDP 读写网络中的数据通道
- 直接字节缓冲区可以通过
FileChannel
的map()
方法将文件区域直接映射到物理内存中来创建。该方法返回MappedByteBuffer。
2.3 选择器(Selector)
选择器是SelectableChannel的多路复用器,用于监控SelectableChannel的IO状态,满足条件再发送给服务端。如此便实现了单线程管理多个网络连接的channel。
- Channel必须是非阻塞的。所以FileChannel不适用Selector,因为FileChannel不能切换为非阻塞模式,更准确的来说是因为FileChannel没有继承SelectableChannel。Socket channel可以正常使用。
- 通过Selector监听Channel可以监听四种不同类型的事件:Connect、Accept、Write、Read。
- 通道触发了一个事件意思是该事件已经就绪。比如某个Channel成功连接到另一个服务器称为连接就绪。一个Server Socket Channel准备好接收新进入的连接称为接收就绪。一个有数据可读的通道可以说是读就绪。等待写数据的通道可以说是写就绪。
- 服务端通道会轮询获取选择器上已经就绪的事件。
2.4 NIO网络通讯原理图
3.NIO总结
BIO(同步) | NIO(同步) | AIO(异步) | |
---|---|---|---|
数据等待阶段 | 阻塞 | 不阻塞 | 不阻塞 |
数据拷贝阶段 | 阻塞 | 阻塞 | 不阻塞 |
- 同步:同步io指的是调用方通过主动等待获取调用返回的结果来获取消息通知
- 异步:异步io指的是被调用方通过某种方式(如,回调函数)来通知调用方获取消息
BIO
- BIO一个连接对应一个处理线程,采用Stream(面向流)的方式传输数据。
- BIO由用户进程监控数据状态
- BIO可以通过多线程的方式改进,处理多个IO请求,但是线程太多会增加CPU负担。因此可以用线程池的方式进一步优化,但是当每个线程中传输数据量都较大时会导致大于线程池容量的连接无法进行。存在某些线程中有连接但无数据传输,一直阻塞造成CPU资源浪费。BIO一般不适用于多线程的应用场景。
NIO
- NIO是一条有效(数据准备好了)的请求,对应一个线程,采用Buffer存储数据;Channel读写Buffer中的数据;Selector监听多个通道事件(accept,connect,read,write)的方式。
- NIO将通道注册到Selector(多路复用器)上,Selector是内核级的监控数据状态的机制,所有的连接只需要一个线程轮序监测,当数据准备好则才开启一个线程处理,如果无特殊设定,Selector轮询监控通道的过程是阻塞的。
- 当Selector发现各通道中都没有数据准备好时可以直接返回让用户进程做其他事;buffer可以存储数据的读写进度;所以NIO是非阻塞的。
AIO
- AIO在有连接请求时立即返回,随时可以发送下一个请求,可以提高效率,保证并发。
- AIO的数据状态监测和拷贝数据都交给内核,当数据准备好并拷贝到用户空间之后再通知用户进程去处理数据。(回调)
4. 零拷贝
传统IO流程
上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作
- 利用缓冲区read方法读取硬件数据导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU)。将数据放入内核缓冲区。
- 发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。
- 发生第三次数据拷贝,我们调用 write 方法,系统将用户缓冲区的数据拷贝到 Socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。
- 第四次拷贝,数据异步的从 Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。
- write 方法返回,再次从内核态切换到用户态。
mmap优化
mmap 通过内存映射(直接缓冲区),将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。
现在,你只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但不减少上下文切换次数。
sendFile(零拷贝:无需CPU拷贝)
从文件进入到网络协议栈,只需 2 次拷贝:第一次使用 DMA 引擎从文件拷贝到内核缓冲区,第二次从内核缓冲区将数据拷贝到网络协议栈;内核缓存区只会拷贝一些 offset 和 length 信息到 SocketBuffer,基本无消耗。
mmap和sengFile区别
- mmap 适合小数据量读写,sendFile 适合大文件传输。
- mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
- sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
5. Reactor模式
单Reactor单线程
在Reactor模式中,包含如下角色:
- Reactor 将I/O事件发派给对应的Handler(相当于NIO中的
Selector
) - Acceptor 处理客户端连接请求(相当于NIO中的
ServerSocketChannel
) - Handlers 执行非阻塞读/写(相当于NIO中的
SocketChannel
)
- Select 是前面 I/O 复用模型介绍的标准网络编程 API,可以实现应用程序通过一个阻塞对象监听多路连接请求
- Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发
- 如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理
- 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应
- Handler 会完成 Read→业务处理→Send 的完整业务流程
- 优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成
- 缺点:性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈
- 缺点:可靠性问题,线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。
单Reactor多线程
- Reactor 对象通过select 监控客户端请求事件, 收到事件后,通过dispatch进行分发
- 如果建立连接请求, 则右Acceptor 通过accept 处理连接请求, 然后创建一个Handler对象处理完成连接后的各种事件
- 如果不是连接请求,则由reactor分发调用连接对应的handler 来处理
4.handler 只负责响应事件,不做具体的业务处理, 通过read 读取数据后,会分发给后面的worker线程池的某个线程处理业务 - worker 线程池会分配独立线程完成真正的业务,并将结果返回给handler
- handler收到响应后,通过send 将结果返回给client
- 优点:可以充分的利用多核cpu 的处理能力
- 缺点:多线程数据共享和访问比较复杂, reactor 处理所有的事件的监听和响应,在单线程运行, 在高并发场景容易出现性能瓶颈.
主从Reactor多线程
- Reactor主线程 MainReactor 对象通过select 监听连接事件, 收到事件后,通过Acceptor 处理连接事件
- 当 Acceptor 处理连接事件后,MainReactor 将连接分配给SubReactor
- subreactor 将连接加入到连接队列进行监听,并创建handler进行各种事件处理
- 当有新事件发生时, subreactor 就会调用对应的handler处理
- handler 通过read 读取数据,分发给后面的worker 线程处理
- worker 线程池分配独立的worker 线程进行业务处理,并返回结果
- handler 收到响应的结果后,再通过send 将结果返回给client
- Reactor 主线程可以对应多个Reactor 子线程, 即MainRecator 可以关联多个SubReactor
- 优点:父线程与子线程的数据交互简单职责明确,父线程只需要接收- 新连接,子线程完成后续的业务处理。
- 优点:父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。
- 缺点:编程复杂度较高