彻底理解Netty,这一篇文章就够了

3次阅读

共计 4495 个字符,预计需要花费 12 分钟才能阅读完成。

Netty 到底是什么
从 HTTP 说起
有了 Netty,你可以实现自己的 HTTP 服务器,FTP 服务器,UDP 服务器,RPC 服务器,WebSocket 服务器,Redis 的 Proxy 服务器,MySQL 的 Proxy 服务器等等。
我们回顾一下传统的 HTTP 服务器的原理
1、创建一个 ServerSocket,监听并绑定一个端口
2、一系列客户端来请求这个端口
3、服务器使用 Accept,获得一个来自客户端的 Socket 连接对象
4、启动一个新线程处理连接
4.1、读 Socket,得到字节流
4.2、解码协议,得到 Http 请求对象
4.3、处理 Http 请求,得到一个结果,封装成一个 HttpResponse 对象
4.4、编码协议,将结果序列化字节流 写 Socket,将字节流发给客户端
5、继续循环步骤 3
HTTP 服务器之所以称为 HTTP 服务器,是因为编码解码协议是 HTTP 协议,如果协议是 Redis 协议,那它就成了 Redis 服务器,如果协议是 WebSocket,那它就成了 WebSocket 服务器,等等。使用 Netty 你就可以定制编解码协议,实现自己的特定协议的服务器。
NIO
上面是一个传统处理 http 的服务器,但是在高并发的环境下,线程数量会比较多,System load 也会比较高,于是就有了 NIO。
他并不是 Java 独有的概念,NIO 代表的一个词汇叫着 IO 多路复用。它是由操作系统提供的系统调用,早期这个操作系统调用的名字是 select,但是性能低下,后来渐渐演化成了 Linux 下的 epoll 和 Mac 里的 kqueue。我们一般就说是 epoll,因为没有人拿苹果电脑作为服务器使用对外提供服务。而 Netty 就是基于 Java NIO 技术封装的一套框架。为什么要封装,因为原生的 Java NIO 使用起来没那么方便,而且还有臭名昭著的 bug,Netty 把它封装之后,提供了一个易于操作的使用模式和接口,用户使用起来也就便捷多了。
说 NIO 之前先说一下 BIO(Blocking IO), 如何理解这个 Blocking 呢?
客户端监听(Listen)时,Accept 是阻塞的,只有新连接来了,Accept 才会返回,主线程才能继
读写 socket 时,Read 是阻塞的,只有请求消息来了,Read 才能返回,子线程才能继续处理
读写 socket 时,Write 是阻塞的,只有客户端把消息收了,Write 才能返回,子线程才能继续读取下一个请求
传统的 BIO 模式下,从头到尾的所有线程都是阻塞的,这些线程就干等着,占用系统的资源,什么事也不干。
那么 NIO 是怎么做到非阻塞的呢。它用的是事件机制。它可以用一个线程把 Accept,读写操作,请求处理的逻辑全干了。如果什么事都没得做,它也不会死循环,它会将线程休眠起来,直到下一个事件来了再继续干活,这样的一个线程称之为 NIO 线程。用伪代码表示:
while true {
events = takeEvents(fds) // 获取事件,如果没有事件,线程就休眠
for event in events {if event.isAcceptable {
doAccept() // 新链接来了
} elif event.isReadable {
request = doRead() // 读消息
if request.isComplete() {
doProcess()
}
} elif event.isWriteable {
doWrite() // 写消息
}
}
}
Reactor 线程模型
Reactor 单线程模型
一个 NIO 线程 + 一个 accept 线程:

Reactor 多线程模型

Reactor 主从模型
主从 Reactor 多线程:多个 acceptor 的 NIO 线程池用于接受客户端的连接

Netty 可以基于如上三种模型进行灵活的配置。
总结
Netty 是建立在 NIO 基础之上,Netty 在 NIO 之上又提供了更高层次的抽象。
在 Netty 里面,Accept 连接可以使用单独的线程池去处理,读写操作又是另外的线程池来处理。
Accept 连接和读写操作也可以使用同一个线程池来进行处理。而请求处理逻辑既可以使用单独的线程池进行处理,也可以跟放在读写线程一块处理。线程池中的每一个线程都是 NIO 线程。用户可以根据实际情况进行组装,构造出满足系统需求的高性能并发模型。
为什么选择 Netty
如果不用 netty,使用原生 JDK 的话,有如下问题:
1、API 复杂
2、对多线程很熟悉:因为 NIO 涉及到 Reactor 模式
3、高可用的话:需要出路断连重连、半包读写、失败缓存等问题
4、JDK NIO 的 bug
而 Netty 来说,他的 api 简单、性能高而且社区活跃(dubbo、rocketmq 等都使用了它)
什么是 TCP 粘包 / 拆包
现象
先看如下代码,这个代码是使用 netty 在 client 端重复写 100 次数据给 server 端,ByteBuf 是 netty 的一个字节容器,里面存放是的需要发送的数据
public class FirstClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) {
for (int i = 0; i < 1000; i++) {
ByteBuf buffer = getByteBuf(ctx);
ctx.channel().writeAndFlush(buffer);
}
}
private ByteBuf getByteBuf(ChannelHandlerContext ctx) {
byte[] bytes = “ 需要更多资料加群:586446657”.getBytes(Charset.forName(“utf-8”));
ByteBuf buffer = ctx.alloc().buffer();
buffer.writeBytes(bytes);
return buffer;
}
}

