网络编程
网络编程使用java.net包
常见的软件架构:
1.C/S:即Client/Server,在用户本地需要下载并安装客户端程序,在远程有一个服务器端程序。例如QQ
优点:画面可以做得非常精美,用户体验好
缺点:既要开发客户端,又要开发服务端。用户需要下载和更新的时候太麻烦
2.B/S:即Browser/Server,只需要一个浏览器,用户通过不同的网址,客户访问不同的服务器。例如京东
优点:不需要开发客户端,只需要页面+服务端。开发、部署、维护都很简单。用户也不需要下载。
缺点:如果应用过大,用户体验会受到影响
网络编程三要素
1.确认对方的地址(IP)
2.确认对方接收数据的软件(端口号,一个端口号只能被一个软件绑定使用)
3.确定网络传输的规则(协议)
所以,网络编程的三要素:IP(设备在网络中的地址,是唯一的标识)、端口号(应用程序在设备中唯一的标识)、协议
IPv4
全称:Internet Protocol version 4,互联网通信协议第四版
采用32位地址长度,分成4组。所以真正的IP地址并不是我们在电脑上所看到的IP地址。
32位,就是4个字节,为了更好表示,点分十进制法应运而生:将每个字节的二进制表示转成对应十进制数字。
网络编程的内容在我之前的文章里面有相对详细的讲解。我们知道IPv4最多只能表示42.9亿个地址。在2019年11月26日已经全部分配完毕。IPv6应运而生
IPv4的地址分类形式
公网地址(万维网使用)和私有地址(局域网使用)。192.168.开头的就是私有地址,范围即为192.168.0.0--192.168.255.255,专门为组织机构内部使用,以此节省IP
怎么个节约法呢?以网吧为例,网吧里的电脑有很多,但不是每一台电脑在连接外部网络时都有一个公网IP,它们往往共享同一个公网IP,再由路由器给每一台电脑分配局域网IP,这样就实现了IP的节约。
一个特殊的IP:127.0.0.1,也可以试试localhost,它是回送地址,也称本地回环地址,也称本地IP,永远只会寻找当前所在本机。
假设192.168.1.100是我电脑的IP,那么这个IP跟127.0.0.1是一样的吗?答案是否定的。每一个路由器给你分配的IP都有可能不同,即当你换了一个地方上网,局域网IP有可能会发生变化。但如果我们往127.0.0.1发送数据,那么它是不需要经过路由器的,网卡会直接把数据发过来。
关于网络编程的两个常用cmd命令:ipconfig和ping,不赘述,网络编程的内容在我之前的文章有详细的讲解。
IPv6
采用128位地址长度,分成8组,即每组16个bit,它能给地球上每一粒沙子都分配一个IP地址。为了方便表示,冒分十六进制表示法应运而生。冒分就是用冒号隔开,并且还会把每一节的前导零省略。如果计算出的16进制表示形式中间有多个连续的,那么就可以使用0位压缩表示法,即把这些0及其中间的冒号都统一用两个冒号表示。
InetAddress的使用
此类表示互联网协议地址。它有两个子类:Inet4Address和Inet6Address,在底层它会判断你的系统是使用4还是6,创建的时候实际上创建的是子类。它没有对外提供构造方法,我们需要使用它的静态方法getByName去获取对象。
| 方法名称 | 说明 |
|---|---|
| static InetAddress getByName(String host) | 确认主机名称的IP地址,主机名称可以是及其名称,也可以是IP地址 |
| String getHostName() | 获取此IP地址的主机名 |
| String getHostAddress() | 返回文本显示中的IP地址字符串 |
csharp
public class MyNetDemo {
public static void main(String[] args) throws UnknownHostException {
//1.获取InetAddress对象
//它是IP的对象 实际上可以理解成就是一台电脑的对象
InetAddress address = InetAddress.getByName("tkv");
System.out.println(address);
System.out.println(address.getHostAddress());
System.out.println(address.getHostName());
}
}
端口号
端口号是应用程序在设备中唯一的标识,它是由两个字节表示的整数,取值范围为0-65535
其中0-1023之间的端口号用于一些知名的网络服务或者应用,我们自己使用1024以上的端口号即可。一个端口号只能被一个应用程序使用。
UDP协议
全称用户数据报协议(User Datagram Protocol),是面向无连接通信协议。速度快,有大小限制,一次最多发送64K,但数据不安全,易丢失数据。所以适用于丢失一点数据也无伤大雅的情况,例如网络会议、语音通话、在线视频。
理解面向无连接:UDP不会检查两台电脑是否已经连接成功。
ini
public class MySendMessageDemo {
public static void main(String[] args) throws IOException {
//1.创建DatagramSocket对象
//创建时还会绑定端口,如果是空参构造,则会在所有可用的端口中随机选择一个
//如果使用有参的,就使用指定的端口
DatagramSocket ds = new DatagramSocket();
//2.打包数据
String str = "666";
byte[] bytes = str.getBytes();
InetAddress addr = InetAddress.getByName("127.0.0.1");
int port = 10086;
DatagramPacket dp = new DatagramPacket(bytes,bytes.length,addr,port);
//3.发送数据
ds.send(dp);
//4.释放资源
ds.close();
}
}
ini
public class MyReceiveMessageDemo {
public static void main(String[] args) throws IOException {
//1.创建DatagramSocket对象
//在接受的时候一定要绑定端口,而且绑定的端口一定要与发送的端口一致
DatagramSocket ds = new DatagramSocket(10086);
//2.接收数据包
byte[] bytes = new byte[1024];
DatagramPacket dp = new DatagramPacket(bytes,bytes.length);
ds.receive(dp);//阻塞方法
//3.解析数据包
byte[] data = dp.getData();
int len = dp.getLength();
InetAddress addr = dp.getAddress();
int port = dp.getPort();
System.out.println("接收到数据" + new String(data,0,len));
System.out.println("从" + addr + "这台电脑的" + port + "这个端口发出的");
//4.释放资源
ds.close();
}
}
UDP有三种通信方式:单播、组播、广播,顾名思义。上面的代码就是单播,一对一。
组播:组播地址:224.0.0.0-239.255.255.255,其中224.0.0.0-224.0.0.255为预留的组播地址。它和IP不一样的地方在于,IP只能表示一台电脑,而一个组播地址可以表示局域网内的多台电脑。
ini
public class SendMessageDemo {
public static void main(String[] args) throws IOException {
//创建MulticastSocket对象
MulticastSocket ms = new MulticastSocket();
//创建DatagramPacket对象
String s = "小野寺小咲不要哭";
byte[] bytes = s.getBytes();
InetAddress addr = InetAddress.getByName("224.0.0.1");
int port = 8080;
DatagramPacket dp = new DatagramPacket(bytes,bytes.length,addr,port);
//3.发送数据
ms.send(dp);
//4.释放资源
ms.close();
}
}
ini
public class ReceiveMessageDemo {
public static void main(String[] args) throws IOException {
//1.创建MulticastSocket对象
MulticastSocket ms = new MulticastSocket(8080);
//2.将当前本机添加到224.0.0.1的这一组当中
InetAddress addr = InetAddress.getByName("224.0.0.1");
ms.joinGroup(addr);
//3.创建DatagramPacket数据包对象
byte[] bytes = new byte[1024];
DatagramPacket dp = new DatagramPacket(bytes,bytes.length);
//4.接收数据
ms.receive(dp);
//5.解析数据
byte[] data = dp.getData();
int len = dp.getLength();
String ip = dp.getAddress().getHostAddress();
String name = dp.getAddress().getHostName();
System.out.println("IP为:" + ip + ",主机名为:" + name + "的人,发送了数据:" + new String(data,0,len));
//6.释放资源
ms.close();
}
}
可以多创建几个接收端验证。
广播:广播地址:255.255.255.255
其代码和单播几乎一样,只需要把单播的IP改为广播地址,就可以对局域网中所有的电脑发送数据。
TCP协议
全称传输控制协议TCP(Transmission Control Protocol),是面向连接的通信协议。速度慢,没有大小限制,数据安全。它要确保连接成功才会发送数据。适用于对数据要求高的情况,例如下载软件、文字聊天、发送邮件。
TCP在通信的两端各建立一个Socket对象,通过Socket产生IO流来进行网络通信。
java
public class Client {
public static void main(String[] args) throws IOException {
//1.创建Socket对象
//在创建对象的同时会连接服务端,如果连接不上,会报错
Socket socket = new Socket("127.0.0.1",8080);
//2.从连接通道中获取输出流
OutputStream os = socket.getOutputStream();
//写出数据
os.write("吾妻樱岛麻衣".getBytes());
//3.释放资源
os.close();
socket.close();
}
}
csharp
public class Server {
public static void main(String[] args) throws IOException {
//1.创建对象ServerSocket
ServerSocket ss= new ServerSocket(8080);
//2.监听客户端连接
Socket socket = ss.accept();//阻塞
//3.从连接通道中获取输入流读取数据
InputStream is = socket.getInputStream();
int b;
while((b = is.read()) != -1) {
System.out.println((char)b);
}
//4.释放资源
socket.close();//断开与客户端的连接
ss.close();//关闭服务器
}
}
运行后发现,会出现乱码。分析一下原因:在写数据的时候,我们没有指定规则,所以使用IDEA的默认规则UTF-8,那么一个中文汉字就对应三个字节,而在读数据的时候是一个字节一个字节地读,然后把它转成字符。所以相当于每次转的是三分之一个中文。所以我们不能拿字节流去读,可以使用转换流将字节流变成字符流。
java
public class Server {
public static void main(String[] args) throws IOException {
//1.创建对象ServerSocket
ServerSocket ss= new ServerSocket(8080);
//2.监听客户端连接
Socket socket = ss.accept();//阻塞
//3.从连接通道中获取输入流读取数据
InputStream is = socket.getInputStream();
InputStreamReader isr = new InputStreamReader(is);//转换流
BufferedReader br = new BufferedReader(isr);//缓冲流
int b;
while((b = br.read()) != -1) {
System.out.println((char)b);
}
//4.释放资源
socket.close();//断开与客户端的连接
ss.close();//关闭服务器
}
}
TCP三次握手
比喻理解:打电话
A:喂,能听到吗?(第一次握手:A要确认B是否在线以及能否听到自己的声音)
B:可以听到,你听得到吗?(第二次握手:B确认了自己能听到A的声音,要确认A能否听到自己的声音)
A:可以听到,开始说吧!(第三次握手:A确认了自己能听到B的声音,要让B确认自己能听到B的声音)
TCP握手同理:
客户端:你好,能建立连接吗?(SYN)
服务器:收到,我准备好了,你呢?(SYN-ACK)
客户端:我也准备好了,开始传数据吧!(ACK)
这些SYN、ACK是什么?为了搞懂这个,我们先学习一下TCP头的格式
TCP头格式包含:源端口号(16位)、目标端口号(16位)、序列号(32位)、确认应答号(32位)、首部长度(4位)、保留(6位)、控制位(6位)、窗口大小(16位)、校验和(16位)、紧急指针(16位)、选项(长度可变)、数据
详细说说6个控制位:
1. URG - 紧急指针有效
当这个标志位被设置为1时,它告诉接收方这个数据包里有紧急数据,需要优先处理。它需要和头部的紧急指针字段配合使用
例如,在远程命令行操作中,用户突然按下 Ctrl+C 来中断一个正在运行的命令,这个中断信号就可以被标记为紧急数据,希望接收方能立即处理,而不是在接收缓冲区里排队
2. ACK - 确认号有效
当ACK=1时,表示头部的确认应答号字段(ack)是有效的。TCP规定除了最初建立连接时的SYN包之外,该位必须设置为1
3. PSH - 推送功能
用于催促接收方的应用程序立刻从TCP接收缓冲区中读取数据。通常情况下,为了提高效率,TCP会等接收缓冲区攒到一定量的数据后再提交给应用程序。但当发送方设置PSH=1时,就相当于告诉接收方,这边的数据已经是一个完整的消息了(比如一个HTTP请求),需要立刻交给上层应用程序处理。这样接收方的TCP栈在收到PSH=1的包后,会立即将数据交付给应用程序,而不是等待缓冲区填满
4. RST - 连接重置
当RST=1时,表示要求立即重置连接
例如:
拒绝连接:当服务器收到一个连接请求(SYN包),但目标端口没有进程在监听时,会回复一个RST包
异常终止:当一方发现通信出现不可恢复的错误,或者想立即断开连接时(而不是通过正常的四次挥手),可以发送RST包。这相当于直接拔掉电话线,而不是说再见
处理半开连接:一方已经崩溃重启,另一方还认为连接存在。当重启方收到来自另一方的数据包时,由于它已经不记得这个连接了,会回复一个RST包来告知对方
5. SYN - 同步序列号
用于建立连接
简单理解:SYN=1 是发起和同意握手的信号
6. FIN - 结束连接
用于正常关闭连接
在四次挥手中,当一方数据发送完毕,想要关闭连接时,会发送一个FIN=1的报文段
第一次挥手 (A -> B):FIN=1, ACK=1。意思是:"我这边没有数据要发送了,我想关闭连接。"(但还可以接收数据)
另一方收到后,会先确认这个FIN请求,等自己的数据也发送完毕后,再发送自己的FIN包
了解了控制位后,我们再来分析TCP三次握手:
客户端:你好,能建立连接吗?(SYN=1,ACK=0,seq=x)
服务端:收到,我准备好了,你呢?(SYN=1,ACK=1,seq=y,ack=x+1)
客户端:我也准备好了,开始传数据吧!(SYN=1,ACK=1,seq=x+1,ack=y+1)
seq是序列号,表示本次发送数据的起始编号
ack是确认号(上文提及过),是期望对方下一次发送数据的起始编号,它的潜台词是:你发给我的,编号在ack-1及其之前的数据我都收到了,我接下来希望你从第ack号开始发
第一次握手:Client -> Server,包含:
SYN=1(我要建立连接)
ACK=0(因为这是第一个包,所以没有什么需要确认的)
seq=x(我打算从我这边的第x号话筒说我的第一句话(实际上就是第一次握手的信息),这个x是操作系统内核随机生成的一个值,称为ISN)
此时,Client进入SYN-SENT状态
第二次握手:Server->Client
Server收到后,明白了两件事:
1.Client的初始序列号是x
2.Client想要和我建立连接
Server回复一个包,包含:
SYN=1(我同意建立连接)
ACK=1(我这个包里的ack字段是有效的)
seq=y(那我就从我这边的第y号话筒说我的第一句话(实际上就是第二次握手的信息),这个y是Server操作系统内核随机生成的一个值)
ack=x+1(我已经准备好听你从你的第x+1号话筒说话了)(因为第一次握手时Client已经占用了第x号话筒)
此时Server进入SYN-RECEIVED状态
第三次握手:Client -> Server
Client收到Server的回复后,也明白了两件事:
1.Server的初始序列号为y
2.Server已经准备好接收我从x+1号话筒传输的数据
此时Client认为连接已建立,进入ESTABLISHED状态
Client回复一个包,包含:
SYN=0(连接已经建立,不需要发送申请或告知同意)
ACK=1(我这个包的ack字段是有效的)
seq=x+1(我准备从第x+1号话筒说话了,这正好符合Server的预期)
ack=y+1(我也准备好听你从你的第y+1号话筒讲话了)
Server接收到此消息后,认为连接已建立,进入ESTABLISHED状态
❓追问:ack=x+1为什么是表示所有序列号小于x+1的字节都收到了?不是仅仅确定了Client消息的起始编码为x吗?x之前的信息Server也不知道啊,因为那不是Client的os内部分配的序号吗?
答:一个TCP连接有自己的宇宙,这个宇宙是第一次握手之后才诞生的。在这个宇宙里,序列号只对本次连接有效。对于这次连接而言,在序列号x之前,什么都不存在。就好比我们认为在宇宙大爆炸之前不存在世界一样,事实上宇宙大爆炸之前,这个世界究竟是什么样的,没有人能说清楚
所以,ack=x+1的真正含义是:
1.确认创世事件:所有发生在x+1之前的事件我都确认已知晓
2.表达未来期望:既然事件x已经发生,那么我们这个宇宙的下一个事件编号就应该从x+1开始
也就是说,这个"已知晓"是相对于这个TCP连接来说的,而不是相对于Client的操作系统来说的
操作系统的角色是多元宇宙的管理者。当它收到一个数据包,知道了源IP、源端口、目标IP、目标端口,它就能唯一确定这是属于哪个宇宙的数据
一个更技术的视角:传输控制块(TCB)
(上文的线程控制块也是TCB,一个是Transmission,一个是Thread)
在操作系统的网络栈中,每一个TCP连接都有一个对应的数据结构,通常称为 TCB
这个TCB里就存储了这个连接独有的本地IP/端口、远端IP/端口、本地当前序列号、远端下一个期望序列号、发送窗口、接收窗口等等状态信息。
TCB就是那个"独立宇宙"的物理载体
再回到微观视角:之所以这些设备能够以某种特定的方式进行交互,本质上是它们的操作系统在指挥硬件执行指令的结果。操作系统本身就是一个庞大、复杂、拥有最高权限的常驻内存程序
唯一确定一个TCP连接
TCP四元组可以唯一地确定一个连接,四元组指的是:源地址、源端口、目标地址、目标端口。服务器可能在不同端口监听,客户端也可能用不同端口连接,所以一定是四元组才能唯一确定一个连接,不能只有源地址与目标地址
昨天学习了TCP头格式,我们知道TCP头格式只有源端口和目标端口(16位)这两个字段,没有源地址与目标地址。源地址与目标地址的字段(32位)是在IP头部中
❓一个服务端监听了一个端口,它的TCP的最大连接数是多少?
答:最大TCP连接数=客户端IP数 × 客户端端口数
这个"客户端"并不只是指一个具体的计算机,而是表示即将接受服务的计算机群体。现在想要求TCP最大连接数。服务端通常在一个固定的端口上监听,过来进行三次握手的客户端,可能是同一个计算机(IP相同)但使用不同的端口,也可能是不同的计算机(IP不相同)。如果假设每台计算机的可用端口数相同,那么TCP的最大连接数就是(IP数-1)× 可用端口数,而IP数=计算机数量,那太多了,所以-1可以省略,所以TCP的最大连接数是客户端IP数量 × 客户端端口数。但是,这是建立在每台计算机的可用端口数相同,或者近似相同的情况下。但事实是,端口数并不统一,不同的客户端操作系统、不同的网络环境会影响到它实际可用的临时端口范围。所以,这个公式应该被理解为一个组合数学上的可能空间,而不是一个可实际累加的精确数值
对于IPv4,客户端的IP数最多为2^32,客户端端口数最多为2^16,即服务端单机最大TCP连接数约为2^48
但这只是个理论上限,服务端最大并发TCP连接数远不能达到理论上限。一方面,每个TCP连接都是一个文件,如果连接过多,文件描述符会被占满。另一方面,每个TCP连接都要占用一定内存,操作系统的内存有限
为什么是三次握手而不是两次、四次?
我们来分析一下在网络阻塞/客户端宕机情形下可能出现的一种情况:
如果是两次握手:
客户端发送SYN报文(seq=200),但这个SYN报文被网络阻塞了。接着客户端重新发送新的SYN报文(seq=100)。但旧的SYN报文比新的SYN报文先到达。服务端收到SYN报文后,进入ESTABLISHED状态,返回一个SYN-ACK报文(seq=400,ack=201),客户端发现ack应当为101,判断为历史连接,那么客户端发送RST报文断开连接。一段时间后,新的SYN报文才到达,这次才能正确握手。可见,如果采用两次握手建立TCP连接,那么服务端在向客户端发送数据前,并没有阻止历史连接,导致服务端建立了一个历史连接,又白白发送了数据,浪费了服务端的资源。因此,在服务端建立连接之前就应该阻止历史连接。
那三次握手是如何阻止历史连接的?我们也分析一下:
还是用上面的数据。服务端收到SYN报文后,进入SYN-RCVD状态而不是直接进入ESTABLISHED状态!也就是说,这时候连接还没有真正建立!接着同上,服务端收到RST后,由于其状态为SYN-RCVD,所以服务端的应用程序完全不知道有这个连接的存在,它可以轻松地撤销这个半连接,恢复到CLOSED状态,应用程序没有受到任何影响,没有数据被发送。也就是说,三次握手相比于两次握手的优势在于,它有一个中间状态SYN-RCVD,可以阻止历史连接,这样可以让客户端先发送RST报文,避免了服务端已经进入ESTABLISHED状态后客户端才发送RST报文。
❓追问:为什么服务端进入ESTABLISHED状态就会浪费资源?
三次握手情况下:
服务端收到SYN报文,发送SYN-ACK报文,此时其内核会为此连接创建一个TCB(传输控制块,存储了一个TCP连接的状态信息,01中最后提及了),并将其状态设为SYN-RCVD.
此时服务端应用程序阻塞在accept(),它对这个新来的连接一无所知,accept()队列为空。
服务端收到ACK报文后,会将对应TCB状态改为ESTABLISHED,并将这个已建立连接的TCB放入"已完成连接队列"中,这个操作会唤醒阻塞在accept()上的应用程序。应用程序的accept()返回,得到一个代表着个连接的Socket文件描述符。从这一刻起,这个应用程序才真正获得了这个连接,它可以调用read()或write()进行向客户端读取/发送数据。
也就是说,在三次握手的情况下,历史连接的RST会在服务端状态为SYN-RCVD时被接收,此时内核只需要丢掉这个半连接的TCB,应用程序对此毫不知情,依旧阻塞在accept()上。
两次握手情况下:
内核收到SYN,发送SYN-ACK,立即创建TCB并将其状态设为ESTABLISHED,然后立即将此TCB放入"已完成连接队列",阻塞在accept()上的应用程序被立刻唤醒。应用程序从accept()返回,拿到Socket文件描述符,进入其业务逻辑。比如说在聊天室中,它可能会立即向客户端发送"欢迎来到聊天室!"或者从数据库查询数据并返回。这就是不必要的资源浪费。这些数据最终被客户端丢弃,服务端的CPU时间、内存带宽、网络带宽彻底浪费。
可见,ESTABLISHED状态是内核向应用程序移交连接所有权的触发器。三次握手的优势在于,它将可能收到RST的危险期严格限制在了SYN-RCVD状态下,从而保护了应用程序,使其永远不会接触到一个无效的连接。而在两次握手的情况下,服务端没有中间状态给客户端阻止历史连接,导致它可能建立一个历史连接,进而导致资源浪费。
另外,TCP协议的通信双方,都必须维护一个序列号(TCP头格式的内容之一),它的作用是:
1.接收方可以去除重复的数据
2.接收方可以根据序列号按序接收
3.判断发出去的数据包中,哪些是对方已经收到的(通过ACK报文中的序列号知道)
解释一下上面三点:
我们将TCP通信比喻成你(发送方)向一位管理员邮寄一本手稿。你们之间只能通过不靠谱的邮政系统(相当于不靠谱的网络)进行邮寄,因此邮件可能丢失、重复、乱序。序列号就是你写在每个信封上的唯一页码编号
1.接收方可以去除重复的数据
你寄出了第100页(序列号=100),但因为网络问题导致这页被复制了,管理员先后收到了两封都写着 "第100页" 的信,它会将自己先收到的第100页放进档案夹,将后收到的第100页直接丢弃。
这是因为接收方的TCP栈维护着一个变量,记录着它期望收到的下一个字节的序列号。如果收到一个序列号小于这个期望值的包,就说明这是一个重复包,直接丢弃。
也就是说,发送方的操作系统内核严格保证了其发出的字节流序列号是单调递增的。例如当应用程序调用send()或write()发送一段数据时,比如"Hello",那么内核就会将这5个字节的数据标记为序列号x到x+4,发送完成后,内核会立即将本地的"下一个序列号"指针向前移动5位,更新为x+5,接下来要发送的第一个字节,其序列号就是x+5.实际上在每个TCP连接中的TCB字段里,有一个snd_nxt变量,它指示了即将要发送的下一个数据的序列号。还有一个变量snd_una,这是已发送但还未收到对方ACK确认的第一个字节的序列号。当构建一个要发送的数据包时,内核从snd_nxt中取出当前值作为该包数据的起始序列号。数据包发出后,根据数据包中数据的长度,更新snd_nxt=snd_nxt+数据长度。当收到对方的ACK,确认对方已经收到了序列号N之前的所有数据,就更新snd_una=N.
如果不是单调递增,那么就无法去重(无法判断是新数据还是旧数据)、无法排序(序列号回退、乱跳)、确认机制失效(ack=N的含义是N之前的所有数据都收到,这依赖于单调性)。
2.接收方可以根据序列号按序接收
你寄出的顺序是第101页、102页、103页,但由于网络路由不同,管理员收到的顺序是第102页、101页、103页
管理员先收到102页,他发现他期望的是第101页,所以它不会把第102页直接交给读者(应用程序),他会把第102页临时存放在一边(缓冲区)。接着他收到第101页,这正是他期望的,于是他将第101页放入档案夹,然后检查临时存放区,发现102页也在,于是把102页页放入档案夹,接着收到103页,直接放入档案夹。
通过排序确保了提交给上层应用的数据永远是顺序正确的字节流
3.发送方可以知道哪些数据已被对方收到
管理员每收到一页都会给你一封确认信,信的内容是:你寄来的截止到103页的所有页我都已经收到,接下来我期望收到第104页。这可以让发送方确认数据交付,同时也让发送方能够触发重传机制。
也就是说,去重和排序是从接收者角度出发,解决了网络带来的重复和乱序问题。确认接收则是从发送方角度出发,解决网络带来的丢失问题。
两次握手只保证了客户端的初始序列号能被服务端成功接收,没办法保证服务端的初始序列号也能被确认接收。也就是说,如果只是两次握手,那么服务端是不知道自己能否与客户端正常通信的。
假设我们只有两次握手:
第一次握手:客户端发送SYN,序列号为client_isn
第二次握手:服务端收到SYN,进入ESTABLISHED状态,发送SYN-ACK,其中包含对客户端序列号的确认ack=client_isn+1,服务端自己的初始序列号:seq=server_isn
假设客户端到服务端的路径是畅通的,但服务端到客户端的路径有问题。那么客户端永远都收不到SYN-ACK,因此它认为连接未建立,会停留在SYN-SENT状态,并会重传自己的SYN,但服务端对此一无所知,它会认为连接已经建立,服务端内核通知上层应用程序(例如从accept()返回)。应用程序会开始使用这个链接,例如调用send()向客户端发送数据,但同样也会在S->C路径上丢失。这会造成资源空耗,在连接超时被重置之前,服务器的内存、端口资源、CPU周期都被这个无效链接白白占用。
而三次握手还是那个道理,它有一个SYN-RCVD状态作为缓冲。只要没有收到客户端发来的ACK,那么服务端就不会进入ESTABLISHED状态。等待ACK连接超时后,会简单地清除这个半连接。也就是说,三次握手确保了双方的初始序列号同步。
总结:两次握手无法防止历史连接的建立,且无法可靠地同步双方序列号,会造成资源浪费。
三次握手就已经可以可靠地建立连接,因此无需四次握手。
❓追问:既然建立TCP连接的序列号是单调递增的,那上限在哪里?既然有上限,又如何规避序列号重复?
答:TCP的序列号是一个32位的无符号整数,即它的取值范围是:0到2^31-1,即0到4294967295.
我们假设某个TCP连接的初始序列号ISN=4294967290.现在客户端开始大量发送数据,序列号快速递增,很快就到达了上限。到达上限4294967295后,就从0开始。此时,一个在网络上延迟的、序列号较小的旧数据包突然到达了接收端,那么接收端会认为这个延迟的旧包是一个合法的新数据包,因为它落在了当前期望的序列号范围之内,这就导致了数据混乱。而且,如果网络速度够快,传输大量数据时序列号回绕的时间就会变短,那么就很容易出现序列号相同的情况。这个问题通过TCP时间戳解决。如果两个TCP连接的序列号相同,但它们的时间戳也大概率时不相同的。也就是说,客户端和服务端的初始化序列号都是随机生成,能很大程度上避免历史报文被下一个相同四元组的连接接收,又引入了时间戳机制,从而基本避免了历史报文被接收的问题。
但时间戳也是有上限的,理论上时间戳也有可能相同。
如果一个旧连接的时间戳已经接近最大值,由于回绕,一个新连接的时间戳很小,那么如果只是单纯比较时间戳大小,就同样会发生数据混乱。
解决方法是:不直接比较时间戳的绝对大小,而是检查它们的差值是否在一个合理的范围内。这套规则基于一个关键假设:数据包在网络中的最大生存时间远远小于时间戳的回绕周期。事实上,这个假设在绝大多数情况下是成立的。
简单来说,其逻辑如下:
1.设置一个值PAWS,一般来说是24天,这个值远小于回绕周期50天,但又远大于数据包最大生存时间(一般是2分钟)。
2.假设新数据包的时间戳为new,旧数据包时间戳为prev,那么delta=new-prev.
如果delta>0,那么说明是新包,接受。
如果delta<0,且|delta|>PAWS,这意味着new比prev小很多,又因为我们假设网络延迟不会超过24天,那么唯一的可能就只剩下时间戳回绕。所以new对应的数据包实际上是新包,因此接受。
如果delta<0,且|delta|<=PAWS,这意味着差值在合理的网络延迟范围内,所以时间戳为new的那个数据包一定是旧包,丢弃。
讨论完这个问题之后,我们就可以明白为什么每次建立TCP连接时的初始序列号要不一样了。如果初始序列号一样,接收方就有可能接收到一个本不应该接收的数据包。这就好比一个陌生人由于填错了收件人的地址姓名电话,导致你收到了一个本不应该由你收下的快递一样。
总结:每次建立连接时的初始化序列号随机产生,在加上时间戳机制与合理的比较算法,很大程度上避免了历史报文被下一个相同四元组的连接接收的问题。
第一次握手丢失了,会发生什么?
第一次握手丢失,意味着客户端在进入SYN-SENT状态后迟迟没有收到服务端的SYN-ACK报文,那么这会触发重传机制,并且重传的SYN报文的序列号是一样的。不同版本的操作系统设置的超时时间不同,而至于重发次数,我们可以查看:
bash
cat /proc/sys/net/ipv4/tcp_syn_retries
通常每次超时的时间是上一次的2倍。
如果到达了等待上限,服务端依然没有回应ACK,那么客户端就不再发送SYN包,然后断开TCP连接。
第二次握手丢失了,会发生什么?
服务端收到客户端的第一次握手后,就会回SYN-ACK报文给客户端,然后进入SYN-RCVD状态。
由于第二次握手丢失,客户端会觉得自己的SYN报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传SYN报文。又因为服务端收不到ACK报文(第三次握手),所以它也会触发超时重传机制,重传SYN-ACK报文。
同样,SYN-ACK报文的最大重传次数也可以查看:
bash
cat /proc/sys/net/ipv4/tcp_synack_retries
也就是说,如果第二次握手丢失了,那么客户端会重传SYN报文(直到收到来自服务端的SYN-ACK),服务端会重传SYN-ACK报文(直到收到来自客户端的ACK)。
第三次握手丢失了,会发生什么?
客户端收到服务端的SYN-ACK报文后,会给服务端回一个ACK报文,也就是第三次握手,此时客户端进入ESTABLISHED状态。
由于第三次握手丢失,服务端始终收不到客户端的ACK报文,会触发超时重传机制,重传SYN-ACK报文,直到收到第三次握手。
可见,ACK报文是不会重传的。
TCP四次挥手的过程
比喻:
A:我说完了。(第一次挥手)
B:好的,我知道你说完了。(第二次挥手)
B:...(可能B还要再说几句)
B:我也说完了。(第三次挥手)
A:好的,我知道你也说完了。(第四次挥手)
第一次挥手
发起者:客户端
动作:当客户端的数据都发送完毕后,它会发送一个TCP报文。这个报文将FIN标志位设置为1,包含了当前的序列号seq=u.随后客户端进入FIN-WAIT-1状态,这表示客户端已经没有数据需要发送了,但它仍然可以接收来自服务器的数据。
第二次挥手
发起者:服务端
动作:服务端收到客户端的FIN报文后,会立即做出回应。回应的报文将ACK标志位设置为1,其中ack=u+1(因为第一次挥手消耗了一个序列号),同时报文包含了自己的序列号seq=v.服务端进入CLOSE-WAIT状态,客户端收到这个ACK报文后,进入FIN-WAIT-2状态。
此时,连接处于半关闭状态。客户端不能再发送数据,但服务端仍然可以向客户端发送数据。
第三次挥手
发起者:服务端
动作:服务端发送第二个报文来关闭自己这个方向的连接。这个报文将FIN标志位设置为1,其中包含了报文的序列号seq=w,报文的确认号ack仍然为u+1,因为没有收到客户端的新数据。此后,服务端进入LAST-ACK状态。
第四次挥手
发起者:客户端
动作:客户端收到服务端的FIN报文后,也做出回应。回应的报文将ACK标志位设置为1,回应的确认号ack=w+1,回应的序列号seq=u+1.客户端发送ACK后,进入TIME-WAIT状态,服务端收到这个ACK后,进入CLOSED状态,连接正式关闭。客户端在TIME-WAIT状态会等到2MSL(两倍的最大报文段生存时间)的时间后才进入CLOSED状态。
TIME-WAIT存在的理由:
1.确保最后一个ACK报文能到达服务端。如果这个ACK丢失,那么服务端在超时后会重传它的FIN报文,这样客户端就能收到重传的FIN,可以再次发送ACK,确保连接正常关闭。如果没有TIME_WAIT状态,而是在发完最后一次ACK报文后直接进入CLOSED状态,那么倘若这个ACK报文丢失了,服务端重传FIN报文,此时客户端已经进入关闭状态,收到FIN报文后会回RST报文,服务端收到RST报文后将其解释为一个错误,这并不优雅。
2.防止历史连接中的数据被后面相同四元组的连接错误接收。等待2MSL时间,可以确保本次连接所产生的所有报文都在网络中消亡了,这样再建立新的连接就不会收到旧的、延迟的报文,避免了数据混乱。
第一次挥手丢失了,会发生什么?
服务端是什么都不知道的,对于客户端来说,它觉得自己已经告知对方自己讲完了,但对方迟迟不回复。因此会触发超时重传机制,重发次数由tcp_orphan_retries参数控制。如果到达了等待的上限,还是没有收到第二次挥手,那么直接进入到CLOSED状态。
第二次挥手丢失了,会发生什么?
对于客户端来说,和第一次挥手丢失没有区别,它依然不知道对方是否知道自己说完了。因此客户端会重传FIN报文。到达上限后,就会断开连接。
第三次挥手丢失了,会发生什么?
服务端收到客户端的FIN报文后,会回复ACK报文,并且进入CLOSE-WAIT状态,即等待应用进程调用close函数关闭连接。调用close函数后,内核发出FIN报文,服务端进入LAST-ACK状态,等待客户端返回ACK报文来确认连接关闭。
如果第三次挥手丢失,那么它就收不到客户端的ACK报文,就会重发FIN报文。重发次数同样由tcp_orphan_retries控制。如果达到了上限,那么服务端就会断开连接。客户端通过close函数关闭连接,处于FIN_WAIT_2状态的时间有限,如果在这个有限的时间tcp_fin_timeout内没有收到服务端的第三次挥手,那么客户端会断开连接。
第四次挥手丢失了,会发生什么?
由于服务端没有收到ACK报文,因此它会重发FIN报文,重发次数仍然由tcp_orphan_retries控制。如果达到上限后依然没有收到客户端的第四次挥手,那么服务端就会断开连接。对于客户端,它在收到第三次挥手后会进入TIME_WAIT状态,开启时长为2MSL的定时器。如果中途再次收到FIN报文,那么就会重置定时器。等待超过2MSL时长后,客户端就会断开连接。