乐趣区

关于程序员:深入Hotspot源码与Linux内核理解NIO与Epoll

一、为什么必须去理解 NIO

首先你须要之后 Netty 的次要实现伎俩就是 Nio, 很多人始终学不明确 Netty,根本原因是 除了日常开发中很难可能实际,很大一部分起因是不相熟 NIO,事实上真正相熟了 NIO 和它背地的原理之后,去查看 Netty 的源码就有如神助!咱们明天就从最根本的 IO、以及 NIO 学起!欢送关注公众号【源码学徒】

二、操作系统是如何定义 I / O 的

I/O相干的操作,具体各位从事 java 的人员并不生疏,顾名思义也就是 Input/Output, 对应着连个动词,Read/Write 读写两个动作,然而在下层零碎利用中无论是读还是写, 操作系统都不会间接的操作物理机磁盘数据,而是由零碎内核加载磁盘数据!咱们以 Read 为例,当程序中发动了一个 Read 申请后,操作系统会将数据从内核缓冲区加载到用户缓冲区,如果内核缓冲区内没有数据,内核会将该次读申请追加到申请队列,当内核将磁盘数据读取到内核缓冲区后,再次执行读申请,将内核缓冲区的数据复制到用户缓冲区,继而返回给下层利用零碎!

write 申请也是相似于上图的状况,用户过程写入到用户缓冲区,复制到内核缓冲区,而后当数据达到一定量级之后由内核写入到网口或者磁盘文件!

假如咱们以 Socket 服务端为例,咱们口述一下一个残缺的读写操作的流程:

  1. 客户端发送一个数据到网卡,由操作系统内核将数据复制到内核缓冲区!
  2. 当用户过程发动 read 申请后,将数据从内核缓冲区复制到用户缓冲区!
  3. 用户缓冲区获取到数据之后程序开始进行业务解决!解决实现后,调用 Write 申请,将数据从用户缓冲区写入到内核缓冲区!
  4. 零碎内核将数据从内核缓冲区写入到网卡,通过底层的通信协定发送到客户端!

三、网络编程中的 IO 模型

本文旨在让初学者先大抵理解一下基本原理,所以这里并不会波及到太多代码,具体的实现逻辑,能够关注后续源码剖析的时候的文章,这里只做一个铺垫,为日后的学习做一个比拟好的铺垫!

1. 同步阻塞 I /O

I. 传统的阻塞 IO 模型

这种模型是单线程利用,服务端监听客户端连贯,当监听到客户端的连贯后立刻去做业务逻辑的解决,该次申请没有解决实现之前 ,服务端接管到的其余连贯 全副阻塞不可操作!当然开发中,咱们也不会这样写,这种写法只会存在于协定 demo 中!这种写法的缺点在哪呢?

咱们看图发现,当一个新连贯被接入后,其余客户端的连贯全副处于阻塞状态,那么当该客户端解决客户端工夫过长的时候,会导致阻塞的客户端连贯越来越多导致系统解体,咱们是否可能找到一个方法,使其可能将业务解决与 Accept 接管新连贯拆散开来!这样业务解决不影响新连贯接入就可能解决该问题!

II. 伪异步阻塞 IO 模型

这种业务模型是是对上一步单线程模型的一种优化,当一个新连贯接入后,获取到这个链接的 Socket, 交给一条新的线程去解决,主程序持续接管下一个新连贯,这样就可能解决同一时间只能解决一个新连贯的问题,然而,明眼人都能看进去,这样有一个很致命的问题,这种模型解决小并发短时间可能不会呈现问题,然而假如有 10w 连贯接入,我须要开启 10w 个线程,这样会把零碎间接压崩!咱们须要 限度线程的数量 ,那么必定就会想到 线程池,咱们来优化一下这个模型吧!

III. 优化伪异步阻塞 IO 模型

这个模型是 JDK1.4 之前,没有 NIO 的时候的一个经典 Socket 模型,服务端接管到客户端新连贯会后,将 Socket 连贯以及业务逻辑包装为工作提交到线程池,由线程池开始执行,同时服务端持续接管新连贯!这样可能解决上一步因为线程爆炸所引发的问题,然而咱们回忆下线程池的的提交步骤:当外围线程池满了之后会将工作搁置到队列,当队列满了之后,会占用最大线程数的数量持续开启线程,当达到最大线程数的时候开始回绝策略! 证实我最大的并发数只有 1500 个,其余的都在队列外面占 1024 个,假如当初的连接数是 1w 个,并且应用的是抛弃策略,那么会有近 6000 的连贯工作被抛弃掉,而且 1500 个线程,线程之间的切换也是一个特地大的开销!这是一个致命的问题!

