IO模型&Netty

一、IO模型

对于一次IO操作,数据会先拷贝到内核空间中,然后再从内核空间拷贝到用户空间中,所以一次read操作,会经历以下两个阶段,基于这两个阶段就产生了五种不同的IO模式。

为了避免用户进程直接操作内核,保证内核安全,操作系统将内存(虚拟内存)划分为两部分:一部分是内核空间(Kernel-Space),另一部分是用户空间(User-Space)。在Linux系统中,内核模块运行在内核空间,对应的进程处于内核态;用户程序运行在用户空间,对应的进程处于用户态。

操作系统的核心是内核程序,它独立于普通的应用程序,既有权限访问受保护的内核空间,也有权限访问硬件设备,而普通的应用程序并没有这样的权限。内核空间总是驻留在内存中,是为操作系统的内核保留的。应用程序不允许直接在内核空间区域进行读写,也不允许直接调用内核代码定义的函数。每个应用程序进程都有一个单独的用户空间,对应的进程处于用户态,用户态进程不能访问内核空间中的数据,也不能直接调用内核函数,因此需要将进程切换到内核态才能进行系统调用(System Call)。

  • 等待数据准备
  • 数据从内核空间拷贝到用户空间
1.阻塞IO(同步阻塞IO Blocking IO, BIO)

在这里插入图片描述

从进程发起IO操作,一直等待上述两个阶段完成。两阶段一起阻塞

2.非阻塞IO(同步非阻塞IO Non-Blocking IO,NIO)

在这里插入图片描述

进程一直询问IO准备好了没有,准备好了再发起读取操作,这时才把数据从内核空间拷贝到用户空间。第一阶段不阻塞但要轮询,第二阶段阻塞

整个IO请求过程中,虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据。仍需要不断地轮询、重复请求、消耗了大量的CPU资源;是比较浪费CPU的方式,一般很少用这种模型,而是在其他模型中使用非阻塞IO这一特性。

同步非阻塞IO也可以简称为NIO,但是它不是Java编程中的NIO。Java编程中的NIO(New IO)类库组件所归属的不是基础IO模型中的NIO模型,而是IO多路复用模型。

3.多路复用IO(IO Multiplexing)

在这里插入图片描述

多个连接使用同一个select去询问IO准备好了没有,如果有准备好了的,就返回有数据准备好了,然后对应的连接再发起读取操作,把数据从内核空间拷贝到用户空间。两阶段分开阻塞

为了提高性能,操作系统引入了一种新的系统调用,专门用于查询IO文件描述符(含socket连接)的就绪状态。在Linux系统中,新的系统调用为select/epoll系统调用。通过该系统调用,一个用户进程(或者线程)可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核就能够将文件描述符的就绪状态返回给用户进程(或者线程),用户空间可以根据文件描述符的就绪状态进行相应的IO系统调用。

I/O复用模型会用到select或poll函数,在I/O复用模型中,并不是阻塞到I/O操作过程中,而是阻塞到select或者poll函数中; 以select为例:进程在select处阻塞,等待几个描述符中的一个变为可操作,如果没等待到就继续阻塞在第一阶段,如果等到了一个描述符变为了可操作,则调用recvfrom函数将数据拷贝到应用缓冲区。

IO多路复用模型的特点是:IO多路复用模型的IO涉及两种系统调用,一种是IO操作的系统调用,另一种是select/epoll就绪查询系统调用。IO多路复用模型建立在操作系统的基础设施之上,即操作系统的内核必须能够提供多路分离的系统调用select/epoll。

IO多路复用模型的优点是一个选择器查询线程可以同时处理成千上万的网络连接,所以用户程序不必创建大量的线程,也不必维护这些线程,从而大大减少了系统的开销。与一个线程维护一个连接的阻塞IO模式相比,这一点是IO多路复用模型的最大优势。

IO多路复用模型的缺点是,本质上select/epoll系统调用是阻塞式的,属于同步IO,需要在读写事件就绪后由系统调用本身负责读写,也就是说这个读写过程是阻塞的。要彻底地解除线程的阻塞,就必须使用异步IO模型。

4.信号驱动IO(SIGIO)

在这里插入图片描述

进程发起读取操作会立即返回,当数据准备好了会以通知的形式告诉进程,进程再发起读取操作,把数据从内核空间拷贝到用户空间。第一阶段不阻塞,第二阶段阻塞

