1、前置知识
1.1、TCP首部格式
从这张图可以看出IP报文由首部+数据组成,而IP数据又是TCP首部+数据的组成,首部就是对数据的描述,可以称之为元数据。
实际上发送方是一层层封包,也即在每一层加上自己的元数据。而接收方是一层层解包,就是在每一层根据元数据将其拆分,在运输层将其合并,在等待缓冲区填满时一并交给应用层
- 源端口和目的端口:端口指定唯一的进程,双方通讯时可以通过端口找到进程
- 序号(seq):占4个字节,范围是[0,2^32-1]。TCP面向字节流传输的,seq序号就是对字节的编号
- 确认号(ack):占4个字节。期待收到对方下一个报文段的第一个数据字节的序号。比如接收到的报文中的seq为500,那么发送确认报文时ack为501,表示可以从501开始传给我。
- 数据偏移:占4位。TCP首部占用字节数,也即报文中的数据离报文首字节偏移的长度
下面有6个控制位,说明报文段的性质
- URG:当URG=1时,表明紧急指针字段有效。告诉系统此报文段有紧急数据,应该尽快传输,具有高优先级,不是按照队列顺序传输。比如给远程的主机发送很长的数据,等待很长的时间,这时可以通过Control+C中断传输,而这个命令就是紧急数据,排在要传输的数据的前面
- ACK:只有大写的ACK=1时,小写的ack才生效。也即ACK=0时,ack无效。当然TCP规定,在连接建立后必须将ACK置为1
- PSH:发送方将PSH置为1,立即创建报文段将其发送出去。接收方收到PSH=1的报文段时,会尽快将接收缓冲区的数据推给应用程序,不需要等到缓冲区填满再交给上层
- RST:当RST=1时,表明TCP连接中出现严重差错,必须释放连接,然后再重新建立连接。RST置为1还用来拒绝一个非法的报文段或拒绝打开一个连接
- SYN:在连接建立的过程中使用,表示想与对方建立连接。当SYN=1,ACK=0时,表明是一个请求连接的报文。当SYN=1,ACK=1时,表明这是响应报文段,同意与之建立连接。
- FIN:用来释放一个连接。当FIN=1时,表明数据已经发送完毕,要求关闭连接。
- 窗口:占用2字节。这是接收方的窗口,是接收方当前可以接收的字节数,而发送方根据此窗口控制发送数据的速度。这个窗口是动态变化的,最终也是接收方缓存的剩余空间决定的。
以上是基础知识,从本文的知识点来说,RST是重中之重。
1.2、三次握手
在客户端请求与服务端连接时,要经过三次握手
- 服务器肯定要先处于监听状态,不然无法监听客户端的连接
- 客户端发送请求报文:SYN=1,seq=x。表明客户端想要与服务端建立连接。
- 服务端发送确认报文:SYN=1,ACK=1,seq=y,ack=x+1。表明服务端同意与客户端建立连接。注意:只有ACK=1时,ack才生效
- 客户端也发送确认报文:ACK=1,seq=x+1,ack=y+1。表明客户端收到服务端的确认报文。
- 双方建立连接,可以进行数据传输
问题1:seq是否不必要?重要的是SYN和ACK的标识符?肯定不是
- seq作用在于同步双方的初始序号,Linux一般是提供随机数,而Windows从0开始。
- 区分新旧连接,重建连接时初始序号的不同可以做区分。
问题2:如果只有两次握手可以吗?不可以,会出现以下问题:
- 连接状态不确定。服务端处于连接状态,但客户端可能未收到响应报文,处于SYN-SENT状态,导致服务端一直未收到客户端的数据
- 冗余连接请求。如果服务端发送确认报文,而客户端未响应,等待超时后会重发确认报文给客户端,直到最大的重试次数
- 可靠性问题。如果客户端未收到确认报文,seq会不一致,加上网络影响,可能会出现丢包
1.3、四次挥手
当客户端已经传输完数据后,会主动关闭连接,然后经历四次挥手的过程
- 客户端主动关闭连接时,发送终止报文给服务端:FIN=1,seq=u。FIN=1是告诉服务器要关闭连接。seq=u表明客户端最后发送的字节号为u
- 服务端发送确认报文:ACK=1,seq=v,ack=u+1。ACK=1表明服务端知晓你要关闭连接,我会将缓冲区的数据发给你。seq=v表明给你发送的最大的字节序号为v。ack=u+1表明收到客户端的序列号为u
- 服务端数据发送完成的报文:FIN=1,ACK=1,seq=w,ack=u+1。FIN=1表明数据传输完毕,可以关闭连接了。ACK=1表明是确认报文
- 客户端收到确认报文后回一个确认报文:ACK=1,seq=u+1,ack=w+1。ack=w+1表明客户端收到了序号到w的字节。
- 客户端处于TIME_WAIT,等待报文往返时间。然后客户端关闭连接
问题1:为何存在TIME_WAIT状态?
- 2MSL是报文最大的往返时间,超过这个时间,报文会被丢弃,超时等待是保证服务器收到最后的ACK报文。
问题2:为何要挥手四次?
- 我们就看下哪次可以省略。第一次挥手是由客户端发送终止报文,无法省略,不然服务器怎么知晓你要关闭连接。
- 第二次挥手是用于确认收到客户端终止报文的,无法省略。那么如果没有数据要发送,直接发送FIN=1,ACK=1的确认报文给客户端可以吗?当然不行,第二次挥手是给客户端的确认报文,如果加上FIN=1,说明这是服务器的终止报文,就不是确认报文了
- 第三次挥手是在无数据发送时的服务器终止报文,无法省略。如果省略会导致客户端无法知晓服务器是否将数据传输完毕,然后终止连接
- 第四次挥手是客户端确认收到服务器的终止报文,无法省略。如果省略,服务器不知道客户端是否收到终止报文,客户端独自关闭连接,然后服务器一直挂着连接,浪费资源。TIME_WAIT也会进一步保证服务器收到最后的ACK报文。
2、异常解析
2.1、抓包工具
winshark是在Windows中的抓包工具,开启捕获操作时,通过过滤可以得到我们想要的抓包数据
tcp.port==8080,表明抓包TCP连接中端口号为8080的报文
2.2、RST报文
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
public class ServerDemo {
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8080));
SocketChannel socketChannel = serverSocketChannel.accept();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
for(;;) {
Thread.sleep(1000);
socketChannel.read(byteBuffer);
socketChannel.write(ByteBuffer.wrap("hello client".getBytes()));
System.out.println("read success");
}
}
}
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
public class ClientDemo {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress(8080));
for (int i = 0; i < 2; i++) {
Thread.sleep(1000);
socketChannel.write(ByteBuffer.wrap("hello server".getBytes()));
System.out.println("write success");
}
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
socketChannel.read(byteBuffer);
socketChannel.close();
Thread.sleep(10000);
}
}
服务器出现异常:
有个小疑问:为何所有write方法都会将PSH置1。哪位大佬看到这里能解答一下?
通过winshark抓包:
56590端口是客户端的,8080是服务端的。在这里只需关注控制位(FIN、ACK、RST)
客户端端口是内核随机分配的,每次运行时端口都会不一样
为啥客户端会发送RST报文?我们猜一下是由于服务器还有数据发送导致客户端发送RST强制中断连接。那么简化一下代码,看看是否有RST报文出现
改成服务器读取一次,客户端写一次,再看下客户端是否调用close的情况
- 客户端调用close:由于客户端要经历四次挥手,但服务端进程已经结束,内核会发送RST报文给客户端,强制关闭连接
- 客户端未调用close:客户端结束进程,给服务端发送RST报文表明强制关闭连接
注意:如果一方已经发送RST报文,而另一方还是调用read/write操作,会有异常抛出。否则就没有异常
客户端未调用close的情况下,是不是也有可能是服务端给客户端发送RST报文。给客户端加个睡眠,看看结果如何?
当客户端增加睡眠时间,服务器给客户端发送RST。
我们让服务端在最后睡眠,客户端直接结束,会发生什么情况?
给服务端增加睡眠时间,客户端先结束,会给服务端发送RST报文。进一步说明先结束的一方会给另一方发送RST
以上代码服务端均未调用close关闭与客户端的连接,如果调用close会不会有RST报文?
当双方都调用close将客户端连接关闭时,报文显示有完整地四次挥手动作
小结:从当前的分析来看,如果没有正常调用close方法(服务端和客户端将socketChannel关闭),就结束进程,先结束的一方会发送RST报文给另一方,强制关闭连接
2.3、异常信息来源
我们很好奇Connection reset by peer异常字段从哪来的?从程序中全局搜索不到。那么最大可能性是由底层传给程序的,所以我们来追踪源码,找到来源
后续代码涉及到C/C++ 语言,以及JVM、glibc知识点,如果不懂的小伙伴,可以直接跳到小结处,我会简略总结一下
1、异常堆栈中显示最近一次调用是FileDispatcherImpl.read0,这是JNI方法,就从FileDispatcherImpl.c找到read0方法。
read(fd, buf, len)是系统调用,没有任何异常信息输出。可能性在于convertReturnVal函数中。
2、在IOUtil.c中找到convertReturnVal函数。
前面几个判断都是特殊情况,而像我们这种异常会在最后一个else中,找一下JNU_ThrowIOExceptionWithLastError函数
3、获取最近一次错误信息,如果有就转换成java的String类型,调用Throw抛出异常
jin_util.c
4、errno是C函数库的全局变量,存储最近一次的错误号。strerror是C函数库中的全局函数,根据错误号获取对应的错误信息
jvm.cpp
os_linux.cpp
5、涉及到C函数库,我们从glibc中寻找。
当接收到RST报文时,C函数库会转换成ECONNRESET错误信息
errlist.c
6、全局变量errno存放最近一次的错误号,如果多线程情况下,errno是不是会被覆盖。
extern:全局标识符
__thread:声明为线程局部变量
attribute_tls_model_ie:指定TLS模型
所以errno是TLS变量,也就是线程本地变量,并不会被其他线程覆盖
include/errno.h
小结:
- 异常关闭连接时会由先结束的一方发送RST报文,如果另一方还继续操作,jvm会从glibc获取最近的错误号
- glibc使用TLS机制,errno(错误号)是线程本地变量,不用担心会被其他线程覆盖
- 根据错误号从定义的错误列表中找到对应的错误信息(Connection reset by peer)
- JVM抛出IOException异常,填入获取到的错误信息
3、总结
抛出Connection reset by peer异常是由于RST报文导致的,服务端或客户端的一方异常关闭连接时,另一方依旧发送报文,这时只能收到RST报文,表明强制关闭连接。
JVM根据glibc最近一次的错误号(ECONNRESET)从错误列表查询到错误信息,抛出IOException