Netty 的设计

Netty 的设计

官方文档

Github 地址:GitHub - netty/netty: Netty project - an event-driven asynchronous network application framework

官网:Netty

文档列表:Netty.docs: Netty.docs: Home

当前时间点(2023 年 12 月 14 日)推荐学习 4.1 版本,Github Release 页面中发布的版本也是 4.1 版本

主要概念介绍(本篇博客内容主要基于此):Netty data model, threading, and gotchas

跟 Netty 类似的高性能 Web 服务有 Akka,性能压测工具 Gatling 就基于 Netty 和 Akka,Akka 已经被 Netty 取代了,没有必要再深入学习

参考:Spark 为何使用 Netty 通信框架替代 Akka_akka 和 netty 关系-CSDN 博客

其他资料

Netty In Action 中文版 PDF:地址

质量很高的系列文章:Netty 系列文章

Netty 的定位以及作用

在 Netty 出现之前,在 Java 中进行网络编程是很麻烦的,只能通过 Socket 库和老版本的阻塞的 IO 也就是 OIO(old, blocking I/O)来实现,因为使用的是阻塞 IO,因此只能为每一个 socket 链接都创建一个线程,因为一个连接随时都可能阻塞,不能因为一个 socket 的阻塞影响到别的请求,这样做的结果就是连接数多了之后会创建大量的线程,这会造成大量的浪费,

关于如何用 OIO 来进行网路编程,请看《用 OIO 来进行 Socket 编程》

后来有了 NIO(non-blocking I/O),我们可以创建线程池来配合 NIO 来应对数量比较大的请求,但是 NIO 写起来太复杂,最终,Netty 出现了,有了 Netty,就可以方便地进行 socket 编程。

每一项技术的出现都是为了解决痛点

Netty 是基于 socket,同时支持 NIO 和 OIO,支持 TCP,也支持 UDP,既可以做客户端也可以做服务端。

Netty 抽象和封装了跟通信协议相关的这些底层的细节,让用户可以投入更多精力关注业务逻辑。

Netty 是事件驱动的(event-driven),这个事件更多指的是跟接收字节和发送字节有关的事件,不过 Netty 也支持用户自定义事件。

Netty 的 API 都是异步的,不会阻塞,基于 ChannelFuture(支持自定义回调函数)和 Promise(类似于 CompletableFuture)等对 JDK 中的 Future 对象的自定义拓展,所有的 API 调用基本都是返回这两种类型的值。

有了 Netty 我们就可以实现一个类似于 Tomcat 服务器的东西,监听指定端口,等待客户端连接,然后建立连接,提供服务。

基本概念

Channel

首先我们要搞清楚,什么是 ChannelChannel 的概念很具有迷惑性,Channel 这个词来自 NIO,翻译成通道,但是其指代的不是客户端跟服务端的连接,而是这个连接的两端,其实,用 socket 这个词表达得更准确,即其为连接两头的端点。

关于 socket 的定义,我们在《用电信号传输 TCP/IP 数据 —— 探索协议栈和网卡》中已经有所了解了

Channel 可以使用不同的传输方法

注意这个传输方法,我们在选择 EventLoopGroup 的实现的时候,必须跟 Channel 对应的传输方法匹配。

用户可以根据不同的传输方法和 socket 类型和是服务端还是客户端来决定使用 Channel 的哪种实现:

不同的 Channel 实现有不同的特性。比如 NIO 和 EPoll/KQueue 支持零拷贝(zero-byte copy),而其他的 Channel 实现并不支持。

关于什么是零拷贝,请看 博客

每一个 Channel 都有自己的唯一的 ChannelPipeline,并与 EventLoopGroup 中的一个 EventLoop 相关联。同时 Event 也在 Channel 上触发

ChannelPipeline - 重点

每个 Channel 都有且只有一个自己的 ChannelPipelineChannelPipeline 其实就是一个 ChannelHandler 列表,ChannelHandler 有三种类型

不同类型的 ChannelHandler 有不同的监听事件的方法,比如 ChannelInboundHandler 监听的事件有:

ChannelOutboundHandler 监听的事件有:

ChannelHandler 是我们基于 Netty 开发的应用中的干活的主力,应用的业务逻辑都是在这里实现。

我们前面说过 EventChannel 发出,Event 进入 Pipeline(管道),即到达 ChannelPipeline 后,实际上就会按照 ChannelPipeline 中注册的 ChannelHandler 的顺序,被所有的 ChannelHandler 处理,最后从最后一个 ChannelHandler 返回的时候,就像是从 ChannelPipeline 这个 Pipeline(管道)的另一口出来了。

ChannelHandler 处理 Event 的时候,你可以处理接收到的 ByteBuf,然后通过显示地调用,来传给下一个 ChannelHandler,或者直接不调用,相当于直接丢弃这个事件。

第一个 ChannelInboundHandler 从 socket 中拿到 ByteBuf,最后一个 ChannelOutboundHandler 处理完之后返回 ByteBuf 到 socket 中。在这个过程中,Netty 提供了很多 ChannelInboundHandler 来简化消息的编码和解码:

注意 ChannelPipelines 中的 ChannelHandler 是可以被 ChannelHandler 自身动态修改的,比如一个实现了 WebSocket 的应用,一开始握手的时候使用的是 HTTP 协议,然后会在握手之后升级协议为 WebSocket 协议,那么升级之后,就会删除掉 ChannelPipelines 中实现握手的 ChannelHandler

过程如下图所示:

关于深度设置 ChannelPipeline 的例子,请看 Datastax Java Driver for Apache Cassandra® 是如何设置他们的 ChannelPipeline 的。

ByteBuf

ByteBuf 是 Netty 版本的 ByteBuffer(NIO 中的概念),目的是简化 API 操作,比如,使用 ByteBuf 就不需要在 read 和 write 模式间的翻转,同时也增加了一些功能,比如零拷贝。

关于什么是零拷贝,请看 博客

ByteBuf 可以表示 JVM 堆内存、本机内存,或者是两者的组合。支持池化(pooling)和引用计数(reference-counting)来提高性能。

Event

Channel 会触发各种事件,我们在 ChannelHandler 中监听的,就是 Channel 发出的 Event,Event 从 Channel 发出,比如收到或者发送消息,都是 Event

EventLoopGroup & EventLoop

EventLoopGroupEventLoop 的容器,每一个 EventLoop 对象都跟一个线程专门关联,同时每个 Channel 在其生命周期内都只与一个 EventLoop 相关联。但是一个 EventLoop 可以同时关联多个 Channel,也就是说,EventLoopChannel 是一对多的关系,从 Channel 的角度看,一个 Channel 从始至终只能被一个固定的线程轮询,这样设计,就不用考虑线程安全问题了。因此,对一个 ChannelChannelHandler 的调用就完全是线性的,非并发的,其代码的编写完全不用考虑线程安全问题。

因为每一个 Channel 都对应着一个 EventLoop,因此,这个 Channel 的所有事件和 ChannelHandler 都是在这个 EventLoop 中执行的,从这个角度看 EventLoop 实际上就是一个专门处理网络 I/O 的线程。

不过这样的设计有一个很大的问题就是,如果有一个 Channel 的一个 ChannelHandler 很慢,花费时间很长,会影响同一个 EventLoop 关联的的其他 Channel 的事件处理,这是这个线程模型唯一的问题,因此,如果我们要在 ChannelHandler 中做一些重操作(耗时操作)比如查询数据库,那一定要开启一个新的线程去做。

EventLoopGroup 有多种实现,我们在选择实现的时候必须跟 Channel 对应的传输方法匹配,不同的实现也会有自带不同个数的 EventLoop

尽量少创建 EventLoopGroups,因为每一个都是一个线程池。过多地创建线程池有资源浪费的风险。

Netty 的使用注意点

Netty 经常用于基于 TCP 的客户端和服务端的通信。

