我为 Netty 贡献源码 |(三)

justtouch
发布于 2022-8-9 18:44
浏览
0收藏

3.3 服务端程序崩溃
我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
TCP 连接正常的状态下,无论是连接时发送的 SYN ,还是连接建立成功后发送的正常数据包,以及最后关闭连接时发送的 FIN ,都会收到对端的 ACK 确认。

当服务端因为某种原因导致崩溃之后,客户端再次向服务端发送数据,就会收到 RST 。

3.4 开启 SO_LINGER 选项设置 l_linger = 0
我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
在前边《2.1.3 针对 SO_LINGER 选项的处理》小节我们介绍 SO_LINGER 选项的时候提到过,当我们将选项参数设置为 l_onoff = 1,l_linger = 0 时,当客户端调用 close 方法关闭连接的时候,这时内核协议栈会发出 RST 而不是 FIN 。跳过正常的四次挥手关闭流程直接强制关闭,Socket 缓冲区的数据来不及处理直接丢弃。

3.5 主动关闭方在关闭时 Socket 接收缓冲区还有未处理数据
我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
  ●   主动关闭方在调用 close() 系统调用关闭 Socket 时,内核会检查 Socket 接收缓冲区中是否还有数据未被读取处理,如果有,则直接清空 Socket 接收缓冲区中的未处理数据,并向对端发送 RST 。
  ●   如果此时 Socket 接收缓冲区中没有未被处理的数据,内核才会走正常的关闭流程,尝试将 Socket 发送缓冲区中的数据发送出去,然后向对端发送 FIN ,走正常的四次挥手关闭流程。
3.6 主动关闭方 close 关闭但在 FIN_WAIT2 状态接收数据
我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
TCP是一个面向连接的、可靠的、基于字节流的全双工传输层通信协议,既然它是全双工的,那就意味着TCP连接同时有一个读通道和写通道。

我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
而调用 close() 来关闭连接,意味着会将读写通道同时关闭,之后不能再读取数据。

如果客户端调用 close() 方法关闭连接,而服务端在 CLOSE_WAIT 状态下继续向客户端发送数据,客户端在 FIN_WAIT2 状态下直接会丢弃数据,并发送 RST 给服务端,直接强制关闭连接,也是个暴脾气,哈哈。

4. Netty 对 RST 包的处理


同 TCP 正常关闭收到 FIN 包一样,当服务端收到 RST 包后,OP_READ 事件活跃,Reactor 线程再次来到了 AbstractNioByteChannel#read 方法处理 OP_READ 事件。

public abstract class AbstractNioByteChannel extends AbstractNioChannel {

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

            ..........省略连接半关闭处理........

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

            ByteBuf byteBuf = null;
            boolean close = false;
            try {
                do {
                    byteBuf = allocHandle.allocate(allocator);
                    //在读取Channel中的数据时会抛出IOExcetion异常             
                    allocHandle.lastBytesRead(doReadBytes(byteBuf));
                    .........省略.............

                } while (allocHandle.continueReading());

                .........省略.............
    
            } catch (Throwable t) {
                 handleReadException(pipeline, byteBuf, t, close, allocHandle);
            } finally {
                 ............省略...............
            }
        }
    }

}

这里和 TCP 正常关闭不同的是,在调用 doReadBytes 方法从 Channel 中读取数据的时候会抛出 IOException 异常。这里会有两种情况抛出异常:

  ●   此时Socket接收缓冲区中只有 RST 包,并没有其他正常数据。
  ●   Socket 接收缓冲区有正常的数据,OP_READ 事件活跃,当调用 doReadBytes 方法从 Channel 中读取数据的过程中,对端发送 RST 强制关闭连接,这时会在读取          的过程中抛出 IOException 异常。
当 doReadBytes 方法抛出 IOException 异常后,会被 catch(){...} 语句捕获到,随后在 handleReadException 方法中处理 TCP 异常关闭的情况。

