作者:一川
https://mp.weixin.qq.com/s/SPdA5XIQVUPOKz4d-bY29g
小编先说:这篇内容同样是来自于内部分享的总结。网络编程永远是java工程师心中的一座高山,而Netty却是照亮山路的明灯。大多数没有深入过网络编程的人都对Netty充满好奇,今天一起来看看。这篇文章也是近期这个领域下为数不多的精彩好文。
Netty概览
Netty是由Trustin Lee在2004年发布的异步事件驱动的网络应用程序框架,作者是个80年的韩国人,同时也是Apache Mina的作者。Netty封装了网络编程的复杂性,其开箱即用的特性,可以让你方便、高效去实现自己HTTP服务器,FTP服务器,UDP服务器,RPC服务器,WebSocket服务器。
Netty业界应用
随着Netty的勃勃发展,由Netty构建的应用如雨后春笋般不断涌出,目前Netty已经被广泛应用于rpc框架、大数据、服务发现、消息中间件等多个领域.如我司的Rpc框架Tesla以及服务发现Discover;阿里的Dubbo、RocketMQ;大数据领域的Spark、Hadoop;
Netty在蘑菇街的应用
实际不仅仅上述所说的应用,实际在我司在很多应用中都使用了Netty来实现应用的网络通讯层,使用一个Boss线程和若干个线程处理epoll的注册和读写事件,在读事件处理完毕后,交由后台的线程池处理;不仅如此,在很多时候,也会使用Netty中的工具来优化我们的应用性能。
Netty详解
在这一部分,主要会从IO传输模型、数据协议、线程模型这三个部分对Netty进行讲解,顺带会讲一下责任链模式在Netty中的应用。
IO传输模型
IO传输模型决定了我们以何种方式更加高效的将我们的数据在内核空间、用户空间、IO设备之间复制、转换、处理。
首先我们来简单的介绍一下IO传输模型,从系统层面上来说,Linux支持五种IO传输模型,包括同步阻塞、同步非阻塞、IO多路复用、信号IO、异步IO,而为什么要弄这么多传输模型呢?其实本质都是为了减少CPU在IO调用上的时间消耗,由于IO操作大都由系统内核调用来完成的,系统调用又是通过cpu来调度的,而CPU的速度远远快于IO操作,这样就会导致cpu会经常处于等IO操作的状态,这样的话,就会很浪费CPU的资源,为了减少cpu在调用IO时候的消耗,逐渐发展出各种IO模型。
接着我们再说一下Java中的BIO、NIO、AIO,Java中的BIO就是基于同步阻塞模型实现的,它的好处就是代码实现起来简单、直观,缺点也很明显IO效率存在瓶颈。具体原理如下图所示,用户进程发送请求给内核,然后由内核去进行通信,在内核准备好之前,线程是被挂起的,所以在等待返回数据、复制数据这两个阶段程序都是处于挂起状态;类比成client和Server模式,则其实现模式为一个连接一个线程,即客户端有连接请求时,服务端就需要启动一个线程进行处理,待操作系统返回结果;如果这个连接不做任何事情,会造成不必要的线程开销,当然这也可以通过线程池机制来改善;
Java中的NIO是基于Linux中的IO多路复用模型实现的,如下图所示,在发起请求时去反复检查数据是否已经准备好,把原来大块不能用的阻塞时间分成许多小阻塞,检查的时候会有一些阻塞,类似自旋,所以线程会不断有机会的去被执行这个检查有没有准备好数据的过程;类比成client和server模式,其实现的模式为一个请求一个线程,及客户端发送的连接请求,都会注册到多路复用器上,多路复用器轮询到连接有IO请求时,才启动一个线程进行处理。这里也能看出来NIO在等待返回数据是非阻塞的。
Java中的AIO属于Linux中异步模型,是基于事件和回调机制来实现的。可以理解为应用操作直接返回而不会阻塞在那里,当后台处理完成,就会通知响应线程进行后续工作。
为什么Netty不再支持BIO和AIO呢,而独取NIO这一瓢呢?BIO通过上述分析我们应该也能知道为啥,当我们在连接数高的情况下,会有性能瓶颈;那为什么AIO也被弃用了呢,这就需要追究到系统层面上了,被常作为服务器的Linux中的AIO系统不仅不成熟,而且性能相较于NIO提升也不明显,所以Netty开发者认为没有必要大费周章去维护这么一套东西。
零拷贝
零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
上面这段话摘自维基百科,解释也比较通俗易懂, 零拷贝实际也就是为了减少内存与其他存储区域之间复制次数,也因此 CPU 的效率就能得以提升,如果按照拷贝区域来划分,在netty中我们可以分成这几种,从系统层面来说,就是用户空间和内核空间之间的数据拷贝,从java应用程序层面来说,包括java堆内和堆外内存之间拷贝、堆内之间的内存拷贝。
1、减少用户和内核空间之间的数据拷贝: FileRegion
这种零拷贝主要应用于文件传输(相关使用方法见io.netty.example.http.file.HttpStaticFileServerHandler),其最终调用方法DefaultFileRegion.transferTo本质其实就是基于NIO的FileChannel.transferTo来实现的。所以我们不如直击其本源,直接来介绍FileChannel.transferTo这个方法,如下图第一个红框中的内容,这个方法可能比简单循环的从一个channel中读取数据再写到目标channel中更有效,(这里划重点“可能”,而为什么是可能呢,因为这依赖于操作系统底层的支持),许多操作系统如Linux就可以直接将字节从文件系统缓存传输到目标通道,而不需要实际复制它们。而操作系统底层又是怎么支持的呢?
假设我们这里有个使用场景是这样的,我们先读取硬盘中的数据,然后再写出到IO设备当中;假设这是个java应用,首先虚拟机会通过用户空间向内核空间发送read()这样的系统调用,紧接着操作系统会将用户空间模式切换到内核空间模式,这里就会涉及到一次上下文切换, 之后内核空间会向硬盘发出一个读取数据的请求,数据这个时候就会被读取到内核空间的缓冲区当中,注意这一步是通过DMA的方式去进行的,也就是direct memory access(这里有一点需要注意的是,一般情况下,硬盘或IO设备到内核空间的复制操做都是由DMA引擎执行的,内核空间和用户空间之间的的数据拷贝都是由cpu执行的);拷贝完之后,用户空间还是无法直接使用的,还需要在将这份数据原封不动的拷贝到用户空间的缓冲区, 接下来你就可以继续执行你的业务逻辑代码,这时候 这个read操作就结束了;接着就是write方法,操作系统会将内核空间上的socket buffer真正的写到IO设备当中去,等操作完成后,write方法就会返回;而这整个流程就会涉及到四次的上下文切换和四次数据拷贝,而其中内核与用户之间的两次数据拷贝,在我们看来是没有必要的。那有没有办法去减少这两次不必要的上下文切换以及数据拷贝呢?
答案是有的,但是需要注意的是,这是得依赖于操作系统的,操作系统支持这样的命令才可以去进行,Java本身是做不了任何事情的,这个时候实际是是调用了sendfilte的系统命令(Linux2.2起始,具体可见https://man7.org/linux/man-pages/man2/sendfile.2.html);首先也是通过直接内存访问的方式将磁盘中的数据拷贝到内核空间,接着将这个数据拷贝到socket 缓冲区中,然后拷贝sokcet缓冲区中的数据到网络IO设备中去进行数据发送。
但是我们发现这里有一层复制好像还是多余的,就是拷贝到socket缓冲区,庆幸的是在Linux2.4中,这便得到了优化,如下图,数据并未被复制到socket关联的缓冲区内。只是将记录数据位置和长度的描述符加入到socket缓冲区中。DMA模块将数据直接从内核缓冲区传递给Protocol Engine,从而中间拷贝到socket缓冲区的这层复制。
2、减少Java堆内和堆外内存拷贝:DirectByteBuffer
比如这里我们举个例子,这里如果我们使用HeapByteBuffer进行数据读取写入的时候,会发生什么呢?首先你再创建的时候jvm会帮我们在堆上面去创建一个byte数组,但是需要注意的是,这个时候这个byte数组是没法直接被操作系统所使用的,操作系统会在Java内存模型外又会开辟一个内存区域,也就是将java堆上byteBuffer 会拷贝到刚刚系统开辟的内存里面,然后再将这里面的数据拿出来跟io设备进行交互;这里大家会不会有疑问,既然操作系统能访问堆上这块数据的,为什么不直接在上面进行操作,但是实际并没有这么简单,当我们操作系统通过JNI访问接口的时候,正好发生了GC,我们都知道除了cms这种使用标记清除算法之外,其他的垃圾回收器一般都会使用标记压缩这种方式,也是为了避免内存碎片,但是这样的话,就会找不到之前的地址了 ,从而发生异常,所以直接操作是不安全的,直接被pass了。
在Netty中接受和发送ByteBuffer可以采用DirectBuffer,DirectBuffer使用堆外直接内存进行socket读写,不需要进行字节缓冲区的二次拷贝。它的原理就是会将这个byte数组直接通过调用C/C++方法放在直接内存中,这样也就避免了一次从java堆上到堆外内存的拷贝,而创建之后如何关联到这块数据呢,是通过address这个变量去做引用的。
3、减少java堆内之间的内存拷贝:CompositeByteBuf
这个是Netty自己实现的,它的作用是聚合多个ByteBuffer对象,然后对外提供统一的ByteBuf接口,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer。但是需要注意的是这种组合是逻辑上的组合,不是物理上的给你放在一块连续的内存区域了。
协议相关
数据协议表示采用什么样的通信协议,是HTTP还是内部私有协议。协议的选择不同,性能模型也就不同。而与协议相关的就是序列化框架,这部分我们稍后会详细的讲解。
半包、粘包
假设我们使用tcp发送两条消息,ABC和DEF,那么接收方接受到的消息一定是这种格式的嘛?不一定,接收方可能一次就接受完了,也有可能分成了三四次接受完数据;而前者就是粘包问题,如我们在第一次就接受到了ABCDEF,一般是由于发送方每次写入的数据小于套接字缓冲区大小或者接收方读取套接字缓冲区数据不够及时,而后者就是半包问题,如可能我们分批接收到AB,CD,EF.一般是由于发送方每次写入的数据 大于套接字缓冲区大小或者发送的数据大于协议的MTU(Maximum Transmission Unit,最大传输单元),必须拆包。
而导致这种现象的本质原因是什么呢?UDP会出现这种情况嘛?其实本质原因就是Tcp是流失协议,在tcp中消息时没有边界的,而udp就不会出现这种问题,因为在udp中数据会像包裹一样被分批打包,虽然一次运输多个,但是每个包都有界限,接收方再一个个签收,所以无粘包、半包问题。那么当我们使用Tcp时,应该如何解决这个问题呢?
半包粘包在Netty中的解决方式
其实解决方式的本质也就是给消息寻找一个边界,Netty为我们提供了如下三种解决方式:
解码器相关类图如下:
编码与解码
以解码为例,在Netty中一般会有两次解码,而上述解决粘包、半包问题的解码器,我们一般称之为一次解码,将原始数据转换为用户数据,相关解码器都继承自ByteToMessageDecoder,但是这个时候我们还是没法进行业务处理,我们还需要进行二次编码,将Message转为我们可直接使用的Java Object对象,相关解码器都继承自MessageToMessageDecoder(编解码的过程通常也称作反序列化/序列化操作)。
而序列化框架的选择也是十分有考究的,一般我们选择序列化框架会从空间占用、编解码速度、可读性、是否支持多语言这几个方面去考虑,而令人欣喜的是 Netty对这些高性能的序列化框架也都有所支持,如下图所示。
线程模型
线程模型涉及到如何读取数据包,读取之后的编解码在哪个线程中进行,编解码后的消息如何派发等方面。实际理解就是如何分配干活的人,最好让大家各司其职都不能闲着,所以线程模型设计得不同,对性能也会有很多影响。
Netty使用了Reactor线程模型,包括了单线程模型、多线程模型和主从多线程模型。
1. Reactor单线程模型
如下图所示,所有的IO操作都由一个NIO线程中进行处理,单线程模型中单个线程处理建立连接并将接收到的消息分发到对应的handler中,但是单线程模型在处理大量连接的时候性能通常不能满足要求。
2. Reactor多线程模型
如下图所示,Rector多线程模型与单线程模型最大的区别就是设计了一个NIO线程池处理I/O操作,并且使用一个专门的NIO线程用于监听服务端、接收客户端的TCP连接请求。Reactor多线程模型足以应对大多数的场景了;但是当百万客户端并发连接时,单个Acceptor线程可能会存在性能不足的问题,为了解决这种问题,就出现了主从Reactor多线程模型。
3. Reactor主从多线程模型
服务端用于接收客户端连接的不再是单个NIO线程,而是分配了一个独立的NIO线程池。Acceptor接收到客户端TCP连接请求并处理完成后(可能包含接入认证等),将新创建的SocketChannel注册到I/O线程池(Sub Reactor子线程池)的某个I/O线程上,由它负责SocketChannel的读写和编解码工作。Acceptor线程池仅仅用于客户端的登录、握手和安全认证,一旦链路建立成功,就将链路注册到后端Sub Reactor子线程池的I/O线程上,再由I/O线程负责后续的I/O操作。
Pipeline模式在Netty中的应用
实际在Netty使用的到的设计模式有很多,包括Bootstrap创建时用到的build模式, ChannelPipeline中使用到的责任链模式,Channel实例化时使用到的工厂模式等等,这里我们会主要介绍一下Pipeline模式在Netty中的应用。
什么是责任链模式呢?
责任链模式由一个或多个不同的责任处理器组成,它会将多个处理器串成链,然后让请求在链上传递,直到被某个处理器终止。那它有什么好处呢?1. 最直观的就是将请求者和接收者解耦 2. 可以让业务以插件式的方式封装到不同的处理器中,每个处理器各司其职。
责任链中的核心组件
一个较为完善的责任链实现应该由责任链处理器、责任链、责任链上下文组成。
+ 责任处理器: 首先整条链路应该是由一个个责任处理器Handler来组成,对此我们可以设计一个处理器接口Handler,其中定义invoke执行相应的handler的业务逻辑,而其方法的入参也就是上下文BasicPipelineContext就像一根绳子一样把一个个Handler串起来,不仅可以给我们提供数据信息,还可以提供整个pipeline的执行状态信息。
+ 责任链: 一般情况下,责任链会包含创建链,删除或添加责任处理器等方法;
+ 责任链上下文:让责任处理接口感知到你的上下文,通过上下文拿到对应的参数和状态
Netty中的责任链模式
先来介绍一下Netty中的责任链相关组件
+ ChannelHandler: 责任处理器,从它的方法里我们也可以看到它可以处理多种事件。如添加或删除Handler
public interface ChannelHandler {
void handlerAdded(ChannelHandlerContext ctx) throws Exception;
void handlerRemoved(ChannelHandlerContext ctx) throws Exception;
...
}
+ ChannelPipeline:也就是责任链,包含链的创建、添加、删除责任处理器方法
public interface ChannelPipeline extends ChannelInboundInvoker, ChannelOutboundInvoker, Iterable<Entry<String, ChannelHandler>> {
ChannelPipeline addFirst(String name, ChannelHandler handler);
ChannelPipeline addLast(String name, ChannelHandler handler);
....
}
+ ChannelHandlerContext: 也就是单条责任链的上下文,包含与之关联的Channel、执行线程.
public interface ChannelHandlerContext extends AttributeMap, ChannelInboundInvoker, ChannelOutboundInvoker {
Channel channel();
EventExecutor executor();
String name();
...
}
Pipeline在Netty中的执行流程
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast("1", new InboundHandlerA());
p.addLast("2", new InboundHandlerB());
p.addLast("3", new OutboundHandlerA());
p.addLast("4", new OutboundHandlerB());
p.addLast("5", new InboundHandlerC());
}
});
ChannelFuture f = b.connect(HOST, PORT).sync();
f.channel().closeFuture().sync();
如上代码只是模拟客户端,其中我们在添加业务处理Handler了的时候便使用到了责任链模式,所以我们核心主要关注BootStrap中的handler方法,在这个方法中我们将5个不同的Handler依次添加到pipeline中,那么在Netty中要怎么创建ChannelPipeline呢?其实Netty在实例化channel的时候,便会自动在AbstractChannel构造器中创建相应的ChannelPipeline,在AbstractChannel的构造器中最终会将其初始化为DefaultChannelPipeline,具体初始化逻辑如下代码,从这段代码里我们便可以的知道Netty中的Pipeline的底层本质便是一个双向链表,并且它的头节点和尾节点在初始化Channel的时候,便由DefaultChannelPipeline构造器中创建完成。
protected DefaultChannelPipeline(Channel channel) {
this.channel = ObjectUtil.checkNotNull(channel, "channel");
succeededFuture = new SucceededChannelFuture(channel, null);
voidPromise = new VoidChannelPromise(channel, true);
tail = new TailContext(this);
head = new HeadContext(this);
head.next = tail;
tail.prev = head;
}
如果按照上面我们分析的数据结构,那么按理说最终我们的上述代码会形成如下管道样式,那么这时候如果一个读请求进来,我们的管道会怎么执行呢?按常理来说,既然是双向链表,应该是顺序往下执行,不断invokeNext就行了吧。但是Netty中真是这样嘛?
这里我们直接来看官方给出执行流程图吧,在Netty中ChannelHandler根据事件的不同分为InboundHandler和OutboundHandler,前者通常由IO线程触发,在Netty触发Inbound事件方法一般以fire开头,如Channel注册事件ChannelHandlerContext.fireChannelRegistered(),而Outbound事件通常为主动发起的网络IO操作,如 绑定操作,ChannelHandlerContext.bind()。而如下图,我们可知,在Netty中,Outbound事件是自顶向下传播的,也就是从Tail节点开始传播,而inbound事件则反之,自底向上进行传播,也就是从Head节点开始传播。所以当一个inbound事件触发时,上面的pipeline执行流程应该是,1 => 2 =>5,反之一个Outbound事件触发时,执行流程应该是 4 => 3
* <pre> *
I/O Request *
via {@link Channel} or *
{@link ChannelHandlerContext} * | * +---------------------------------------------------+---------------+ * | ChannelPipeline | | * | \|/ | * | +---------------------+ +-----------+----------+ | * | | Inbound Handler N | | Outbound Handler 1 | | * | +----------+----------+ +-----------+----------+ | * | /|\ | | * | | \|/ | * | +----------+----------+ +-----------+----------+ | * | | Inbound Handler N-1 | | Outbound Handler 2 | | * | +----------+----------+ +-----------+----------+ | * | /|\ . | * | . . | * | ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()| * | [ method call] [method call] | * | . . | * | . \|/ | * | +----------+----------+ +-----------+----------+ | * | | Inbound Handler 2 | | Outbound Handler M-1 | | * | +----------+----------+ +-----------+----------+ | * | /|\ | | * | | \|/ | * | +----------+----------+ +-----------+----------+ | * | | Inbound Handler 1 | | Outbound Handler M | | * | +----------+----------+ +-----------+----------+ | * | /|\ | | * +---------------+-----------------------------------+---------------+ * | \|/ * +---------------+-----------------------------------+---------------+ * | | | | * | [ Socket.read() ] [ Socket.write() ] | * | | * | Netty Internal I/O Threads (Transport Implementation) | * +-------------------------------------------------------------------+ * </pre>
这里提一嘴,是不是责任链模式都是使用这种双向链表的方式呢?不一定,比如在在Tomcat中就不是这样,在Tomcat中每一个容器都有一个 Pipeline 对象,只要触发这个 Pipeline 的第一个 Valve,这个容器里 Pipeline 中的 Valve 就都会被调用到。但是,不同容器的 Pipeline 是怎么链式触发的呢,比如 Engine 中 Pipeline 需要调用下层容器 Host 中的 Pipeline。而一个容器的Pipeline又怎么调用到下一个容器的Pipeline呢?这是因为 Pipeline 中还有个 getBasic 方法。这个 BasicValve 处于 Valve 链表的末端,它是 Pipeline 中必不可少的一个 Valve,负责调用下层容器的 Pipeline 里的第一个 Valve。而Wrapper中的最后一个Vavle会创建一个filter链,并调用doFilter()方法,最后调用到servlet的service方法,执行具体的业务处理。如下图,
(ps:链路的入口在CoyoteAdapter中如下位置)
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
以上是内部关于netty相关的分享,大家还想看到哪类文章可以给小编留言哦~
注意:本文归作者所有,未经作者允许,不得转载