• I/O数据模型和Reactor模式


    UNIX支持的I/O数据模型和Reactor模式

    1.UNIX支持的I/O数据模型

    前置知识点:

    1. 阻塞与非阻塞:
      • 数据就绪前要不要一直等待?(菜没好,要不要一直等着)
      • 阻塞没有数据传过来时,读会阻塞直到有数据;缓冲区满的时候,写操作也会阻塞。非阻塞遇到这些情况,都是直接返回。
    2. 同步与异步:
      • 数据就绪后,数据操作谁完成?(菜做好了,谁来端)
      • 数据就绪后需要自己去读就是同步,数据就绪后直接读好在回调给程序是异步。

    根据Unix网络编程对I/O模型的分类,UNIX提供了5种I/O模型:

    1. 阻塞I/O:最常用的I/O模型就是阻塞I/O模型,缺省情况下,所有文件操作都是阻塞的。例如在进程空间中调用recvfrom(接收数据),系统调用直到数据包到达且被赋值到应用进程的缓冲区中或者发生错误才会返回,期间会一直等待,所以从调用recvfrom开始到它返回的整段时间内都阻塞的。
    2. 非阻塞I/O:recvfrom从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个ewouldblockb标识,一般都对非阻塞I/O模型进行轮询检查这个状态,查看内核是不是有数据到来。
    3. I/O复用模型:Linux提供select/poll,进程通过讲一个或多个fd传递给select或者poll系统调用,阻塞在select操作上,这样select/poll可以帮我们侦测多个fd是否处于就绪状态,select/poll是顺序扫描;另外Linux还提供了一个epoll系统调用,epoll使用基于事件驱动方式替代顺序扫描,因此性能更高,当有fd就绪时,立即回调函数rollback。
    4. 信号驱动I/O模型:通过系统调用sigaction执行一个信号处理函数,当数据准备就绪时,就为该进程生成一个sigio信号,通过信号回调用纸应用程序调用recvfrom来读取数据,并通知主循环函数处理数据。
    5. 异步I/O:告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户紫的缓冲区)通知我们,这种模型与信号驱动模型的主要区别是:信息好驱动I/O由内核通知我们何时可以开始一个I/O操作;异步I/O模型由内核通知我们I/O操作何时已经完成。
    2.经典的三种I/O模型

    先举个当我们去外面吃饭时的例子:

    1. 食堂排队打饭模式:排队在窗口,打好才走;
    2. 点单,等待被叫号模式:等待叫号,好了自己去端
    3. 包厢模式:点菜后直接被端上桌子

    根据上述的例子我们可以把饭店当作服务器、饭菜当作数据、饭菜好了当作数据就绪、端菜当作数据读取。

    吃饭 I/O模型 java支持时间
    排队打饭 BIO(阻塞I/O) jdk1.4之前
    点单,等待被叫号 NIO(非阻塞I/O) jdk1.4
    包厢模式 AIO(异步I/O) jdk1.7
    3.原生JDK网络编程BIO

    服务端提供IP地址和端口,客户端通过连接操作向服务端发起连接请求,通过三次握手后建立连接,如果连接建立成功后,双方就可以通过套接字进行通信。
    在传统的同步阻塞通信模型中,ServerSocket负责绑定IP,启动监听端口;socket负责发起连接操作。连接成功后,双方通过同步阻塞的方式进行输入流和输出流的交互。

    传统的BIO通信模式:

    1. 采用BIO通行模型的服务端,通常由一个独立的Acceptor(接收器)线程负责监听客户端的连接,它接受到客户端的连接后会创建一个新的线程通过链路的方式来处理,处理完成后,通过输出流的方式返回给客户端,线程销毁。这就是典型的一对一应答模型。
    2. 该模型最大的问题就是缺乏弹性伸缩能力,当客户端的访问增加的时候,服务端的线程个数和客户端并发访问数呈1:1的正比关系,Java中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死掉了。
    3. 为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程,实现1个或多个线程处理N个客户端的模型(但是底层还是使用的同步阻塞I/O),通常被称为“伪异步I/O模型“。
    4.原生JDK网络编程NIO

    NIO 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 I/O 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。NIO翻译成 no-blocking io 或者 new io

    NIO和BIO的区别:

    1. 面向流和面向缓冲

      1. Java NIO和IO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。 Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。
      2. Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
    2. 阻塞和非阻塞IO

      1. Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
      2. Java NIO的非阻塞模式使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。
      3. 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
      4. 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
    3. 选择器

      1. Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
      2. 操作类型SelectionKey
        1. SelectionKey是一个抽象类,表示selectChannel在Selector中注册的标识.每个Channel向Selector注册时,都将会创建一个selectionKey。选择键将Channel与Selector建立了关系,并维护了channel事件。
        2. 在向Selector对象注册感兴趣的事件时,JAVA NIO共定义了四种:OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT(定义在SelectionKey中),分别对应读、写、请求连接、接受连接等网络Socket操作。
        3. ServerSocketChannel和SocketChannel可以注册自己感兴趣的操作类型,当对应操作类型的就绪条件满足时OS会通知channel,下表描述各种Channel允许注册的操作类型,Y表示允许注册,N表示不允许注册,其中服务器SocketChannel指由服务器ServerSocketChannel.accept()返回的对象。
    OP_READ OP_WRITE OP_CONNECT OP_ACCEPT
    服务器ServerSocketChannel Y
    服务器SocketChannel Y Y
    客户端SocketChannel Y Y Y
    5.NIO主要有三个核心部分组成

    buffer缓冲区、Channel管道、Selector选择器

    1. Selector选择器
      Selector的英文含义是“选择器”,也可以称为为“轮询代理器”、“事件订阅器”、“channel容器管理机”都行。应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器。
    2. Channel管道
      通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写。
      1. 所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类。
      2. ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。
      3. ScoketChannel:TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP:端口 到 服务器IP:端口的通信连接。
        通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。
    3. buffer缓冲区
      Buffer用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。以写为例,应用程序都是将数据写入缓冲,再通过通道把缓冲的数据发送出去,读也是一样,数据总是先从通道读到缓冲,应用程序再读缓冲的数据。
      缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存( 其实就是数组)。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
    6.Buffer的属性、分配、读写、常用操作
    1. 重要属性

      1. capacity
        作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”.你只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。

      2. position
        当你写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1.
        当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。

      3. limit
        在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。
        当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)

      4. 总结

        1. 读模式
          capacity buffer的最大长度
          position 读数据的位置(读了多少数据了)
          limit 最多能读到多少数据
          调用flip()方法会将写模式切换为读模式的时候,会将position设回0,并将limit设置成之前position的值。
        2. 写模式
          capacity buffer的最大长度
          position 写数据的位置(写了多少数据了)
          limit 最多能往Buffer里写多少数据,等于buffer的最大长度
    2. 内存的分配
      要想获得一个buffer对象首先要进行分配,每一个Buffer类都有一个allocate方法(可以在堆上或者直接内存上分配)

      1. 直接内存
        ByteBuffer directBuffer = ByteBuffer.allocateDirect(102400);
      2. 堆内存
        ByteBuffer buffer = ByteBuffer.allocate(1024000);
      3. 直接内存的优缺点
        直接内存(堆外内存),其实就是不受JVM控制的内存。
        优势: 
          1. 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到) 
          2. 加快了复制的速度。因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。 
        缺点:
          1. 直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
          2. 堆外内存难以控制,如果内存泄漏,那么很难排查
    3. buffer的读写

      1. 写数据到buffer的两种方式

        1. 读取Channel写到Buffer
          int bytesRead = inChannel.read(buf);
        2. 使用Buffer的put方法写入Buffer
          buf.put(127);
      2. 从Buffer中读取数据的两种方式

        1. 从Buffer读取数据写入到Channel
          int bytesWritten = inChannel.write(buf);
        2. 使用get()方法从Buffer中读取数据
          byte aByte = buf.get();
      3. 使用Buffer读写数据常见步骤

        1. 写入数据到Buffer
        2. 调用flip()方法
          1. flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。
        3. 从Buffer中读取数据
        4. 调用clear()方法或者compact()方法

        当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。
        一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

    7.NIO的Reactor模式

    概述:注册感兴趣的事件->扫描是否有感兴趣的事情发生->事情发生后作出相应的处理。
    Reactor(反应器),“反应”即“倒置”,“控制逆转”,具体事件处理程序不调用反应器,而向反应器注册一个事件处理器,表示自己对某些事件感兴趣,有事件来了,具体事件处理程序通过事件处理器对某个指定的事件发生做出反应;这种控制逆转又称为“好莱坞法则”

    1. 单线程Reactor模式流程
      1. 服务端的Reactor是一个线程对象,该线程会启动事件循环,并使用select来IO实现多路复用。注册一个Acceptor事件处理器到Reactor中,Acceptor所关注的事件是op_accept事件,这样Reactor会监听到客户端向服务端发起的连接事件。
      2. 客户端向服务端发起了连接,当监听到连接事件后,Reactor会将该accept事件派发给相应的Acceptor处理器来处理,Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的READ事件以及对应的READ事件处理器注册到Reactor中,这样一来Reactor就会监听该连接的READ事件了。
      3. 当Reactor监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过SocketChannel的read()方法读取数据,此时read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
      4. 每当处理完所有就绪的感兴趣的I/O事件后,Reactor线程会再次执行select()阻塞等待新的事件就绪并将其分派给对应处理器进行处理。
    2. 多线程Reactor模式流程
      1. 与单线程Reactor模式不同的是,添加了一个工作者线程池,这样能够提高Reactor线程的I/O响应,不至于因为一些耗时的业务逻辑而延迟对后面I/O请求的处理。
    3. 主从多线程Reactory模式
      Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的事件循环逻辑。mainReactor可以只有一个,但subReactor一般会有多个。mainReactor线程主要负责接收客户端的连接请求,然后将接收到的SocketChannel传递给subReactor,由subReactor来完成和客户端的通信。
      1. 注册一个Acceptor事件处理器到mainReactor中,Acceptor事件处理器所关注的事件是ACCEPT事件,这样mainReactor会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。启动mainReactor的事件循环
      2. 客户端向服务器端发起一个连接请求,mainReactor监听到了该ACCEPT事件并将该ACCEPT事件派发给Acceptor处理器来进行处理。Acceptor处理器通过accept()方法得到与这个客户端对应的连接(SocketChannel),然后将这个SocketChannel传递给subReactor线程池。
      3. subReactor线程池分配一个subReactor线程给这个SocketChannel,即,将SocketChannel关注的READ事件以及对应的READ事件处理器注册到subReactor线程中。当然你也注册WRITE事件以及WRITE事件处理器到subReactor线程中以完成I/O写操作。Reactor线程池中的每一Reactor线程都会有自己的Selector、线程和分发的循环逻辑。
      4. 当有I/O事件就绪时,相关的subReactor就将事件派发给响应的处理器处理。注意,这里subReactor线程只负责完成I/O的read()操作,在读取到数据后将业务逻辑的处理放入到线程池中完成,若完成业务逻辑后需要返回数据给客户端,则相关的I/O的write操作还是会被提交回subReactor线程来完成。

    例如一个饭店:

    • 一个人包揽所有:接待、点菜、做饭、上菜、送客等;
    • 多找几个伙计:大家一起做上面的事情;
    • 进一步分工:搞一个或者多个人专门做迎宾;

    饭店伙计:线程、迎宾工作:接入连接、点菜:请求、做菜:业务处理、上菜:响应、送客:断连

    其中,请求(点菜)这个步骤可以分拆为三种:

    • 一个人包揽所有:迎宾、点菜、做饭、上菜等->Reactor单线程
    • 多找几个伙计:大家一起做上面的事情->Reactor多线程模式
    • 进一步分工:搞一个或者多个人专门做迎宾->主从Reactory多线程模式
    8.如何在Netty中使用Reactor模式
    1. Reactor单线程模式
      EventLoopGroup eventGroup = new NioEventLoopGroup(1); ServerBootstrap serverBootstrap = new ServerBootStrap(); serverBootstrap.group(eventGroup);
    2. Reactor多线程模式
      EventLoopGroup eventGroup = new NioEventLoopGroup(); ServerBootstrap serverBootstrap = new ServerBootStrap(); serverBootstrap.group(eventGroup);
    3. 主从Reactor多线程模式
      EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); ServerBootstrap serverBootstrap = new ServerBootStrap(); serverBootstrap.group(bossGroup,workerGroup);
  • 相关阅读:
    Linux各目录及每个目录的详细介绍
    centos7 + mysql5.7 tar包解压安装
    Hive2.0的新特性介绍
    Hadoop HIVE
    PIG执行MR时报Connection refused错误
    Zookeeper启动Permission denied
    Hadoop Pig
    Hadoop组件之-HDFS(HA实现细节)
    Datanode启动问题 FATAL org.apache.hadoop.hdfs.server.datanode.DataNode: Initialization failed for Block pool <registering>
    HDFS Federation
  • 原文地址:https://www.cnblogs.com/binbinshan/p/14154170.html
Copyright © 2020-2023  润新知