4.1 handleReadException
我为 Netty 贡献源码 |(三)-鸿蒙开发者社区我为 Netty 贡献源码 |(三)-鸿蒙开发者社区


        private void handleReadException(ChannelPipeline pipeline, ByteBuf byteBuf, Throwable cause, boolean close,
                RecvByteBufAllocator.Handle allocHandle) {

            if (byteBuf != null) {
                if (byteBuf.isReadable()) {
                    readPending = false;
                    //如果发生异常时,已经读取到了部分数据,则触发ChannelRead事件
                    pipeline.fireChannelRead(byteBuf);
                } else {
                    byteBuf.release();
                }
            }
            allocHandle.readComplete();
            pipeline.fireChannelReadComplete();
            pipeline.fireExceptionCaught(cause);

            if (close || cause instanceof OutOfMemoryError || cause instanceof IOException) {
                closeOnRead(pipeline);
            }
        }

这里可以看出,当服务端接收到 RST 强制关闭连接时,首先会触发 ExceptionCaught 事件在 pipeline 中传播,最终还是会调用到 closeOnRead 方法关闭连接,取消 Channel 注册,并触发 ChannelInactive 事件和 ChannelUnregistered 事件。

当发生异常时,如果已经从 Channel 中读取到了数据,那么也会触发 ChannelRead 事件,随后触发 ChannelReadComplete 事件和 ExceptionCaught 事件。

如果这里大家已经忘记了相关事件的传播处理流程,可以在回顾下这篇文章 一文聊透 Netty IO 事件的编排利器 pipeline

5. TCP 连接半关闭 HalfClosure


TCP 是一个全双工的传输层通信协议,那么我们在关闭 TCP 连接的时候就需要考虑读写这两个通道的关闭。

我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
之前介绍的关闭流程是主动关闭方调用 close 方法也就是 JDK NIO 中 SocketChannel#Close 方法来发送 FIN 关闭连接。但是 close 方法是同时将读写两个通道全部关闭,也就是说主动关闭方在调用 close 方法以后既不能接收对端的数据也不能向对端发送数据了。

我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
比如:主动关闭方调用 close 方法发出 FIN 开始关闭流程之后,如果在 FIN_WAIT2 状态下收到对端发送过来的数据,那么就会直接丢弃,并发送 RST 给对端强制关闭连接。

那么有没有一种更优雅的关闭方式就是只关闭读写通道其中一个,关闭了写通道就不能发送数据给对端,但是还可以接受对端发送过来的数据。

关闭了读通道,就不能读取对端发送过来的数据,但是还可以向对端写数据。当连接上遗留的数据全部处理完毕后,主动关闭方和被动关闭方在先后调用 close 方法关闭连接释放资源。

这种更加优雅的关闭方式就是本小节我们要讨论的 TCP 连接的半关闭 HalfClosure 。

操作系统内核为我们提供了 shutdown 这样一个系统调用来实现 TCP 连接的半关闭,shutdown 函数可以控制只关闭连接的某一个方向,或者全部关闭。

int shutdown(int sockfd, int how)

参数 sockfd 为将要关闭 Socket 的文件描述符,参数 how 表示关闭连接的哪个方向 ( 关闭读 or 关闭写 or 全部关闭 )。

  ●   SHUT_RD:表示关闭读通道,如果此时 Socket 接收缓冲区有已接收的数据,则会将这些数据统统丢弃。如果后续再收到新的数据,虽然也会对这些数据进行 ACK 确认,但是会悄悄丢弃掉。所以在这种情况下,对端虽然收到了 ACK 确认,但是这些以发送的数据可能已经被悄悄丢弃了。
关闭读通道的方法在 JDK NIO 中对应于 SocketChannel#shutdownInput() ,这里需要注意的是此方法并不会发送 FIN。
  ●   SHUT_WR:关闭写通道,这就是本小节的重点,调用该方法发起 TCP 连接的半关闭流程。此时如果 Socket 发送缓冲区还有未发送的数据,则会立即发送出去,并发送一个 FIN 给对端。关闭写通道的方法在 JDK NIO 中对应于 SocketChannel#shutdownOutput()。
  ●   SHUTRDWR : 同时关闭连接读写两个通道。
