IO简介
JAVA IO
IO编程模型
用什么样的通道进行数据的发送和接收(异步/同步、单通道/双通道、有/无缓冲、阻塞/非阻塞)
BIO
同步阻塞IO(传统阻塞型),一个连接对应一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善。一般适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高。
BIO编程简单流程:
服务器端启动一个SeverSocket,然后使用**accept()**来等待客户端与之连接,并一直阻塞在这,直到客户端发来连接。
客户端启动Socket对服务器进行通信,默认情况下服务器需要对每个客户建立一个线程与之通信。
客户端发出请求后,先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝。
如果有响应,客户端线程会等待请求结束后,再继续执行。
注:建立连接后,如果当前线程暂时没有数据可读,则线程就阻塞在Read操作上,造成线程资源的浪费
NIO(面向缓冲区编程)
java NIO 全称 java non-blocking IO,是指JDK提供的新的API。同步非阻塞,服务器实现模式为一个线程处理多个请求,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理。一般用于连接数目多且连接比较短的架构。
NIO的三大核心部分: Selector、Buffer、channel。
服务器端:首先开启一个ServerSocketchannel,这个ServerSocketchannel相当于BIO中的ServerSocket,ServerSocketchannel通过bind函数绑定所要监听的端口,然后将ServerSocketchannel注册到selector上。
遍历selector中的通道,判断通道是否准备就绪,并执行相应的业务(比如说处理连接的通道发现有连接进来,就再新建一个通道;如果有通道有读写操作,就执行相应的读写操作。但整个过程也是同步的,要前面的事件执行完再去执行下一个事件,但是这个通道没有IO请求,也就不会阻塞在这),把数据写到通道中,或者读取到缓冲区中。
1 | public static void main(String[] args) throws Exception{ |
客户端:开启一个SocketChannel,然后调用connect方法与服务器端进行连接。将数据从缓冲区写到通道或者从通道读数据到缓冲区
1 | public static void main(String[] args) throws Exception{ |
Selector
Selector能够检测多个注册的通道上是否有事件发生(注意:多个Channel以事件的方式可以注册到同一个Selector),如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。只有在通道真正有读写请求事件发生时,才会进行读写,大大的减少了系统开销。
将channel注册(register方法)到selector上(每一个channel对应一个selectionKey,注册后会放入集合中),selector对这些通道进行监控,就是调用select方法,该方法会返回一个selectionKey类型的集合。一旦监控到有IO事件发生,得到有时间发生的selectionKey,通过selectionKey反向获取通道。
注:select方法是一个阻塞方法,直到注册的channel里至少有一个事件发生才会返回;
select(long timeout) timeout时间内阻塞
selectNow() 非阻塞
Buffer
本质上是一个可以读写数据的内存块。可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer。
Channel
- 每个 channel 都会对应一个 Buffer
- Selector 对应一个线程,一个线程对应多个 channel(连接)
- 该图反应了有三个 channel 注册到该 selector
- 程序切换到哪个 channel 是由事件决定的, Event 就是一个重要的概念
- Selector 会根据不同的事件,在各个通道上切换
- Buffer 就是一个内存块 ,底层是有一个数组
- 数据的读取写入是通过 Buffer, 这个和 BIO有本质区别,BIO 中要么是输入流,或者是输出流,不能双向,但是 NIO 的 Buffer 是可以读也可以写,需要 flip 方法切换。
- channel是双向的,可以返回底层操作系统的情况,比如 Linux,底层的操作系统通道就是双向的
Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
BIO和NIO区别
- BIO以流的方式处理数据,而NIO以块的方式处理数据,块I/O的效率比流I/O高很多
- BIO是阻塞的,NIO则是非阻塞的
- BIO基于字节流和字符流进行操作,而NIO基于Channel(通道)和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道
AIO
异步非阻塞,jdk1.7引入,AIO 引入了异步通道的概念,采用了Proactor模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。
操作系统 IO
epoll管理成千上万的网络IO,epoll是实现事件循环的技术
- 单线程epoll redis
- 多线程epoll mamcached
- 多进程epoll
epoll 底层数据结构: 红黑树和就绪队列
epoll在创建的时候,实际是创建了红黑树的root节点
epoll由下面三个函数组成
epoll_create
epoll_ctl
epoll_wait 多长时间去轮询一次,返回所有的IO中有多少个可读可写,比如说返回值用nready表示
redis:
然后对这些就绪好的事件进行轮询,判断事件类型,可以按照事件类型将事件大致分为两类:
- sockfd:相当于listenfd,然后执行accept方法
- 其他类型事件:执行recv方法或者send方法
上述是单线程,但如何兼容多核CPU:多开几个redis进程
互联网服务端处理网络请求的原理
首先看看一个典型互联网服务端处理网络请求的典型过程:
由上图可以看到,主要处理步骤包括:
- 1)获取请求数据,客户端与服务器建立连接发出请求,服务器接受请求(1-3);
- 2)构建响应,当服务器接收完请求,并在用户空间处理客户端的请求,直到构建响应完成(4);
- 3)返回数据,服务器将已构建好的响应再通过内核空间的网络 I/O 发还给客户端(5-7)。
设计服务端并发模型时,主要有如下两个关键点:
- 1)服务器如何管理连接,获取输入数据;
- 2)服务器如何处理请求。
以上两个关键点最终都与操作系统的 I/O 模型以及线程(进程)模型相关,下面先详细介绍I/O模型。
I/O 模型的基本认识
介绍操作系统的 I/O 模型之前,先了解一下几个概念:
- 1)阻塞调用与非阻塞调用;
- 2)阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回;
- 3)非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
两者的最大区别在于被调用方在收到请求到返回结果之前的这段时间内,调用方是否一直在等待。
阻塞是指调用方一直在等待而且别的事情什么都不做;非阻塞是指调用方先去忙别的事情。
同步处理与异步处理:同步处理是指被调用方得到最终结果之后才返回给调用方;异步处理是指被调用方先返回应答,然后再计算调用结果,计算完最终结果后再通知并返回给调用方。
阻塞、非阻塞和同步、异步的区别(阻塞、非阻塞和同步、异步其实针对的对象是不一样的):
- 1)阻塞、非阻塞的讨论对象是调用者;
- 2)同步、异步的讨论对象是被调用者。
recvfrom 函数:
recvfrom 函数(经 Socket 接收数据),这里把它视为系统调用。
一个输入操作通常包括两个不同的阶段:
- 1)等待数据准备好;
- 2)从内核向进程复制数据。
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
实际应用程序在系统调用完成上面的 2 步操作时,调用方式的阻塞、非阻塞,操作系统在处理应用程序请求时,处理方式的同步、异步处理的不同,可以分为 5 种 I/O 模型(下面的章节将逐个展开介绍)。(参考《UNIX网络编程卷1》)
I/O模型1:阻塞式 I/O 模型(blocking I/O)
在阻塞式 I/O 模型中,应用程序在从调用 recvfrom 开始到它返回有数据报准备好这段时间是阻塞的,recvfrom 返回成功后,应用进程开始处理数据报。(也就是说读写完数据后进程才会去干其他事情)
比喻:一个人在钓鱼,当没鱼上钩时,就坐在岸边一直等。
优点:程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用 CPU 资源。
缺点:每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。
I/O模型2:非阻塞式 I/O 模型(non-blocking I/O)
在非阻塞式 I/O 模型中,应用程序把一个套接口设置为****非阻塞,就是告诉内核,当所请求的 I/O 操作无法完成时,不要将进程睡眠。
而是返回一个错误,应用程序基于 I/O 操作函数将不断的轮询数据是否已经准备好,如果没有准备好,继续轮询,直到数据准备好为止。
比喻:边钓鱼边玩手机,隔会再看看有没有鱼上钩,有的话就迅速拉杆。
优点:不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。
缺点:轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。
I/O模型3:I/O 复用模型(I/O multiplexing)
在 I/O 复用模型中,会用到 Select 或 Poll 函数或 Epoll 函数(Linux 2.6 以后的内核开始支持),这两个函数也会使进程阻塞,但是和阻塞 I/O 有所不同。
这两个函数可以同时阻塞多个 I/O 操作,而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。
比喻:放了一堆鱼竿,在岸边一直守着这堆鱼竿,没鱼上钩就玩手机。
优点:可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样可以大大节省系统资源。
缺点:当连接数较少时效率相比多线程+阻塞 I/O 模型效率较低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会有增加。
众所周之,Nginx这样的高性能互联网反向代理服务器大获成功的关键就是得益于Epoll。
I/O模型4:信号驱动式 I/O 模型(signal-driven I/O)
在信号驱动式 I/O 模型中,应用程序使用套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。
当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。
比喻:鱼竿上系了个铃铛,当铃铛响,就知道鱼上钩,然后可以专心玩手机。
优点:线程并没有在等待数据时被阻塞,可以提高资源的利用率。
缺点:信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。
信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。
但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失。
I/O模型5:异步 I/O 模型(即AIO,全称asynchronous I/O)
由 POSIX 规范定义,应用程序告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到应用程序的缓冲区)完成后通知应用程序。
这种模型与信号驱动模型的主要区别在于:信号驱动 I/O 是由内核通知应用程序何时启动一个 I/O 操作,而异步 I/O 模型是由内核通知应用程序 I/O 操作何时完成。
优点:异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠。
缺点:要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。
而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 IO 复用模型模式为主。
关于AIO的介绍,请见:《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》。
5 种 I/O 模型总结
从上图中我们可以看出,越往后,阻塞越少,理论上效率也是最优。
这五种 I/O 模型中,前四种属于同步 I/O,因为其中真正的 I/O 操作(recvfrom)将阻塞进程/线程,只有异步 I/O 模型才与 POSIX 定义的异步 I/O 相匹配。