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 取代了,没有必要再深入学习
其他资料
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
首先我们要搞清楚,什么是 Channel
,Channel
的概念很具有迷惑性,Channel 这个词来自 NIO,翻译成通道,但是其指代的不是客户端跟服务端的连接,而是这个连接的两端,其实,用 socket 这个词表达得更准确,即其为连接两头的端点。
关于 socket 的定义,我们在《用电信号传输 TCP/IP 数据 —— 探索协议栈和网卡》中已经有所了解了
Channel
可以使用不同的传输方法:
注意这个传输方法,我们在选择
EventLoopGroup
的实现的时候,必须跟Channel
对应的传输方法匹配。
-
OIO
-
NIO
-
在 Linux 系统,使用 EPoll
-
在 BSD/MacOS 系统中,使用 KQueue
-
支持在同一个 JVM 虚拟机中相互通信
-
或者内嵌方式(Embedded),内嵌方式通常用于测试
用户可以根据不同的传输方法和 socket 类型和是服务端还是客户端来决定使用 Channel
的哪种实现:
-
NioServerSocketChannel
(NIO transport, server socket) -
NioSocketChannel
(NIO transport, client socket) -
EPollDatagramChannel
(EPoll transport, UDP socket)
不同的 Channel
实现有不同的特性。比如 NIO 和 EPoll/KQueue 支持零拷贝(zero-byte copy),而其他的 Channel
实现并不支持。
关于什么是零拷贝,请看博客
每一个Channel
都有自己的唯一的ChannelPipeline
,并与EventLoopGroup
中的一个EventLoop
相关联。同时Event
也在Channel
上触发
ChannelPipeline - 重点
每个 Channel
都有且只有一个自己的 ChannelPipeline
,ChannelPipeline
其实就是一个 ChannelHandler
列表,ChannelHandler
有三种类型
-
ChannelInboundHandler
处理入站事件 -
ChannelOutboundHandler
处理出站事件 -
既是
ChannelInboundHandler
同时也是ChannelOutboundHandler
不同类型的 ChannelHandler
有不同的监听事件的方法,比如ChannelInboundHandler
监听的事件有:
-
channelRegistered
-
channelUnregistered
-
channelActive
-
channelInactive
-
channelRead
-
channelReadComplete
-
userEventTriggered
-
channelWritabilityChanged
-
exceptionCaught
-
handlerAdded
-
handlerRemoved
ChannelOutboundHandler
监听的事件有:
-
bind
-
connect
-
disconnect
-
close
-
deregister
-
read
-
write
-
flush
-
handlerAdded
-
handlerRemoved
ChannelHandler
是我们基于 Netty 开发的应用中的干活的主力,应用的业务逻辑都是在这里实现。
我们前面说过Event
从Channel
发出,Event
进入 Pipeline(管道),即到达ChannelPipeline
后,实际上就会按照ChannelPipeline
中注册的ChannelHandler
的顺序,被所有的ChannelHandler
处理,最后从最后一个ChannelHandler
返回的时候,就像是从ChannelPipeline
这个 Pipeline(管道)的另一口出来了。
在ChannelHandler
处理Event
的时候,你可以处理接收到的ByteBuf
,然后通过显示地调用,来传给下一个ChannelHandler
,或者直接不调用,相当于直接丢弃这个事件。
第一个ChannelInboundHandler
从 socket 中拿到ByteBuf
,最后一个ChannelOutboundHandler
处理完之后返回ByteBuf
到 socket 中。在这个过程中,Netty 提供了很多ChannelInboundHandler
来简化消息的编码和解码:
-
ByteToMessageDecoder
:从ByteBuf
解码为消息,可以解码成字符串 -
MessageToByteEncoder
:从常用类型比如字符串编码为ByteBuf
-
MessageToMessageDecoder/Encoder/Codec
:将一个类型的消息转化为另一种类型的消息。
注意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
EventLoopGroup
是EventLoop
的容器,每一个EventLoop
对象都跟一个线程专门关联,同时每个 Channel
在其生命周期内都只与一个 EventLoop
相关联。但是一个EventLoop
可以同时关联多个Channel
,也就是说,EventLoop
和Channel
是一对多的关系,从Channel
的角度看,一个Channel
从始至终只能被一个固定的线程轮询,这样设计,就不用考虑线程安全问题了。因此,对一个Channel
的ChannelHandler
的调用就完全是线性的,非并发的,其代码的编写完全不用考虑线程安全问题。
因为每一个
Channel
都对应着一个EventLoop
,因此,这个Channel
的所有事件和ChannelHandler
都是在这个EventLoop
中执行的,从这个角度看EventLoop
实际上就是一个专门处理网络I/
O 的线程。
不过这样的设计有一个很大的问题就是,如果有一个Channel
的一个ChannelHandler
很慢,花费时间很长,会影响同一个EventLoop
关联的的其他Channel
的事件处理,这是这个线程模型唯一的问题,因此,如果我们要在ChannelHandler
中做一些重操作(耗时操作)比如查询数据库,那一定要开启一个新的线程去做。
EventLoopGroup
有多种实现,我们在选择实现的时候必须跟 Channel
对应的传输方法匹配,不同的实现也会有自带不同个数的EventLoop
。
-
NIOEventLoopGroup
为 NIO 传输方法设计,默认创建处理器核心个数两倍的EventLoop
,并均匀地分给每一个Channel
EPollEventLoopGroup
和KqueueEventLoopGroup
默认也是同样数量的EventLoop
-
OIOEventLoopGroup
为 OIO 传输方法设计,会为每一个Channel
创建一个EventLoop
。
尽量少创建EventLoopGroups
,因为每一个都是一个线程池。过多地创建线程池有资源浪费的风险。
Netty 的使用注意点
Netty 经常用于基于 TCP 的客户端和服务端的通信。
当我们在使用ServerBootstrap
创建 Netty 服务端的时候,会创建一个Server Channel
,同时会根据实际建立的连接给每个客户端额外创建一个Child Channel
,跟此客户端的后续通信都走这个Child Channel
,有多少个客户端就会创建多少个child channel
。
在 TCP/IP
中,一个主要的概念是ServerSocket
和Socket
,其中ServerSocket
用于服务器监听,当一个客户端连接请求到来时,ServerSocket
接受这个请求,并创建一个新的Socket
来代表这个新的连接。
在 Netty 框架中,这个概念被抽象化为ServerSocketChannel
(相当于ServerSocket
)和SocketChannel
(相当于Socket
),其中ServerSocketChannel
是Server 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 中通通不存在
- 首先是
ServerSocket#accept
方法的阻塞,在 Netty 中有一个专门的EventLoopGroup
(线程池)来处理客户端的想要建立连接的请求,因此不会影响跟已建立连接的客户端的 I/O,已建立连接的客户端的 I/O,由另一个单独的EventLoopGroup
处理。 - 然后因为对 socket 的写入和读取都改成了异步的方式,因此都不会阻塞。
Netty 跟 OIO 相比的核心优化是,通过避免线程的阻塞,实现快速响应请求,而且因为避免了阻塞,所以可以用更少的线程来处理大量的请求。
我们在程序设计中,也可以借鉴 Netty 的这种思路,即:我们应该极力避免线程阻塞,应该尽量使用非租塞的 API,而且,非租塞往往跟异步组合使用,这样,才能增加后端的响应速度,提升用户体验。
有一个误区我们应该避免,就是阻塞其实不会浪费 CPU 性能的浪费,线程阻塞的时候,CPU 会去执行其他的线程,线程阻塞的坏处在于白白增加了等待的时间,拖慢了业务的执行速度。
同样的线程数,IO 密集型线程(会阻塞)的 CPU 占用率是低于 CPU 密集型的 CPU 占用率的,因此可以得出结论,线程组赛不会浪费 CPU 性能,请看《确定做一项工作所需要的线程数》