Netty之旅1: 一文搞懂NIO线程模型

什么是Netty?

Netty是由JBOSS提供的一个java开源框架。

Netty提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务和客户端程序。
通俗的说,netty是一个好使的处理Socket的东西。作为当前最流行的NIO框架,Netty在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,一些业界著名的开源组件也基于Netty的NIO框架构建。

Netty是比较偏向于原理的一门技术,很多好的公司都是非常重视Netty的经验。要学好netty必须先学好nio。本文的目的是帮助读者彻底搞懂NIO线程模型。

关键概念

在学习NIO模型之前,我们要先了解几个关键概念,如果不明白这些概念,就无法真正明白技术的设计思路和本质。在我看来,这些概念是了解计算机世界的基础。

1、计算机世界的速度鄙视

内存读数据:纳秒级别
千兆网卡读数据:微秒级别。1微秒=1000纳秒,网卡比内存速度慢1000倍。
磁盘读数据:毫秒级别。1毫秒等于10万纳秒,硬盘比内存慢10万倍。
打个比方,如果cpu从磁盘读数据时间好比飞机绕地球一周的话,那么cpu从内存读取数据时间可以类比手中钢笔转一圈。那么当进程需要从网络或者磁盘读取数据时,需要相当长的等待过程。在这个漫长等待过程中进程可以干嘛呢?可否进行优化,高效利用cpu进行数据处理,这是所有IO开发者必须考虑的问题。

2、同步与异步

同步与异步是指进程在发生功能调用时候,进程是主动去获取结果还是调用完就不管了。
同步:就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。一定要等到结果再去执行后面的事情。
异步:与同步相反,进程发出调用后,不管事件做没做完直接走了,等这个事件做完之后再回调我就好了。

3、阻塞与非阻塞

阻塞和非阻塞是进程在访问数据的时候(比如执行到read()、 accpect()、 select()等方法),针对数据是否准备就绪的一种处理方式。
阻塞:当数据没有准备好的时候,往往需要等待缓冲区中的数据准备好过后才处理其他的事情,否则线程会一直等待在那里(即当前线程会被挂起), 直到数据准备好再继续执行。
非阻塞:当数据没有准备好的时候,线程不会傻傻等着,而是直接返回去干别的事情。

为了更好的理解同步与异步,阻塞与非阻塞概念,下面举个段子

老张爱喝茶,废话不说,煮开水。
出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。
1 老张把水壶放到火上,立等水开。(同步阻塞,BIO
老张觉得自己有点傻
2 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有(轮询,同步非阻塞,NIO
同步体现在:水开没开,一定要老王自己去看。虽然老王可能在煮水过程中去干点别的事,但是最后一定是他自己亲自确认水煮开了。
老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。
3 老张把响水壶放到火上,立等水开。(异步阻塞,这方法太傻了,没有体现响水壶价值,网络编程中也不存在这种模型)
老张觉得这样傻等意义不大。
4 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,等听到水响的声音后(回调注册),再去拿壶。(异步非阻塞,AIO
老张觉得自己聪明了。
所谓同步异步,只是对于水壶而言。
普通水壶,同步;响水壶,异步。
虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了(回调注册机制)。这是普通水壶所不能及的。
同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。
所谓阻塞非阻塞,仅仅对于老张而言。
立等的老张,阻塞;看电视的老张,非阻塞。

4、BIO(Blocking IO)

BIO线程模型

这是最简单的IO模型,对于服务端来说,来一个客户端的连接,服务端就为其启动一个Thread。
由图可知,服务端的线程个数和客户端并发访问数呈1:1的正比关系,对于连接数目比较小且固定的架构,这种模型是很适合的。编程也非常简单,废话不多上代码。
bio服务端代码:

public class SocketServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(9000);
        while (true) {
            System.out.println("等待连接。。");
            //阻塞方法
            Socket socket = serverSocket.accept();
            System.out.println("有客户端连接了。。");
           //来一个客户端就启动一个新线程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        handler(socket);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
            //handler(socket);

        }
    }

    private static void handler(Socket socket) throws IOException {
        System.out.println("thread id = " + Thread.currentThread().getId());
        byte[] bytes = new byte[1024];

        System.out.println("准备read。。");
        //接收客户端的数据,阻塞方法,没有数据可读时就阻塞
        int read = socket.getInputStream().read(bytes);
        System.out.println("read完毕。。");
        if (read != -1) {
            System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
            System.out.println("thread id = " + Thread.currentThread().getId());

        }
        socket.getOutputStream().write("HelloClient".getBytes());
        socket.getOutputStream().flush();
    }
}

bio客户端代码:

public class SocketClient {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1", 9000);
        //向服务端发送数据
        socket.getOutputStream().write("HelloServer".getBytes());
        socket.getOutputStream().flush();
        System.out.println("向服务端发送数据结束");
        byte[] bytes = new byte[1024];
        //接收服务端回传的数据
        socket.getInputStream().read(bytes);
        System.out.println("接收到服务端的数据:" + new String(bytes));
        socket.close();
    }
}

