NIO闲聊
自从JAVA1.4推出NIO起,JAVA网络编程进入了一个全新的时代,传统网络IO(OIO)是傻等式的,一旦IO操作发起,那么用户线程就陷入很傻很天真的等待中,直到IO操作结束或者发生了断连,而NIO则要聪明许多是事件触发式的,只有当前有IO事件发生了,才会通知用户线程执行IO操作,当前操作结束之后不会阻塞等待可以执行其他的业务操作等待下一次事件,就好比上银行取钱,一种方式排队傻等直到排到你,一种是登记排号,登记完之后该干嘛干嘛去,等轮到你的时候业务员电话通知你去办理,脑子正常的都会喜欢后面那种方式。相比OIO的线程和连接的N对N,NIO只需少数几个线程处理N个连接,这就是著名的多路复用模型,对于Netty来讲,它并不是标准的多路复用,Netty的IO事件和非IO事件由同一个线程池进行调度,通过一个参数来控制IO事件和非IO事件的执行时间,这种方式下控制复杂一些,但是CPU使用率更高,因为会有很少的上下文切换(Context Switch)。
但是天下没有免费的午餐,NIO带来了性能方面的优势,但是相比OIO,在使用上复杂度飙升。比如我想执行一次写操作,OIO几行代码就搞定了:
- OutputStream os = s.getOutputStream();
- String str = "test";
- os.write(str.getBytes());
而只用NIO则需要一坨代码:
- while (running) {
- SocketChannel socketChanel = serverSocketChanel.accept();
- socketChanel.configureBlocking(false);
- socketChanel.register(selector, SelectionKey.OP_READ
- | SelectionKey.OP_WRITE);
- int count = selector.select();
- if (count > 0) {
- Set<SelectionKey> keys = selector.selectedKeys();
- Iterator<SelectionKey> iter = keys.iterator();
- while (iter.hasNext()) {
- SelectionKey key = iter.next();
- if (key.isWritable()) {
- ByteBuffer buff = ByteBuffer.allocate(1024);
- buff.put("<html>test</html>".getBytes());
- System.out.println("writable");
- buff.flip();
- socketChanel.write(buff);
- buff.clear();
- }
- iter.remove();
- }
- }
- }
这也可以理解,还是拿前面取钱例子,第二种方式下,用户是爽了,但是银行需要搞一套排号 的系统,还要比较负责有耐心的业务员,总之银行很麻烦。除此之外NIO还带来了更多可靠性问题。Netty牛的地方就是屏蔽NIO编程的复杂性,简化编程模型,让开发者聚焦业务逻辑,而且针对NIO中的一些可靠性问题就行了处理。下面对Netty对几个可靠性问题处理进行学习。
连接超时处理
在OIO中,连接超时处理非常简单,只需调用一个setConnectTimeout方法设置连接超时时间即可,但是JDK的NIO API中并没有提供设置超时时间的方法,显然无论从服务器资源还是用户体验的角度,连接必须要用超时时间,一方面服务器的句柄资源是有限的,既然我不能为你服务那么请你尽早放手吧,长时间无法连接上服务器时非常有必要释放句柄资源,另一方面在用户发起操作时如果系统长时间不给响应显然不是一种好体验,这时候也非常有必要在超时之后提示用户当前无法连接上服务器。Netty对外提供了设置连接超时时间的API,通过ChannleOption.CONNECT_TIMEOUT_MILLIS设置超时时间timetou,Netty的处理是在调用建连之后马上启动一个延时任务,该任务在timeout时间过后执行,任务中执行关闭操作,连接建立成功之后取消这个任务,处理代码在AbstractNioUnsafe类的connect方法中:
- @Override
- public void connect(
- final SocketAddress remoteAddress, final SocketAddress localAddress, final ChannelPromise promise) {
- if (!promise.setUncancellable() || !ensureOpen(promise)) {
- return;
- }
-
- try {
- if (connectPromise != null) {
- throw new IllegalStateException("connection attempt already made");
- }
-
- boolean wasActive = isActive();
- if (doConnect(remoteAddress, localAddress)) {
- fulfillConnectPromise(promise, wasActive);
- } else {
- connectPromise = promise;
- requestedRemoteAddress = remoteAddress;
-
-
- int connectTimeoutMillis = config().getConnectTimeoutMillis();
- if (connectTimeoutMillis > 0) {
- connectTimeoutFuture = eventLoop().schedule(new OneTimeTask() {
- @Override
- public void run() {
- ChannelPromise connectPromise = AbstractNioChannel.this.connectPromise;
- ConnectTimeoutException cause =
- new ConnectTimeoutException("connection timed out: " + remoteAddress);
- if (connectPromise != null && connectPromise.tryFailure(cause)) {
- close(voidPromise());
- }
- }
- }, connectTimeoutMillis, TimeUnit.MILLISECONDS);
- }
-
- promise.addListener(new ChannelFutureListener() {
- @Override
- public void operationComplete(ChannelFuture future) throws Exception {
- if (future.isCancelled()) {
- if (connectTimeoutFuture != null) {
- connectTimeoutFuture.cancel(false);
- }
- connectPromise = null;
- close(voidPromise());
- }
- }
- });
- }
- } catch (Throwable t) {
- if (t instanceof ConnectException) {
- Throwable newT = new ConnectException(t.getMessage() + ": " + remoteAddress);
- newT.setStackTrace(t.getStackTrace());
- t = newT;
- }
- promise.tryFailure(t);
- closeIfClosed();
- }
- }
分包传输
在TCP协议中,分包传输是非常,一份信息可能分几次达到目的地,在OIO中这是没有问题的,因为OIO是傻等式的,不读到完整的信息它是不会罢手的,但是NIO就不同了,它是基于事件的,只有有数据来了它才会去读取,那么问题来了,在读取到数据之后对数据进行业务解析时该如何处理?比如说我想解析一个整形数,但是当前只读取到了两个字节的数据,还有两个字节的数据在后面的传输包中,由于NIO的非阻塞性,业务数据的解析时机成了一个大问题,因为可能无法一次取到完整的数据。
基于上面这个问题,Netty框架设计了一个ReplayingDecoder来解决这种场景中的问题,ReplayingDecoder的核心原理是,当ReplayingDecoder在进行数据解析时,如果发现当前ByteBuf中所有可读数据并不完整,比如我想解析出一个整型数,但是ByteBuf中数据小于4个字节,那么此时会抛出一个Signal类型的Error,抛Error的操作在ReplayingDecoderBuffer中进行,一个ByteBuf的装饰器。在ReplayingDecoder会捕捉一个Error,捕捉到Signal之后会把ByteBuf中的读指针还原到之前的断点处(checkpoint,默认是ByteBuf的其实读位置),然后结束这次解析操作,等待下一次IO读事件。如果只是简单的整形数解析问题不大,但是如果数据解析逻辑复杂是,这种处理方式存在一个问题,在网络条件比较糟糕时,解析逻辑会反复执行多次,如果解析过程是一个耗CPU的操作,那么这对CPU是个大负担。可以通过ReplayingDecoder中的断点和状态机来解决这个问题,使用者可以在ReplayingDecoder中保存之前的解析结果、状态和读指针断点,举个例子,我要解析8个字节的数据,把前后四个字节都解析成整形数,并且把这两个数据相加当做解析结果,代码如下:
- public class TowIntegerReplayingDecoder extends ReplayingDecoder<Integer> {
-
- private static final int PARSE_1 = 1;
- private static final int PARSE_2 = 2;
- private int number1;
- private int number2;
-
- @Override
- protected void decode(ChannelHandlerContext ctx, ByteBuf in,
- List<Object> out) throws Exception {
- switch (state()) {
- case PARSE_1:
- number1 = in.readInt();
- checkpoint(PARSE_2);
- break;
- case PARSE_2:
- number2 = in.readInt();
- checkpoint(PARSE_1);
- out.add(number1 + number2);
- break;
- default:
- break;
- }
-
- }
-
- }
在代码中,把解析分成两个阶段,当一个阶段解析完成之后,记录第一个阶段的解析结果,并且更新解析状态和读指针,这样如果由于数据不完整导致第二阶段的解析无法完成,下次IO事件触发时,该解析器会直接进入第二阶段的解析,而不会重复第一阶段的解析,这样会减少重复解析大概率。基于这种设计,ReplayingDecoder必须是Channel独有的,它的实例不能被共享,没有Channel实例必须有个单独的ReplayingDecoder解析器实例,而且不能添加Sharable注解,因为它是用状态了,如果在多个Channel中共享了,那么状态就乱套了。
Selector空轮询处理
在NIO中通过Selector的轮询当前是否有IO事件,根据JDK NIO api描述,Selector的select方法会一直阻塞,直到IO事件达到或超时,但是在Linux平台上这里有时会出现问题,在某些场景下select方法会直接返回,即使没有超时并且也没有IO事件到达,这就是著名的epoll bug,这是一个比较严重的bug,它会导致线程陷入死循环,会让CPU飙到100%,极大地影响系统的可靠性,到目前为止,JDK都没有完全解决这个问题。
但是Netty有效的规避了这个问题,经过实践证明,epoll bug已Netty框架解决,Netty的处理方式是这样的:
记录select空转的次数,定义一个阀值,这个阀值默认是512,可以在应用层通过设置系统属性io.netty.selectorAutoRebuildThreshold传入,当空转的次数超过了这个阀值,重新构建新Selector,将老Selector上注册的Channel转移到新建的Selector上,关闭老Selector,用新的Selector代替老Selector,详细实现可以查看NioEventLoop中的selector和rebuildSelector方法:
- for (;;) {
- long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
- if (timeoutMillis <= 0) {
- if (selectCnt == 0) {
- selector.selectNow();
- selectCnt = 1;
- }
- break;
- }
-
- int selectedKeys = selector.select(timeoutMillis);
- selectCnt ++;
-
- if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks()) {
-
-
-
- break;
- }
- if (selectedKeys == 0 && Thread.interrupted()) {
-
-
-
-
-
- if (logger.isDebugEnabled()) {
- logger.debug("Selector.select() returned prematurely because " +
- "Thread.currentThread().interrupt() was called. Use " +
- "NioEventLoop.shutdownGracefully() to shutdown the NioEventLoop.");
- }
- selectCnt = 1;
- break;
- }
- if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
- selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
-
-
- logger.warn(
- "Selector.select() returned prematurely {} times in a row; rebuilding selector.",
- selectCnt);
-
- rebuildSelector();
- selector = this.selector;
-
-
- selector.selectNow();
- selectCnt = 1;
- break;
- }
-
- currentTimeNanos = System.nanoTime();
- }
- public void rebuildSelector() {
- if (!inEventLoop()) {
- execute(new Runnable() {
- @Override
- public void run() {
- rebuildSelector();
- }
- });
- return;
- }
-
- final Selector oldSelector = selector;
- final Selector newSelector;
-
- if (oldSelector == null) {
- return;
- }
-
- try {
- newSelector = openSelector();
- } catch (Exception e) {
- logger.warn("Failed to create a new Selector.", e);
- return;
- }
-
-
- int nChannels = 0;
- for (;;) {
- try {
- for (SelectionKey key: oldSelector.keys()) {
- Object a = key.attachment();
- try {
- if (!key.isValid() || key.channel().keyFor(newSelector) != null) {
- continue;
- }
-
- int interestOps = key.interestOps();
- key.cancel();
- key.channel().register(newSelector, interestOps, a);
- nChannels ++;
- } catch (Exception e) {
- logger.warn("Failed to re-register a Channel to the new Selector.", e);
- if (a instanceof AbstractNioChannel) {
- AbstractNioChannel ch = (AbstractNioChannel) a;
- ch.unsafe().close(ch.unsafe().voidPromise());
- } else {
- @SuppressWarnings("unchecked")
- NioTask<SelectableChannel> task = (NioTask<SelectableChannel>) a;
- invokeChannelUnregistered(task, key, e);
- }
- }
- }
- } catch (ConcurrentModificationException e) {
-
- continue;
- }
-
- break;
- }
-
- selector = newSelector;
-
- try {
-
- oldSelector.close();
- } catch (Throwable t) {
- if (logger.isWarnEnabled()) {
- logger.warn("Failed to close the old Selector.", t);
- }
- }
-
- logger.info("Migrated " + nChannels + " channel(s) to the new Selector.");
- }
防止线程跑飞
线程是多路复用器的核心,所有IO事件执行的载体,一旦线程出现异常线程跑飞(run方法执行结束),那么可能会导致整个多路复用器不可用,导致挂载在多路复用器上的连接不可用,进而大量的业务请求失败。由于Netty中的同时处理IO事件和非IO事件逻辑,所以线程不仅仅要处理IO异常,业务测触发的异常也需要被正确的处理,一旦处理不当,会导致线程跑飞。Netty的处理是在run方法中catch所有的Throwable即所有的Exception和Error,不做任何处理,休眠1s继续执行循环,休眠1s的目的是为了防止捕获异常之后继续执行再次进入该异常形成死循环。实现代码在NioEventLoop的run方法中:
- @Override
- protected void run() {
- for (;;) {
- oldWakenUp = wakenUp.getAndSet(false);
- try {
- ...
- } catch (Throwable t) {
- logger.warn("Unexpected exception in the selector loop.", t);
-
-
-
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
-
- }
- }
- }
- }
内存保护
ByteBuf内存泄露保护
为了提升内存的利用率,Netty提供了内存池和对象池,内存泄露保护主要是针对Netty中的内存池的,Netty要求在使用完内存池中的内存之后要显示的归还,以免内存中的对象存在额外的引用造成内存泄露,Netty提供了SimpleChannelInboundHandler,该处理器会自动释放内存,使用者可以直接继承该处理器,它的channelRead方法的finally块中调用了释放内存的方法,另外内存泄露监控处理可以参考ResourceLeakDetector类中的代码:
- @Override
- public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
- boolean release = true;
- try {
- if (acceptInboundMessage(msg)) {
- @SuppressWarnings("unchecked")
- I imsg = (I) msg;
- channelRead0(ctx, imsg);
- } else {
- release = false;
- ctx.fireChannelRead(msg);
- }
- } finally {
- if (autoRelease && release) {
- ReferenceCountUtil.release(msg);
- }
- }
- }
ByteBuf的内存溢出保护
为了防止一些超长的恶意流量耗尽服务器内存压垮服务器,有必要会缓存区设置上限,Netty做了如下处理:
- 在内存分配的时候指定缓冲区长度上限(io.netty.buffer.ByteBufAllocator.buffer(int, int))。
- 在对缓冲区进行写入操作的时候,如果缓冲区容量不足需要扩展,首先对最大容量进行判断,如果扩展后的容量超过上限,则拒绝扩展(io.netty.buffer.ByteBuf.ensureWritable(int)方法中处理)。
- 在解码的时候,对消息长度进行判断,如果超过最大容量上限,则抛出解码异常,拒绝分配内存(io.netty.handler.codec.DelimiterBasedFrameDecoder.decode(ChannelHandlerContext, ByteBuf)方法中处理,在fail方法中抛出TooLongFrameException异常)。
连接中断处理
在客户端和服务端建立起连接之后,如果连接发生了意外中断,Netty也会及时释放连接句柄资源(因为TCP是全双工协议,通信双方都需要关闭和释放Socket句柄才不会发生句柄的泄漏,如不经过特殊处理是会发生句柄泄露的),原理如下:
在读取数据时会调用io.netty.buffer.AbstractByteBuf.writeBytes(ScatteringByteChannel, int),然后调用io.netty.buffer.ByteBuf.setBytes(int, ScatteringByteChannel, int),setBytes方法调用nio.channel.read,如果当前连接已经意外中断,会收到JDK NIO层抛出的ClosedChannelException异常,setBytes方法捕获该异常之后直接返回-1,
在NioByteUnsafe.read方法中,发现当前读取到的字节长度为-1,即调用io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe.closeOnRead(ChannelPipeline)方法,然后调用io.netty.channel.AbstractChannel.AbstractUnsafe.close(ChannelPromise)关闭连接释放句柄资源。参考相关的代码:
-
- public void read() {
- ...
- boolean close = false;
- try {
- ...
- do {
- ...
- int localReadAmount = doReadBytes(byteBuf);
- if (localReadAmount <= 0) {
- ...
- close = localReadAmount < 0;
- break;
- }
- ...
- } while (...);
-
- ...
-
- if (close) {
- closeOnRead(pipeline);
- close = false;
- }
- } catch (Throwable t) {
- ...
- } finally {
- ...
- }
- }
-
-
- protected int doReadBytes(ByteBuf byteBuf) throws Exception {
- return byteBuf.writeBytes(javaChannel(), byteBuf.writableBytes());
- }
-
-
- public int writeBytes(ScatteringByteChannel in, int length) throws IOException {
- ensureWritable(length);
- int writtenBytes = setBytes(writerIndex, in, length);
- if (writtenBytes > 0) {
- writerIndex += writtenBytes;
- }
- return writtenBytes;
- }
-
-
- public int setBytes(int index, ScatteringByteChannel in, int length) throws IOException {
- ensureAccessible();
- try {
- return in.read((ByteBuffer) internalNioBuffer().clear().position(index).limit(index + length));
- } catch (ClosedChannelException e) {
- return -1;
- }
- }
流量整形
一般大型的系统都包含多个模块,在部署时不同的模块可能部署在不同的机器上,比如我司的项目,至少5个部件起,少了都不好意思拿出去见人。这种情况下系统运行时会涉及到大量的上下游部件的通信,但是由于不同服务器无论是从硬件配置,还是系统模块的业务特性都会存在差异,这就导致到服务器的处理能力,以及不同时间段服务器的负载都是有差异的,这就可能会导致问题:上下游消息的传递速度和下游部件的消息处理速度失去平衡,下游部件接收到的消息量远远超过了它的处理能力,导致大量的业务无法被及时的处理,甚至可能导致下游服务器被压垮。
在Netty框架中提供了流量整形处理机制来应付这种场景,通过控制服务器单位时间内发送/接收消息的字节数来使上下游服务器处理相对平衡的状态。Netty中的流量整形包含了两种:一种是针对单个连接的流量整形,另一种是针对全局即所有连接的流量整形。这两种方式的流量整形原理是类似的,只是流量整形器的作用域不同,一个是全局的,一个是连接建立后创建,连接关闭后被回收。GlobalTrafficShapingHandler处理全局流量整形,ChannelTrafficShapingHandler处理单链路流量整形,流量整形处理有三个重要的参数:
- writeLimit:每秒最多可以写多个字节的数据。
- readLimit:每秒最多可以读多少个字节的数据。
- checkInterval:流量检查的间隔时间,默认1s。
以读操作为例,流量整形的工作过程大致如下:
- 启动一个定时任务,每隔checkInterval毫秒执行一次,在任务中清除累加的读写字节数还原成0,更新上次流量整形检查时间。
- 执行读操作,触发channelRead方法,记录当前已读取的字节数并且和上次流量整形检查之后的所有读操作读取的字节数进行累加。
- 根据时间间隔和已读取的流量数计算当前流量判断当前读取操作是否已导致每秒读取的字节数超过了阀值readLimit,计算公式是:(bytes * 1000 / limit - interval) / 10 * 10,其中,bytes是上次流量整形检查之后的所有读操作累计读取的字节数,limit 就是readLimit,interval是当前时间距上次检查经过的时间毫秒数,如果该公式计算出来的值大于固定的阀值10,那么说明流量数已经超标,那么把该读操作放到延时任务中处理,延时的毫秒数就是上面那个公式计算出来的值。
下面是相关的代码:
-
- public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
- long size = calculateSize(msg);
- long curtime = System.currentTimeMillis();
-
- if (trafficCounter != null) {
-
- trafficCounter.bytesRecvFlowControl(size);
- if (readLimit == 0) {
-
- ctx.fireChannelRead(msg);
-
- return;
- }
-
-
- long wait = getTimeToWait(readLimit,
- trafficCounter.currentReadBytes(),
- trafficCounter.lastTime(), curtime);
- if (wait >= MINIMAL_WAIT) {
-
-
- if (!isSuspended(ctx)) {
- ctx.attr(READ_SUSPENDED).set(true);
-
-
-
- Attribute<Runnable> attr = ctx.attr(REOPEN_TASK);
- Runnable reopenTask = attr.get();
- if (reopenTask == null) {
- reopenTask = new ReopenReadTimerTask(ctx);
- attr.set(reopenTask);
- }
- ctx.executor().schedule(reopenTask, wait,
- TimeUnit.MILLISECONDS);
- } else {
-
-
- Runnable bufferUpdateTask = new Runnable() {
- @Override
- public void run() {
- ctx.fireChannelRead(msg);
- }
- };
- ctx.executor().schedule(bufferUpdateTask, wait, TimeUnit.MILLISECONDS);
- return;
- }
- }
- }
- ctx.fireChannelRead(msg);
- }
-
-
- private static long getTimeToWait(long limit, long bytes, long lastTime, long curtime) {
- long interval = curtime - lastTime;
- if (interval <= 0) {
-
- return 0;
- }
- return (bytes * 1000 / limit - interval) / 10 * 10;
- }
-
- private static class TrafficMonitoringTask implements Runnable {
- ...
- @Override
- public void run() {
- if (!counter.monitorActive.get()) {
- return;
- }
- long endTime = System.currentTimeMillis();
-
- counter.resetAccounting(endTime);
- if (trafficShapingHandler1 != null) {
- trafficShapingHandler1.doAccounting(counter);
- }
- counter.scheduledFuture = counter.executor.schedule(this, counter.checkInterval.get(),
- TimeUnit.MILLISECONDS);
- }
- }