Netty快速上手:Netty没有你想象的那么难

该文章是Netty相关文章。目的是让读者能够快速的了解netty的相关知识以及开发方法。因此本文章在正式介绍Netty开发前先介绍了Netty的前置相关内容:线程模型,JavaNIO,零拷贝等。本文章以大纲框架的形式整体介绍了Netty,希望对读者有些帮助。文中图片多来自于百度网络,如果有侵权,可以联系我进行删除。内容若有不当欢迎在评论区指出。

Netty

netty是由JBOSS提供的一个Java开源框架,是一个异步的,基于事件驱动的网络应用框架,用以快速开发高性能,高可靠性的网络IO程序.

NIO模型

  1. 阻塞IO:发起请求就一直等待,直到数据返回。在IO执行的两个阶段都被block了

  1. 非阻塞IO:应用程序不断在一个循环里调用recvfrom,轮询内核,看是否准备好了数据,比较浪费CPU

  1. io复用:一个或一组线程处理多个连接可以同时对多个读/写操作的IO函数进行轮询检测,直到有数据可读或可写时,才真正调用IO操作函数

  1. 信号驱动IO:事先发出一个请求,当有数据后会返回一个标识回调,然后通过recvfrmo去请求数据

  1. 异步io:发出请求就返回,剩下的事情会异步自动完成,不需要做任何处理

异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

Java NIO

  1. 三大核心Channel(通道),Buffer(缓冲区),Selector(选择器)。数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中,Selector用于监听多个通道的事件。
  2. Channel:是双向的,既可以用来进行读操作,又可以用来进行写操作

    • FileChannel 文件IO,不支持非阻塞模式,无法同Selector一同使用。
    • DatagramChannel 用于处理UDP的连接。
    • SocketChannel 用于处理TCP客户端的连接。
    • ServerSocketChannel 用于处理TCP服务端的连接。
  3. Buffer:它通过几个变量来保存这个数据的当前位置状态:

    • capacity:缓冲区数组的总长度
    • position:下一个要操作的数据元素的位置
    • limit:缓冲区数组中不可操作的下一个元素的位置
  4. 向Buffer中写数据:

    • 从Channel写到Buffer (fileChannel.read(buf))
    • 通过Buffer的put()方法 (buf.put(…))
  5. 从Buffer中读取数据:

    • 从Buffer读取到Channel (channel.write(buf))
    • 使用get()方法从Buffer中读取数据 (buf.get())
  6. Buffer常用方法

    1. flip():写模式下调用flip()之后,Buffer从写模式变成读模式。limit设置为position,position将被设回0
    2. clear()方法:position将被设回0,limit设置成capacity,Buffer被清空了,但Buffer中的数据并未被清除。
    3. compact():将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面,limit设置成capacity,准备继续写入。读模式变成写模式
    4. Buffer.rewind()方法将position设回0,所以你可以重读Buffer中的所有数据
  7. Selector:Selector一起使用时,Channel必须处于非阻塞模式下。通过channel.register,将channel登记到Selector上,同时添加关注的事件(SelectionKey),常用方法如下:

    • select()阻塞到至少有一个通道在你注册的事件上就绪了。
    • select(long timeout)和select()一样,除了最长会阻塞timeout毫秒(参数)。
    • selectNow()不会阻塞,不管什么通道就绪都立刻返回
    • selectedKeys()方法访问就绪的通道。Selector不会自己从已选择键集中移除SelectionKey实例。

NIO其他功能:

  1. MappedByteBuffer是NIO引入的文件内存映射方案,读写性能极高。
  2. transferFrom & transferTo:FileChannel的transferFrom()方法可以将数据从源通道传输到FileChannel中.
  3. 分散(scatter)从Channel中读取是指在读操作时将读取的数据写入多个buffer中。因此,Channel将从Channel中读取的数据“分散(scatter)”到多个Buffer中。
  4. 聚集(gather)写入Channel是指在写操作时将多个buffer的数据写入同一个Channel,因此,Channel 将多个Buffer中的数据“聚集(gather)”后发送到Channel。

