我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)

lemonvita
发布于 2022-7-19 10:27
浏览
0收藏

8. 提交 PR ,修复 Bug


笔者首先向 Netty  社区提交了一个 Issue,在 Issue 中详细为社区人员描述了这个 Bug 产生的原因。也就是上一小节中的内容。

Issue : https://github.com/netty/netty/issues/11981

 

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区

 image.png

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区 SO_LINGERHalfClosureBug.png


随后笔者按照《7. 啊哈!!Bug !!》小节中介绍的修复思路为这个 Issue 提交了修复 PR ,

PR  :https://github.com/netty/netty/pull/11982

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区 PR.png


笔者修复后的 ShutdownOutput 流程逻辑如下:

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区

 image.png


编写单元测试,然后信心满满地等待 PR 被 Merged。

public class SocketHalfClosedTest extends AbstractSocketTest {

    @Test
    @Timeout(value = 5000, unit = MILLISECONDS)
    public void testHalfClosureReceiveDataOnFinalWait2StateWhenSoLingerSet(TestInfo testInfo) throws Throwable {
        run(testInfo, new Runner<ServerBootstrap, Bootstrap>() {
            @Override
            public void run(ServerBootstrap serverBootstrap, Bootstrap bootstrap) throws Throwable {
                testHalfClosureReceiveDataOnFinalWait2StateWhenSoLingerSet(serverBootstrap, bootstrap);
            }
        });
    }

    private void testHalfClosureReceiveDataOnFinalWait2StateWhenSoLingerSet(ServerBootstrap sb, Bootstrap cb)
            throws Throwable {
        Channel serverChannel = null;
        Channel clientChannel = null;

        final CountDownLatch waitHalfClosureDone = new CountDownLatch(1);
        try {
            sb.childOption(ChannelOption.SO_LINGER, 1)
              .childHandler(new ChannelInitializer<Channel>() {

                  @Override
                  protected void initChannel(Channel ch) throws Exception {
                      ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {

                            @Override
                            public void channelActive(final ChannelHandlerContext ctx) {
                                SocketChannel channel = (SocketChannel) ctx.channel();
                                channel.shutdownOutput();
                            }

                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) {
                                ReferenceCountUtil.release(msg);
                                waitHalfClosureDone.countDown();
                            }
                        });
                  }
              });

            cb.option(ChannelOption.ALLOW_HALF_CLOSURE, true)
              .handler(new ChannelInitializer<Channel>() {
                  @Override
                  protected void initChannel(Channel ch) throws Exception {
                      ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {

                            @Override
                            public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
                                if (ChannelInputShutdownEvent.INSTANCE == evt) {
                                    ctx.writeAndFlush(ctx.alloc().buffer().writeZero(16));
                                }

                                if (ChannelInputShutdownReadComplete.INSTANCE == evt) {
                                    ctx.close();
                                }
                            }
                        });
                  }
              });

            serverChannel = sb.bind().sync().channel();
            clientChannel = cb.connect(serverChannel.localAddress()).sync().channel();
            waitHalfClosureDone.await();
        } finally {
            if (clientChannel != null) {
                clientChannel.close().sync();
            }

            if (serverChannel != null) {
                serverChannel.close().sync();
            }
        }
    }
}

还是那句话 “理想很丰满,现实很骨感”,Netty 作为一个世界知名的高性能开源框架,必定有着非常严格的代码规范。比如:

 

 •代码书写规范:函数与函数之间的空行个数,单行代码的长度,函数命名的长度, .... 等。

 

 •注释的规范:单行注释的长度,字符与字符之间的空格,...... 等。

 

 •单元测试规范。


PR 提交过去也是出现了很多规范类的 CheckStyle 错误,也是经过了多轮 Review 和多轮修改最终通过了 Netty 的 CI 流程被 Merged 进主干分支。并在 Netty 的

4.1.73.Final 中发布。

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区

 image.png

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区 image.png


在  4.1.73.Final  版本发布之后,笔者第一时间拉下来最新的代码,看到 Git 记录中出现了自己的名字,想象着自己的代码跑在了各大知名框架中,还是很有成就感的一件事。

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区

 文章封面.png


9. 被动关闭方处理TCP半关闭流程

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区 TCP连接半关闭.png


当主动关闭方调用 shutdownOutput 后,内核会检查此时 Socket 发送缓冲区是否还有数据,如果有就将数据发送出去,并关闭 Socket 的写通道,随后发送 FIN 给对端。

 

接下来的流程和《1. 正常 TCP 连接关闭》小节中的流程一样,服务端 OP_READ 事件活跃,Reactor 线程开始处理 OP_READ 事件。

public abstract class AbstractNioByteChannel extends AbstractNioChannel {

