Netty——NIO 空轮询 bug
文章目录
- 1. NIO 服务端的常见代码
- 2. 成因
- 3. 危害
- 4. 一种简单的解决方案
- 5. Netty 的解决方案
- 5.1 涉及的变量、常量和方法
- 5.2 判断流程
- 5.3 优点
- 6. JDK 对 NIO 的优化
- 7. 总结
1. NIO 服务端的常见代码
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
serverSocketChannel.configureBlocking(false);
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 如果没有连接的通道,则跳过本轮循环
int readyChannels = selector.select();
if (readyChannels == 0) {
continue;
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
if (selectionKey.isAcceptable()) {
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) {
// 处理读取数据的逻辑
}
iterator.remove();
}
}
}
说明:select()
是阻塞的,直到有返回值。但当它频繁返回 0
时,会导致 CPU 不断跳过本轮循环,在循环内空转,这就出现了空轮询 bug。
2. 成因
Selector
借助操作系统的多路复用机制(例如 Linux 系统的 epoll
)来监控多个通道的 I/O 事件。不过在某些特定情形下,Selector
对象的 select()
或 select(long timeout)
方法可能会提前返回 0。此时没有任何通道准备好进行 I/O 操作,这就引发了空轮询现象。
这种 bug 通常是由 底层操作系统的多路复用机制、Java 虚拟机(JVM)以及网络环境等多方面因素共同导致的。例如,在 Linux 系统中使用 epoll
时,当 epoll_ctl
操作失败或者 epoll_wait
返回错误时,就可能致使 Selector
出现空轮询问题。
3. 危害
空轮询 bug 会 让 Selector
不断地进行无意义的轮询,从而 使 CPU 使用率急剧上升,系统性能大幅下降。
4. 一种简单的解决方案
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
serverSocketChannel.configureBlocking(false);
int emptySelections = 0; // 统计空轮询的次数
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 如果没有连接的通道,则空轮询的次数加一,并跳过本轮循环
int readyChannels = selector.select();
if (readyChannels == 0) {
emptySelections++;
// 如果空轮询的次数大于等于 100 次,则重建 selector,并将 空轮询的次数 置为 0
if (emptySelections >= 100) {
selector = rebuildSelector(selector);
emptySelections = 0;
}
continue;
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
if (selectionKey.isAcceptable()) {
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) {
// 处理读取数据的逻辑
}
iterator.remove();
}
}
}
private static Selector rebuildSelector(Selector oldSelector) throws IOException {
Selector newSelector = Selector.open();
// 将旧 selector 的所有 有效 的通道 注册到新 selector 里
for (SelectionKey key : oldSelector.keys()) {
if (key.isValid() && key.channel().isOpen()) {
int interestOps = key.interestOps();
key.cancel(); // 从旧 selector 中取消注册
key.channel().register(newSelector, interestOps);
}
}
oldSelector.close();
return newSelector;
}
说明:本解决方案认为 只要一个 selector 有至少 100 次的空轮询,就对其进行重建,重建时排除无效的通道。
缺点:检测机制不精确。如果空轮询的次数在很长一段时间才累积到 100 次,那么这时候大概率不需要重建 selector。因为在这个场景中,空轮询是一个偶发事件,这样就会由于重建 selector 浪费一定的时间。如果将判断条件改为 在某个时间段内的空轮询次数超过某个值,那就会避免无效的 selector 重建。
5. Netty 的解决方案
5.1 涉及的变量、常量和方法
private static final int SELECTOR_AUTO_REBUILD_THRESHOLD
常量:空轮询次数的阈值(默认为512
),如果超过,则重建 selector。int selectCnt
变量:run
方法循环外的局部变量,用于统计空轮询的次数。每次轮询都加一,无论是否是空轮询,如果不是空轮询,则会将其重新置为0
。protected void run()
方法:相当于 NIO 代码中的main
方法,包含一个无限循环,在循环中监听并处理。private int select(long deadlineNanos)
方法:监测已注册的通道中有没有发生事件,如果发生,则返回发生事件的通道数。该方法实际上使用的是 NIO 的Selector
的select
方法,由于多种因素,该方法可能返回0
。private boolean unexpectedSelectorWakeup(int selectCnt)
方法:判断空轮询次数有没有大于等于阈值,如果超过,则触发重建 selector 的机制,并在run
方法中将空轮询次数置为0
。public void rebuildSelector()
方法:触发重建 selector 的流程。private void rebuildSelector0()
方法:重建 selector。
5.2 判断流程
- 在
run
方法中,通过select
方法监测有没有通道发生事件。如果由于某些因素,该方法返回0
,则表示发生了空轮询,之后调用unexpectedSelectorWakeup
方法,如果该方法返回true
,则将selectCnt
置为0
。 - 在
unexpectedSelectorWakeup
方法中判断selectCnt
是否大于等于SELECTOR_AUTO_REBUILD_THRESHOLD
。如果是,则会调用rebuildSelector
方法。 - 在
rebuildSelector
方法中调用rebuildSelector0
方法。 - 在
rebuildSelector0
方法中,将通道绑定到新的 selector,最后关闭旧的 selector。
5.3 优点
这种方案比简单方案优秀的就是它的监测机制,简单方案监测的是 自服务端启动开始的空轮询次数,这种方案监测的是 连续的空轮询次数(因为一旦轮询不是空轮询,selectCnt
就会清零)。
6. JDK 对 NIO 的优化
在 JDK 1.5、JDK 1.6 等早期版本中,Selector
的空轮询 bug 比较突出。Oracle 对 JDK 不断进行优化和修复,JDK 1.8 及后续版本在处理 Selector
相关问题上有了更好的表现。虽然不能绝对保证不会出现空轮询,但出现的概率相比早期版本大大降低。
7. 总结
NIO 的 Selector
借助底层操作系统的多路复用机制来监听多个通道的事件,由于某些因素,可能导致这个方法频繁返回 0
,从而服务端不停地在循环中空转,这就是 NIO 的空轮询问题。
解决这个问题的方法就是统计空轮询的次数,当超过一个数值后,就重建 selector。其中的统计空轮询的机制很重要,建议设计为 统计连续空轮询的次数。
这个问题在 JDK 的早期版本中比较退出,但随着 JDK 的升级,这个 bug 出现的概率降低了许多。