Linux的NIO:

  1. select:阻塞地同时探测一组支持非阻塞的IO设备,直至某一个设备触发了事件或者超过了指定的等待时间。当select函数返回后可以遍历文件描述符,找到就绪的描述符

缺点:

1. 单进程所打开的FD是具有一定限制的,
2. 套接字比较多的时候,每次select()都要通过遍历Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间
3. 每次都需要把fd集合从⽤用户态拷贝到内核态,这个开销在fd很多时会很⼤大
  1. poll:本质上和select没有区别,fd使用链表实现,没有最大连接数的限制。

    • 缺点:

      1. 大量的fd数组都需要从用户态拷贝到内核态。
      2. poll的“水平触发”:如果报告了fd后,没有被处理,则下次poll还会再次报告该fd。
  2. epoll:
    epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。

    • LT(水平触发)模式下,只要这个文件描述符还有数据可读,每次 epoll都会返回它的事件,提醒用户程序去操作;
    • ET(边缘触发)模式下,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,否则下次的 epoll不会返回余下的数据,会丢掉事件(只通知一次)。
**epoll底层原理**:调用epoll_create后,内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket,建立一个rdllist双向链表,用于存储准备就绪的事件。在epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就阻塞。

零拷贝:

对一个操作系统进程来说,它既有内核空间(与其他进程共享),也有用户空间(进程私有),它们都是处于虚拟地址空间中。进程无法直接操作I/O设备,必须通过操作系统调用请求内核来协助完成I/O动作。将静态文件展示给用户需要先将静态内容从磁盘中拷贝出来放到内存buf中,然后再将这个buf通过socket发给用户
问题:经历了4次copy过程,4次内核切换

1. 用户态到内核态:调用read,文件copy到内核态内存
2. 内核态到用户态:内核态内存数据copy到用户态内存
3. 用户态到内核态:调用writer:用户态内存数据到内核态socket的buffer内存中
4. 最后内核模式下的socket模式下的buffer数据copy到网卡设备中传送
5. 从内核态回到用户态执行下一个循环

Linux:零拷贝技术消除传输数据在存储器之间不必要的中间拷贝次数,减少了用户进程地址空间和内核地址空间之间因为上下文切换而带来的开销。

常见零拷贝技术

  • mmap():应用程序调用mmap(),磁盘上的数据会通过DMA被拷贝到内核缓冲区,然后操作系统会把这段内核缓冲区与应用程序共享,这样就不需要把内核缓冲区的内容往用户空间拷贝。数据向网络中写时,只需要把数据从这块共享的内核缓冲区中拷贝到socket缓冲区中去就行了,这些操作都发生在内核态.
  • sendfile():DMA将磁盘数据复制到kernel buffer,然后将内核中的kernel buffer直接拷贝到socket buffer;一旦数据全都拷贝到socket buffer,sendfile()系统调用将会return、代表数据转化的完成。
  • splice():从磁盘读取到内核buffer后,在内核空间直接与socket buffer建立pipe管道,不需要内核支持。
  • DMA scatter/gather:批量copy

零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

netty 介绍

Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。

  1. 设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;
  2. 高度可定制的线程模型 - 单线程,一个或多个线程池.
  3. 使用方便:详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK 5(Netty 3.x)或 6(Netty 4.x)就 足够了。
  4. 高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。
  5. 安全:完整的 SSL/TLS 和 StartTLS 支持。
  6. 社区活跃、不断更新:社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入
  7. Java原生NIO使用起码麻烦需要自己管理线程,Netty对JDK自带的NIO的api进行了封装,提供了更简单优雅的实现方式。由于netty5使用ForkJoinPool增加了复杂性,并且没有显示出明显的性能优势,所以netty5现在被废弃掉了。

netty线程模型

Reactor模式:是事件驱动的,多个并发输入源。它有一个服务处理器,有多个请求处理器;这个服务处理器会同步的将输入的客户端请求事件多路复用的分发给相应的请求处理器。

单Reactor单线程:多路复用、事件分发和消息的处理都是在一个Reactor线程上完成。

* 优点:
    * 模型简单,实现方便
