本文是对Netty官网Related articles Netty data model, threading, and gotchas 的翻译版本。
在我们开始之前,需要注意的是本文包含一些从Netty in Action中得到的经验。这本书是相当有用的读物,可以从第二版中受益匪浅。本文分为两部分:第一部分构建了上下文并且介绍了数据模型(data model);第二部分讨论了Netty的实际使用以及需要避免的潜在错误。根据你要查找的内容,你可以选择跳过其中一个部分。
介绍
在Java的早期,进行网络编程的唯一方法是使用 Socket API 和阻塞式 I/O。这意味着你需要为每个连接启动一个线程来处理并发连接,因为 I/O 随时可能阻塞。阻塞模型适用于少量的连接/线程,但是当处理大量的连接时,上下文切换和创建大量线程的开销会成为性能瓶颈。Java最终增加了非阻塞I/O(NIO)使得可以通过线程池处理大量的连接。然而,API 比旧的阻塞 I/O API (OIO) 复杂得多,并且实现高性能网络操作可能相当困难。这就是 Netty 的用武之地。
Netty的核心是一个简化网络操作的Java库。它支持阻塞和非阻塞两种 I/O 模型,面向连接的协议(如 TCP)和无连接协议(如 UDP),以及管理客户端和服务器上的数据传输。事实上,它抽象出了所有那些较低级别的细节,并允许用户专注于他们的业务逻辑。API 是事件驱动的;由于它是一个网络编程库,核心的事件(event)与收发的字节有关,但 Netty 还支持用户定义事件来执行自定义逻辑。Netty的API是完全异步的,即使配置了使用阻塞 I/O 模型。Netty大量使用ChannelFuture
,它是一个Java Future的拓展版本支持注册回调方法。Netty还使用了Promise
对象,一个可以被写入的Future,与Java1.8的CompletableFuture非常类似。
在本文中,我们将讨论数据模型、展示一个pipeline的例子以及线程模型和它们的实现。
数据模型
Netty的数据模型相当简单:核心模型是Channel
,每个Channel
都有自己的ChannelPipeline
并且每个Channel
都关联到EventLoopGroup
中的一个EventLoop
上。
Channel
Netty Channel是进站/出站数据的载体:与Java NIO Channel是同样的概念。举个例子,对于 TCP 它代表一个与远程主机的连接,对于 UDP 它代表出站数据报的地址或者本地端口上进站数据的监听。每个Channel
都有自己的ChannelPipeline
:稍后会详细介绍。在Channel
上会触发事件,例如当Channel
注册/取消注册时,当它处于活动/非活动状态时,或者当接收或发送字节时。Channel
可以被用于不同的传输,例如 OIO ,NIO,EPoll (Linux),KQueue (BSD/MacOS),Local (within the same JVM),或者Embedded(经常用于集成测试)。使用的Channel
实现取决于传输及socket类型,例如NioServerSocketChannel
(NIO传输,server socket),NioSocketChannel
(NIO传输,client socket),EPollDatagramChannel
(EPoll 传输,UDP socket)。并不是所有的Channel
实现都有相同的特性,比如NIO和EPoll/KQueue支持零拷贝,而其他的实现不支持。
ChannelPipeline
每个Channel
都有唯一一个ChannelPipeline
。ChannelPipeline
是一个ChannelHandler
的列表,每个ChannelHandler
可能是一个ChannelInboundHandler
(处理进站事件),或者ChannelOutboundHandler
(处理出站事件),或者两者兼有。
这些ChannelHandler
是基于 Netty 的服务器或驱动程序的主力:它们包含应用程序或客户端的业务逻辑。它们的功能与 Unix 管道非常相似:事件进入管道的一端,由一系列处理程序处理,然后通过管道的另一端退出,除非它们被丢弃。一个ChannelHandler
必须显式地触发下一个处理相关payload的ChannelHandler
,否则处理流程不会继续。第一个入站ChannelHandler
从socket接收一个 ByteBuf(见下文),最后一个出站ChannelHandler
产生一个 ByteBuf 以写入socket。将字节解码为消息的ChannelHandler
通常扩展 Netty 提供的 ByteToMessageDecoder
,将消息编码为字节的ChannelHandler
通常扩展 MessageToByteEncoder
。Netty提供MessageToMessageDecoder/Encoder/Codec用以简化一些通用的ChannelHandler
的编写。
注意,ChannelPipeline
可以由ChannelHandler
自己修改。例如,实现 STARTTLS 的应用程序可能会在连接升级为使用 TLS 后删除实现 TLS 握手的ChannelHandler
。我们将在本文后面介绍另一个动态修改的ChannelPipeline
示例。ByteBuf
是NIO ByteBuffer的Netty定制版。Netty实现自己的缓冲区是为了简化API(例如不需要切换读写模式)和添加功能(例如零拷贝)。一个ByteBuf
可以表示JVM堆内存(heap memory)和堆外内存(native memory),或者两者的组合。池化和引用计数可用于提高性能,了解它们的工作原理是使用 Netty 编写高性能应用程序的关键。在本文中,我们专注于使用 Netty 调试和扩展代码,因此我们不会太仔细地研究它们。
EventLoopGroup
它是EventLoop
对象的容器。每个EventLoop
对象都只与一个线程相关联。每个Channel
在其生命周期内都与一个EventLoop
相关联。
虽然每个Channel
都与一个EventLoop
相关联,但是一个EventLoop
最终可能绑定多个Channel
。这是很重要的一个点,我们稍后会讨论其中的含义。EventLoopGroup
有多种实现,特定的实现必须匹配其传输:例如,NIOEventLoopGroup
必须用于NIO传输,OIOEventLoopGroup
必须用于OIO传输等等。EventLoopGroup
的实现决定了其中创建的EventLoop
的数量:OIOEventLoopGroup
为每个新的Channel
都创建一个新的EventLoop
,而NIO/EPoll/Kqueue的实现创建2倍于处理器数量的EventLoop
并均匀地分发给不同的Channel
。Channel
的所有event和handler都在其单个 EventLoop 上执行。这些 EventLoop 对象可以被认为是 I/O 线程,因为它们处理 Netty 应用程序或驱动程序中的所有 I/O,包括在ChannelPipeline
中的ChannelHandler
中发生的任何处理。
使用Netty
Netty最常用于基于 TCP 的客户端-服务器上下文中,因此下面的讨论假设如此。
服务端与客户端
服务端代码使用ServerBootstrap
对象初始化Netty。初始化时会创建一个server channel,这个channel产生子channel来处理客户端。当创建server channel,必须提供两个EventLoopGroup
对象,一个绑定给主server channel,一个供子channel使用。完全配置ServerBootstrap
后,将其绑定到端口并等待连接。
请注意,虽然从技术上讲,可以为服务器和子通道使用相同的EventLoopGroup
,但这可能是个坏主意,因为最终可能会在服务器和客户端通道之一之间共享单个EventLoop
,并且客户端通道可能最终阻止server channel 使用EventLoop
,从而阻止服务器接受连接。
与服务端类似,客户端使用Bootstrap
对象引导。它配置一个单独的EventLoopGroup
,以及ChannelHandler
等等。随后,你可以连接到一个远程的端口,然后通过channel进行读写。
ChannelPipeline 实例
如前所述,ChannelPipeline
及其关联的ChannelHandler
链是基于 Netty 的应用程序或驱动程序的核心。Netty 提供ChannelHandler
实现来简化开发,例如处理 TLS、编码/解码 HTTP 以及实现 WebSocket 协议。例如,下图说明了 Netty pipeline的简化版本,用于在服务器上实现 WebSocket 协议。
最上面一行是处理来自客户端的请求的ChannelInboundHandler
。最下面一行是一个ChannelOutboundHandler
,它将响应分派给客户端。描述的所有 ChannelHandler
都是由 Netty 提供的,因此开发人员需要做的就是将它们串在一起以实现 WebSocket 协议。这种开箱即用的方法是 Netty 构建高性能和高并发服务器组件的重要组成部分。
WebSocket 协议更有趣的一点是该协议包括从 HTTP 到 WebSockets 的upgrade。此时,某些HTTP特定的组件不再需要了。Netty的WebSocketChannelHandlers
通过动态修改ChannelPipeline
响应这一点。
上面的简化图显示了ChannelHandler
如何通过删除不再需要的组件来清理自己的ChannelPipeline
。重写pipeline是一个非常强大的功能,尽管它会让事情变得有点难以理解。
有关设置ChannelPipeline
的深入示例,可以看看Datastax Java Driver for Apache Cassandra®怎样设置它的ChannelPipeline。
线程相关
正如我们之前所了解的,每个Channel
都被分配了一个EventLoop
,它对应于一个 I/O 线程。除了 OIO(它为每个 Channel 创建一个新的 EventLoop)之外,EventLoop
是从EventLoopGroup
中均匀分配的。
这个简单的线程模型意味着你在实现ChannelHandler
逻辑时不需要担心并发问题。在一次pipeline的执行流程中,Netty会保证单线程地线性执行。更进一步,因为不需要创建大量的线程(默认2倍处理器数量),CPU不必承担过重的上下文切换负载。
另一方面,需要注意不要创建多个EventLoopGroup
,因为每个EventLoopGroup
都会创建自己的线程池。一个例外是指定serverEventLoopGroup
和子channelEventLoopGroup
,你不希望用于接受连接的线程也用于处理它们,因为如果它被占用,你的服务器将成为接受连接的瓶颈。
非常需要注意的是,由于EventLoop
(或者说线程池)是由多个channel
共享的,所以一个channel
处理缓慢会影响到多个请求,尤其是当连接/channel的数量急剧增长超过EventLoop
中线程的数量时。这可能是这里最大的与线程相关的问题,因此需要重申:任何密集的处理都应该隔离到一个单独的线程,你不应该在你的ChannelHandler
中长时间阻塞。因为如果你锁定了一个EventLoop
,任何其他碰巧分配了相同EventLoop
的请求/channel都将被阻塞,等待EventLoop
释放。例如,这可能会导致瓶颈,CASSANDRA-15013和相关的blog。
要点
如果你跳到最后,或者你只需要复习,这里是最重要的要点:
Channel
是主要容器,它包含一个ChannelPipeline
,并与来自EventLoopGroup
的EventLoop
(线程的容器)相关联。ChannelPipeline
包含一个包含业务逻辑的ChannelInboundHandler
(处理入站消息)和ChannelOutboundHandler
(处理出站消息)链。EventLoop
本质上是一个 I/O 线程,可以被多个Channel
共享。ChannelHandler
在这些EventLoop
线程上执行。- Server 和 Client 初始化类似,除了 ServerChannel 处理接受连接并创建子通道来服务请求。
- 不要为你的server Channel 和你的子 Channel 使用相同的
EventLoopGroup
。 - Netty 带有大量可以在应用程序中使用的内建的handler(例如用于 TLS)。
- 如果
ChannelHandler
阻塞或很慢,它们将阻碍处理碰巧使用相同EventLoop
的Channel
上的请求。