NIO编程(七)—— nio.Selector之读写事件

1,074 阅读9分钟

这是我参与11月更文挑战的第15天,活动详情查看:2021最后一次更文挑战

上一篇博客Netty编程(六)—— nio.Selector之基本使用 - 掘金 (juejin.cn)介绍了selector以及如何处理连接事件,这篇博客介绍如何处理selector的读写事件。

读事件

在Accept事件中,若有客户端与服务器端建立了连接,需要将其对应的SocketChannel设置为非阻塞,并注册到选择其中,对其添加关注read事件,当客户端发来消息后服务器channel就可以使用read()方法进行读取操作。

read()方法可以将通道传输的数据读入到buffer中,它会返回一个整型数,表示本次读取的字节数,当返回值是-1时代表本次读操作结束,读操作结束后就需要使用key.cancel()取消这个事件,否则就会一直循环下去,具体的代码如下:

else if(key.isReadable()){
    try{
        SocketChannel channel = ((SocketChannel) key.channel());//拿到channel
        ByteBuffer buffer = ByteBuffer.allocate(100);
        int size = channel.read(buffer);//如果是正常断开,size = -1
        if(size == -1)
        {
            key.cancel();
        }
        else
        {
            buffer.flip();
            ByteBufferUtil.debugAll(buffer);//缓存可视化
        }
    }catch (IOException e)
    {
        e.printStackTrace();
        key.cancel();
    }
}

需要注意的是两处的key.cancel(),下面解释一下两处的作用:

  • 第一次是用在正常读取结束时,结束该读事件,否则selector会认为你没有处理这个读事件,不会阻塞在selector.select()上而陷入死循环。
  • 第二次是用在客户端非正常关闭情况下,代码会进入catch块,在这里结束该读事件,防止死循环。

删除事件

Netty编程(六)—— nio.Selector之基本使用 - 掘金 (juejin.cn)最后提到代码中每对事件进行一次迭代,都会执行iter.remove()删除这个事件,现在就来解释以下如果不删除为什么会出现问题。这里我放上一张包含注释的代码截图加以说明。

在这里插入图片描述

在这段代码里,可以看作有两个集合:

  1. 代码第20行建立的selector会维护一个集合,保存的是注册在该selector上的channel
  2. 代码29行selector.selectedKeys()会维护一个集合,这个集合是事件集合

一开始selector集合只有一个ssc(新建的ServerSocketChannel),它关注accept事件,而事件集合为空:

在这里插入图片描述

之后有一个客户端A连接了服务端,此时事件集合中会有一个accept事件,同时连接成功后selector下的channel集合会增加一个sc(获得的SocketChannel),它关注read事件。但是需要注意的是:事件集合不会自动删除已处理完毕的事件,所以即使连接事件已经处理完毕了,还依然在事件集合中。

在这里插入图片描述

之后,连接上的客户端A向服务端发送消息,此时selector发现了有新事件(读),就在事件集合中加入了sc通道上的读事件:

在这里插入图片描述

因为有了新事件(读事件),所以不会再阻塞在第28行的selector.select()上了,下面就会遍历事件集合并处理每一个事件,==因为事件集合不会自动删除已完成的事件,所以一开始连接事件还存在==,因此会进入33行的连接事件处理,但此时并没有客户端要连接,因此会发生错误。

所以说没处理完一个事件,就需要将它从事件集合中删除,所以第32行的iter.remove()是必不可少的。

消息边界问题

实际传输的问题

传输的文本不一定每次都恰好与缓冲区的大小匹配,在实际情况下,有可能出现下面几种情况:

  • 文本大于缓冲区大小:此时需要将缓冲区进行扩容
  • 发生粘包现象:比如说客户端发了三次内容:"AB"、"CD"、"EF",但是服务器接收到了"ABCDEF"
  • 发生半包现象:同样是上面情况,但是服务器接收到了"ABC"、"DEF"

