netty① 前序- io模式

61 阅读15分钟

传统线程模型

文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的 该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

image.png

用户进程缓存区

用户进程通过系统调用访问系统资源时,需要从用户态切换至内核态,这对应一些特殊的堆栈和内存环境,当资源访问完毕后,需要从内核态切换成用户态,并恢复成之前用户进程的上下文环境,这样的切换会有一定的耗时。于是有这样:申请一个字节数组buffer,每次读取固定大小的数据填充buffer,以较小的次数填充完毕后,以后的操作直接读取buffer,这样的操作的避免频繁的内核态与用户态之间的切换

内核缓存区

当一个用户进程需要从磁盘读取数据时,内核一般不直接读取磁盘,而是会从内核缓存区读取,内核缓存区默认的情况下,将读取数据块的请求加入队列,并将用户进程挂起,为其他进程提供服务,待内核缓存区写入相应数据后,唤醒用户线程,读取内核缓存区数据到用户缓存区中

IO模式

对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

  1. 等待数据准备 (Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process) 正式因为这两个阶段,linux系统产生了下面五种网络模式的方案

BIO

用代码模拟下BIO的运作方式

服务端
public class TimeServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket();
        serverSocket.bind(new InetSocketAddress(7000));
        while (true){
            System.out.println("accept方法进入阻塞,等待请求进入");
            Socket accept = serverSocket.accept();
            System.out.println("request in");
            new Thread(new RequestHandler(accept)).start();
        }
    }

    static class RequestHandler implements Runnable{
        Socket socket;
        public RequestHandler(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            try(BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                PrintWriter writer = new PrintWriter(socket.getOutputStream())){
                StringBuilder sb = new StringBuilder();
                System.out.println("准备接收数据");
                String temp;
                while((temp = reader.readLine()) != null){
                    System.out.println(temp);
                    sb.append(temp);
                }
                System.out.println("request param"+ sb);
                System.out.println("开始业务方法");
                writer.println(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
                writer.flush();
                System.out.println("业务处理结束");
            }catch (Exception e){
                e.printStackTrace();
            }finally {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

客户端
public class TimeClient {
    static String params = "日出而作,日入而息。\n" +
            "凿井而饮,耕田而食。\n" +
            "帝力于我何有哉!";

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket();
        socket.connect(new InetSocketAddress(7000));
        try(BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            PrintWriter writer = new PrintWriter(socket.getOutputStream())){
            ByteArrayInputStream byteInputStream = new ByteArrayInputStream(params.getBytes(StandardCharsets.UTF_8));
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(byteInputStream));
            String param;
            while ((param = bufferedReader.readLine())!=null){
                writer.println(param);
                System.out.println(param);
                writer.flush();
            }
            socket.shutdownOutput();
            System.out.println("发送完毕");
            StringBuilder sb = new StringBuilder();
            String temp;
            while((temp = reader.readLine()) != null){
                sb.append(temp);
            }
            System.out.println("get result"+ sb);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

可以看到一个连接进入后,就开启了一条线程为其服务。 server端的诉求应该是使用尽可能的线程处理尽可能多的请求。

bio的性质决定了读取数据时,没有数据就一直等待,也就是阻塞在那里。为了能及时的响应每个请求,就必须为每个请求开启一个线程,这是利用了线程充当了socket监视器,将java虚拟机的线程调度充当了通知机制。高并发场景下,后续的请求将持续等待前面的请求处理完毕。在此场景下,即使使用线程池,线程也是用一个少一个。所有的线程都将阻塞在等待数据就绪或处理业务io上。 根据我们前文说的IO两个阶段,IO的第一个阶段等待数据就绪的理论时长可能是无限长的,而第二个阶段只是将数据从内核拷贝至用户空间,耗时是很短的,再到业务处理流程,代码的处理时间可以忽略不计,业务执行的耗时长可能是开启了后续的IO,如调用数据库或第三方连接,同理大部分时间也是消耗在等待io就绪上。

NIO

java在nio抽象出了channel的概念。通道(Channel)可以理解为数据传输的管道。通道与流不同的是,流只是在一个方向上移动(一个流必须是inputStream或者outputStream的子类),而通道可以用于读、写或者同时用于读写。

image.png

  • FileChannel 双向通道,FileInputStream getChannel()获得的FileChannel对象是只读的
  • SocketChannel 双向通道
  • ServerSocketChannel 不实现读写接口,负责监听传入的连接和创建新的SocketChannel对象,本身不传输数据

nio 的socket通道 (SocketChannel,ServerSocketChannel,DatagramChannel)被实例化时都会创建一个对等bio中的socket对象,通过socket.getChannel()可获取相应的通道。直接new Socket对象,getChannel()返回为null;channel与之对应的数据读写是ByteBuffer

一个通道创建时默认是阻塞的,可通过socketChannel.configureBlocking(false);设置为非阻塞 isBlocking()返回阻塞状态

简单看下NIO下的编写方式

public static void main(String[] args) throws IOException, InterruptedException {
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress(7000));
    //设置非阻塞状态
    serverSocketChannel.configureBlocking(false);
    /*//使用serverSocket.accept()会阻塞
    serverSocketChannel.socket().accept();*/
    //没有请求进入时会立即返回null
    while(true){
            SocketChannel accept = serverSocketChannel.accept();
            if(accept == null){
                    //System.out.println("没有请求进入");
                    //TimeUnit.SECONDS.sleep(2);
            }else {
                    System.out.println("请求进入");
                    accept.configureBlocking(true);
                    //byteBuffer痛点1 容量有上限 无法动态扩容
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    while(accept.read(byteBuffer) > 0){
                            //痛点2 读写指针不独立 要随时注意position位置
                            byteBuffer.flip();
                            byte[] bytes = new byte[byteBuffer.remaining()];
                            byteBuffer.get(bytes);
                            System.out.println(new String(bytes));
                            byteBuffer.clear();
                    }”
                    byte[] bytes = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")).getBytes(StandardCharsets.UTF_8);
                    byteBuffer.put(bytes);
                    //要时刻注意指针转换
                    byteBuffer.flip();
                    accept.write(byteBuffer);
                    accept.shutdownOutput();
                    System.out.println("请求结束");
            }
    }
}
可以看到nio在发起请求后立即获得了响应结果
客户端的非阻塞调用
    SocketChannel socketChannel = SocketChannel.open();
    socketChannel.configureBlocking(false);
    socketChannel.connect(new InetSocketAddress(7000));
    while (!socketChannel.finishConnect()){
            //do something while not connect
    }
    //do something while connecting
    socketChannel.close();

基于此,我们模拟 NIO下的 网络编程方式

public class TimeServerWithChannel {
    //管理负责的所有连接
    static BlockingDeque<SocketChannel> socketChannels = new LinkedBlockingDeque<>();

    static {
        //工作线程
        new Thread(() -> {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
            StringBuilder sb = new StringBuilder();
            while (true) {
                try{
                    if(!socketChannels.isEmpty()){
                        byteBuffer.clear();
                        sb.setLength(0);
                        SocketChannel poll = socketChannels.poll();
                        if(poll.read(byteBuffer) > 0){
                            //读取到业务数据了 就要进行业务处理了
                            extracted(byteBuffer, sb);
                            while (poll.read(byteBuffer) > 0){
                                extracted(byteBuffer, sb);
                            }
                            System.out.println(sb);
                            poll.write(ByteBuffer.wrap(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")).getBytes(StandardCharsets.UTF_8)));
                            poll.shutdownOutput();
                        }else {
                            //返回队列
                            System.out.println("返回队列");
                            socketChannels.put(poll);
                        }
                    }
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }).start();
    }

    private static void extracted(ByteBuffer byteBuffer, StringBuilder sb) {
        byteBuffer.flip();
        byte[] bytes = new byte[byteBuffer.remaining()];
        byteBuffer.get(bytes);
        String s = new String(bytes);
        sb.append(s);
        byteBuffer.clear();
    }

    public static void main(String[] args) throws Exception {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(7000));
        serverSocketChannel.configureBlocking(false);
        while(true) {
            SocketChannel accept = serverSocketChannel.accept();
            if (accept == null) {
               // TimeUnit.SECONDS.sleep(1);
            } else {
                System.out.println("请求进入");
                accept.configureBlocking(false);
                socketChannels.put(accept);
            }
        }
    }
}

客户端
public class TimeClientWithChannel {
    static List<Integer> list = new ArrayList<>();
    static {
        IntStream.range(1,10).forEach(list::add);
    }
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < list.size(); i++) {
            extracted(i);
            TimeUnit.MILLISECONDS.sleep(5);
        }
        TimeUnit.MINUTES.sleep(1);
    }

private static void extracted(int i) {
        new Thread(()->{
            try {
                SocketChannel socketChannel = SocketChannel.open();
                socketChannel.configureBlocking(false);
                socketChannel.connect(new InetSocketAddress(7000));
                long start = System.currentTimeMillis();
                while(!socketChannel.finishConnect()){
                    if(System.currentTimeMillis()-start >3000){
                        throw new RuntimeException("连接超时");
                    }
                }
               // TimeUnit.SECONDS.sleep(5);
                socketChannel.write(ByteBuffer.wrap(list.get(i).toString().getBytes(StandardCharsets.UTF_8)));
                socketChannel.shutdownOutput();
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
                while (true){
                    if(socketChannel.read(byteBuffer)>0){
                        byteBuffer.flip();
                        byte[] bytes = new byte[byteBuffer.remaining()];
                        byteBuffer.get(bytes);
                        System.out.println(new String(bytes));
                        break;
                    }
                }
                socketChannel.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
}

非阻塞场景下一个线程可以处理多个请求,因为线程并不会在等待数据时阻塞住,可以轮询所有已接入的client,只对数据就绪的请求进行处理。 然而单独的使用上述nio模型并不能显著提升效率。因为必须轮询所有的channel才能对已就绪的请求进行处理,假设同时接入100个请求,只有第100个请求数据就绪,那前面的99次检查的耗时将白白浪费,同时有可能刚检查过的client下一毫秒io就已就绪,必须再轮询一轮才能针对进行处理。并且轮询过程中大量的系统调用造成的上下文切换开销也很大。

NIO的特点是用户进程需要不断的主动询问kernel数据好了没有

IO多路复用( IO multiplexing)

Jdk1.4之后我们可以基于selector 使用单线程更有效率地管理多个io通道 选择器是对 select()、 poll()等本地调用(native call)或者类似的操作系统特定的系统调用的一个包装

将Channel注册到Selector对象中,一个键(SelectionKey对象)将会被返回。SelectionKey 会记住您关心的通道。它们也会追踪对应的通道是否已经就绪。SelectionKey表示一种注册关系,当需要终止这种关系时,可以调用cancel()方法。调用cancel()方法时,关系不会立即取消,但selectionKey不再允许使用,当再次调用 select( )方法时(或者一个正在进行的 select()调用结束时),已取消的键的集合中的被取消的键将被清理掉,并且相应的注销也将完成。

每个Channel在注册到Selector上的时候,都有一个感兴趣的操作。 对于ServerSocketChannel,只会在选择器上注册一个,其感兴趣的操作是ACCEPT,表示其只关心客户端的连接请求 SocketChannel感兴趣的操作是CONNECT、READ、WRITE,因为其要于server建立连接,也需要进行读、写数据。

每个selector会维护三个SelectionKey的集合

  • Keys() 返回已注册过的键
  • selectedKeys() 感兴趣的操作ready后会进入此集合
  • cancelledKeys() 调用cancel()方法后的会进入此集合,直至关系被解除

select()执行过程

  1. 检查canceledKeys,如果不为空,从keys,selectedKeys中移除,相关通道注销,canceledKeys清空
  2. 检查keys中 interestOps,底层系统进行查询,确定每个通道关心操作的状态,返回后,通道将进入selectedKeys,selectionKey readyOps将会被新的比特掩码更新,注意,是 | 或操作,之前设定好的比特位不会被清理,ready的状态是累积的
  3. 重复执行1操作,select的返回值不是已准备好的通道总数,而是自上一次select调用后就绪的通道数,上一次已就绪的这次仍然就绪的不会计入其中 使用canceledKeys来进行延迟注销是为了防止正在进行的select操作冲突,同时注销通道是一个代价较高的操作,防止调用cancel方法线程进入阻塞状态

代码实例:

--- server 
public class SelectServerSockect {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        ssc.configureBlocking(false);
        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        while (true){
           //阻塞直到一个通道就绪,SelectionKey中的ready集合将会更新,返回值为就绪的通道数 
            int num = selector.select();
            if(num == 0){
                continue;
            }
            System.out.println(num);
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()){
                SelectionKey next = iterator.next();
                if(next.isAcceptable()){
                    ServerSocketChannel channel = (ServerSocketChannel) next.channel();
                    SocketChannel accept = channel.accept();
                    accept.configureBlocking(false);
                    //注册到selector上 感兴趣的操作是读
                    accept.register(selector,SelectionKey.OP_READ);
                }
                //ServerSocketChannel SocketChannel都注册到了一个Selector上 所有会有多种Ops
                if(next.isReadable()){
                    SocketChannel channel = (SocketChannel) next.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    while(channel.read(byteBuffer) > 0){
                        byteBuffer.flip();
                        while (byteBuffer.hasRemaining()){
                            channel.write(byteBuffer);
                        }
                        byteBuffer.clear();
                    }
                    channel.close();
                }
                //selectedKeys 需要手动移除
                iterator.remove();
            }
        }
    }

}

    ------client
public class SelectSocketClient {
    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        socketChannel.connect(new InetSocketAddress(8080));
        while (!socketChannel.finishConnect()) {
            //
        }
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        socketChannel.write(ByteBuffer.wrap("whats up~".getBytes(StandardCharsets.UTF_8)));
        while (true) {
            if (socketChannel.read(byteBuffer) > 0) {
                byteBuffer.flip();
                byte[] bytes = new byte[byteBuffer.remaining()];
                byteBuffer.get(bytes);
                System.out.println(new String(bytes));
                break;
            }
        }
        socketChannel.close();
    }
}

多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。多路复用解决了上面NIO轮询带来的频繁上下文切换的问题,将多个io通道交给Selector,也就是内核处理。工作线程只需要在Selector发现read事件就绪后进行处理即可,所以吞吐量提高了,cpu的能力得到发挥

本质上就是直接把n个fd传给内核,内核去轮循监听然后返回fd,返回的fd会被标识有无数据,这个时候应用根据情况再去调用read去读取有数据的fd

信号驱动 I/O

信号驱动 I/O 并不常用,它是一种半异步的 I/O 模型。在使用信号驱动 I/O 时,当数据准备就绪后,内核通过发送一个 SIGIO 信号通知应用进程,应用进程就可以开始读取数据了。

异步 I/O

异步 I/O 最重要的一点是从内核缓冲区拷贝数据到用户态缓冲区的过程也是由系统异步完成,应用进程只需要在指定的数组中引用数据即可。异步 I/O 与信号驱动 I/O 这种半异步模式的主要区别:信号驱动 I/O 由内核通知何时可以开始一个 I/O 操作,而异步 I/O 由内核通知 I/O 操作何时已经完成。

阻塞

阻塞/非阻塞:发起io请求后是否立即有响应结果

同步

用户进程触发io操作 等待或轮询的方式查看io是否就绪 从逻辑上和阻塞一样,但他还会抢占cpu去执行其他逻辑,会主动监测io是否就绪

异步

用户进程触发io操作后就干别的事了,数据从内核缓存区拷贝到用户缓存区也是有系统异步完成

Select poll epoll

  • Select 有io事件就绪后,不返回具体那个sock出现了数据,只能无差别轮询所有流,找出能读/写数据的流进行操作,只能监视1024个链接,线程不安全,sock a在另一个线程被关闭会发生不可预料的场景
  • Poll 去掉1024个链接限制,线程仍然不安全
  • Epoll 线程安全,同时返回具体哪个sock有数据,只支持在linux上使用 以上都是io多路复用的解决方案,但都是同步io,读写事件就绪后自己负责读写,io第二个阶段是阻塞的
1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

2select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

以上很多都是系统底层的概念,像select,poll,epoll基本直接引用的别人的说法,但这些只要能帮助我们更好的理解代码的思路就够了,毕竟我们是写java的不是吗

reactor线程模型

image.png

  • Reactor反应器线程的职责:负责响应IO事件,并且分发到Handlers处理器。
  • Handlers处理器的职责:非阻塞地执行业务处理逻辑。

IO 多路复用结合线程池,就是 Reactor 模式的基本设计思想

Reactor 模式: 服务器端程序处理客户端传来的多个请求,并将它们同步分派到相应的处理线程,因此 Reactor 模式也叫 Dispatcher 模式

EventLoopGroup 是 Netty Reactor 线程模型的具体实现方式,Netty 通过创建不同的 EventLoopGroup 参数配置,就可以支持 Reactor 的三种线程模型:

  • 单线程模型:EventLoopGroup 只包含一个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup
一个线程支持处理的连接数非常有限,CPU 很容易打满,性能方面有明显瓶颈;
当多个事件被同时触发时,只要有一个事件没有处理完,其他后面的事件就无法执行,这就会造成消息积压及请求超时;
线程在处理 I/O 事件时,Select 无法同时处理建立其他连接、事件分发等操作;
如果 I/O 线程一直处于满负荷状态,很可能造成服务端节点不可用。
  • 多线程模型:EventLoopGroup 包含多个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup;
读取数据依然保留了串行化的设计。当客户端有数据发送至服务端时,Select 会监听到可读事件,
数据读取完毕后提交到业务线程池中并发处理。
  • 主从多线程模型:EventLoopGroup 包含多个 EventLoop,Boss 是主 Reactor,Worker 是从 Reactor,它们分别使用不同的 EventLoopGroup,主 Reactor 负责新的网络连接 Channel 创建,然后把 Channel 注册到从 Reactor。
主从多线程模型由多个 Reactor 线程组成,每个 Reactor 线程都有独立的 Selector 对象。
MainReactor 仅负责处理客户端连接的 Accept 事件,连接建立成功后将新创建的连接对象注册至 SubReactor。
SubReactor它将负责连接生命周期内所有的 I/O 事件。
在海量客户端并发请求的场景下,主从多线程模式甚至可以适当增加 SubReactor 线程的数量,
从而利用多核能力提升系统的吞吐量。