在介绍完了 TCP 连接半关闭的系统调用之后,我们接下来看下 TCP 连接半关闭的流程:

 我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
  ●   首先客户端会调用 shutdownOutput 方法发起半关闭流程,关闭客户端连接的写通道,然后发送 FIN 给服务端。
  ●   和我们在《1. 正常 TCP 连接关闭》小节里介绍的流程一样,服务端的内核协议栈在接收到客户端发来的 FIN 后,会自动向客户端回复 ACK 确认,随后内核会将            文件结束符 EOF 插入到 Socket 的接收缓冲区中,此时 OP_READ 事件活跃,再一次进入到 AbstractNioByteChannel.NioByteUnsafe#read 方法处理 OP_READ 事          件,此时客户端的连接状态为 FIN_WAIT2 ,服务端的连接状态为 CLOSE_WAIT 。
  ●   服务端在收到连接半关闭请求后,会立马调用 shutdownInput 关闭自己的读通道。随后在 pipeline 中触发 ChannelInputShutdownEvent 事件,用户可以在该           事件中处理遗留的数据,处于 CLOSE_WAIT 状态的服务端可以继续向处于 FIN_WAIT2 状态的客户端继续发送数据。
  ●   当 TCP 连接处于半关闭状态的时候,JDK NIO Selector 会不断的通知 OP_READ 事件活跃直到 TCP 连接真正的关闭,所以用户在处理完                                                   ChannelInputShutdownEvent 事件之后,又会立马收到处理 OP_READ 事件的通知,在这次通知中触发 ChannelInputShutdownReadComplete 事件,表示遗留数         据已经处理完毕,用户可以在这个事件响应中调用 close 来彻底关闭连接。 此后服务端结束 CLOSE_WAIT 状态进入 LAST_ACK 状态。
  ●   客户端收到服务端发送过来的 FIN 后,调用 close 方法注销 Channel ,关闭连接。结束 FIN_WAIT2 状态进入 TIME_WAIT 状态。

6. 主动关闭方发起 TCP 半关闭流程


在 TCP 半关闭的场景下,主动关闭方需要调用 shutdownOutput 方法向被动关闭方发送 FIN 开始 TCP 半关闭流程。

在本小节的示例中,客户端可以在自己的 ChannelHandler 中调用 Channel 的 shutdownOutput 方法来发起 TCP 半关闭流程。

        SocketChannel sc = (SocketChannel) ctx.channel();     
        sc.shutdownOutput();

下面我们就来分析下在 netty 中对于 shutdownOutput 的实现。

public class NioSocketChannel extends AbstractNioByteChannel implements io.netty.channel.socket.SocketChannel {

    @Override
    public ChannelFuture shutdownOutput() {
        return shutdownOutput(newPromise());
    }

    @Override
    public ChannelFuture shutdownOutput(final ChannelPromise promise) {
        final EventLoop loop = eventLoop();
        if (loop.inEventLoop()) {
            ((AbstractUnsafe) unsafe()).shutdownOutput(promise);
        } else {
            loop.execute(new Runnable() {
                @Override
                public void run() {
                    ((AbstractUnsafe) unsafe()).shutdownOutput(promise);
                }
            });
        }
        return promise;
    }

}

从如上代码中,我们可以看出对于 shutdownOutput 的操作也是必须在 Reactor 线程中完成。