* 缺点:
    
    * 性能差:单线程无法发挥多核性能,
    * 可靠性差:线程意外终止或死循环,则整个模块不可用

单Reactor多线程
一个Reactor线程负责监听服务端的连接请求和接收客户端的TCP读写请求;NIO线程池负责消息的读取、解码、编码和发送

优点:可以充分的利用多核cpu的处理能

缺点:Reactor处理所有事件的监听和响应,在单线程运行,在高并发场景容易出现性能瓶颈.

主从 Reactor 多线程
MainReactor负责监听服务端的连接请求,接收到客户端的连接后,将SocketChannel从MainReactor上移除,重新注册到SubReactor线程池的线程上。SubReactor处理I/O的读写操作,NIO线程池负责消息的读取、解码、编码和发送。

netty工作原理图

NioEventLoopGroup:主要管理 eventLoop 的生命周期,可以理解为一个线程池,内部维护了一组线程,每个线程(NioEventLoop)负责处理多个 Channel 上的事件,而一个 Channel 只对应于一个线程
ChannelHandler用于处理Channel对应的事件
示例代码

public class NettyServer {
    public static void main(String[] args) throws Exception {

        //bossGroup和workerGroup分别对应mainReactor和subReactor
        NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
        NioEventLoopGroup workGroup = new NioEventLoopGroup();

        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workGroup)
                //用来指定一个Channel工厂,mainReactor用来包装SocketChannel.
                .channel(NioServerSocketChannel.class)
                //用于指定TCP相关的参数以及一些Netty自定义的参数
                .option(ChannelOption.SO_BACKLOG, 100)
                //childHandler()用于指定subReactor中的处理器,类似的,handler()用于指定mainReactor的处理器
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    //ChannelInitializer,它是一个特殊的Handler,功能是初始化多个Handler。完成初始化工作后,netty会将ChannelInitializer从Handler链上删除。
                    @Override
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        ChannelPipeline pipeline = socketChannel.pipeline();
                        //addLast(Handler)方法中不指定线程池那么将使用默认的subReacor即woker线程池执行处理器中的业务逻辑代码。
                        pipeline.addLast(new StringDecoder());
                        pipeline.addLast(new StringEncoder());
                        pipeline.addLast(new MyServerHandler());
                    }
                });
        //sync() 同步阻塞直到bind成功
        ChannelFuture f = bootstrap.bind(8888).sync();
        //sync()同步阻塞直到netty工作结束
        f.channel().closeFuture().sync();

    }
}

线程组

  • NioEventLoopGroup:

    1. NioEventLoopGroup初始化时未指定线程数,那么会使用默认线程数。
    2. 每个NioEventLoopGroup对象内部都有一组可执行的NioEventLoop数组。
    3. 当有IO事件来时,需要从线程池中选择一个线程出来执行,这时候的NioEventLoop选择策略是由EventExecutorChooser实现的,并调用该类的next()方法。
    4. 每个NioEventLoopGroup对象都有一个NioEventLoop选择器与之对应,其会根据NioEventLoop的个数,EventExecutorChooser(如果是2的幂次方,则按位运算,否则使用普通的轮询)
  • NioEventLoop
    NioEventLoop 肩负着两种任务:

    1. 作为 IO 线程, 执行与 Channel 相关的 IO 操作, 包括 调用 select 等待就绪的 IO 事件、读写数据与数据的处理等;
    2. 作为任务队列, 执行 taskQueue 中的任务, 例如用户调用 eventLoop.schedule 提交的定时任务也是这个线程执行的

BootStrap和ServerBootstrap

ServerBootstrap是一个工具类,用来配置netty

  1. channel():提供一个ChannelFactory来创建channel,不同协议的连接有不同的 Channel 类型与之对应,常见的Channel类型:

    • NioSocketChannel, 代表异步的客户端 TCP Socket 连接.
    • NioServerSocketChannel, 异步的服务器端 TCP Socket 连接.
    • NioDatagramChannel, 异步的 UDP 连接
  2. group():配置工作线程组,用于处理channel的事件
  3. ChannelHandler():用户自定义的事件处理器

