网络IO知识梳理

网络IO知识梳理

这一块的概念一直不是很清晰,下面从这三个问题入手,对网络IO相关的概念做一个梳理

1.BIO与NIO的区别

在理解这两个概念之前,还是回顾一些IO模型,之前在我对同步异步、阻塞非阻塞的理解这篇文章中,已经对这五种IO模型做了一个梳理,主要有以下五种:

image-20200908093946254

实际上我们通常说的BIO、NIO以及AIO,这些都是Java里面的实现,是对操作系统的IO模型做了一个封装,这样程序员就无需考虑操作系统级别的事情,只需要按照JDK API进行编码即可。

下面先说一下BIO是怎么回事,BIO也就是阻塞IO,具体是哪里发生阻塞了呢?BIO中有一个线程专门执行accept()这个系统调用,来接收客户端的连接,这个系统调用是阻塞的,如果没有客户端进行连接,那么这个方法将一直阻塞下去,如果有连接请求,那么服务端会单独为每一个连接分配一个线程,因为连接中的读写操作都是阻塞的,如果不分配新的线程进行处理的话,那么后续的其他连接就要一直一个接着一个的进行阻塞,那画面太美了,所以只能为每个连接单独分配一个线程,,但是这种做法问题也是非常的明显,如果客户端连接数特别多怎么办?也就是C10K问题,假如说有一万个客户端请求连接,那么这种情况服务端一定是顶不住的,因为线程在操作系统中是一个很宝贵的资源,虽然说没有进程那样笨重,但是据统计每个线程起码还是要占用1M的内存空间的,况且在Linux操作系统中,实际上线程和进程的创建方式是相同的系统调用,而且每个线程都会对应一个单独的函数调用栈,这样的话,10000个线程,内存是完全顶不住的。

有一种方法,能够暂时环节一下这个问题,就是将服务端的线程设计成线程池的方式,但是这种方式其实也只不过是掩耳盗铃罢了,我们都知道线程池只是对最大允许的线程数做一个约束,但是还是应对不了高并发的情况的,甚至可能会触发线程池的拒绝策略。。。

BIO代码演示:

代码来自博客:https://www.jianshu.com/p/a4e03835921a

客户端

public class IOClient {

  public static void main(String[] args) {
    // TODO 创建多个线程,模拟多个客户端连接服务端
    new Thread(() -> {
      try {
        Socket socket = new Socket("127.0.0.1", 3333);
        while (true) {
          try {
            socket.getOutputStream().write((new Date() + ": hello world").getBytes());
            Thread.sleep(2000);
          } catch (Exception e) {
          }
        }
      } catch (IOException e) {
      }
    }).start();

  }
}

服务端

public class IOServer {

  public static void main(String[] args) throws IOException {
    // TODO 服务端处理客户端连接请求
    ServerSocket serverSocket = new ServerSocket(3333);

    // 接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理
    new Thread(() -> {
      while (true) {
        try {
          // 阻塞方法获取新的连接
          Socket socket = serverSocket.accept();

          // 每一个新的连接都创建一个线程,负责读取数据
          new Thread(() -> {
            try {
              int len;
              byte[] data = new byte[1024];
              InputStream inputStream = socket.getInputStream();
              // 按字节流方式读取数据
              while ((len = inputStream.read(data)) != -1) {
                System.out.println(new String(data, 0, len));
              }
            } catch (IOException e) {
            }
          }).start();

        } catch (IOException e) {
        }

      }
    }).start();

  }
}

根据上面的分析,目前BIO在实际场景中使用的并不多,下面来看NIO

实际上在Java NIO中,不仅是非阻塞的,而且实现了IO模型中的多路复用。NIO提供了与传统BIO模型中的 SocketServerSocket 相对应的 SocketChannelServerSocketChannel,一个比较灵活的地方就是,这两个通道支持阻塞和非阻塞两种方式,可以使用进行配置:clientChannel.configureBlocking(false);,看来BIO还是有一定应用场景的,哈哈哈

另外,在java.nio这个包中,引入了三个类,分别是Channel、Buffer、Selector,那么NIO是如何实现非阻塞的呢?这里就需要拿IO流和NIO流做对比,在上面的BIO实现代码中我们可以看到,BIO的读写是通过inputStream和outputStream进行的,这种IO是面向流的,如果没有数据可读或者可写,那么线程进行一直阻塞,直到有数据过来,而NIO的流是怎么做的呢?面向缓冲区。这也算是之前提到的中间件思想吧,就是中间加一个东西,往往会起到一个意想不到的效果,而这里,就实现了非阻塞,我们可以这样理解,Buffer就是Channel的两个端点,客户端或者服务端不管是进行读还是写,都是直接和Buffer打交道的,不直接和Channel打交道,这样,假如说进行读操作的时候,数据从Channel中读到Buffer,而这个时候线程可以去做其他的事情,不是一直阻塞在这里,写操作也是如此。