这里大家可以发现 shutdownOutput 半关闭的流程其实和 close 的流程非常的相似。

      private void shutdownOutput(final ChannelPromise promise, Throwable cause) {
            if (!promise.setUncancellable()) {
                return;
            }

            //如果Channel已经close了,直接返回
            final ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
            if (outboundBuffer == null) {
                promise.setFailure(new ClosedChannelException());
                return;
            }
            
            //半关闭状态下,不允许继续写入数据到Socket
            this.outboundBuffer = null; 

            final Throwable shutdownCause = cause == null ?
                    new ChannelOutputShutdownException("Channel output shutdown") :
                    new ChannelOutputShutdownException("Channel output shutdown", cause);

            Executor closeExecutor = prepareToClose();
            if (closeExecutor != null) {
                closeExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        try {           
                            // 将jdk nio 底层的Socket shutdown
                            doShutdownOutput();
                            promise.setSuccess();
                        } catch (Throwable err) {
                            promise.setFailure(err);
                        } finally {
                            // Dispatch to the EventLoop
                            eventLoop().execute(new Runnable() {
                                @Override
                                public void run() {
                                    //清理ChannelOutboundBuffer,并触发ChannelOutputShutdownEvent事件
                                    closeOutboundBufferForShutdown(pipeline, outboundBuffer, shutdownCause);
                                }
                            });
                        }
                    }
                });
            } else {
                try {
                    // 在 Reactor 线程中执行
                    doShutdownOutput();
                    promise.setSuccess();
                } catch (Throwable err) {
                    promise.setFailure(err);
                } finally {
                    closeOutboundBufferForShutdown(pipeline, outboundBuffer, shutdownCause);
                }
            }
        }

一开始都需要通过 ChannelOutboundBuffer 是否为 null 来判断当前 Channel 是否已经关闭了,如果已经关闭,则停止执行后续半关闭流程。

当 shutdownOutput 方法调用之后,主动关闭方连接的写通道就被关闭了,所以在这个状态下是不允许用户继续向 Channel 写入数据的, 所以这里会将 Channel 对应的写入缓冲队列 ChannelOutboundBuffer 设置为 null 。

和前边我们介绍调用 close 方法发起 TCP 连接的正常关闭流程一样,这里也会调用 prepareToClose() 方法来处理设置 SO_LINGER 选项的情况。

     @Override
        protected Executor prepareToClose() {
            try {
                if (javaChannel().isOpen() && config().getSoLinger() > 0) {
                    doDeregister();
                    return GlobalEventExecutor.INSTANCE;
                }
            } catch (Throwable ignore) {

            }
            return null;
        }

如果 Socket 设置了 SO_LINGER 选项则需要首先将 Channel 注销,后续的半关闭流程需要在 GlobalEventExecutor 线程中执行。否则继续在 Reactor 线程中执行。

关于 prepareToClose() 方法的详细介绍,大家可以回看本文中的《 2.1.3 针对 SO_LINGER 选项的处理》小节

接下来就会调用 doShutdownOutput() 方法关闭底层 JDK NIO SocketChannel 的写通道。此时内核协议栈会向对端发送 FIN 发起 TCP 半关闭流程。

public class NioSocketChannel extends AbstractNioByteChannel implements io.netty.channel.socket.SocketChannel {

    protected final void doShutdownOutput() throws Exception {
        if (PlatformDependent.javaVersion() >= 7) {
            javaChannel().shutdownOutput();
        } else {
            javaChannel().socket().shutdownOutput();
        }
    }

}

我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
当半关闭流程发起之后,ShutdownOutput 的核心任务就算结束了,此时就需要设置用户持有的 shutdownOutputPromise 成功,随后用户就会得到通知。

最后在 Reactor 线程中清理 ChannelOutboundBuffer 中的待发送数据,并在 pipeline 中传播 ChannelOutputShutdownEvent 事件。相关的清理细节笔者已经在本文前边相关的章节中详细介绍过了,这里不在重复。

    private void closeOutboundBufferForShutdown(
                ChannelPipeline pipeline, ChannelOutboundBuffer buffer, Throwable cause) {
            //shutdownOutput半关闭后需要清理channelOutboundBuffer中的待发送数据flushedEntry
            buffer.failFlushed(cause, false);
            //循环清理channelOutboundBuffer中的unflushedEntry
            buffer.close(cause, true);
            pipeline.fireUserEventTriggered(ChannelOutputShutdownEvent.INSTANCE);
        }

