Netty入门(二)——网络传输

一、五种 IO 模型

在理解 Netty 的底层原理之前,有必要先了解 Java 网络通信中支持的多种 I/O 模型。Netty 的高性能本质上正是得益于其对 I/O 多路复用的封装与优化。了解下面五种常见 I/O 模型的工作机制与差异后,你应该就能理解 Netty 为什么采用当前的架构。

1.1 阻塞 I/O(BIO)

在 BIO(Blocking I/O)模型中,应用进程向内核发起 I/O 请求时,会调用阻塞式系统调用。也就是说,发起 I/O 调用的线程会一直阻塞在调用方法处,直到内核数据准备完成,并将数据拷贝到用户空间缓冲区后才返回结果。这种方式严重浪费了线程资源,因为在等待 I/O 的过程中线程不能做其他任务。

在传统的 BIO 模式下,实现异步只能通过多线程模型。即每一个请求需要创建一个独立线程来处理读写操作。如果并发连接数非常多,则会导致线程资源耗尽,系统吞吐能力急剧下降。

1.2 同步非阻塞 I/O(NIO)

NIO引入了 Channel、Selector 和 Buffer 等概念。在这种模式下,线程向内核发起 I/O 调用后会立即返回,不会阻塞等待结果。线程可以通过轮询 Selector 判断是否有数据准备好,从而进行后续处理。

尽管线程可以避免阻塞,但轮询的方式会造成大量的系统调用,尤其在连接数很多但活跃连接很少的场景下,轮询会占用大量 CPU 资源,导致效率低下。此外,轮询 Selector 获取就绪通道时还涉及用户态与内核态频繁切换。

1.3 I/O 多路复用

多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。,一旦某个通道就绪,线程便可进行处理,从而实现一个线程处理多个连接

  • select/poll 是最早的实现,存在最大文件描述符限制,并且每次调用都需遍历整个文件描述符列表
  • epoll 是 Linux 提供的优化版本,不再有 FD 数量限制,且在就绪事件处理上更加高效。
    Netty 基于 NIO + epoll 实现的高性能 I/O。

1.4 信号驱动 I/O(SIGIO)

通过内核向应用程序发送 SIGIO 信号来通知 I/O 就绪状态。应用程序首先通过 fcntl 启用文件描述符的信号驱动模式,然后注册信号处理函数,当内核准备好数据后通过信号触发处理函数,从而进行数据读取。

1.5 异步 I/O(AIO)

应用程序调用 aio_read()时,内核一方面去取数据报内容返回,另一方面将程序控制权还给应用进程,应用进程继续处理其他事情,是一种非阻塞的状态。内核在完成数据准备并将其复制到用户缓冲区后,返回aio_read中定义好的函数处理程序。

关于NIO和AIO的区别:NIO使用多路复用机制,通过少量线程轮询处理大量请求;AIO采用异步回调机制,仅在IO操作完成时分配线程处理有效请求。

二、Reactor 线程模型

在Java中,有专门的NIO包封装了IO多路复用相关的方法。Netty可以看做是对Java-NIO包使用细节做了许多优化的进一步封装(类比Redisson和Redis)。所以通常使用Netty而不是直接使用Java NIO包,下面的Reactor线程模型就是一个典型例子。

2.1 简介

Reactor模式是一种基于事件驱动的模式。Netty 的 I/O 模型是基于非阻塞 I/O 实现的,底层依赖的是 NIO 框架的多路复用器 Selector。采用 epoll 模式后,只需要一个线程负责 Selector 的轮询。当有数据处于就绪状态后,需要一个事件分发器 (Event Dispather),它负责将读写事件分发给对应的读写事件处理器(Event Handler)。

事件分发器有两种设计模式:Reactor 和 Proactor。Reactor 采用同步 I/O, Proactor 采用异步 I/O。根据Reactor的数量和线程池的数量,又可以将Reactor分为三种模型

① 单 Reactor 单线程模型

在该模型中,只有一个线程负责所有操作,包括连接接收、读写事件的监听和业务处理等。所有 I/O 操作都由一个线程串行处理,设计简单,但是缺点也很明显:

  • 所有 I/O 操作阻塞或耗时处理都会影响其他连接
  • 单线程 CPU 无法充分利用多核资源
  • 一旦某个连接的业务处理逻辑阻塞,会影响整个系统的响应能力
    适用于连接数少、逻辑简单的场景。