在这里插入图片描述 解决这个问题一般有三种方法:

  • 固定消息长度:每次发送的数据包大小一样,服务器按预定长度读取,当发送的数据较少时,需要将数据进行填充,直到长度与消息规定长度一致。但是这样浪费带宽
  • 另一种思路是按分隔符拆分,缺点是效率低,需要一个一个字符地去匹配分隔符(例如遇到'\n'说明一条消息结束)
  • TLV 格式,即 Type 类型、Length 长度、Value 数据

NIO的解决方法

这里使用上面说到的第二种方法,但是遇到消息过长,导致一次buffer装不下时就需要使用另一个更大的buffer,这个更大的buffer首先装入之前buffer的内容,接着装入新传来的消息: 在这里插入图片描述

Channel的register方法还有第三个参数附件,可以向其中放入一个Object类型的对象,该对象会与登记的Channel以及其对应的SelectionKey绑定,可以使用可通过SelectionKey的attachment()方法获得附件,延长bytebuffer的生命周期到和key一样长。

ByteBuffer buffer = ByteBuffer.allocate(16);
// 添加通道对应的Buffer附件
socketChannel.register(selector, SelectionKey.OP_READ, buffer);

当Channel中的数据大于缓冲区时,需要对缓冲区进行扩容操作。此代码中的扩容的判定方法:Channel调用compact方法后,他的position与limit相等,说明缓冲区中的数据并未被读取(容量太小),此时创建新的缓冲区,其大小扩大为两倍。同时还要将旧缓冲区中的数据拷贝到新的缓冲区中,同时调用SelectionKey的attach方法将新的缓冲区作为新的附件放入SelectionKey中

// 如果缓冲区太小,就进行扩容
if (buffer.position() == buffer.limit()) {
    ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity()*2);
    buffer.flip();
    // 将旧buffer中的内容放入新的buffer中
    newBuffer.put(buffer);
    // 将新buffer作为附件放到key中
    key.attach(newBuffer);
}

下面给出完整的加入扩容机制的服务端代码,每次客户端发来的消息以'\n'为结尾,如果客户端每次发来的消息小于初始的buffer大小,那么直接输出即可,如果大于初始的buffer大小,就需要对客户端chennel绑定的buffer进行扩容:

public class SelectorServer {

    private static void split(ByteBuffer buffer) {
        buffer.flip();
        for(int i = 0; i < buffer.limit(); i++) {
            // 遍历寻找分隔符
            // get(i)不会移动position
            if (buffer.get(i) == '\n') {
                // 缓冲区长度
                int length = i+1-buffer.position();
                ByteBuffer target = ByteBuffer.allocate(length);
                // 将前面的内容写入target缓冲区
                for(int j = 0; j < length; j++) {
                    // 将buffer中的数据写入target中
                    target.put(buffer.get());
                }
                // 打印结果
                ByteBufferUtil.debugAll(target);
            }
        }
        // 切换为写模式,但是缓冲区可能未读完,这里需要使用compact
        buffer.compact();
    }