上述的三种模型除了有上述的问题之外,还有一个特地致命的问题,他是阻塞的!

在哪里阻塞的呢?

  • 连贯的时候,当没有客户端连贯的时候是阻塞的!没有客户端连贯的时候,线程只能傻傻的阻塞在哪里期待新连贯接入!
  • 期待数据写入的时候是阻塞的,当一个新连贯接入后然而不写入数据,那么线程会始终期待数据写入,直到数据写入实现后才会进行阻塞!假如咱们应用 优化后的伪异步线程模型,1000 个连贯可能只有 100 个连贯会频繁写入数据,残余 900 个连贯都很少写入,那么就会有 900 个线程在傻傻期待客户端写入数据,所以,这也是一个很重大的性能开销!

当初咱们总结一下上述模型的问题:

  1. 线程开销节约重大!
  2. 线程间的切换频繁,效率低下!
  3. read/write 执行的时候会进行阻塞!
  4. accept 会阻塞期待新连贯

那么,咱们是否有一种计划,用很少的线程去治理成千上万的连贯,read/write 会阻塞过程,那么就会进入到上面的模型

2. 同步非阻塞 I /O

同步非阻塞 I / O 模型就必须应用 java NIO 来实现了,看一段简略的代码:

public static void main(String[] args) throws IOException {
    // 新接连池
    List<SocketChannel> socketChannelList = new ArrayList<>(8);
    // 开启服务端 Socket
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress(8098));
    // 设置为非阻塞
    serverSocketChannel.configureBlocking(false);
    while (true) {
        // 探测新连贯,因为设置了非阻塞,这里即便没有新连贯也不会阻塞,而是间接返回 null
        SocketChannel socketChannel = serverSocketChannel.accept();
        // 当返回值不为 null 的时候,证实存在新连贯
        if(socketChannel!=null){System.out.println("新连贯接入");
            // 将客户端设置为非阻塞  这样 read/write 不会阻塞
            socketChannel.configureBlocking(false);
            // 将新连贯退出到线程池
            socketChannelList.add(socketChannel);
        }
        // 迭代器遍历连接池
        Iterator<SocketChannel> iterator = socketChannelList.iterator();
        while (iterator.hasNext()) {ByteBuffer byteBuffer = ByteBuffer.allocate(128);
            SocketChannel channel = iterator.next();
            // 读取客户端数据 当客户端数据没有写入实现的时候也不会阻塞,长度为 0
            int read = channel.read(byteBuffer);

            if(read > 0) {
                // 当存在数据的时候打印数据
                System.out.println(new String(byteBuffer.array()));
            }else if(read == -1) {
                // 客户端退出的时候删除该连贯
                iterator.remove();
                System.out.println("断开连接");
            }
        }
    }
}

上述代码咱们能够看到一个要害的逻辑:serverSocketChannel.configureBlocking(false); 这里被设置为非阻塞的时候无论是 accept 还是 read/write 都不会阻塞!具体的为什么会非阻塞,我放到文章前面说,咱们看一下这种的实现逻辑有什么问题!

看这里,咱们仿佛确实应用了一条线程解决了所有的连贯以及读写操作,然而假如咱们有 10w 连贯,沉闷连贯(常常 read/write)只有 1000,然而咱们这个线程须要每次否轮询 10w 条数据处理,极大的耗费了 CPU!

咱们期待什么?期待的是,每次轮询值轮询有数据的 Channel, 没有数据的就不论他,比方刚刚的例子,只有 1000 个沉闷连贯,那么每次就只轮询这 1000 个,其余的有读写了有数据就轮询,没读写就不轮询!

3. 多路复用模型

多路复用模型是 JAVA NIO 举荐应用的经典模型,外部通过 Selector 进行事件抉择,Selector 事件抉择通过零碎实现,具体流程看一段代码:

public static void main(String[] args) throws IOException {
    // 开启服务端 Socket
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress(8098));
    // 设置为非阻塞
    serverSocketChannel.configureBlocking(false);
    // 开启一个选择器
    Selector selector = Selector.open();
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    while (true) {
        // 阻塞期待须要解决的事件产生
        selector.select();
        // 获取 selector 中注册的全副事件的 SelectionKey 实例
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        // 获取曾经筹备实现的 key
        Iterator<SelectionKey> iterator = selectionKeys.iterator();
        while (iterator.hasNext()) {SelectionKey next = iterator.next();
            // 当发现连贯事件
            if(next.isAcceptable()) {
                // 获取客户端连贯
                SocketChannel socketChannel = serverSocketChannel.accept();
                // 设置非阻塞
                socketChannel.configureBlocking(false);
                // 将该客户端连贯注册进选择器 并关注读事件
                socketChannel.register(selector, SelectionKey.OP_READ);
                // 如果是读事件
            }else if(next.isReadable()){ByteBuffer allocate = ByteBuffer.allocate(128);
                // 获取与此 key 惟一绑定的 channel
                SocketChannel channel = (SocketChannel) next.channel();
                // 开始读取数据
                int read = channel.read(allocate);
                if(read > 0){System.out.println(new String(allocate.array()));
                }else if(read == -1){System.out.println("断开连接");
                    channel.close();}
            }
            // 删除这个事件
            iterator.remove();}
    }
}

相比下面的同步非阻塞 IO,这里多了一个 selector 选择器,可能对关注不同事件的 Socket 进行注册,后续如果关注的事件满足了条件的话,就将该 socket 放回到到外面,期待客户端轮询!

NIO 底层在 JDK1.4 版本是用 linux 的内核函数 select()或 poll()来实现,跟下面的 NioServer 代码相似,selector 每次都会轮询所有的 sockchannel 看下哪个 channel 有读写事件,有的话就解决,没有就持续遍历,JDK1.5 开始引入了 epoll 基于事件响应机制来优化 NIO,首先咱们会将咱们的 SocketChannel 注册到对应的选择器上并抉择关注的事件,后续操作系统会依据咱们设置的感兴趣的事件将实现的事件 SocketChannel 放回到选择器中,期待用户的解决!那么它可能解决上述的问题吗?

必定是能够的,因为 下面的一个同步非阻塞 I / O 痛点在于 CPU 总是在做很多无用的轮询 ,在这个模型里被解决了!这个模型从 selector 中获取到的 Channel 全副是就绪的,后续只须要也就是说他 每次轮询都不会做无用功!

深刻 底层概念解析
select 模型

如果要深入分析 NIO 的底层咱们须要逐渐的剖析,首先,咱们须要理解一种叫做 select()函数的模型,它是什么呢?他也是 NIO 所应用的多路复用的模型之一,是 JDK1.4 的时候所应用的一种模型,他是 epoll 模型之前所广泛应用的一种模型,他的效率不高,然而过后被广泛应用,起初才会被人优化为 epoll!

他是如何做到多路复用的呢?如图:

  1. 首先咱们须要理解操作系统有一个叫做工作队列的概念,由 CPU 轮流执行工作队列外面的过程,咱们平时书写的 Socket 服务端客户端程序也是存在于工作队列的过程中,只有它存在于工作队列,它就会被 CPU 调用执行!咱们下文将该网络程序称之为 过程 A

  1. 他的外部会保护一个 Socket 列表,当调用零碎函数 select(socket[])的时候,操作系统会将 过程 A 退出到 Socket 列表中的每一个 Socket 的期待队列中,同时将 过程 A 从工作队列移除,此时,过程 A 处于阻塞状态!
  2. 当网卡接管到数据之后,触发操作系统的中断程序,依据该程序的 Socket 端口取对应的 Socket 列表中寻找该 过程 A ,并将 过程 A 从所有的 Socket 列表中的期待队列移除,并退出到操作系统的工作队列!

  3. 此时过程 A 被唤醒,此时晓得至多有一个 Socket 存在数据,开始顺次遍历所有的 Socket,寻找存在数据的 Socket 并进行后续的业务操作

该种构造的核心思想是,我先让所有的 Socket 都持有这个过程 A 的援用,当操作系统触发 Socket 中断之后,基于端口寻找到对应的 Socket, 就可能找到该 Socket 对应的过程,再基于过程,就可能找到所有被监控的 Socket! 要留神,当过程 A 被唤醒,就证实一件事,操作系统产生了 Socket 中断,就至多有一个 Socket 的数据准备就绪,只须要将所有的 Socket 遍历,就可能找到并解决本次客户端传入的数据!

然而,你会发现,这种操作极为繁琐,两头仿佛存在了很多遍历,先将过程 A 退出的所有的 Socket 期待队列须要遍历一次,产生中断之后须要遍历一次 Socket 列表,将所有对于过程 A 的援用移除,并将过程 A 的援用退出到工作队列!因为此时过程 A 并不知道哪一个 Socket 是有数据的,所以,由须要再次遍历一遍 Socket 列表,能力真正的解决数据,整个操作总共遍历了 3 此 Socket,为了保障性能,所以 1.4 版本种,最多只能监控 1024 个 Socket, 去掉规范输入输入和谬误输入只剩下 1021 个,因为如果 Socket 过多势必造成每次遍历耗费性能极大!