② 单 Reactor 多线程模型

该模型仍然只有一个 Reactor 线程负责监听 accept 和读写事件,但将耗时的业务处理交给线程池(通常称为 Worker 线程池)异步处理。这样可以避免业务处理阻塞 Reactor 线程。

这种方式提高了系统吞吐能力,但连接数和事件数量激增后,单线程处理事件的能力将成为性能瓶颈。

③ 多 Reactor 多线程模型(主从 Reactor 模型)

在该模型中,系统通常分为两个 Reactor:主 Reactor 和从 Reactor。

  • 主 Reactor 负责监听连接请求(accept),并将建立的连接注册到从 Reactor。
  • 从 Reactor 负责处理具体连接的读写事件及派发业务逻辑到线程池处理。
    每个从 Reactor 通常配备多个线程组成的 EventLoopGroup,可充分利用多核 CPU,实现高并发下的高性能。
    Netty 采用的就是改进的多 Reactor 多线程模型。

2.2 EventLoop 与 EventLoopGroup

Reactor 模型在 Netty 中的实现依赖于两个核心组件:EventLoop 与 EventLoopGroup。Reactor模型的大致运行模式如下:

bash 复制代码
- 连接注册:建立连接后,将channel注册到selector上
- 事件轮询:selcetor上轮询(select()函数)获取已经注册的channel的所有I/O事件(多路复用)
- 事件分发:把准备就绪的I/O事件分配到对应线程进行处理
- 事件处理:每个worker线程执行事件任务
2.2.1 EventLoop

EventLoop是一个通用的事件等待和处理的程序模型,主要用来解决多线程资源消耗高的问题。Node.js 中也采用了 EventLoop 的运行机制。

bash 复制代码
# Netty中的EventLoop
- 一个Reactor模型的事件处理器。
- 单独一个线程。
- 内部会维护一个selector(处理I/O事件)和一个taskQueue任务队列。

注:

  • taskQueue任务队是多生产者单消费者队列,在多线程并发添加任务时,可以保证线程安全。
  • I/O事件就是selectionKey中的事件,如accept、connect、read、write等。
  • 任务主要分为普通任务和定时任务。
    • 普通任务:通过 NioEventLoop 的 execute() 方法向任务队列 taskQueue 中添加任务。例如 Netty 在写数据时会封装 WriteAndFlushTask 提交给 taskQueue。
    • 定时任务:通过调用 NioEventLoop 的 schedule() 方法向 定时任务队列 scheduledTaskQueue 添加一个定时任务,用于周期性执行该任务(如心跳消息发送等)。定时任务队列的任务 到了执行时间后,会合并到 普通任务 队列中进行真正执行。

每个 EventLoop 会不断执行事件循环(event loop):

  1. Selector事件轮询
  2. I/O事件处理
  3. 任务处理
2.2.2 EventLoopGroup

EventLoopGroup 是 EventLoop 的集合,类似线程池与线程的关系。

Netty 4 引入了无锁串行化设计:每个 Channel 在创建后绑定唯一的 EventLoop,保证所有 I/O 操作都由同一个线程处理,从而避免了并发问题,减少了上下文切换成本。

三、TCP 拆包粘包问题及解决方案

网络通信中,数据传输往往并非"发一个包,对方就收一个包"这么理想。在实际项目中我们常会遇到 TCP 拆包与粘包的问题,这对于数据完整性的保证提出了挑战。Netty 在设计上也必须应对这一问题,下面我们将解释其产生原因及常见的解决方法。

3.1 产生原因

由于 TCP 是面向字节流的传输协议,它本身并不具备消息边界的概念,因此在实际传输过程中会出现如下情况:

  • 粘包:发送端连续发送多个小数据包,接收端一次性读取多个包内容,导致数据粘在一起。
  • 拆包:一个大包被分为多个 TCP 数据段发送,接收端需要多次接收才能拼装完整消息。

拆包的主要原因:MSS(TCP 最大报文段长度) + TCP 首部 + IP 首部 > MTU(链路层最大传输单元)。

3.2 常见解决方案