bio模型有两个问题,
1、线程的阻塞浪费资源。
2、当客户端连接很多时候,因为有大量线程上下文切换操作,服务端系统的性能急剧下降,给服务器压力非常大。因此这种模型显然无法满足高性能、高并发接入的场景。

铺垫了这么多,我们本文的重点NIO线程模型终于要登场了!!!

NIO(Non Blocking IO)线程模型

先来对nio有一个直观的认识。由下图可知,不论有多少客户端连接,服务端只需要一个线程就好了,和bio相比,多了一个selector,叫做多路复用选择器。
从适用角度来说,NIO方式适用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯。NIO编程比较复杂, JDK1.4 开始支持。

NIO线程模型

如下图,NIO 有三大核心组件: Channel(通道), Buffer(缓冲区),Selector(选择器)
1、channel 类似于流,每个 channel 对应一个 buffer缓冲区,buffer 底层就是个数组。换句话说,Java IO是面向流的,而Java NIO是面向缓存区的。
2、channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理。
3、selector 可以对应一个或多个线程。
4、NIO 的 Buffer 和 channel 都是既可以读也可以写。


nio模型三大核心组件

NIO模型如果只用上图表示的话就还是有点简单了,每个客户端的channel如何与服务端建立连接,单个thread又是如何处理这么多客户端的读写请求呢?
在了解NIO三大核心组件的基础上,让我们看看NIO 的详细模型。

NIO模型详解

这个图初看有点懵,我们通过NIO demo的源码来详细分析它。
首先我们要知道,网络通信中端和端之间一定要先建立连接,再开始信息交互(读写)。因此selector主要接收的就是两种事件的注册:
1、连接事件
2、读写事件
这两个事件对应的分别是代码中的ServerSocketChannel和SocketChannel,后者的句柄依赖于前者。了解这个后再分析NIO会轻松许多。上NIO服务端代码:

public class NIOServer {

    //public static ExecutorService pool = Executors.newFixedThreadPool(10);

