我为Netty贡献源码|且看Netty如何应对TCP连接的正常关闭(五)
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
image.png
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 连接的时候就需要考虑读写这两个通道的关闭。
image.png
之前介绍的关闭流程是主动关闭方调用 close 方法也就是 JDK NIO 中 SocketChannel#Close 方法来发送 FIN 关闭连接。但是 close 方法是同时将读写两个通道全部关闭,也就是说主动关闭方在调用 close 方法以后既不能接收对端的数据也不能向对端发送数据了。
close关闭但FIN_WAIT2接收数据 RST.png
比如:主动关闭方调用 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 连接半关闭的流程:
TCP连接半关闭.png
•首先客户端会调用 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 状态。