nio阻塞、非阻塞、selecor

一、阻塞

服务器端代码:

package com.test.c3.block;

import com.test.utils.ByteBufferUtil;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;

public class Server {

    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(16);
        //建立一个服务端通道
        try (ServerSocketChannel ssc = ServerSocketChannel.open();){
            //绑定端口
            ssc.bind(new InetSocketAddress(8080));
            List<SocketChannel> list = new ArrayList<>();
            while (true){
                System.out.println("before connecting...");
                //获取链接,当没有链接的时候 会阻塞
                SocketChannel sc = ssc.accept();
                list.add(sc);
                System.out.println("after connecting... " + sc);
                for (SocketChannel socketChannel : list) {
                    System.out.println("before reading");
                    //等待读取数据,会阻塞
                    socketChannel.read(byteBuffer);
                    byteBuffer.flip();
                    ByteBufferUtil.debugRead(byteBuffer);
                    byteBuffer.clear();
                    System.out.println("after reading");
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

客户端:

package com.test.c3.block;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;

public class Client {

    public static void main(String[] args) {
        try (SocketChannel sc = SocketChannel.open();){
            sc.connect(new InetSocketAddress("127.0.0.1", 8080));
            System.out.println("go!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这里主要看的是服务端的代码,这里面有两个地方是阻塞的

  • ssc.accept();
  • sc.read()

上面的两个方法是阻塞方法,就会出现这样的现象:

1、进入程序的时候,就会阻塞到accept()

2、当进来第一个客户端的时候,就会在read方法上阻塞

3、当第一个客户端发送消息后,程序又会在accept()方法阻塞,这样第一个客户端怎么发送消息也接受不到,只有第二个客户端链接的时候才能读取到数据

二、非阻塞

nio本身就提供了非阻塞的调用方式。configureBlocking() 把channel设置为非阻塞的。

package com.test.c3.block;

import com.test.utils.ByteBufferUtil;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;

public class Server {

    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(16);
        //建立一个服务端通道
        try (ServerSocketChannel ssc = ServerSocketChannel.open();){
            //绑定端口
            ssc.bind(new InetSocketAddress(8080));
            ssc.configureBlocking(false);
            List<SocketChannel> list = new ArrayList<>();
            while (true){
                //获取链接,当没有链接的时候 会阻塞
                SocketChannel sc = ssc.accept();
                if (sc != null) {
                    sc.configureBlocking(false);
                    list.add(sc);
                    System.out.println("after connecting... " + sc);
                }
                for (SocketChannel socketChannel : list) {
                    //等待读取数据,会阻塞
                    int read = socketChannel.read(byteBuffer);
                    if(read > 0){
                        byteBuffer.flip();
                        ByteBufferUtil.debugRead(byteBuffer);
                        byteBuffer.clear();
                        System.out.println("after reading");
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

注意的是,使用ssc.accept(); 当没有的时候,会返回null,sc.read()没有数据的时候,返回值是0。

缺点是,线程一直在循环,导致CPU一直在占用,使得性能贬低。

三、Selector多路复用器

多路复用器针对channel进行管理,可以很大的提高单线程下处理网络通信的效率。首先需要把channel注册到selector中,当某个channel出现需要监听的事件的时候,就会把事件放入相关集合中,需要注意channel必须是非阻塞的状态下才能注册到selector中。

selector的事件有四种:

  • connect:客户端连接成功后触发,在客户端进行监听
  • accept:服务端接收到新的连接触发,在服务端进行监听
  • read:当channel中存在可读数据的时候触发
  • write:当向channel中写数据的时候触发

下面是一个简单的服务器端例子:

package com.test.c3.selector;

import com.test.utils.ByteBufferUtil;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

public class Server {

    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(16);
        try (ServerSocketChannel ssc = ServerSocketChannel.open();
             //得到一个多路复用器
             Selector selector = Selector.open();
            ){
            ssc.bind(new InetSocketAddress(8080));
            ssc.configureBlocking(false);
            //把ssc注册到复用器中,同时只监听accept事件
            ssc.register(selector, SelectionKey.OP_ACCEPT);
            while (true){
                //阻塞等待是否存在,监听到的事件,返回值就是事件个数
                int eventCount = selector.select();
                System.out.println("事件个数:" + eventCount);
                //获取到所有事件的集合
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                //获取事件的遍历器
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while(iterator.hasNext()){
                    SelectionKey selectionKey = iterator.next();
                    //移除处理过的事件,处理过的事件不易出,就会一直存在
                    iterator.remove();
                    //判断类型,进行不同的处理
                    if(selectionKey.isAcceptable()){
                        //accept事件
                        //获取对应的channel
                        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
                        SocketChannel socketChannel = serverSocketChannel.accept();
                        socketChannel.configureBlocking(false);
                        //注册给selector
                        socketChannel.register(selector, SelectionKey.OP_READ);
                    }else if(selectionKey.isReadable()){
                        //read事件
                        SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                        socketChannel.read(byteBuffer);
                        byteBuffer.flip();
                        ByteBufferUtil.debugRead(byteBuffer);
                        byteBuffer.clear();
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

流程解释:

1、开启一个selector   调用Selector.open();

2、各个channel注册到selector中,同时监听感兴趣的事件,注意channel必须是非阻塞的

3、使用 selector.select()进行阻塞等待,当存在事件的时候,就会向下执行,传递参数设置超时时间,也可以调用selector.selectNow(),立即返回

4、使用select.selectedKey(),获取全部可执行的事件,Set<SelectionKey>

5、便利Set<SelectionKey>,根据事件不同类型,进行不同的处理

6、处理完成后的SelectionKey必须移除,否则一直存在Set<SelectionKey>中

7、即使不想处理SelectionKey,也要取消cancel()

断开处理

客户端可能存在异常断开,或者正常断开的情况,

  • 正常断开,直接判断read返回的数据是否为-1,如果是,则取消key,关闭通道
  • 异常断开,会抛出异常,直接在catch中捕获,取消key,关闭通道即可

 消息边界

消息边界的处理,主要是因为网络传输过程中,无法确定一次传输的数据大小,所以针对接收ByteBuffer的大小就不好确定,可能出现半包、粘包或者扩容的情况。

下面主要说一下半包、粘包的情况,例如:

ByteBuffer buffer = ByteBuffer.allocate(4); System.out.println(StandardCharsets.UTF_8.decode(buffer));

接收数据的时候,定了一个大小为4的ByteBuffer,当客户端传输过来一个“你好”的时候,因为使用的是UTF-8,所以一个汉字是三个字节,这时候就出现粘包现象,"好"被拆分成了2部分,最终导致乱码的出现。

解决办法:

  • 约定固定大小数据(浪费空间)
  • 定义分隔符(可能存在扩容、效率底下)
  • 息分为2部分(LTV),第一部分是消息体大小,第二部分是消息体,根据第一次读取到的数据来分配byteBuffer

其中第三种是业界常用的方法,http2.0请求就是典型的LTV协议,L=length,T=type,V=value,根据实际接收数据的大小,进行buffer的分配。

附件:

  • 绑定附件,注册绑定或者selectionKey.attach()
  • 获取附件,selectionKey.attachment()

服务器简单解决办法,使用扩容方式,当发现position=limit的时候,也就是没有读取到分隔符“\n”,证明数据大了,对byteBuffer进行扩容,并把新的byteBuffer放入附件中,整理后代码:

package com.test.c3.selector;

import com.test.utils.ByteBufferUtil;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.sql.SQLOutput;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

public class Server {

    public static void main(String[] args) throws Exception{
        //消息边界,就是传递过来的数据大小不确定,导致粘包、半包的情况或者需要扩容情况
        //粘包、半包的情况
        //1、约定固定大小数据(浪费空间) 2、定义分隔符(可能存在扩容、效率底下) 3、消息分为2部分(LTV),第一部分是消息体大小,第二部分是消息体,根据第一次读取到的数据来分配byteBuffer

        ServerSocketChannel ssc = ServerSocketChannel.open();
        //得到一个多路复用器
        Selector selector = Selector.open();
        ssc.bind(new InetSocketAddress(8080));
        ssc.configureBlocking(false);
        //把ssc注册到复用器中,同时只监听accept事件
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        while (true){
            //阻塞等待是否存在,监听到的事件,返回值就是事件个数
            int eventCount = selector.select();
            System.out.println("事件个数:" + eventCount);
            //获取到所有事件的集合
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            //获取事件的遍历器
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while(iterator.hasNext()){
                SelectionKey selectionKey = iterator.next();
                //移除处理过的事件,处理过的事件不易出,就会一直存在
                iterator.remove();
                //判断类型,进行不同的处理
                if(selectionKey.isAcceptable()){
                    //accept事件
                    //获取对应的channel
                    ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    socketChannel.configureBlocking(false);
                    //注册给selector
                    ByteBuffer byteBuffer = ByteBuffer.allocate(16);
                    //把byteBuffer按照附件的形式,一起注册到selector中
                    socketChannel.register(selector, SelectionKey.OP_READ, byteBuffer);
                }else if(selectionKey.isReadable()){
                    //read事件
                    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                    //获取key上关联的附件
                    ByteBuffer byteBuffer = (ByteBuffer) selectionKey.attachment();
                    try{
                        int read = socketChannel.read(byteBuffer);
                        if(read == -1){
                            //处理断开的情况下,取消事件、关闭通道
                            selectionKey.cancel();
                            socketChannel.close();
                        }else{
                            split(byteBuffer);
                            //当压缩后 position和limit相等的时候,就需要扩容了。因为没有读到\n
                            if(byteBuffer.position() == byteBuffer.limit()){
                                //发现需要扩容之后,创建一个新的byteBuffer,放入附件中
                                ByteBuffer newByteBuffer = ByteBuffer.allocate(byteBuffer.capacity()  * 2);
                                byteBuffer.flip();
                                newByteBuffer.put(byteBuffer);
                                selectionKey.attach(newByteBuffer);
                            }
                        }
                    }catch (Exception e){
                        e.printStackTrace();
                        //处理断开的情况下,取消事件、关闭通道
                        selectionKey.cancel();
                        socketChannel.close();
                    }
                }
            }
        }
    }


    private static void split(ByteBuffer byteBuffer){
        byteBuffer.flip();
        for (int i = 0; i < byteBuffer.limit() ; i++) {
            if(byteBuffer.get(i) == '\n'){
                int length = i + 1 - byteBuffer.position();
                //存入新的 byteBuffer
                ByteBuffer target = ByteBuffer.allocate(length);
                for (int j = 0; j < length; j++) {
                    byte b = byteBuffer.get();
                    target.put(b);
                }
                ByteBufferUtil.debugAll(target);
            }
        }
        byteBuffer.compact();
    }

}

ByteBuffer的大小

  • ByteBuffer 必须一个channel维护一个,否则会导致数据混乱
  • ByteBuffer 不可能定义太大
  • 可以使用扩容的思想,先定义一个小的然后不够在扩容,但是可能涉及到数据拷贝,传送门
  • 可以使用多数组方式进行,一个数组不够写入新的数组,虽然避免了拷贝,但是解析增加复杂度

Write事件

在想channel中写数据的时候,可能由于数据太大,没办法一次性全部写完,所以可以分为多次进行编写,但是如果直接使用while()循环的话又会导致阻塞,所以可以先写一次,然后在把channel注册到selector中,同时带上没有写完的数据,循环关注写事件。

服务器代码整理:

package com.test.c3.selector;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.Iterator;

public class WriteServer {

    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        ssc.bind(new InetSocketAddress(8080));
        while (true){
            selector.select();
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()){
                SelectionKey nextKey = iterator.next();
                iterator.remove();
                if(nextKey.isAcceptable()){
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    SelectionKey scKey = sc.register(selector, 0, null);
                    //写数据,大量的
                    StringBuilder sb = new StringBuilder();
                    for (int i = 0; i < 3000000; i++) {
                        sb.append("a");
                    }
                    ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());
                    //返回值代表实际写入的字节数
                    int write = sc.write(buffer);
                    System.out.println(write);
                    //判断是否还有剩余内容
                    if(buffer.hasRemaining()){
                        //有内容,则关注可写事件,拿出原来的关注事件拼接
                        scKey.interestOps(scKey.interestOps() + SelectionKey.OP_WRITE);
                        //未写完的数据 挂在scKey
                        scKey.attach(buffer);
                    }
                }else if(nextKey.isWritable()){
                    ByteBuffer buffer = (ByteBuffer) nextKey.attachment();
                    SocketChannel sc = (SocketChannel) nextKey.channel();
                    int write = sc.write(buffer);
                    System.out.println(write);
                    //清理操作
                    if(!buffer.hasRemaining()){
                        nextKey.attach(null);
                        nextKey.interestOps(nextKey.interestOps() - SelectionKey.OP_WRITE);
                    }
                }

            }

        }
    }
}

客户端代码:

package com.test.c3.selector;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class WriteClinet {
    public static void main(String[] args) throws IOException {
        SocketChannel sc = SocketChannel.open();
        sc.connect(new InetSocketAddress("127.0.0.1", 8080));
        //接收数据
        int count = 0;
        while(true){
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024 * 10);
            count += sc.read(byteBuffer);
            System.out.println(count);
            byteBuffer.clear();
        }
    }
}

以上就是简单学习了一下nio的相关知识,主要是针对三代组件中的ByteBuffer 和 Selector的简单使用进行介绍。

多线程优化

优化思路,为了复用多核CPU:

  • 使用一个thread监听连接事件
  • 使用多个thread监听读写事件
package com.test.c4;

import com.test.utils.ByteBufferUtil;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.concurrent.ConcurrentLinkedDeque;

@Slf4j
public class MultiThreadServer {

    public static void main(String[] args) throws IOException {
        Thread.currentThread().setName("Boss");
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        Selector boss = Selector.open();
        ssc.register(boss, SelectionKey.OP_ACCEPT);
        ssc.bind(new InetSocketAddress(8080));
        //创建worker,并初始化
        //worker有限制,一个worker对应多个 SocketChannel
        Worker worker = new Worker("worker-0");
        while(true){
            boss.select();
            Iterator<SelectionKey> iterator = boss.selectedKeys().iterator();
            while(iterator.hasNext()){
                SelectionKey key = iterator.next();
                iterator.remove();
                if(key.isAcceptable()){
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    log.debug("连接了。。。。{}", sc.getRemoteAddress());
                    //关联,注意selector,把读写事件交给worker中的selector监听
                    log.debug("注册worker签。。。。{}", sc.getRemoteAddress());
                    worker.register(sc);
                    log.debug("注册worker后。。。。{}", sc.getRemoteAddress());
                }
            }
        }
    }

    static class Worker implements Runnable{
        private Thread thread;
        private Selector selector;
        private String name;
        //使用队列进行数据传递
        private ConcurrentLinkedDeque<Runnable> queue = new ConcurrentLinkedDeque<>();
        //还未初始化
        private volatile boolean start = false;

        public Worker(String name) {
            this.name = name;
        }

        /**
         * 初始化线程 和 selector
         */
        public void register(SocketChannel sc) throws IOException {
            if(!start){
                thread = new Thread(this, name);
                selector = Selector.open();
                thread.start();
                start = true;
            }
            queue.add(()->{
                try {
                    sc.register(selector, SelectionKey.OP_READ + SelectionKey.OP_WRITE);
                } catch (ClosedChannelException e) {
                    e.printStackTrace();
                }
            });
            //唤醒selector
            selector.wakeup();
        }

        @Override
        public void run() {
            //监测读写事件
            while(true){
                try {
                    //register() 方法执行,就执行select(),导致selector阻塞
                    //其实就是注意 selector.select()会阻塞住selector,所以需要注意顺序
                    selector.select();
                    //把队列中需要执行的代码拿出来,执行,这样保证了 sc.register 一定在 selector.select();后面执行,但是需要唤醒selector
                    Runnable task = queue.poll();
                    if(task != null){
                        task.run();
                    }
                    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
                    while(iterator.hasNext()){
                        SelectionKey key = iterator.next();
                        iterator.remove();
                        if(key.isReadable()){
                            ByteBuffer buffer = ByteBuffer.allocate(10);
                            SocketChannel channel = (SocketChannel) key.channel();
                            log.debug("读数据了。。。。{}", channel.getRemoteAddress());
                            channel.read(buffer);
                            buffer.flip();
                            ByteBufferUtil.debugAll(buffer);
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

客户端:

package com.test.c4;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;

public class Client {

    public static void main(String[] args) throws IOException {
        SocketChannel sc = SocketChannel.open();
        sc.connect(new InetSocketAddress("localhost", 8080));
        sc.write(StandardCharsets.UTF_8.encode("hello word~!~"));
        System.in.read();
    }
}

因为worker是单独的一个线程,而selector.select()是阻塞的,所以boss线程,向worker线程中的selector注册的时候,如果在selector.select()之后,就会导致无法注册上。所以使用队列的方式,把注册放入队列中,同时对selector进行唤醒,

queue.add(()->{
    try {
        sc.register(selector, SelectionKey.OP_READ + SelectionKey.OP_WRITE);
    } catch (ClosedChannelException e) {
        e.printStackTrace();
    }
});
//唤醒selector
selector.wakeup();

这样在就能保证一定能注册上,也是netty的解决方式雏形。

selector.select();
//把队列中需要执行的代码拿出来,执行,这样保证了 sc.register 一定在 selector.select();后面执行,但是需要唤醒selector
Runnable task = queue.poll();
if(task != null){
    task.run();
}

其实selector.wakeup();就是让selector唤醒,不论selector是否执行select()方法,也就是select()在wakeup()前后执行都无所谓。

Guess you like

Origin blog.csdn.net/liming0025/article/details/119829198