吃透netty

大纲

整体架构

模块

三个模块

core

protosupport

transport

1. Core 核心层

Core 核心层是 Netty 最精华的内容,它提供了底层网络通信的通用抽象和实现,包括可扩展的事件模型、通用的通信 API、支持零拷贝的 ByteBuf 等。

2. Protocol Support 协议支持层

协议支持层基本上覆盖了主流协议的编解码实现,如 HTTP、SSL、Protobuf、压缩、大文件传输、WebSocket、文本、二进制等主流协议,此外 Netty 还支持自定义应用层协议。Netty 丰富的协议支持降低了用户的开发成本,基于 Netty 我们可以快速开发 HTTP、WebSocket 等服务。

3. Transport Service 传输服务层

传输服务层提供了网络传输能力的定义和实现方法。它支持 Socket、HTTP 隧道、虚拟机管道等传输方式。Netty 对 TCP、UDP 等数据传输做了抽象和封装,用户可以更聚焦在业务逻辑实现上,而不必关系底层数据传输的细节。

逻辑结构

网络通信层

主要负责网络事件的监听,当网络数据读取到内核缓冲区后,会触发各种网络事件,会被注册到事件调度层进

行处理

网络通信层的核心组件包含BootStrap、ServerBootStrap、Channel三个组件。

- **<font style="color:rgb(59, 67, 81);">BootStrap &ServerBootStrap 引导  

如下图所示,Netty 中的引导器共分为两种类型:一个为用于客户端引导的 Bootstrap,另一个为用于服务端引导的 ServerBootStrap**,它们都继承自抽象类 AbstractBootstrap。
ServerBootStrap用于服务器启动时绑定本地端口,会绑定两个eventloopgroup,一个boss,一个worker。而BootStrap主要是用与服务器进行通信,只有一个eventloopgroup
- channel
Channel 的字面意思是“通道”,它是网络通信的载体,说白了就是操作内核缓冲区或者说我们
可以使用channel的API来操作底层Socket

channel还有不同的状态,这些不同的状态就对应的不同的回调事件

事件调度层

事件调度层的职责是通过 Reactor 线程模型对各类事件进行聚合处理,通过 Selector 主循环线程集成多种事件( I/O 事件、信号事件、定时事件等),实际的业务处理逻辑是交由服务编排层中相关的 Handler 完成。

事件调度层的核心组件包括 EventLoopGroup、EventLoop

这group其实就是线程池来着,eventloop就是线程

- eventloopgroup和eventloop和channel的关系  


特别地说一下,channel被建立后,就会分配一个eventloop与其绑定,且可不是绑死的,可多次松绑or绑定

一个 Channel 在它的生命周期里,只能绑定到一个 EventLoop。

一个 EventLoop 可以同时绑定多个 Channel,并处理它们的 I/O 事件

然后eventloop通过操作系统的epoll监听多个channel的事件,事件复杂度接近 O (1)

·类关系

EventLoopGroup 是 Netty 的核心处理引擎,那么 EventLoopGroup 和之前课程所提到的 Reactor 线程模型到底是什么关系呢?其实 EventLoopGroup 是 Netty Reactor 线程模型的具体实现方式,Netty 通过创建不同的 EventLoopGroup 参数配置,就可以支持 Reactor 的三种线程模型:

    1. **<font style="color:rgb(59, 67, 81);">单线程模型</font>**<font style="color:rgb(59, 67, 81);">:EventLoopGroup 只包含一个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup;</font>
    2. **<font style="color:rgb(59, 67, 81);">多线程模型</font>**<font style="color:rgb(59, 67, 81);">:EventLoopGroup 包含多个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup;</font>
    3. **<font style="color:rgb(59, 67, 81);">主从多线程模型</font>**<font style="color:rgb(59, 67, 81);">:EventLoopGroup 包含多个 EventLoop,Boss 是主 Reactor,Worker 是从 Reactor,它们分别使用不同的 EventLoopGroup,主 Reactor 负责新的网络连接 Channel 创建,然后把 Channel 注册到从 Reactor。</font>

服务编排层

服务编排层的职责是负责组装各类服务,它是 Netty 的核心处理链,用以实现网络事件的动态编排和有序传播。

服务编排层的核心组件包括 ChannelPipeline、ChannelHandler、ChannelHandlerContext。

- **ChannelPipeline**   

ChannelPipeline 是 Netty 的核心编排组件,**负责组装各种 ChannelHandler。**其实就是把channelHandler给串联起来,本质是一个双向链表。当网络事件来的时候,就会依次调用channelPipeline里的Handler对其进行拦截

ChannelPipeline 是线程安全的,因为每一个新的 Channel 都会对应绑定一个新的 ChannelPipeline。一个 ChannelPipeline 关联一个 EventLoop,一个 EventLoop 仅会绑定一个线程。