5.异步IO(Asynchronous IO,AIO)

在这里插入图片描述

进程发起读取操作会立即返回,等到数据准备好且已经拷贝到用户空间了再通知进程拿数据。两个阶段都不阻塞

6.五种IO模型的对比

在这里插入图片描述

同步非同步的区别在于调用操作系统的recvfrom()的时候是否阻塞,可见除了最后的异步IO其它都是同步IO。

7.阻塞与非阻塞

阻塞IO指的是需要内核IO操作彻底完成后才返回到用户空间执行用户程序的操作指令。“阻塞”指的是用户程序(发起IO请求的进程或者线程)的执行状态。可以说传统的IO模型都是阻塞IO模型,并且在Java中默认创建的socket都属于阻塞IO模型。

8.同步与异步

简单来说,可以将同步与异步看成发起IO请求的两种方式。同步IO是指用户空间(进程或者线程)是主动发起IO请求的一方,系统内核是被动接收方。异步IO则反过来,系统内核是主动发起IO请求的一方,用户空间是被动接收方。

二、IO基础
1.IO读写的基本原理

为了避免用户进程直接操作内核,保证内核安全,操作系统将内存(虚拟内存)划分为两部分:一部分是内核空间(Kernel-Space),另一部分是用户空间(User-Space)。在Linux系统中,内核模块运行在内核空间,对应的进程处于内核态用户程序运行在用户空间,对应的进程处于用户态

操作系统的核心是内核程序,它独立于普通的应用程序,既有权限访问受保护的内核空间,也有权限访问硬件设备,而普通的应用程序并没有这样的权限。内核空间总是驻留在内存中,是为操作系统的内核保留的。应用程序不允许直接在内核空间区域进行读写,也不允许直接调用内核代码定义的函数。每个应用程序进程都有一个单独的用户空间,对应的进程处于用户态,用户态进程不能访问内核空间中的数据,也不能直接调用内核函数,因此需要将进程切换到内核态才能进行系统调用。

内核态进程可以执行任意命令,调用系统的一切资源,而用户态进程只能执行简单的运算,不能直接调用系统资源,那么问题来了:用户态进程如何执行系统调用呢?答案是:用户态进程必须通过系统调用(System Call)向内核发出指令,完成调用系统资源之类的操作。

用户程序进行IO的读写依赖于底层的IO读写,基本上会用到底层的read和write两大系统调用。虽然在不同的操作系统中read和write两大系统调用的名称和形式可能不完全一样,但是它们的基本功能是一样的。

操作系统层面的read系统调用并不是直接从物理设备把数据读取到应用的内存中,write系统调用也不是直接把数据写入物理设备。上层应用无论是调用操作系统的read还是调用操作系统的write,都会涉及缓冲区。 具体来说,上层应用通过操作系统的read系统调用把数据从内核缓冲区复制到应用程序的进程缓冲区,通过操作系统的write系统调用把数据从应用程序的进程缓冲区复制到操作系统的内核缓冲区

简单来说,应用程序的IO操作实际上不是物理设备级别的读写,而是缓存的复制。read和write两大系统调用都不负责数据在内核缓冲区和物理设备(如磁盘、网卡等)之间的交换。这个底层的读写交换操作是由操作系统内核(Kernel)来完成的。所以,在应用程序中,无论是对socket的IO操作还是对文件的IO操作,都属于上层应用的开发,它们在输入(Input)和输出(Output)维度上的执行流程是类似的,都是在内核缓冲区和进程缓冲区之间进行数据交换。

2.内核缓冲区与进程缓冲区

为什么设置那么多的缓冲区,导致读写过程那么麻烦呢?

缓冲区的目的是减少与设备之间的频繁物理交换。计算机的外部物理设备与内存和CPU相比,有着非常大的差距,外部设备的直接读写涉及操作系统的中断。发生系统中断时,需要保存之前的进程数据和状态等信息,结束中断之后,还需要恢复之前的进程数据和状态等信息。为了减少底层系统的频繁中断所导致的时间损耗、性能损耗,出现了内核缓冲区。

