【IO 和 NIO】

仅针对网络IO的介绍,要搞懂IO和NIO的大致原理,我们要介绍两个概念:

一.【总体流程】

【IO】
read系统调用,是把数据从内核缓冲区复制到进程缓冲区;write系统调用,是把数据从进程缓冲区复制到内核缓冲区。
这个两个系统调用,都不负责数据在内核缓冲区和磁盘之间的交换。底层的读写交换,是由操作系统kernel内核完成的。
 
【内核缓冲和进程缓冲区】
1.缓冲区的目的,是为了减少频繁的系统IO调用。
2.系统调用需要保存之前的进程数据和状态等信息,而结束调用之后回来还需要恢复之前的信息,为了减少这种损耗时间、也损耗性能的系统调用,于是出现了缓冲区。有了缓冲区,操作系统使用read函数把数据从内核缓冲区复制到进程缓冲区,write把数据从进程缓冲区复制到内核缓冲区中。等待缓冲区达到一定数量的时候,再进行IO的调用,提升性能。至于什么时候读取和存储则由内核来决定,用户程序不需要关心。
在linux系统中,系统内核也有个缓冲区叫做内核缓冲区。每个进程有自己独立的缓冲区,叫做进程缓冲区。
所以,用户程序的IO读写程序,大多数情况下,并没有进行实际的IO操作,而是在读写自己的进程缓冲区。
 
看个大致例子,流程图如下,先介绍链接,再介绍读写:

 1. server创建一个socket,并进行端口的绑定,设定监听端口,这时候就可以接受client的网络链接请求了。

         这时候我们有两种选择,用户的进程/线程是阻塞 等待连接还是一直主动询问操作系统,接口有没有链接进来。

              ServerSocketChannel sc = ServerSocketChannel.open();
sc.bind(new InetSocketAddress(PORT));
sc.configureBlocking(false);//可以阻塞/非阻塞

   1.1 【等待连接】即 我们常说的BIO,我们线程阻塞,挂起,并等待操作系统的唤醒。

   1.2 【主动询问】即 NIO(非阻塞IO,不是java NIO,java的NIO其实指的是IOM-多路复用),不断的询问操作系统,是否有链接进来。

2.client 发起链接,数据通过网卡触发CPU的终端,CPU根据socket(源IP,源端口,目标IP,目标端口),定位到具体的进程。

3.一方面通过触发CPU的中断,进行排队,一方面网络数据写入内核缓冲区。

4.CPU唤醒对应的进程(如果是阻塞的话,非阻塞,则通过一直询问内核来获取)。

5.进程进行CPU分片的排队,轮到进程A时候,进行数据的读取。

6.触发进行数据读取,把对应的需要读取的数据,从内核缓冲区,读到进程缓冲区之后,进行读取。

那单个 简单的网络IO流程如上,那就引发具体的问题:

1. BIO 线程进行阻塞,时效性肯定是更高的,但线程阻塞无法做其他事情了,吞吐量就会小很多了。

2. NIO 如果有1000个链接,读写 每次询问操作系统,会触发上下文切换,同样会极大的影响性能。

二.【细分IO】

接下来就详细讨论下各个IO的具体情况,涉及的是 大纲图 6的流程,即触发 流程6的时候,用户的进程是在做什么的。

开始介绍6流程时候,我们先了解现有计算机结构。目前网卡数据的写入一般由DMA进行完成的,因此网络的IP包不会触发CPU的上下文切换。

2.1【BIO】 

 1 public static final Integer PORT = 8080;
 2 
 3     public static void main(String[] args) throws Exception{
 4         ServerSocket serverSocket = new ServerSocket(PORT);
 5         System.out.println("-----1. 等待连接-------");
 6 
 7         while (true) {
 8             Socket socket = serverSocket.accept();//阻塞
 9             System.out.println("-----2. 已连接,客户端--" + socket.getRemoteSocketAddress() + "-----");
10             InputStream inputStream = socket.getInputStream();
11             BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));//阻塞
12             System.out.println("----客户端信息:" + reader.readLine());
13             inputStream.close();
14             socket.close();
15         }
16     }

以上是BIO的Demo

1.首先我们看到,Socket绑定端口之后,accept获取链接的时候是阻塞方式的。

2.通过字节流方式读取网络数据也是阻塞的。即我们通过inputStream获取字节流的时候,如果无数据的时候读取不到,代码是不会往下执行的。

     如下图所示:即,我们代码触发read网络IO流时候,会等待网卡写入数据到内核缓冲区的数据(①阶段),等缓冲区数据够了(比如 等待一个完整的socket数据包),进行复制到用户的缓冲区(②阶段),复制完成之后,用户线程即可往下执行代码,进行数据的读取了。