ChannelOutputShutdownEvent 是一种 UserEventTriggered 事件,它是 netty  提供的一种事件扩展机制可以允许用户自定义异步事件,这样可以使得用户能够灵活的定义各种复杂场景的处理机制。

UserEventTriggered 也是一种 Inbound 类事件,在 pipeline 中的传播反向也是从前向后传播。

我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
我们可以在 ChannelHandler 中这样捕获 ChannelOutputShutdownEvent 写通道关闭事件:

public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (ChannelOutputShutdownEvent.INSTANCE == evt) {
              .......处理写通道关闭事件.........
        }
    }

}

此时主动关闭方已经关闭了写通道,进入 FIN_WAIT2 状态。因为现在读通道还没有关闭,所以在 FIN_WAIT2 状态下还是可以继续接受并处理对端发来的数据的。

理想很美好,现实却很骨感,在本小节中主动关闭方在 FIN_WAIT2 状态下真的可以接收来自对端的数据吗??

大家先可以结合笔者在 《 2.1.3 针对 SO_LINGER 选项的处理》小节中介绍的内容以及本小节介绍的 TCP 写通道关闭流程,对照下面这副图认真思考下这个问题。

我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
7. 啊哈!!Bug !!


在为大家解释这个 Bug 之前,笔者先再次带大家回顾下本文《 2.1.3 针对 SO_LINGER 选项的处理》小节中 prepareToClose 方法的逻辑,它有两个关键点:

当使用了 SO_LINGER 选项后,调用 Socket 的 close 方法会阻塞关闭流程,所以需要将 Socket 的关闭动作放在 GlobalEventExecutor 中执行。
当使用了 SO_LINGER 选项后,为了防止在延迟关闭期间继续处理读写事件,产生不必要的 CPU 资源浪费,所以需要调用 doDeregister() 方法将 Channel 从 Reactor 中注销掉。

     @Override
        protected Executor prepareToClose() {
            try {
                if (javaChannel().isOpen() && config().getSoLinger() > 0) {
                    doDeregister();
                    return GlobalEventExecutor.INSTANCE;
                }
            } catch (Throwable ignore) {

            }
            return null;
        }

这些逻辑在 close 的关闭场景中是合理的,但是在 shutdownOutput 半关闭场景就出问题了。

假设用户在开启了 SO_LINGER 选项的情况下,调用 shutdownOutput 半关闭 TCP 连接,那么用户的本意是只关闭写通道,但是仍然希望在 FIN_WAIT2 状态下接收来自服务端发送过来的数据,实现优雅关闭。

但实际上 netty 在 shutdownOutput 方法中调用了 prepareToClose() 方法从而间接导致了 doDeregister() 方法的调用,Channel 从 Reactor 中注销掉,也就是说从此以后无法在产生 OP_READ 活跃事件无法接收并且处理服务端发送过来的数据。

由于以上原因,如下如图所示,主动关闭方在 FIN_WAIT2 状态下是无法接收到数据的,因为此时 Channel 已经从 Reactor 上注销了。

我为 Netty 贡献源码 |(三)-鸿蒙开发者社区
另外还有一点就是,无论 SO_LINGER 选项是否设置,shutdown 系统调用函数均不会阻塞,这里和 close 系统调用不同。所以这里也并不需要用一个 GlobalEventExecutor 去执行 shutdownOutput 任务,直接在 Reactor 线程中执行即可。

所以综合以上两点原因,在 shutdownOutput 中是不需要调用 prepareToClose() 方法的。

现在我们知道了 Bug 产生的原因,那么修复过程就变的非常简单了~~~

 

本文转载自公众号捉虫大师

分类
标签
已于2022-8-9 18:44:10修改
收藏
回复
举报
回复
    相关推荐