    public static void main(String[] args) throws IOException
    {
        Selector selector = Selector.open();//建立selector
        try (ServerSocketChannel ssc = ServerSocketChannel.open()) {
            ssc.configureBlocking(false);//将通道设置成非阻塞模式
            SelectionKey ssckey = ssc.register(selector,0,null);//将通道注册到selector上
            ssckey.interestOps(SelectionKey.OP_ACCEPT );//设置关注连接事件
            ssc.bind(new InetSocketAddress(8080));
            while(true)
            {
                int channelnum = selector.select();//阻塞,等待事件,有事件发生则停止阻塞
                Iterator<SelectionKey> iter = selector.selectedKeys().iterator();//获得发生的事件
                while(iter.hasNext()){//遍历未处理的事件
                    SelectionKey key = iter.next();//拿到该事件的SelectionKey
                    iter.remove();
                    if (key.isAcceptable()) {//连接事件
                        ServerSocketChannel channel = ((ServerSocketChannel) key.channel());//获得该事件的channel
                        SocketChannel sc = channel.accept();//接受客户端的连接
                        sc.configureBlocking(false);
                        ByteBuffer buffer = ByteBuffer.allocate(16);
                        SelectionKey sckey = sc.register(selector, 0, buffer);//将新的通道注册在selector上
                        sckey.interestOps(SelectionKey.OP_READ);
                    }
                    else if(key.isReadable()){
                        try{
                            SocketChannel channel = ((SocketChannel) key.channel());//拿到channel

                            //通过key获得附件
                            ByteBuffer buffer = ((ByteBuffer) key.attachment());
                            int size = channel.read(buffer);//如果是正常断开,size = -1
                            if(size == -1)
                            {
                                key.cancel();
                                channel.close();
                                ByteBufferUtil.debugAll(((ByteBuffer) key.attachment()));//缓存可视化
                            }
                            else
                            {
                                split(buffer);
                                if(buffer.position()==buffer.limit())//缓冲区满了,需要扩容了
                                {
                                    ByteBuffer newbuffer = ByteBuffer.allocate(buffer.capacity()*2);//建立一个新的缓冲区
                                    buffer.flip();//切换模式
                                    newbuffer.put(buffer);//把原先buffer的内容放到新的buffer中
                                    key.attach(newbuffer);//将这个channel的附件换成新的更大的缓冲区
                                }
                            }

                        }catch (IOException e)
                        {
                            e.printStackTrace();
                            key.cancel();
                        }

                    }
                    else if(key.isWritable()){

                    }
                    else if(key.isConnectable()){

                    }
                }
            }
        }
    }
}

写事件

如果服务端向客户端写数据的话,首先存在buffer中,然后通过SocketChannel进行传输,不过当传输的数据量非常大时就会出现问题,为了分析这种方式的问题,具体来看下面的代码:

这是服务端的代码,他接受到客户端的连接后,通过通道传输大量的数据,当然,有可能一次传输不完,所以在一个while循环中,我们输出每次传输出去的字节数 在这里插入图片描述 这是客户端的代码,他开辟了一个缓冲区,用来接收服务端传来的数据,同样的,他也不可能一次就接收完毕,因此在每次接收后输出一下接收的字节数:

在这里插入图片描述

下面是执行结果,左侧是完整的服务端的结果,右侧是部分的客户端的结果,可以发现,服务端有大量的0出现,即服务端程序在这次while循环中属于空转,探究其中原因,主要是因为服务端一次传输过大的数据(例如第一次4587485),而客户端接收能力有限,在客户端接收之前服务端是不能继续发送的,因此服务端会陷入空转现象。 在这里插入图片描述

为了解决这个空转问题,我们可以让多路复用器selector去控制channel,如果可以传输数据时(即上一次发送的数据客户端接收完毕了),再让channel继续发送一批,否则让他处理其他事件或者直接阻塞住。

通过之前的分析 ,我们可以设计这样的思路,先在接收到客户端请求后发送一次数据,如果没发完,就给这个通道注册写事件,让channel在写事件的代码块中继续传输余下的数据,具体步骤如下:

  1. 向客户端发送大量数据
  2. 返回值代表实际写入的字节数
  3. 判断是否有剩余内容
  4. 关注可写事件
  5. 把未写完的数据挂到sckey上
  6. 清理操作

服务端需要修改的代码有两处,如下图所示

第一处是将while换成了if,如果第一次没有传输玩buffer的内容,就需要给这个channel注册写事件,同时需要将存有余下数据的buffer给他当作附件,这样做延长了该buffer的生命周期,使channel在写代码块中还能够使用这个buffer在这里插入图片描述

第二处是在写模块中增加写buffer余下数据的代码,这里不需要while(如果用while就和第一个版本一样了),因为是写事件,所以只要selector检测这个channel能写了,就继续向客户端输出数据。值得注意的是,每次写完都需要调用buffer.hasRemaining()来检测是否写完了,写完了就要清除关联的buffer,并且取消写事件了。

在这里插入图片描述

下面这幅图是服务端的结果,可以看到没有输出0,说明不再有空转发生了。

在这里插入图片描述

服务端读写功能完整代码

给出服务端完整的代码如下:

public class SelectorServer {