BIO只是网络IO的一种作业模型,有对应的优缺点如下,但是目前不常用,一般再本地的IO上使用,性能不下于NIO,特别是JDK1.5之后有优化过。

BIO的优点:

程序简单,在阻塞等待数据期间,用户线程挂起。用户线程基本不会占用 CPU 资源。
 
BIO的缺点:
一般情况下,会为每个连接配套一条独立的线程,或者说一条线程维护一个连接成功的IO流的读写。在并发量小的情况下,这个没有什么问题。但是,当在高并发的场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。因此,基本上,BIO模型在高并发场景下是不可用的。

2.2【BIO】 

 1 public static final Integer PORT = 8080;
 2 
 3     public static final LinkedList<SocketChannel> clients = new LinkedList<>();
 4     public static void main(String[] args) throws Exception{
 5         ServerSocketChannel sc = ServerSocketChannel.open();
 6         sc.bind(new InetSocketAddress(PORT));
 7         sc.configureBlocking(false);//可以阻塞/非阻塞
 8         System.out.println("-----1. 等待连接-------");
 9         while (true) {
10             Thread.sleep(1000);
11             SocketChannel client = sc.accept();//非阻塞
12             //处理链接
13             if (client == null) {
14                 System.out.println("-----1.1 未有连接-------");
15             } else {
16                 client.configureBlocking(false);//可以阻塞/非阻塞
17                 System.out.println("-----2. 已连接,客户端--" + client.socket().getRemoteSocketAddress() + "-----");
18                 clients.add(client);
19             }
20 
21             for(SocketChannel temp:clients){
22                 ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
23                 int num = temp.read(buffer);
24                 while (num <= 0){
25                     Thread.sleep(1000);
26                     System.out.println("----2.1 未有消息-------");
27                     num = temp.read(buffer);//非阻塞
28                 }
29 
30                 buffer.flip();
31                 byte[] msg = new byte[buffer.limit()];
32                 buffer.get(msg);
33                 System.out.println("----客户端信息:"+new String(msg));
34                 clients.remove(temp);
35                 buffer.clear();
36                 temp.close();
37             }
38 
39         }
40 
41     }

以上是NIO的Demo(注:是Non Blocking IO,不是JAVA说的 NewIO)

1.首先我们看到,Socket绑定端口之后,accept获取链接的时候是非阻塞方式的。会一直循环,并不会卡住代码流程。

2.获取连接之后,设置客户端也是非阻塞的,由于为了测试方便,因此都在一个循环流程里面。刚兴趣的可以自己跑一跑这个demo,看看场景。

     如下图所示:即,我们代码触发read网络IO流时候,会一直以上代码的while里面循环,会等待网卡写入数据到内核缓冲区的数据(①阶段),等缓冲区数据够了(比如 等待一个完整的socket数据包),进行复制到用户的缓冲区(②阶段),复制完成之后,获取到数据(即 num > 0),然后进行数据的读取了。

NIO的特点如下: 应用程序的线程需要不断的进行 I/O 系统调用,轮询数据是否已经准备好,如果没有准备好,继续轮询,直到完成系统调用为止。

NIO的优点:
每次发起的 IO 系统调用,在内核的等待数据过程中可以立即返回。用户线程不会阻塞。
NIO的缺点:
需要不断的重复发起IO系统调用,这种不断的轮询,将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低。
 
 
那是不是有NIO就万事大吉了?这里引出一个计算机的难题 C10K,
假设有1000个链接,为了处理这些链接有几种方式:
1.是不是要1000个线程去轮询等待处理,每个线程询问操作系统,该链接是否具备读的条件。
2.我们用其他方式,通过线程池,去处理读写,但是轮询这1000个链接,有读写事件的,再进行读写,但是每次还得去调用操作系统询问链接情况。
 
不论采用哪种方式,都会存在一个问题,即,这1000个链接,得询问操作系统1000次,这种也是非常耗时的操作,所以基本不会用在正式环境上。
那操作系统能不能提供一个方式,每次我调用询问的时候,直接给我返回可用的(链接/读写事件)。这就是我们经常用的IO多路复用(我习惯叫IOM,即我们常说的java NIO,即 New IO),为什么叫IO多路复用呢?即,操作系统开辟一路进程监视这些链接描述符,我们只需询问这个进程,就可以知道哪些链接具备 链接/读写事件了。即多个链接用一个进程进行监听,称为 IO 多路复用。liunx 多路复用,有select,poll和epoll,下面再详细介绍,这边不展开。
 