而Selctor这个Java中的封装类,本质上与而是对底层系统调用的封装,这个selector的职责就是轮询多个客户端连接, 一旦某个客户端有数据准备就绪,那么就会通知后面的工作线程进行处理,当然,我们需要事先将要进行监听的连接注册到selector中

虽然NIO相对于BIO相比,方方面面有了很大的起色,但是现在实际的开发中,我了解到的是,很少有人直接使用NIO进行网络编程的,因为没有理由不选Netty(霸气,之前看到一个文章跟别人学的,哈哈哈)。因为NIO能够做的事情,Netty都可以做的更好,NIO本身用起来还是比较复杂,并且还存在一直被拿出来鞭尸的epoll空轮询bug,什么是epoll,空轮询如何产生的?下面两部分继续介绍。

对了,再附上一个文章,文章中举的例子非常生动形象:NIO与传统IO的区别

2.select poll epoll

这三个系统调用都是多路复用的实现机制,他们都是同步的,但是他们的执行过程是否是阻塞的呢?

我查阅了一些资料,最后在CS-Notesissues里面找到了正确的答案,总结起来就是,这三个系统调用都可以通过参数进行配置,来决定是否阻塞,拿epoll来讲,可以对timeout参数进行配置:

image-20200909090807745

具体详情,可以跳转至issues查看

下面对他们三个做一个整体的比较:

select

前面也是提过,如果想要使用Selector进行轮询,那么需要将想要监听的连接在select上面进行注册,每个连接都对应一个fd,也就是操作系统中的文件描述符,而select的实现机制就是将所有的文件描述符存放在一个数组里面,然后进行轮询查找,这个过程的时间复杂度是O(n),并且因为存储结构使用的数组,那么就有一个最大连接数的限制,一般都是1024,并且还要考虑到将我们注册的文件描述符从用户空间复制到内核空间的代价

poll

这个整体都和上面的select差不多,唯一的区别就是存储结构使用的是链表,这样存储文件描述符的时候就没有的最大的限制,但是还是有内存的限制的,但是目前只有比较新的系统才支持poll,有的不支持

epoll

epoll对上面两个系统调用存在的问题做了优化,存储结构使用的是红黑树+链表,红黑树存放的是所有注册的连接,链表存放的所有有事件发生的fd集合,epoll方式没有最大并发连接的限制,并没有采用之前两个轮询的方式,而是在每个连接注册一个callback函数,都有读写事件发生时,可以调用这个函数来通知,这样就可以实现精确的读取,而不用从头到尾遍历找到是哪个连接产生了读写事件

还有就是epoll利用mmap()文件映射内存加速与内核空间的消息传递,即epoll使用mmap减少复制开销。

但是一个缺点就是目前只有Linux才有epoll这个系统调用,并且如果连接数比较少,并且都很活跃的话,其实epoll并没有占据多少优势

3.NIO的epoll空轮询bug

这个bug在网上一直用臭名昭著来形容,不仅是这个bug存在的问题比较严重,而且持续的时间也是比较长,听说很早就出现这个问题了,但是即使到了jdk1.8版本这个问题还是没能得到一个彻底的解决,只是减少了问题发生的频率

这个问题是如何产生的呢?先来看一下Java官网的bug报告:

https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6670302

问题发生的情况:

A DESCRIPTION OF THE PROBLEM :
The NIO selector wakes up infinitely in this situation..

0. server waits for connection
1. client connects and write message
2. server accepts and register OP_READ
3. server reads message and remove OP_READ from interest op set
4. client close the connection
5. server write message (without any reading.. surely OP_READ is not set)
6. server's select wakes up infinitely with return value 0

当客户端的连接socket突然发生中断的时候,这个时候就会将epoll的事件集中对应的状态更改为POLLHUP或者POLLERR,事件集合发生了变化,这就导致Selector会被唤醒,进而导致CPU 100%问题。selector.select()在while()循环中一直进行空轮询

使用NIO不仅编程模型复杂,而且还有潜在的bug,并且这里面的bug还不止空轮询这么一个,所以这里还是强推Netty,Netty针对这种情况提供了解决办法,他并没有从根本上解决这个问题,而是在框架里面做了一个很好的规避,Netty的做法首先是检测问题,如果监测到了一段时间的空轮询次数超过了一个阙值,那么重新一个新的Selector,然后将之前的事件集全部转移过来

image-20200909162129752

4.这是结尾

上面的总结基本上缓解了我对于这一块子的疑惑,但是总体上还是一个比较肤浅的理解,不过对基础的BIO、NIO有了一个感性的认知之后,有助于接下来对Netty的探索,目前Netty作为Dubbo、Spark、ES、RocketMQ等优秀开源框架底层的网络通信支持,真的是太牛了,后面一起继续探索!

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://hadoo666.top/archives/网络io知识梳理md