ChannelPipeline 中包含入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处

理器,我们结合客户端和服务端的数据收发流程来理解 Netty 的这两个概念

- **<font style="color:rgb(59, 67, 81);">ChannelHandler & ChannelHandlerContext</font>**

为啥每个channelHandler都要绑定一个**ChannelHandlerContext?**

有点类似于工人和工具工位的关系

ChannelHandlerContext 用于保存 ChannelHandler 上下文,通过 ChannelHandlerContext 我们可以知道 ChannelPipeline 和 ChannelHandler 的关联关系。ChannelHandlerContext 可以实现 ChannelHandler 之间的交互,ChannelHandlerContext 包含了 ChannelHandler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。此外,你可以试想这样一个场景,如果每个 ChannelHandler 都有一些通用的逻辑需要实现,没有 ChannelHandlerContext 这层模型抽象,你是不是需要写很多相同的代码呢?

1
2
3
4
5
6
7
8
public class MyHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("收到消息: " + msg);
ctx.fireChannelRead(msg); // 传递给下一个 Handler
}
}

整体流程

服务端启动要干什么

配置线程池

Channel 初始化

端口绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 检测是否使用Epoll优化性能
// 启动Netty服务器
@SneakyThrows(InterruptedException.class)
@Override
public void start() {
if (!start.compareAndSet(false, true)) return;
// 配置服务器参数,如端口、TCP参数等
serverBootstrap
.group(eventLoopGroupBoss, eventLoopGroupWorker)
.channel(SystemUtil.useEpoll() ? EpollServerSocketChannel.class : NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024) // TCP连接的最大队列长度
.option(ChannelOption.SO_REUSEADDR, true) // 允许端口重用
.option(ChannelOption.SO_KEEPALIVE, true) // 保持连接检测
.childOption(ChannelOption.TCP_NODELAY, true) // 禁用Nagle算法,适用于小数据即时传输
.childOption(ChannelOption.SO_SNDBUF, 65535) // 设置发送缓冲区大小
.childOption(ChannelOption.SO_RCVBUF, 65535) // 设置接收缓冲区大小
.localAddress(new InetSocketAddress(config.getPort())) // 绑定监听端口
.childHandler(new ChannelInitializer<>() { // 定义处理新连接的管道初始化逻辑
@Override
protected void initChannel(Channel ch) throws Exception {
ch.pipeline().addLast(
new HttpServerCodec(), // 处理HTTP请求的编解码器
new HttpObjectAggregator(config.getNetty().getMaxContentLength()), // 聚合HTTP请求
new HttpServerExpectContinueHandler(), // 处理HTTP 100 Continue请求
new NettyHttpServerHandler(nettyProcessor) // 自定义的处理器
);
}
});
serverBootstrap.bind().sync();
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.ADVANCED);
log.info("gateway startup on port {}", this.config.getPort());
}

eventloop精髓

网络框架的设计离不开 I/O 线程模型,线程模型的优劣直接决定了系统的吞吐量、可扩展性、安全性等

三种 Reactor 线程模型

主从

前两种就是建立和业务处理没分开,无法轻松建立大量连接,建立连接会被耗时的业务请求影响到

Netty EventLoop 实现原理