    private static void split(ByteBuffer buffer) {
        buffer.flip();
        for(int i = 0; i < buffer.limit(); i++) {
            // 遍历寻找分隔符
            // get(i)不会移动position
            if (buffer.get(i) == '\n') {
                // 缓冲区长度
                int length = i+1-buffer.position();
                ByteBuffer target = ByteBuffer.allocate(length);
                // 将前面的内容写入target缓冲区
                for(int j = 0; j < length; j++) {
                    // 将buffer中的数据写入target中
                    target.put(buffer.get());
                }
                // 打印结果
                ByteBufferUtil.debugAll(target);
            }
        }
        // 切换为写模式,但是缓冲区可能未读完,这里需要使用compact
        buffer.compact();
    }


    public static void main(String[] args) throws IOException
    {
        Selector selector = Selector.open();//建立selector
        try (ServerSocketChannel ssc = ServerSocketChannel.open()) {
            ssc.configureBlocking(false);//将通道设置成非阻塞模式
            SelectionKey ssckey = ssc.register(selector,0,null);//将通道注册到selector上
            ssckey.interestOps(SelectionKey.OP_ACCEPT );//设置关注连接事件
            ssc.bind(new InetSocketAddress(8080));
            while(true)
            {
                int channelnum = selector.select();//阻塞,等待事件,有事件发生则停止阻塞
                Iterator<SelectionKey> iter = selector.selectedKeys().iterator();//获得发生的事件
                while(iter.hasNext()){//遍历未处理的事件
                    SelectionKey key = iter.next();//拿到该事件的SelectionKey
                    iter.remove();
                    if (key.isAcceptable()) {//连接事件
                        ServerSocketChannel channel = ((ServerSocketChannel) key.channel());//获得该事件的channel
                        SocketChannel sc = channel.accept();//接受客户端的连接
                        sc.configureBlocking(false);
                        ByteBuffer buffer = ByteBuffer.allocate(16);
                        SelectionKey sckey = sc.register(selector, 0, buffer);//将新的通道注册在selector上
                        sckey.interestOps(SelectionKey.OP_READ);

                        StringBuffer sb = new StringBuffer();
                        for (int i = 0; i < 9999999; i++) {
                            sb.append('a');
                        }
                        ByteBuffer bb = Charset.defaultCharset().encode(sb.toString());
                        if(bb.hasRemaining()){
                            sc.register(selector,SelectionKey.OP_WRITE,bb);
                        }
                    }
                    else if(key.isReadable()){
                        try{
                            SocketChannel channel = ((SocketChannel) key.channel());//拿到channel

                            //通过key获得附件
                            ByteBuffer buffer = ((ByteBuffer) key.attachment());
                            int size = channel.read(buffer);//如果是正常断开,size = -1
                            if(size == -1)
                            {
                                key.cancel();
                                channel.close();
                                ByteBufferUtil.debugAll(((ByteBuffer) key.attachment()));//缓存可视化
                            }
                            else
                            {
                                split(buffer);
                                if(buffer.position()==buffer.limit())//缓冲区满了,需要扩容了
                                {
                                    ByteBuffer newbuffer = ByteBuffer.allocate(buffer.capacity()*2);//建立一个新的缓冲区
                                    buffer.flip();//切换模式
                                    newbuffer.put(buffer);//把原先buffer的内容放到新的buffer中
                                    key.attach(newbuffer);//将这个channel的附件换成新的更大的缓冲区
                                }
                            }

                        }catch (IOException e)
                        {
                            e.printStackTrace();
                            key.cancel();
                        }

                    }
                    else if(key.isWritable()){

                        SocketChannel writechannel = (SocketChannel) key.channel();
                        //获得buffer,这个buffer包含了剩余没有传输的数据
                        ByteBuffer buffer = ((ByteBuffer) key.attachment());
                        int writesize = writechannel.write(buffer);
                        System.out.println(writesize);
                        if(!buffer.hasRemaining())
                        {
                            //清楚关联的buffer
                            key.attach(null);
                            //取消事件
                            key.interestOps(0);
                        }

                    }
                    else if(key.isConnectable()){

                    }
                }
            }
        }
    }
}