从 client 端读取到的数据为:

从服务端的控制台输出可以看出,存在三种类型的输出
一种是正常的字符串输出。
一种是多个字符串“粘”在了一起,我们定义这种 ByteBuf 为粘包。
一种是一个字符串被“拆”开,形成一个破碎的包,我们定义这种 ByteBuf 为半包。
透过现象分析原因
应用层面使用了 Netty,但是对于操作系统来说,只认 TCP 协议,尽管我们的应用层是按照 ByteBuf 为 单位来发送数据,server 按照 Bytebuf 读取,但是到了底层操作系统仍然是按照字节流发送数据,因此,数据到了服务端,也是按照字节流的方式读入,然后到了 Netty 应用层面,重新拼装成 ByteBuf,而这里的 ByteBuf 与客户端按顺序发送的 ByteBuf 可能是不对等的。因此,我们需要在客户端根据自定义协议来组装我们应用层的数据包,然后在服务端根据我们的应用层的协议来组装数据包,这个过程通常在服务端称为拆包,而在客户端称为粘包。
拆包和粘包是相对的,一端粘了包,另外一端就需要将粘过的包拆开,发送端将三个数据包粘成两个 TCP 数据包发送到接收端,接收端就需要根据应用协议将两个数据包重新组装成三个数据包。
如何解决
在没有 Netty 的情况下,用户如果自己需要拆包,基本原理就是不断从 TCP 缓冲区中读取数据,每次读取完都需要判断是否是一个完整的数据包 如果当前读取的数据不足以拼接成一个完整的业务数据包,那就保留该数据,继续从 TCP 缓冲区中读取,直到得到一个完整的数据包。如果当前读到的数据加上已经读取的数据足够拼接成一个数据包,那就将已经读取的数据拼接上本次读取的数据,构成一个完整的业务数据包传递到业务逻辑,多余的数据仍然保留,以便和下次读到的数据尝试拼接。
而在 Netty 中,已经造好了许多类型的拆包器,我们直接用就好:

选好拆包器后,在代码中 client 段和 server 端将拆包器加入到 chanelPipeline 之中就好了:
如上实例中:
客户端:
ch.pipeline().addLast(new FixedLengthFrameDecoder(31));
服务端:
ch.pipeline().addLast(new FixedLengthFrameDecoder(31));

Netty 的零拷贝
传统意义的拷贝
是在发送数据的时候,传统的实现方式是:

File.read(bytes)
Socket.send(bytes)

这种方式需要四次数据拷贝和四次上下文切换:

数据从磁盘读取到内核的 read buffer
数据从内核缓冲区拷贝到用户缓冲区
数据从用户缓冲区拷贝到内核的 socket buffer
数据从内核的 socket buffer 拷贝到网卡接口(硬件)的缓冲区

零拷贝的概念
明显上面的第二步和第三步是没有必要的,通过 java 的 FileChannel.transferTo 方法,可以避免上面两次多余的拷贝(当然这需要底层操作系统支持)

调用 transferTo, 数据从文件由 DMA 引擎拷贝到内核 read buffer
接着 DMA 从内核 read buffer 将数据拷贝到网卡接口 buffer

上面的两次操作都不需要 CPU 参与,所以就达到了零拷贝。
Netty 中的零拷贝
主要体现在三个方面:
1、bytebuffer
Netty 发送和接收消息主要使用 bytebuffer,bytebuffer 使用对外内存(DirectMemory)直接进行 Socket 读写。
原因:如果使用传统的堆内存进行 Socket 读写,JVM 会将堆内存 buffer 拷贝一份到直接内存中然后再写入 socket,多了一次缓冲区的内存拷贝。DirectMemory 中可以直接通过 DMA 发送到网卡接口
2、Composite Buffers
传统的 ByteBuffer,如果需要将两个 ByteBuffer 中的数据组合到一起,我们需要首先创建一个 size=size1+size2 大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用 Netty 提供的组合 ByteBuf,就可以避免这样的操作,因为 CompositeByteBuf 并没有真正将多个 Buffer 组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。
3、对于 FileChannel.transferTo 的使用
Netty 中使用了 FileChannel 的 transferTo 方法,该方法依赖于操作系统实现零拷贝。
Netty 内部执行流程
服务端:

1、创建 ServerBootStrap 实例
2、设置并绑定 Reactor 线程池:EventLoopGroup,EventLoop 就是处理所有注册到本线程的 Selector 上面的 Channel
3、设置并绑定服务端的 channel
4、5、创建处理网络事件的 ChannelPipeline 和 handler,网络时间以流的形式在其中流转,handler 完成多数的功能定制:比如编解码 SSl 安全认证
6、绑定并启动监听端口
7、当轮训到准备就绪的 channel 后,由 Reactor 线程:NioEventLoop 执行 pipline 中的方法,最终调度并执行 channelHandler
客户端

总结
以上就是我对 Netty 相关知识整理,如果有不同的见解,欢迎讨论!

正文完
 0