Netty 的设计

警告
本文最后更新于 2024-01-28,文中内容可能已过时。

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 对应的传输方法匹配。

  • 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 都有且只有一个自己的 ChannelPipelineChannelPipeline 其实就是一个 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 开发的应用中的干活的主力,应用的业务逻辑都是在这里实现。

我们前面说过EventChannel发出,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

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

  • NIOEventLoopGroup为 NIO 传输方法设计,默认创建处理器核心个数两倍的EventLoop,并均匀地分给每一个Channel

    EPollEventLoopGroupKqueueEventLoopGroup默认也是同样数量的EventLoop

  • OIOEventLoopGroup为 OIO 传输方法设计,会为每一个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 中通通不存在

  • 首先是ServerSocket#accept方法的阻塞,在 Netty 中有一个专门的EventLoopGroup(线程池)来处理客户端的想要建立连接的请求,因此不会影响跟已建立连接的客户端的 I/O,已建立连接的客户端的 I/O,由另一个单独的EventLoopGroup处理。
  • 然后因为对 socket 的写入和读取都改成了异步的方式,因此都不会阻塞。

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

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

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

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

0%