出站和入站:

ChannelHandler下主要是两个子接口

  1. ChannelInboundHandler(入站): 处理输入数据和Channel状态类型改变。

    • 适配器: ChannelInboundHandlerAdapter(适配器设计模式)
    • 常用的: SimpleChannelInboundHandler
  2. ChannelOutboundHandler(出站): 处理输出数据

    • 适配器: ChannelOutboundHandlerAdapter

ChannelPipeline 是一个 Handler 的集合,它负责处理和拦截 inbound 或者 outbound 的事件和操作,一个贯穿 Netty 的链。每个新的通道Channel,Netty都会创建一个新的ChannelPipeline,并将器pipeline附加到channel中。DefaultChinnelPipeline它的Handel头部和尾部的Handel是固定的,我们所添加的Handel是添加在这个头和尾之前的Handel。

ChannelHandlerContext:ChannelPipeline并不是直接管理ChannelHandler,而是通过ChannelHandlerContext来间接管理。

image

Netty编码器

网络中都是以字节码的数据形式来传输数据的,服务器编码数据后发送到客户端,客户端需要对数据进行解码

  • encoder 负责把业务数据转换成字节码数据
  • decoder 负责把字节码数据转换成业务数据

Netty提供了一些默认的编码器:
StringEncoder:对字符串数据进行编码
ObjectEncoder:对 Java 对象进行编码
StringDecoder:对字符串数据进行解码
ObjectDecoder:对 Java 对象进行解码

抽象解码器

  1. ByteToMessageDecoder: 用于将字节转为消息,需要检查缓冲区是否有足够的字节
  2. ReplayingDecoder: 继承ByteToMessageDecoder,不需要检查缓冲区是否有足够的字节,但是ReplayingDecoder速度略慢于ByteToMessageDecoder,同时不是所有的ByteBuf都支持。

    • 选择:项目复杂性高则使用ReplayingDecoder,否则使用 ByteToMessageDecoder
  3. MessageToMessageDecoder: 用于从一种消息解码为另外一种消息

TCP粘包:

UDP是基于帧的,包的首部有数据报文的长度.TCP是基于字节流,没有边界的。TCP的首部没有表示数据长度的字段。

  • 发生TCP粘包或拆包的原因:

    1. 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。
    2. 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。
    3. 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。
    4. 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包。
  • 解决方式:
  1. 发送定长消息,如果位置不够,填充特殊字符
  2. 在每一个包的尾部加一个特殊分割符
  3. 发送端给每个数据包添加包首部,首部中应该至少包含数据包的长度。
  • Netty 已经提供了编码器用于解决粘包。

    1. LineBasedFrameDecoder 可以基于换行符解决。
    2. DelimiterBasedFrameDecoder可基于分隔符解决。
    3. FixedLengthFrameDecoder可指定长度解决。

netty的零拷贝

Netty完全工作在用户态的,Netty的零拷贝更多的对数据操作的优化。

Netty的零拷贝(或者说ByteBuf的复用)主要体现在以下几个方面:

  1. DirectByteBuf通过直接在堆外分配内存的方式,避免了数据从堆内拷贝到堆外的过程
  2. 通过组合ByteBuf类:即CompositeByteBuf,将多个ByteBuf合并为一个逻辑上的ByteBuf, 而不需要进行数据拷贝
  3. 通过各种包装方法, 将 byte[]、ByteBuffer等包装成一个ByteBuf对象,而不需要进行数据的拷贝
  4. 通过slice方法, 将一个ByteBuf分解为多个共享同一个存储区域的ByteBuf, 避免了内存的拷贝,这在需要进行拆包操作时非常管用
  5. 通过FileRegion包装的FileChannel.tranferTo方法进行文件传输时, 可以直接将文件缓冲区的数据发送到目标Channel, 减少了通过循环write方式导致的内存拷贝。但是这种方式是需要得到操作系统的零拷贝的支持的,如果netty所运行的操作系统不支持零拷贝的特性,则netty仍然无法做到零拷贝。
阅读 3.3k

推荐阅读