Netty 如何解决 JDK NIO 中的空轮询 Bug?

Sherwin.Wei Lv7

Netty 如何解决 JDK NIO 中的空轮询 Bug?

回答重点

Netty 实际上并没有解决 JDK 原生 NIO 中空轮询 bug,而是通过其他途径绕开了这个错误。

具体操作如下:

1)统计空轮询次数:Netty 通过 selectCnt 计数器来统计连续空轮询的次数。每次执行 Selector.select() 方法后,如果发现没有 I/O 事件,selectCnt 就会递增。

2)设置阈值:Netty 定义了一个阈值 SELECTOR_AUTO_REBUILD_THRESHOLD,默认值为 512。当空轮询次数达到这个阈值时,Netty 会触发重建 Selector 的操作。

3)重建 Selector:当达到空轮询的阈值时,Netty 会创建一个新的 Selector,并将所有注册的 Channel 从旧的 Selector 转移到新的 Selector 上。这一过程涉及到取消旧 Selector 上的注册,并在新 Selector 上重新注册 Channel。

4)关闭旧的 Selector:在成功重建 Selector 并将 Channel 重新注册后,Netty 会关闭旧的 Selector,从而避免继续在旧的 Selector 上发生空轮询。

总结来看,就是通过 selectCnt 统计没有 I/O 事件的次数来判断当前是否发生了空轮询,如果发生了就重建一个 Selector 替换之前出问题的 Selector,所以说 Netty 实际上没解决空轮询的 bug,只是绕开了这个问题。

扩展知识

官方 bug 描述

The NIO selector wakes up infinitely in this situation..(以下情形可以复现)

  1. server waits for connection
  2. client connects and write message
  3. server accepts and register OP_READ
  4. server reads message and remove OP_READ from interest op set
  5. client close the connection
  6. server write message (without any reading.. surely OP_READ is not set)
  7. server’s select wakes up infinitely with return value 0

空轮询 bug 原因:

当连接的 Socket 被突然中断(如对端异常关闭)时,epoll 会将该 Socket 的事件标记为 EPOLLHUP 或 EPOLLERR,导致 Selector 被唤醒。 然而,SelectionKey 并未定义处理这些异常事件的类型,导致 Selector 被唤醒后,无法处理这些异常事件,从而进入空轮询状态,导致 CPU 占用率过高。

Netty 解决空轮询源码解析

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
88
89
90
91
92
93
94
95
96
97
private void select(boolean oldWakenUp) throws IOException {
Selector selector = this.selector;
try {
// selectCnt 记录轮询次数,空轮询次数超过 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512)后,重建 selector
int selectCnt = 0;

// 记录当前时间
long currentTimeNanos = System.nanoTime();

// selectDeadLineNanos = 当前时间 + 距离最早的定时任务开始执行的时间
// 计算出 select 操作必须在哪个时间点之前被 wakeUp(不然一直被阻塞的话,定时任务就没法被执行)
long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);

// 归一化的定时任务截止时间(与系统启动时间的偏移量)
long normalizedDeadlineNanos = selectDeadLineNanos - initialNanoTime();
if (nextWakeupTime != normalizedDeadlineNanos) {
nextWakeupTime = normalizedDeadlineNanos;
}

for (;;) {
// 计算出当前 select 操作能阻塞的最久时间
long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;

// 超过最长等待时间:有定时任务需要执行
if (timeoutMillis <= 0) {
if (selectCnt == 0) {
// 非阻塞操作,没有数据返回则返回 0
selector.selectNow();
selectCnt = 1;
}
break;
}

// 如果任务队列中有任务且 wakenUp 为 true,说明任务没机会调用 Selector#wakeup。
// 因此我们需要检查任务队列,防止任务被延迟执行。
if (hasTasks() && wakenUp.compareAndSet(false, true)) {
selector.selectNow(); // 强制执行 selectNow
selectCnt = 1;
break;
}

// 进行 select 阻塞操作,直到有事件触发
int selectedKeys = selector.select(timeoutMillis);
selectCnt++; // 记录空轮询次数

// 如果选中事件不为 0,或者发生了唤醒,或者有任务需要执行,或者有定时任务触发
// 都可以跳出循环
if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
break;
}

// 如果 select 没有触发超时返回,并且确实是监听到了新事件而不是空轮询,
// 那么就一定会在上面的 if 中返回了。所以往下走的话,有 2 个情况:
// 1. select 超时
// 2. 发生了空轮询
if (Thread.interrupted()) {
// 如果线程被中断,则重置选中的键并跳出循环,避免进入忙等状态。
if (logger.isDebugEnabled()) {
logger.debug("Selector.select() prematurely returned due to thread interruption. " +
"Use NioEventLoop.shutdownGracefully() to shut down the NioEventLoop.");
}
selectCnt = 1;
break;
}

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_AUTO_REBUILD_THRESHOLD(默认 512),
// 需要重建 selector
selector = selectRebuildSelector(selectCnt);
selectCnt = 1;
break;
}

currentTimeNanos = time;
}

// 如果空轮询次数过多,输出调试日志
if (selectCnt > MIN_PREMATURE_SELECTOR_RETURNS) {
if (logger.isDebugEnabled()) {
logger.debug("Selector.select() prematurely returned {} times in a row for Selector {}.",
selectCnt - 1, selector);
}
}
} catch (CancelledKeyException e) {
// 捕获 CancelledKeyException 异常并记录日志
if (logger.isDebugEnabled()) {
logger.debug(CancelledKeyException.class.getSimpleName() + " raised by a Selector {} - JDK bug?",
selector, e);
}
}
}
Comments