epoll 模型

epoll 总共分为三个比拟重要的函数:

  1. epoll_create 对应 JDK NIO 代码种的Selector.open()
  2. epoll_ctl 对应 JDK NIO 代码中的socketChannel.register(selector,xxxx);
  3. epoll_wait 对应 JDK NIO 代码中的 selector.select();

感兴趣的能够下载一个 open-jdk-8u 的源代码,也能够关注公众号回复 openJdk 获取源码压缩包!

他是如何优化 select 的呢?

  1. epoll_create:这些零碎调用将返回一个非负文件描述符,他也和 Socket 一样,存在一个期待队列,然而,他还存在一个就绪队列!

  2. epoll_ctl:增加 Socket 的监督,对应 Java 中将 SocketChannel 注册到 Selector 中,他会将创立的文件描述符的援用增加到 Socket 的期待队列!这点比拟难了解,留神是将EPFD(Epoll 文件描述符)放到 Socket 的期待队列!

  3. 当操作系统产生中断程序后,基于端口号(客户端的端口号是惟一的)寻找到对应的 Socket, 获取到 EPFD 的援用,将该 Socket 的援用退出到 EPFD 的就序列表!

  4. epoll_wait:查看 EPFD 的就绪列表是否存在 Socket 的援用,如果存在就间接返回,不存在就将过程 A 退出到 EPFD 的期待队列,并移除过程 A 再工作队列的援用!

  5. 当网卡再次接管到数据,产生中断,进行上述步骤,将该 Socket 的因援用退出到就序列表,并唤醒 过程 A ,移除该 EPFD 期待队列的过程 A,将过程 A 退出到工作队列,程序继续执行!

4. 异步非阻塞 I /O

异步非阻塞模型是用户利用只须要收回对应的事件,并注册对应的回调函数,由操作系统实现后,回调回调函数,实现具体的约为操作!先看一段代码

public static void main(String[] args) throws Exception {final AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));
        // 监听连贯事件,并注册回调
        serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
            @Override
            public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
                try {System.out.println("2--"+Thread.currentThread().getName());
                    // 再此接管客户端连贯,如果不写这行代码前面的客户端连贯连不上服务端
                    serverChannel.accept(attachment, this);
                    System.out.println(socketChannel.getRemoteAddress());
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    // 监听 read 事件并注册回调
                    socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                        @Override
                        public void completed(Integer result, ByteBuffer buffer) {System.out.println("3--"+Thread.currentThread().getName());
                            buffer.flip();
                            System.out.println(new String(buffer.array(), 0, result));
                            // 向客户端回写一个数据
                            socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
                        }
                        // 产生谬误调这个
                        @Override
                        public void failed(Throwable exc, ByteBuffer buffer) {exc.printStackTrace();
                        }
                    });
                } catch (IOException e) {e.printStackTrace();
                }
            }
            // 产生谬误调这个
            @Override
            public void failed(Throwable exc, Object attachment) {exc.printStackTrace();
            }
        });

        System.out.println("1--"+Thread.currentThread().getName());
        Thread.sleep(Integer.MAX_VALUE);
    }
}

AIO 客户端

public static void main(String... args) throws Exception {AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel.open();
    socketChannel.connect(new InetSocketAddress("127.0.0.1", 9000)).get();
    socketChannel.write(ByteBuffer.wrap("HelloServer".getBytes()));
    ByteBuffer buffer = ByteBuffer.allocate(512);
    Integer len = socketChannel.read(buffer).get();
    if (len != -1) {System.out.println("客户端收到信息:" + new String(buffer.array(), 0, len));
    }
}

整体逻辑就是,通知零碎我要关注一个连贯的事件,如果有连贯事件就调用我注册的这个回调函数,回调函数中获取到客户端的连贯,而后再次注册一个 read 申请,通知零碎,如果有可读的数据就调用我注册的这个回调函数!当存在数据的时候,执行 read 回调,并写出数据!

为什么 Netty 应用 NIO 而不是 AIO?

在 Linux 零碎上,AIO 的底层实现仍应用 Epoll,没有很好实现 AIO,因而在性能上没有显著的劣势,而且被 JDK 封装了一层不容易深度优化,Linux 上 AIO 还不够成熟。Netty 是 异步非阻塞 框架,Netty 在 NIO 上做了很多异步的封装。简略来说,当初的 AIO 实现比拟鸡肋!

退出移动版