为了解决拆包粘包问题,需要在应用层设计明确的消息边界标识或消息结构,常用的方式:

  1. 定长报文:每条消息固定字节长度,接收端按长度读取即可。
  2. 分隔符报文 :使用特殊字符作为每条消息的结束标志,例如 \r\n 或特殊字节序列。
  3. 长度字段报文(Netty) :报文头包含消息体的长度字段,接收端先读取长度字段再读取数据体。
    Netty 中已经内置了多种拆包粘包处理器,开发者可以根据协议类型选择合适的 Decoder,例如:
java 复制代码
// 固定长度解码器
FixedLengthFrameDecoder
// ​基于分隔符的解码器
DelimiterBasedFrameDecoder
// 长度字段解码器
LengthFieldBasedFrameDecoder

四、Netty 编解码器机制与自定义协议设计

为了实现灵活的网络传输,Netty 提供了强大的编解码器机制,帮助开发者将自定义的业务对象转换为 ByteBuf(字节流),以支持序列化、协议兼容与数据完整性。

4.1 编码器(Encoder)

编码器用于将业务对象编码为字节流发送至对端,常用类型:

java 复制代码
// 将 POJO 转为 ByteBuf
MessageToByteEncoder

//一种消息类型编码成另外一种消息类型
MessageToMessageEncoder

4.2 解码器(Decoder)

解码器用于从 ByteBuf 中读取字节数据,并转换为业务对象,常用类:

java 复制代码
// 将字节流解码为消息对象
ByteToMessageDecoder

// 将字节流解码为消息对象,逻辑简化,自动校验可读性
ReplayingDecoder

//将一种消息类型解码为另外一种消息类型
MessageToMessageDecoder

注:解(编)码器可以分为一次解码器二次解码器 ,一次解码器用于解决 TCP 拆包/粘包问题 ,按协议解析后得到的字节数据。如果你需要对解析后的字节数据做对象模型的转换,需要用到二次解码器,。

4.3 自定义协议格式设计

为了支持高性能和可扩展性,自定义协议设计需明确以下字段(如魔数、版本、序列化方式、消息体长度等),确保数据传输的可靠性和可解析性。

注:如何判断 ByteBuf 是否存在完整的报文?

常用的做法是通过读取消息长度 dataLength 进行判断。如果 ByteBuf 的可读数据长度小于 dataLength,说明 ByteBuf 还不够获取一个完整的报文。

五、writeAndFlush 机制

Netty 如何将编解码后的结果发送出去呢?在 Netty 中实现数据发送非常简单,只需要调用 writeAndFlush 方法即可。writeAndFlush 的处理流程,可以总结以下三点:

  • writeAndFlush 属于出站操作,它是从 Pipeline 的 Tail 节点开始进行事件传播,一直向前传播到 Head 节点。不管在 write 还是 flush 过程,Head 节点都中扮演着重要的角色。
  • write 方法并没有将数据写入 Socket 缓冲区,只是将数据写入到 ChannelOutboundBuffer 缓存中,ChannelOutboundBuffer 缓存内部是由单向链表实现的。
  • flush 方法才最终将数据写入到 Socket 缓冲区。
    详细的分析可以阅读这篇文章
相关推荐
小毛驴8505 分钟前
WebSocket 在多线程环境下处理 Session并发
网络·websocket·网络协议
乌恩大侠15 分钟前
USRP 毫米波通信解决方案
网络·5g·fpga开发
Asu520217 分钟前
思途spring学习0807
java·开发语言·spring boot·学习
遇见火星22 分钟前
Jenkins全链路教程——Jenkins用户权限矩阵配置
java·矩阵·jenkins
埃泽漫笔29 分钟前
什么是SpringBoot
java·spring boot
zhang10620935 分钟前
PDF注释的加载和保存的实现
java·开发语言·pdf·pdfbox·批注
码银1 小时前
什么是逻辑外键?我们要怎么实现逻辑外键?
java·数据库·spring boot
VBA63371 小时前
VBA之Word应用第四章第一节:段落集合Paragraphs对象(一)
开发语言
SugarFreeOixi1 小时前
Idea打包可执行jar,MANIFEST.MF文件没有Main-Class属性:找不到或无法加载主类
java·jar