操作系统会对内核缓冲区进行监控,等待缓冲区达到一定数量的时候,再进行IO设备的中断处理,集中执行物理设备的实际IO操作,通过这种机制来提升系统的性能。至于具体什么时候执行系统中断(包括读中断、写中断)则由操作系统的内核来决定,应用程序不需要关心。

上层应用使用read系统调用时,仅仅把数据从内核缓冲区复制到应用的缓冲区(进程缓冲区);上层应用使用write系统调用时,仅仅把数据从应用的缓冲区复制到内核缓冲区。

内核缓冲区与应用缓冲区在数量上也不同。在Linux系统中,操作系统内核只有一个内核缓冲区。每个用户程序(进程)都有自己独立的缓冲区,叫作用户缓冲区或者进程缓冲区。在大多数情况下,Linux系统中用户程序的IO读写程序并没有进行实际的IO操作,而是在用户缓冲区和内核缓冲区之间直接进行数据的交换。

三、Reactor模式简介

Reactor模式由Reactor线程、Handlers处理器两大角色组成,两大角色的职责分别如下:

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

在Java的OIO编程中,原始的网络服务器程序一般使用一个while循环不断地监听端口是否有新的连接。如果有,就调用一个处理函数来完成传输处理。

如果前一个网络连接的handle(socket)没有处理完,那么后面的新连接无法被服务端接收,于是后面的请求就会被阻塞,导致服务器的吞吐量太低。为了解决这个严重的连接阻塞问题,出现了一个极为经典的模式:Connection Per Thread(一个线程处理一个连接)模式。

Connection Per Thread模式的缺点是对应于大量的连接,需要耗费大量的线程资源,对线程资源要求太高。
而且,线程的反复创建、销毁、切换也需要代价。因此,在高并发的应用场景下,多线程OIO的缺陷是致命的。

2.单线程Reactor模式

在Reactor模式中有Reactor和Handler两个重要的组件:
(1)Reactor:负责查询IO事件,当检测到一个IO事件时将其发送给相应的Handler处理器去处理。这里的IO事件就是NIO中选择器查询出来的通道IO事件。
(2)Handler:与IO事件(或者选择键)绑定,负责IO事件的处理,完成真正的连接建立、通道的读取、处理业务逻辑、负责将结果写到通道等。

Reactor和Handlers处于一个线程中执行。

在这里插入图片描述

3.多线程Reactor模式

多线程Reactor的演进分为两个方面:
(1)升级Handler。既要使用多线程,又要尽可能高效率,则可以考虑使用线程池。具体操作是将负责数据传输处理的IOHandler处理器的执行放入独立的线程池中。这样,业务处理线程与负责新连接监听的反应器线程就能相互隔离,避免服务器的连接监听受到阻塞。
(2)升级Reactor。可以考虑引入多个Selector(选择器),提升选择大量通道的能力。具体操作是将反应器线程拆分为多个子反应器(SubReactor)线程;同时,引入多个选择器,并且为每一个SubReactor引入一个线程,一个线程负责一个选择器的事件轮询。

在这里插入图片描述

四、Netty
1.Netty的Reactor模式

Reactor模式中IO事件的处理流程:

在这里插入图片描述

Reactor模式中IO事件的处理流程大致分为4步,具体如下:
第1步:通道注册。IO事件源于通道(Channel),IO是和通道(对应于底层连接而言)强相关的。一个IO事件一定属于某个通道。如果要查询通道的事件,首先就要将通道注册到选择器。
第2步:查询事件。在Reactor模式中,一个线程会负责一个反应器(或者SubReactor子反应器),不断地轮询,查询选择器中的IO事件(选择键)。
第3步:事件分发。如果查询到IO事件,则分发给与IO事件有绑定关系的Handler业务处理器。
第4步:完成真正的IO操作和业务处理,这一步由Handler业务处理器负责。

在这里插入图片描述

2.server端工作原理

在这里插入图片描述

server端启动时绑定本地某个端口,将自己NioServerSocketChannel注册到某个boss NioEventLoop的selector上。
server端包含1个boss NioEventLoopGroup和1个worker NioEventLoopGroup,

NioEventLoopGroup相当于1个事件循环组,这个组里包含多个事件循环NioEventLoop,
每个NioEventLoop包含1个selector和1个事件循环线程。