    public static void main(String[] args) throws IOException {
        // 创建一个在本地端口进行监听的服务Socket通道.并设置为非阻塞方式
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //必须配置为非阻塞才能往selector上注册,否则会报错,selector模式本身就是非阻塞模式
        ssc.configureBlocking(false);
        ssc.socket().bind(new InetSocketAddress(9000));
        // 创建一个选择器selector
        Selector selector = Selector.open();
        // 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            System.out.println("等待事件发生。。");
            // 轮询监听channel里的key,select是阻塞的,accept()也是阻塞的
            int select = selector.select();

            System.out.println("有事件发生了。。");
            // 有客户端请求,被轮询监听到
            Iterator it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                //删除本次已处理的key,防止下次select重复处理
                it.remove();
                handle(key);
            }
        }
    }

    private static void handle(SelectionKey key) throws IOException {
        if (key.isAcceptable()) {
            System.out.println("有客户端连接事件发生了。。");
            ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
            //NIO非阻塞体现:此处accept方法是阻塞的,但是这里因为是发生了连接事件,所以这个方法会马上执行完,不会阻塞
            //处理完连接请求不会继续等待客户端的数据发送
            SocketChannel sc = ssc.accept();
            sc.configureBlocking(false);
            //通过Selector监听Channel时对读事件感兴趣
            sc.register(key.selector(), SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            System.out.println("有客户端数据可读事件发生了。。");
            SocketChannel sc = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            //NIO非阻塞体现:首先read方法不会阻塞,其次这种事件响应模型,当调用到read方法时肯定是发生了客户端发送数据的事件
            int len = sc.read(buffer);
            if (len != -1) {
                System.out.println("读取到客户端发送的数据:" + new String(buffer.array(), 0, len));
            }
            ByteBuffer bufferToWrite = ByteBuffer.wrap("HelloClient".getBytes());
            sc.write(bufferToWrite);
            key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
        } else if (key.isWritable()) {
            SocketChannel sc = (SocketChannel) key.channel();
            System.out.println("write事件");
            // NIO事件触发是水平触发
            // 使用Java的NIO编程的时候,在没有数据可以往外写的时候要取消写事件,
            // 在有数据往外写的时候再注册写事件
            key.interestOps(SelectionKey.OP_READ);
            //sc.close();
        }
    }
}

NIO服务端程序详细分析(一定要结合NIO模型详解图来看):
1、服务端会创建一个 ServerSocketChannel 和 Selector ,并将 ServerSocketChannel 注册到 Selector 上
2、 selector 通过 select() 方法监听 channel 事件,当客户端连接时,selector 监听到连接事件, 获取到 ServerSocketChannel 注册时绑定的 selectionKey
3、selectionKey 通过 channel() 方法可以获取绑定的 ServerSocketChannel
4、ServerSocketChannel 通过 accept() 方法得到 SocketChannel
5、将 SocketChannel 注册到 Selector 上,关心** read 事件**。
6、注册后返回一个 SelectionKey, 会和该 SocketChannel 关联,这样客户端就真正和服务端建立连接了。

7、selector 继续通过 select() 方法监听事件,当客户端发送数据给服务端,selector 监听到read事件,获取到 SocketChannel 注册时绑定的 selectionKey
8、selectionKey 通过 channel() 方法可以获取绑定的 socketChannel
9、将 socketChannel 里的数据读取出来
10、用 socketChannel 将服务端数据写回客户端

NIO模型的服务端非阻塞体现在:
1、客户端和服务端channel建立连接之后,并不是开始读数据了,而是handel方法就此结束了,开始处理下一个handel事件,不会傻等客户端发起read。(见handel 的if(key.isAcceptable())分支代码注释)这是非阻塞的一个体现。
2、handel()方法中:尽管accept()和read()是阻塞的方法,但是我是在只有接受到read请求时候才会调用该方法啊,这是前提(见key.isReadable()分支),所以根本不会阻塞。
综上两点,nio模型下,调用任何io操作的API都不会阻塞。

另附NIO客户端代码:

public class NioClient {
    //通道管理器
    private Selector selector;

    /**
     * 启动客户端测试
     *
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {
        NioClient client = new NioClient();
        client.initClient("127.0.0.1", 9000);
        client.connect();
    }

    /**
     * 获得一个Socket通道,并对该通道做一些初始化的工作
     *
     * @param ip   连接的服务器的ip
     * @param port 连接的服务器的端口号
     * @throws IOException
     */
    public void initClient(String ip, int port) throws IOException {
        // 获得一个Socket通道
        SocketChannel channel = SocketChannel.open();
        // 设置通道为非阻塞
        channel.configureBlocking(false);
        // 获得一个通道管理器
        this.selector = Selector.open();

        // 客户端连接服务器,其实方法执行并没有实现连接,需要在listen()方法中调
        //用channel.finishConnect() 才能完成连接
        channel.connect(new InetSocketAddress(ip, port));
        //将通道管理器和该通道绑定,并为该通道注册SelectionKey.OP_CONNECT事件。
        channel.register(selector, SelectionKey.OP_CONNECT);
    }

