本文是对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都有唯一一个ChannelPipelineChannelPipeline是一个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,并与来自EventLoopGroupEventLoop(线程的容器)相关联。
  • ChannelPipeline包含一个包含业务逻辑的ChannelInboundHandler(处理入站消息)和ChannelOutboundHandler(处理出站消息)链。
  • EventLoop本质上是一个 I/O 线程,可以被多个Channel共享。ChannelHandler在这些EventLoop线程上执行。
  • Server 和 Client 初始化类似,除了 ServerChannel 处理接受连接并创建子通道来服务请求。
  • 不要为你的server Channel 和你的子 Channel 使用相同的EventLoopGroup
  • Netty 带有大量可以在应用程序中使用的内建的handler(例如用于 TLS)。
  • 如果ChannelHandler阻塞或很慢,它们将阻碍处理碰巧使用相同EventLoopChannel上的请求。