每个boss NioEventLoop循环执行的任务包含3步:

  • 第1步:轮询accept事件
  • 第2步:处理io任务,即accept事件,与client建立连接,生成NioSocketChannel,并将NioSocketChannel注册到某个worker NioEventLoop的selector上
  • 第3步:处理任务队列中的任务,runAllTasks。任务队列中的任务包括用户调用eventloop.execute或schedule执行的任务,或者其它线程提交到该eventloop的任务

每个worker NioEventLoop循环执行的任务包含3步:

  • 第1步:轮询read、write事件
  • 第2步:处理io任务,即read、write事件,在NioSocketChannel可读、可写事件发生时进行处理
  • 第3步:处理任务队列中的任务,runAllTasks

服务端创建示例代码:

//1.创建一个服务端的引导类
ServerBootstrap bootstrap = new ServerBootstrap();

//2.创建反应器事件轮询组
//boss轮询组(负责处理父通道:连接/接收监听(如NioServerSocketChannel))
EventLoopGroup bossGroup = new NioEventLoopGroup(2);

//worker轮询组(负责处理子通道:读/写监听(如NioSocketChannel))
//线程数可以不配置,默认为cpu核心数的2倍
EventLoopGroup workerGroup = new NioEventLoopGroup(4);

//3.设置父子轮询组
bootstrap.group(bossGroup, workerGroup);

//如果不需要分开监听新连接事件和输出事件,就不一定非得配置两个轮询组,可以仅配置一个EventLoopGroup反应器轮询组。
//在这种模式下,新连接监听IO事件和数据传输IO事件可能被挤在了同一个线程中处理。
//b.group(workerGroup);

//4.设置传输通道类型,Netty不仅支持Java NIO,也支持阻塞式的OIO
bootstrap.channel(NioServerSocketChannel.class);

//5.设置监听端口
bootstrap.localAddress(new InetSocketAddress(8000));

//6.设置通道参数
//option方法的作用是给父通道(Parent Channel)设置一些与传输协议相关的选项。
//如果要给子通道(Child Channel)设置一些通道选项,则需要调用childOption()设置方法。
//该选项表示是否开启TCP底层心跳机制,true为开启,false为关闭
bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
bootstrap.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);

//7.装配子通道的Pipeline流水线
//每一个通道都用一条ChannelPipeline流水线,它的内部有一个双向的链表。
//装配流水线的方式是:将业务处理器ChannelHandler实例包装之后加入双向链表中。
//ChannelInitializer处理器有一个泛型参数SocketChannel,它代表需要初始化的通道类型,
//这个类型需要和前面的引导类(bootstrap.channel)中设置的传输通道类型一一对应起来。
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {   
    //有连接到达时,会创建一个通道的子通道,并初始化    
    @Override    
    protected void initChannel(SocketChannel ch) throws Exception {
        //这里可以管理子通道中的Handler业务处理器       
        //向子通道流水线添加一个Handler业务处理器        
        //ch.pipeline().addLast(new MyHandler());    
    }
});

//父通道(NioServerSocketChannel)的内部业务处理是固定的:接收新连接后,创建子通道,然后初始化子通道,
//所以不需要特别的配置,由Netty自行进行装配。如果需要完成特殊的父通道业务处理,
//可以类似地调用ServerBootstrap的handler(ChannelHandler handler)方法,为父通道设置ChannelInitializer初始化器。

//8.开始绑定端口,并通过调用sync()同步方法阻塞直到绑定成功
ChannelFuture channelFuture = bootstrap.bind().sync();

//在Netty中,所有的IO操作都是异步执行的
//Netty中的IO操作都会返回异步任务实例(如channelFuture实例)。
//通过该异步任务实例,既可以实现同步阻塞一直到channelFuture异步任务执行完成,
//也可以通过为其增加事件监听器的方式注册异步回调逻辑,以获得Netty中的IO操作的真正结果
//bootstrap.bind().addListener( future -> {    
    //..
//});
SocketAddress address = channelFuture.channel().localAddress();
System.out.println("服务器启动成功,监听端口:"+address);

//9.自我阻塞,直到监听通道关闭
ChannelFuture closeFuture = channelFuture.channel().closeFuture();
closeFuture.sync();
System.out.println("监听关闭");