        @Override
        public final void read() {
            final ChannelConfig config = config();

            if (shouldBreakReadReady(config)) {
                clearReadPending();
                return;
            }

            ..........省略获取allocHandle过程.......

            ByteBuf byteBuf = null;
            boolean close = false;
            try {
                do {
                    byteBuf = allocHandle.allocate(allocator);
                    //记录本次读取了多少字节数
                    allocHandle.lastBytesRead(doReadBytes(byteBuf));
                    //如果本次没有读取到任何字节,则退出循环 进行下一轮事件轮询
                    // -1 表示客户端主动关闭了连接close或者shutdownOutput 这里均会返回-1
                    if (allocHandle.lastBytesRead() <= 0) {
                        // nothing was read. release the buffer.
                        byteBuf.release();
                        byteBuf = null;
                        //当客户端主动关闭连接时(客户端发送fin1),会触发read就绪事件,这里从channel读取的数据会是-1
                        close = allocHandle.lastBytesRead() < 0;
                        if (close) {
                            // There is nothing left to read as we received an EOF.
                            readPending = false;
                        }
                        break;
                    }

                    .........省略.............

                } while (allocHandle.continueReading());

                allocHandle.readComplete();
                pipeline.fireChannelReadComplete();

                if (close) {
                    //此时客户端发送fin1(fi_wait_1状态)主动关闭连接,服务端接收到fin,并回复ack进入close_wait状态                    
                    closeOnRead(pipeline);
                }
            } catch (Throwable t) {
                 ............省略...............
            } finally {
                 ............省略...............
            }
        }
    }

}

这里通过 doReadBytes 方法从 Channel 中读取数据依然返回 -1 。随后又会进入 closeOnRead 方法处理半关闭逻辑。

 

9.1 closeOnRead

  private void closeOnRead(ChannelPipeline pipeline) {    
            if (!isInputShutdown0()) {
                if (isAllowHalfClosure(config())) {             
                    shutdownInput();
                    pipeline.fireUserEventTriggered(ChannelInputShutdownEvent.INSTANCE);
                } else {
                       .....省略正常关闭....
                }
            } else {
                .....省略....
            }
        }

首先会调用 isInputShutdown0 方法判断服务端 Channel 的读通道是否已经关闭,现在客户端 Channel 的写通道已经关闭,但此时服务端才刚开始处理半关闭,所以现在服务端 Channel 读写通道都还没有关闭。

 @Override
    public boolean isInputShutdown() {
        return javaChannel().socket().isInputShutdown() || !isActive();
    }

随后判断服务端是否支持半关闭 isAllowHalfClosure。

 private static boolean isAllowHalfClosure(ChannelConfig config) {
        return config instanceof SocketChannelConfig &&
                ((SocketChannelConfig) config).isAllowHalfClosure();
    }

可通过如下配置开启半关闭的支持:

 ServerBootstrap sb = new ServerBootstrap();
    sb.childOption(ChannelOption.ALLOW_HALF_CLOSURE, true)                 

  如果服务端开启了半关闭的支持 isAllowHalfClosure == true ,下面就正式进入了半关闭的处理流程:

 

1.调用 shutdownInput 方法关闭服务端 Channel 的读通道,如果此时 Socket 接收缓冲区还有数据,则会将这些数据统统丢弃。注意关闭读通道并不会向对端发送 FIN ,此时服务端连接依然处于 CLOSE_WAIT 状态。

  private void shutdownInput0() throws Exception {
        if (PlatformDependent.javaVersion() >= 7) {
            //调用底层JDK socketChannel关闭接收方向的通道
            javaChannel().shutdownInput();
        } else {
            javaChannel().socket().shutdownInput();
        }
    }

2.在 pipeline 中触发 ChannelInputShutdownEvent 事件,我们可以在 ChannelInputShutdownEvent 事件的回调方法中,向客户端发送遗留的数据,做到真正的优雅关闭。这里就是图中处于 CLOSE_WAIT 状态下的服务端在半关闭场景下可以继续向处于 FIN_WAIT2 状态下的客户端发送数据的地方。

public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (ChannelInputShutdownEvent.INSTANCE == evt) {
            //在close_wait状态下,发送数据给对端
            ctx.writeAndFlush(message);
        }
    }

}

在连接半关闭的情况下,JDK NIO Selector 会不停的通知 OP_READ 事件活跃,所以 read loop 会一直不停的执行,当 Reactor 处理完 ChannelInputShutdownEvent 之后,由于 Selector 又会通知 OP_READ 事件活跃,所以半关闭流程再一次来到了 closeOnRead 方法。

     

 //表示Input已经shutdown了,再次对channel进行读取返回-1  设置该标志
        private boolean inputClosedSeenErrorOnRead;

        private void closeOnRead(ChannelPipeline pipeline) {    
            if (!isInputShutdown0()) {
                if (isAllowHalfClosure(config())) {             
                       .....省略半关闭.....
                } else {
                       .....省略正常关闭....
                }
            } else {
                inputClosedSeenErrorOnRead = true;
                pipeline.fireUserEventTriggered(ChannelInputShutdownReadComplete.INSTANCE);
            }
        }