当我们在使用 ServerBootstrap 创建 Netty 服务端的时候,会创建一个 Server Channel,同时会根据实际建立的连接给每个客户端额外创建一个 Child Channel,跟此客户端的后续通信都走这个 Child Channel,有多少个客户端就会创建多少个 child channel

TCP/IP 中,一个主要的概念是 ServerSocketSocket,其中 ServerSocket 用于服务器监听,当一个客户端连接请求到来时,ServerSocket 接受这个请求,并创建一个新的 Socket 来代表这个新的连接。
在 Netty 框架中,这个概念被抽象化为 ServerSocketChannel(相当于 ServerSocket)和 SocketChannel(相当于 Socket),其中 ServerSocketChannelServer Channel,是监听连接的通道,SocketChannel 是一旦有新的连接请求,就会创建的 Child Channel,专门用来处理这个连接的读写事件。这样做的好处是可以让每个连接都在自己的通道中进行处理,独立于其他通道,从而实现真正的高并发处理。不过,在 Netty 服务器端接受新的客户端连接时新创建的子 Channel,不会使用新的端口。所有的连接都会共享服务器启动时绑定的那个端口。

在 TCP 连接中,每个连接都由一个四元组来唯一确定,这个四元组包括:源 IP,源端口,目的 IP,目的端口。因此,对于服务端来说,即使只有一个监听端口,也可以处理来自不同客户端的多个并发连接请求。从服务端的角度看,每个客户端的连接虽然共享相同的服务器端口号,但他们的源 IP 地址和源端口号都是不同的,会构成不同的四元组,从而让服务器能够区分开来。
这个问题,我们在《多个客户端如何同时连接同一个服务器的同一个端口》中学习过

同时服务端必须提供两个 EventLoopGroup,一个 EventLoopGroup 用来监听 Server Channel 的 socket(仅一个) 以创立连接,一个用来监听多个 Child Channel 的 socket 来处理连接建立之后的数据处理。

为什么要分成两个 EventLoopGroup,我们前面说过,每一个 Channel 都有一个唯一对应的 EventLoop,也就是一个线程,而 EventLoop 的数目是有限的,因此一个 EventLoop 往往对应着多个 Channel,如果只有一个 EventLoopGroup,服务监听的 Channel 就会跟客户端处理 Channel 共用同一个 EventLoop,也就是一个线程,此时如果有一个客户端的 ChannelPipeline 阻塞了,Server Channel 的事件就会阻塞,无法被及时处理,反映到客户端就是服务端无法连接,因此,必须为 Server Channel 单独分配一个 EventLoop,保证无论如何服务端都不会被阻塞,最简单的办法就是专门分配一个 EventLoopGroup

客户端的创建也需要指定 EventLoopGroup 不过,客户端与服务端交换完数据之后,连接就会断开,不需要持续监听端口,因此只用一个 EventLoopGroup 即可。这就是服务端跟客户端的区别。

Netty 的线程模型

参考此 博客 中总结的线程模型

跟 OIO 的对比

关于如何用 OIO 来进行网路编程,请看《用 OIO 来进行 Socket 编程》

OIO 中面临的组赛的问题,在 Netty 中通通不存在

Netty 跟 OIO 相比的核心优化是,通过避免线程的阻塞,实现快速响应请求,而且因为避免了阻塞,所以可以用更少的线程来处理大量的请求。

我们在程序设计中,也可以借鉴 Netty 的这种思路,即:我们应该极力避免线程阻塞,应该尽量使用非租塞的 API,而且,非租塞往往跟异步组合使用,这样,才能增加后端的响应速度,提升用户体验。

有一个误区我们应该避免,就是阻塞其实不会浪费 CPU 性能的浪费,线程阻塞的时候,CPU 会去执行其他的线程,线程阻塞的坏处在于白白增加了等待的时间,拖慢了业务的执行速度

同样的线程数,IO 密集型线程(会阻塞)的 CPU 占用率是低于 CPU 密集型的 CPU 占用率的,因此可以得出结论,线程组赛不会浪费 CPU 性能,请看《确定做一项工作所需要的线程数》