//10.释放所有资源,包括创建的反应器线程
//关闭反应器轮询组,同时会关闭内部的子反应器线程,
//也会关闭内部的选择器、内部的轮询线程以及负责查询的所有子通道。
//在子通道关闭后,会释放掉底层的资源,如Socket文件描述符等。
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();

channelOption常见配置项

  • SO_RCVBUF和SO_SNDBUF
    这两个为TCP传输选项,每个TCP socket(套接字)在内核中都有一个发送缓冲区和一个接收缓冲区,这两个选项就是用来设置TCP连接的两个缓冲区大小的。
  • TCP_NODELAY
    此为TCP传输选项,如果设置为true就表示立即发送数据。TCP_NODELAY用于开启或关闭Nagle算法。
    如果要求高实时性,有数据发送时就马上发送,就将该选项设置为true(关闭Nagle算法);
    如果要减少发送次数、减少网络交互,就设置为false(开启Nagle算法),等累积一定大小的数据后再发送。
    关于TCP_NODELAY的值,Netty默认为true,而操作系统默认为false。
  • SO_KEEPALIVE
    此为TCP传输选项,表示是否开启TCP的心跳机制。true为连接保持心跳,默认值为false。
    启用该功能时,TCP会主动探测空闲连接的有效性。
    需要注意的是:默认的心跳间隔是7200秒,即2小时。Netty默认关闭该功能。
  • SO_REUSEADDR
    此为TCP传输选项,为true时表示地址复用,默认值为false。
  • SO_LINGER
    此为TCP传输选项,可以用来控制socket.close()方法被调用后的行为,包括延迟关闭时间。
  • SO_BACKLOG
    此为TCP传输选项,表示服务端接收连接的队列长度,如果队列已满,客户端连接将被拒绝。
  • SO_BROADCAST
    此为TCP传输选项,表示设置为广播模式。

流水线配置

public class SimpleOutChannelInitializer extends ChannelInitializer<SocketChannel> {   
    @Override    
    protected void initChannel(EmbeddedChannel ch) throws Exception { 
        ch.pipeline() //流水线,注意添加顺序
            .addLast(new SimpleInHandlerA()) //添加入站处理器
            .addLast(new SimpleInHandlerB()) //添加入站处理器
            .addLast(new SimpleInHandlerC()) //添加入站处理器
            .addLast(new SimpleOutHandlerA()) //添加出站处理器
            .addLast(new SimpleOutHandlerB()) //添加出站处理器
            .addLast(new SimpleOutHandlerC()); //添加出站处理器
    }
}

一个简单的入站处理器:

@Slf4j
public class SimpleInHandlerA extends ChannelInboundHandlerAdapter {    
    @Override    
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {      
        log.info("入站处理器A,读取到数据: {}", msg);        
        Object o = msg+"-handlerA";        
        super.channelRead(ctx, o);   //注释后可截断流水线
    }
}

出站处理器执行顺序:

在这里插入图片描述

一个简单的出站处理器:

@Slf4j
public class SimpleOutHandlerA extends ChannelOutboundHandlerAdapter {
    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        log.info("出站处理器A,写数据.");
        super.write(ctx, msg, promise);
    }
}

出站处理器执行顺序:

在这里插入图片描述

解码/编码器:
现实情况下,所谓的入站和出站处理器一般都是用于数据的解码和编码,
所以Netty提供了一些常见的解码和编码器,也可自定义自己的解码/编码器
下面定义一个简单的解码器用于解码出int类型的数字:

@Slf4j
public class Byte2IntegerDecoder extends ByteToMessageDecoder {    
    @Override   
    protected void decode(ChannelHandlerContext ctx,
                                            ByteBuf in, List<Object> out) throws Exception {   
        while (in.readableBytes() >= 4){ 
            int i = in.readInt();           
            log.info("解码出一个整数:{}", i);           
            out.add(i);       
        }   
    }
 }
3.client端工作原理

在这里插入图片描述

client端启动时connect到server,建立NioSocketChannel,并注册到某个NioEventLoop的selector上,client端只包含1个NioEventLoopGroup。

每个NioEventLoop循环执行的任务包含3步:

  • 第1步:轮询connect、read、write事件
  • 第2步:处理io任务,即connect、read、write事件,在NioSocketChannel连接建立、可读、可写事件发生时进行处理
  • 第3步:处理非io任务,runAllTasks
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值