2.3【IOM】
 1 public static final Integer PORT = 8080;
 2 
 3     public static void main(String[] args) throws Exception{
 4 
 5         //SelectorProvider.provider().openServerSocketChannel();
 6         //sun.nio.ch.DefaultSelectorProvider.create()
 7         //这边windows是 WindowsSelectorProvider()
 8         ServerSocketChannel sc = ServerSocketChannel.open();
 9         sc.bind(new InetSocketAddress(PORT));
10         sc.configureBlocking(false);//为什么要设置非阻塞,因为再我们调用多路复用器selector询问这个是否有链接进来时候,期望serverSocket是立即答复。
11 
12         //多路复用器
13         Selector selector = Selector.open();
14         System.out.println("-----1. 等待连接-------");
15         while (true){
16             Thread.sleep(1000);
17             SocketChannel client = sc.accept();//非阻塞
18             //处理链接
19             if (client == null) {
20                 System.out.println("-----1.1 未有连接-------");
21             } else {
22                 //把sc注册进去,仅注册读事件
23                 client.configureBlocking(false);
24                 ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
25                 client.register(selector, SelectionKey.OP_READ,buffer);
26                 System.out.println("-----2. 已连接,客户端--" + client.socket().getRemoteSocketAddress() + "-----");
27             }
28 
29             //这里为什么要阻塞
30             while (selector.select(1000) > 0){
31                 //调用多路复用器获取
32                 System.out.println("-----3 调用多路复用器-------");
33                 Set<SelectionKey> selectionKeySet = selector.selectedKeys(); //从多路复用器,取出有效的链接(仅注册read事件),调用多路复用器这个是阻塞的,等待多路复用器返回链接
34 
35                 Iterator<SelectionKey> iterator = selectionKeySet.iterator();
36                 while(iterator.hasNext()){
37                     SelectionKey key = iterator.next();
38                     iterator.remove();
39                     SocketChannel temp = (SocketChannel)key.channel();
40                     ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
41                     int num = temp.read(buffer);//为什么要设置非阻塞,因为再我们调用多路复用器selector询问这个是否有链接进来时候,期望serverSocket是立即答复。
42 
43                     //循环等待有消息
44                     while (num <= 0){
45                         Thread.sleep(1000);
46                         System.out.println("----2.1 未有消息-------");
47                         num = temp.read(buffer);//非阻塞
48                     }
49 
50                     buffer.flip();
51                     byte[] msg = new byte[buffer.limit()];
52                     buffer.get(msg);
53                     System.out.println("----客户端信息"+temp.socket().getPort()+":"+new String(msg));
54                     buffer.clear();
55                 }
56             }
57 
58         }
59     }

以上是IOM的Demo(注:就是JAVA说的 NewIO)

1.首先我们看到,跟之前IO不同的地方在于 。

       Selector selector = Selector.open();
       client.register(selector, SelectionKey.OP_READ,buffer);

2.打开Selector,即多路复用器,然后把我们关心的事件注册到多路复用器上,这里为了区分,我仅仅把读事件注册进去,这个即告诉操作系统,如果发现该端口的链接存在读事件返回回来即可。

3.selector.select() 我们再调用多路复用器询问的时候,如果存在读事件时候,即返回对应的key,我们循环Key,进行读。

     如下图所示:即,类似BIO,因为由操作系统帮我们去校验链接的状态了,不用一个个链接循环去询问(一个个循环触发上下文切换,非常耗时),同样会等待网卡写入数据到内核缓冲区的数据(①阶段),等缓冲区数据够了(比如 等待一个完整的socket数据包),进行复制到用户的缓冲区(②阶段),复制完成之后,获取到数据,然后进行数据的读取了。由于有IO多路复用器已经帮我们过滤了,获取到的都是具备完善的条件的(即 可读/可写),因此通过阻塞进行数据的读,时效性更高。

多路复用IO的优点:

用select/epoll的优势在于,它可以同时处理成千上万个连接(connection)。与一条线程维护一个连接相比,I/O多路复用技术的最大优势是:系统不必创建线程,也不必维护这些线程,从而大大减小了系统的开销。
Java的NIO(new IO)技术,使用的就是IO多路复用模型。在linux系统上,使用的是epoll系统调用。
多路复用IO的缺点:
本质上,select/epoll系统调用,属于同步IO,也是阻塞IO。都需要在读写事件就绪后,自己负责进行读写,也就是说这个读写过程是阻塞的。
 
以上,本质上,都是用户的进程主动询问操作系统的,因此都是属于 同步IO,但是区分阻塞和非阻塞。目前异步IO,即AIO,由于需要操作系统的支持,但是目前操作系统支持还不完善,因此还未大规模使用。

 
AIO的晚点再更新
 
 

原文地址:https://www.cnblogs.com/HA-Tinker/p/13826978.html