那么此时服务端的读通道已经关闭了 isInputShutdown0 == true 。所以流程来到 else 分支。

 

 •设置 inputClosedSeenErrorOnRead = true 表示此时 Channel 的读通道已经关闭了,不能再继续响应 OP_READ 事件,因为半关闭状态下,Selector 会不停的通知 OP_READ 事件,如果不停无脑响应的话,会造成极大的 CPU 资源的浪费。

 

不过 JDK 这样处理也是合理的,毕竟半关闭状态连接并没有完全关闭,只要连接没有完全关闭,就不停的通知你,直到关闭连接为止。
 

•在 pipeline 中触发 ChannelInputShutdownReadComplete 事件,此事件的触发标志着服务端在 CLOSE_WAIT 状态下已经将所有遗留的数据发送给了客户端,服务端可以在该事件的回调中关闭 Channel ,结束 CLOSE_WAIT 进入 LAST_ACK 状态。

 @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (ChannelInputShutdownReadComplete.INSTANCE == evt) {      
            ctx.close();
        }
    }

因为半关闭的状态下,在没有调用 close 方法关闭 Channel 之前,JDK NIO Selector 会一直不停的通知 OP_READ 事件,所以流程马上又会回到 OP_READ 事件的处理方法中。

public abstract class AbstractNioByteChannel extends AbstractNioChannel {

        @Override
        public final void read() {
            final ChannelConfig config = config();

            if (shouldBreakReadReady(config)) {
                clearReadPending();
                return;
            }

            ..........省略获取allocHandle过程.......

            try {
                do {
                          .........省略.............

                } while (allocHandle.continueReading());

               .........省略.............
            } catch (Throwable t) {
                 ............省略...............
            } finally {
                 ............省略...............
            }
        }
    }

}

那么这次我们就不能在响应 OP_READ 事件了,需要调用 clearReadPending 方法将读事件从 Reactor 中取消掉,停止对 OP_READ 事件的监听。否则 Reactor 线程就会在半关闭期间内一直在这里空转,导致 CPU 100%。

 

这里的 shouldBreakReadReady 方法就是用来判断在半关闭期间是否取消 OP_READ 事件的监听。这里的 inputClosedSeenErrorOnRead 已经设置为 true 了。

 

final boolean shouldBreakReadReady(ChannelConfig config) {
        return isInputShutdown0() && (inputClosedSeenErrorOnRead || !isAllowHalfClosure(config));
    }

到这里为止,netty 关于连接关闭所要面对的所有处理场景,笔者就为大家一一介绍完了。

 
总结


本文我们介绍了 netty 在面对 TCP 连接关闭时的三种处理场景时的处理逻辑和过程。

 

这三种处理场景分别是:TCP 连接的正常关闭,TCP 连接的异常关闭,以及用于优雅关闭的 TCP 连接的半关闭。同时我们也发现了 netty 关于半关闭处理时的一个 BUG 。

 

BUG :https://github.com/netty/netty/issues/11981


这个 Bug 导致主动关闭方在 FIN_WAIT2 状态下无法接受到来自被动关闭方在 CLOSE_WAIT 状态下发送的数据。随后又详细分析了这个 Bug 的整个修复过程。

 

其中我们还穿插介绍了 SO_LINGER 选项对于 TCP 连接关闭行为的影响,以及 netty 针对 SO_LINGER 选项的处理过程。

 

同时笔者还为大家列举了关于导致 TCP 连接异常关闭的 7 种场景:

 

1.半连接队列 SYN-Queue 已满

 

2.全连接队列 ACCEPT-Queue 已满

 

3.连接未被监听的端口

 

4.服务端程序崩溃


5.开启 SO_LINGER 选项设置 l_linger = 0


6.主动关闭方在关闭时 Socket 接收缓冲区还有未处理数据


7.主动关闭方 close 关闭但在 FIN_WAIT2 状态接收数据


以及 Netty 对 RST 包的处理流程。最后笔者还介绍了用于连接半关闭的系统调用 shutdown 的使用方法,以及 netty 对连接半关闭的流程处理逻辑。

 

其中笔者还详细对比了 shutdown 系统调用和 close 系统调用的区别与联系。它们在调用之后都会向对端发送 FIN 包。但是在设置 SO_LINGER 选项的时候 close 系统调用会阻塞,shutdown 系统调用则不会阻塞。

我为Netty贡献源码|且看Netty如何应对 TCP 连接的正常关闭(七)-鸿蒙开发者社区

 TCP连接半关闭.png


最后笔者需要特别强调的是在我们使用 shutdown 进行 TCP 连接的半关闭时,作为连接的被动关闭方,在最后一定要记得调用 close 方法来彻底关闭连接,并释放连接相关资源。否则被动关闭方就会一直停留在 CLOSE_WAIT 状态。

 

而作为主动关闭方在 FIN_WAIT2 状态下接收到来自被动关闭方在 CLOSE_WAIT 状态下发送的 FIN 之后,记得要释放客户端的资源。

 

好了,本文的内容就到这里,感谢大家收看到这里,我们下篇文章见~~~

标签
已于2022-7-19 10:27:19修改
收藏
回复
举报
回复
    相关推荐