在 Netty 中 EventLoop 可以理解为 Reactor 线程模型的事件处理引擎,每个 EventLoop 线程都维护一个 Selector 选择器和任务队列 taskQueue。它主要负责处理 I/O 事件、普通任务和定时任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
* EventLoop 主循环
* 不停地轮询 I/O 事件 + 执行任务队列(普通任务/定时任务)
*/
protected void run() {
// 死循环,直到 EventLoop 被关闭
for (;;) {
try {
try {
// 根据策略决定如何调用 select()(I/O 多路复用)
// selectStrategy 会返回 CONTINUE/BUSY_WAIT/SELECT
switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
case SelectStrategy.CONTINUE:
// CONTINUE 表示继续下一次循环,不阻塞
continue;
case SelectStrategy.BUSY_WAIT:
case SelectStrategy.SELECT:
// 轮询 I/O 事件
// wakenUp.getAndSet(false) 用来标记是否需要立即唤醒
select(wakenUp.getAndSet(false));

// 如果在 select 期间又被要求唤醒,则调用 wakeup()
if (wakenUp.get()) {
selector.wakeup();
}
default:
// 其他情况不处理
}
} catch (IOException e) {
// 如果 select 出现 IO 异常,重建 selector
rebuildSelector0();
// 打印/处理异常
handleLoopException(e);
// 跳过这次循环,继续下一次
continue;
}

// 已取消的 SelectionKey 数量清零
cancelledKeys = 0;
// 是否需要再次 select 清零
needsToSelectAgain = false;

// I/O 与任务执行时间比例(默认 50:50)
final int ioRatio = this.ioRatio;

if (ioRatio == 100) {
// ioRatio=100,表示只处理 I/O,再统一处理任务
try {
processSelectedKeys(); // 处理就绪的 I/O 事件
} finally {
runAllTasks(); // 处理所有任务(普通/定时)
}
} else {
// ioRatio < 100,需要在 I/O 与任务之间分配时间
final long ioStartTime = System.nanoTime();
try {
processSelectedKeys(); // 处理就绪的 I/O 事件
} finally {
// 计算本轮 I/O 花费的时间
final long ioTime = System.nanoTime() - ioStartTime;
// 按 ioRatio 分配执行任务的时间
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
}

} catch (Throwable t) {
// 捕获所有异常,避免循环退出
handleLoopException(t);
}

try {
// 如果正在关闭
if (isShuttingDown()) {
// 关闭所有 Channel
closeAll();
// 确认是否可以安全关闭
if (confirmShutdown()) {
return; // 安全关闭后退出循环
}
}
} catch (Throwable t) {
// 关闭过程出现异常,打印处理
handleLoopException(t);
}
}
}

  • select(…) 👉 负责 I/O 事件的监听(类似 Selector.select())。
  • processSelectedKeys() 👉 处理就绪的 I/O 事件(读/写/连接)。
  • runAllTasks() 👉 处理任务队列里的任务(用户提交的 Runnable、定时任务等)。
  • ioRatio 👉 控制 I/O 与任务的时间分配。
  • isShuttingDown()/confirmShutdown() 👉 支持优雅关闭。

事件处理机制

对于eventloop来说就是无锁串行化,不同eventloop之间是不会有交集的

**一个 ****Channel** 在注册到某个 EventLoop 之后,它的整个生命周期都只会由这个 EventLoop 负责

结合 Netty 的整体架构,我们一起看下 EventLoop 的事件流转图,以便更好地理解 Netty EventLoop 的设计原理。NioEventLoop 的事件处理机制采用的是无锁串行化的设计思路

  • BossEventLoopGroup WorkerEventLoopGroup 包含一个或者多个 NioEventLoop。BossEventLoopGroup 负责监听客户端的 Accept 事件,当事件触发时,将事件注册至 WorkerEventLoopGroup 中的一个 NioEventLoop 上。每新建一个 Channel, 只选择一个 NioEventLoop 与其绑定。所以说 Channel 生命周期的所有事件处理都是线程独立的,不同的 NioEventLoop 线程之间不会发生任何交集。
  • NioEventLoop 完成数据读取后,会调用绑定的 ChannelPipeline 进行事件传播,ChannelPipeline 也是线程安全的,数据会被传递到 ChannelPipeline 的第一个 ChannelHandler 中。数据处理完成后,将加工完成的数据再传递给下一个 ChannelHandler,整个过程是串行化执行,不会发生线程上下文切换的问题。

NioEventLoop 无锁串行化的设计不仅使系统吞吐量达到最大化,而且降低了用户开发业务逻辑的难度,不需要花太多精力关心线程安全问题。虽然单线程执行避免了线程切换,但是它的缺陷就是不能执行时间过长的 I/O 操作,一旦某个 I/O 事件发生阻塞,那么后续的所有 I/O 事件都无法执行,甚至造成事件积压。在使用 Netty 进行程序开发时,我们一定要对 ChannelHandler 的实现逻辑有充分的风险意识。

NioEventLoop 线程的可靠性至关重要,一旦 NioEventLoop 发生阻塞或者陷入空轮询,就会导致整个系统不可用。在 JDK 中, Epoll 的实现是存在漏洞的,即使 Selector 轮询的事件列表为空,NIO 线程一样可以被唤醒,导致 CPU 100% 占用。这就是臭名昭著的 JDK epoll 空轮询的 Bug。Netty 作为一个高性能、高可靠的网络框架,需要保证 I/O 线程的安全性。那么它是如何解决 JDK epoll 空轮询的 Bug 呢?实际上 Netty 并没有从根源上解决该问题,而是巧妙地规避了这个问题。

netty如何规避的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
long time = System.nanoTime();

if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {

selectCnt = 1;

} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&

selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {

selector = selectRebuildSelector(selectCnt);

selectCnt = 1;

break;

}

Netty 提供了一种检测机制判断线程是否可能陷入空轮询,具体的实现方式如下:

  1. 每次执行 Select 操作之前记录当前时间 currentTimeNanos。
  2. time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos,如果事件轮询的持续时间大于等于 timeoutMillis,那么说明是正常的,否则表明阻塞时间并未达到预期,可能触发了空轮询的 Bug。
  3. Netty 引入了计数变量 selectCnt。在正常情况下,selectCnt 会重置,否则会对 selectCnt 自增计数。当 selectCnt 达到 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512) 阈值时,会触发重建 Selector 对象。