    /**
     * 采用轮询的方式监听selector上是否有需要处理的事件,如果有,则进行处理
     *
     * @throws IOException
     */
    public void connect() throws IOException {
        // 轮询访问selector
        while (true) {
            selector.select();
            // 获得selector中选中的项的迭代器
            Iterator it = this.selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = (SelectionKey) it.next();
                // 删除已选的key,以防重复处理
                it.remove();
                // 连接事件发生
                if (key.isConnectable()) {
                    SocketChannel channel = (SocketChannel) key.channel();
                    // 如果正在连接,则完成连接
                    if (channel.isConnectionPending()) {
                        channel.finishConnect();
                    }
                    // 设置成非阻塞
                    channel.configureBlocking(false);
                    //在这里可以给服务端发送信息哦
                    ByteBuffer buffer = ByteBuffer.wrap("HelloServer".getBytes());
                    channel.write(buffer);
                    //在和服务端连接成功之后,为了可以接收到服务端的信息,需要给通道设置读的权限。
                    channel.register(this.selector, SelectionKey.OP_READ);                 // 获得了可读的事件
                } else if (key.isReadable()) {
                    read(key);
                }
            }
        }
    }

    /**
     * 处理读取服务端发来的信息 的事件
     *
     * @param key
     * @throws IOException
     */
    public void read(SelectionKey key) throws IOException {
        //和服务端的read方法一样
        // 服务器可读取消息:得到事件发生的Socket通道
        SocketChannel channel = (SocketChannel) key.channel();
        // 创建读取的缓冲区
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        int len = channel.read(buffer);
        if (len != -1) {
            System.out.println("客户端收到信息:" + new String(buffer.array(), 0, len));
        }
    }
}

IO多路复用

我们已经知道,NIO的实现模式是一个线程可以处理多个IO请求,客户端发送的连接请求都会注册到多路复用器selector上,多路复用器轮询到连接有IO请求就进行处理。顺便说一下,这也是redis后台的线程模型
通俗的说,因为服务端只有一个线程,所以哪怕来10万个请求,也要挨着排好队一个一个去处理。这就是复用的概念。别小看服务端只有一个线程要排队处理这么多请求,因为是非阻塞的,且没有了上下文的切换,所以执行速度非常快。

当然,IO多路复用模型希望每次的read操作最好还是轻量级的,比如弹幕,聊天等轻量级消息。handel要轻量级读写也是这个原因。因为如果read数据量太大,会影响别的客户端操作。如果想优化,可以设立一个线程池,handel请求放在线程池中去处理。 redis最新的版本正在开发这个功能,但是难度还是有点大,毕竟涉及到并发和分布式锁。

IO多路复用模型非阻塞的,也就是说,客户端和服务端连接建立好后,客户端即使你现在不发数据也没关系,只要你在selector中注册过就好,我先去处理别的事情,等你想发数据时候seletor能够感知到,我那个时候轮询到你再读数据也不迟
I/O多路复用底层一般用的Linux API(select,poll,epoll)来实现,他们的区别如下表:

selector底层调用的linuxAPI

JDK1.5以后采用epoll,这是一种回调机制而不是遍历方法。大家想一下,当有10万个客户端连接时候,如果采用select或者poll,哪怕有99999个channel都暂时没有事件,只有一个channel有事件,selector也得轮询10万个channel,这个性能开销比较大。而epoll由主动轮询转化为被动接受通知,性能得到优化。

NIO与netty

看得出来,nio的编程是十分复杂的,而且很容易出错,因此在服务端编程时候,我们更多是的使用基于nio封装的netty。
Netty是建立在NIO基础之上,Netty在NIO之上又提供了更高层次的抽象
可以说,想要了解netty,必须先了解nio。本文作为netty之旅开始,后面会陆续推出netty的相关系列文章,欢迎对netty感兴趣读者关注。

你可能感兴趣的:(Netty之旅1: 一文搞懂NIO线程模型)