Netty 采用这种方法巧妙地规避了 JDK Bug。异常的 Selector 中所有的 SelectionKey 会重新注册到新建的 Selector 上,重建完成之后异常的 Selector 就可以废弃了。

背景:Selector 的空轮询 bug

  • 在 JDK 的部分版本里,<font style="color:rgb(59, 67, 81);">Selector.select(timeout)</font> 存在 bug:
    即使没有任何事件,它也可能立即返回,没有正常阻塞到超时时间。
  • 结果就是:
    EventLoop 线程会疯狂地“空转”,CPU 飙升到 100%,但又没干实事。

👉 如果发现 <font style="color:rgb(59, 67, 81);">select()</font>连续过早返回 512 次(怀疑 JDK 的 Selector 空轮询 bug),就会 销毁旧 Selector,重建一个新的,从而避免 EventLoop 陷入死循环空转。

正常情况:无事件会在阻塞一段时间,异常情况:立即返回

任务处理机制

  • 处理普通任务

Netty 高性能内存管理设计

认识jemalloc

前言

netty的内存管理也是参考jemalloc的设计。

内存分配器:jemalloc,temalloc,ptmalloc

他们都有一个目的即:提高内存分配回收的效率,以及尽可能地减少内存碎片

内存碎片:

Linux会把物理内存分配为一个个4kb的内存页,

物理内存的分配和回收都是基于 Page 完成的,而页里面产生的碎片成为内部碎片,而页与页之间

叫做外部碎片

内存分配算法

三种。动态分配,伙伴分配,slab

动态分配

动态分配DMA

会用一个链表来维护空闲内存块,程序申请内存时就从这个链表里面挑选

  • 内存就像一个大仓库/超市
  • 空闲分区(free block) = 货架上的空篮子(还没被进程用的内存)。
  • 进程的请求(malloc 申请内存) = 顾客来装东西(要一块内存)。
  • 分配策略(first fit / next fit / best fit) = 超市员工怎么选篮子给你。

三种算法

伙伴算法

伙伴算法就是把内存分成 2 的幂次方大小的块,按需拆分、按对合并,分配快、回收快,但会浪费一些空间(内部碎片)。

比如你分配17kb的,就得申请32kb的

slab算法

Linux 内核使用的就是 Slab 算法

jemalloc架构设计

netty的实现

基本使用

设计原则

Netty 高性能的内存管理也是借鉴 jemalloc 实现的

内存分规格

tiny,smaller,normal,huge

当想要申请的内存大小大于huge时,netty是会采用非池化的手段

netty的核心组件

可以看做是jemalloc的Java版实现

PoolArena也就是jemalloc的arena,

1
2
3
4
5
6
Class PoolArena{
smallSubpagePools // 用来存放比基本单位page 8k小的内存块
tinySubpagePools //同上
PoolChunkList
}

采用固定数量的多个 Arena 进行内存分配,Arena的数量跟CPU核数有关。当线程申请时,就会给它分配一个Arena,且在这个线程声明周期内,线程只会更这个Arena打交道

Netty 借鉴了 jemalloc 中 Arena 的设计思想,采用固定数量的多个 Arena 进行内存分配,Arena 的默认数量与 CPU 核数有关,通过创建多个 Arena 来缓解资源竞争问题,从而提高内存分配效率。线程在首次申请分配内存时,会通过 round-robin 的方式轮询 Arena 数组,选择一个固定的 Arena,在线程的生命周期内只与该 Arena 打交道,所以每个线程都保存了 Arena 信息,从而提高访问效率。

PoolChunk:

PoolArena 的数据结构包含两个 PoolSubpage 数组和六个 PoolChunkList,两个 PoolSubpage 数组分别存放 Tiny 和 Small 类型的内存块,六个 PoolChunkList 分别存储不同利用率的 Chunk,构成一个双向循环链表。

PoolArena 对应实现了 Subpage 和 Chunk 中的内存分配,其 中 PoolSubpage 用于分配小于 8K 的内存,PoolChunkList 用于分配大于 8K 的内存

随着PoolChunk的使用率变化,PoolChunk会在不同的PoolChunkList之间移动

分配流程

内存回收

面试回答

首先说下它整体是参考jemalloc –> 说说设计原则(分规格,就是有不同规格的内存块,以及实现了每个线程都有自己的内存) —> 列举下组件(poolArean,chunk,subpage,以及基础的分配单位page)–> 分配流程(先检查请求分配的大小是tiny还是smaller,然后检查PoolThreadCache是否够,如果够就使用自己线程私有内存,然后再进行Arena分配,最终返回一个Bytebuffer给 用户)