目录
前言
TCP协议
TCP协议的格式
TCP原理
1、确认应答
2、超时重传
3、连接管理
4、滑动窗口
5、流量控制
6、拥塞控制
7、延时应答
8、捎带应答
9、面向字节流
10、异常情况
总结
TCP协议是一种传输层协议,也是TCP/IP协议栈中非常重要的一个协议,它提供了面向连接,可靠性传输,面向字节流等特性。使传输的效率和可靠性大大提高。可靠性是TCP协议的最核心的机制。它可以在数据传输时提供各种控制和错误恢复机制,确保数据在网络中可靠传输。
TCP,即Transmission Control Protocol,传输控制协议。人如其名,要对数据的传输进行一个详细的控制。
可以看出TCP的报文格式还是比UDP比较复杂的。
上述就是TCP报文的报头结构。
下面进行详细的介绍。
TCP对数据传输的管控机制,主要在两个方面,一是效率,而是安全,保证在数据传输安全的前提下,尽可能的提高传输效率。
实现可靠性最核心的机制。
TCP发送方将每个字节的数据进行编号,即为序号。
接收端在接收到这个带有序号的数据之后,就进行发送ack报文,这个ack报文里面带有接收端给发送方的确认序号,确认序号就是从接收到的数据中最后一个字节序号的下一个序号,就是告诉发送端,我已经收到了你发的数据,下一次你从哪里开始发送。
在网络中由于网络的原因,有可能会有后发先至的情况。
在发送方发送的数据中,由于是TCP是对每一个字节进行编号的,在网络传输的过程中,可能有某个字节因为网络不稳定的原因延迟到达,其他字节已经在TCP的接收缓冲区中了,这是数据就会产生紊乱。但是因为TCP的序号的作用,可以针对序号在接收缓冲区中进行排序,使数据变得有序。这样上层的应用层就不会读到错误的数据了。
如果网络稳定,数据也没有产生错误,那么数据就很顺利的发送并接收,但是如果网络在发送数据的时候,因为某个结点出了问题,这样数据就不能发送到接收端了,就会产生丢包问题。
丢包之后,接收端肯定就收不到数据,就不能发送ack确认报文。
此时发送方就在等接收方给它的确认报文,等待一段时间之后,还是没有收到接收方发来的确认报文,发送方就会视为刚才发送的数据丢包了,就会进行重发。
如果说发送的数据接收端收到了,但是接收端发送的ack丢包了,发送方也就等不到ack报文了。
发送方也是一样在等待一定的时间之后,如果还是没有ack报文,那么发送方就认为数据丢包了,此时发送方就会再次发送一次之前发送的数据。
上述过程客户端B就会接收到重复的数据,但是TCP协议因为序号的存在,是能够帮我们自动识别出哪些是重复数据,并且把重复的数据丢弃。
如果有多个数据都丢包了,那么TCP会继续进行超时重传,但是每丢包一次,超时重传的时间就变长了,也就说重传的频率变低了。
如果多次重传,都没有得到ack回应,那么TCP就会尝试重置连接,如果重置连接也失效,那么TCP此时就之间断开连接。
TCP实现可靠性是由确认连接和超时重传实现的。
建立连接 :三次握手
握手:是通信双方建立一次网络交互,相当于客户端和服务器之间通过三次交互,互相建立了连接关系(双方各自记录对方的信息)。
syn为同步报文段 :其本质就是要和对方申请建立一个连接。
ack为确认报文段。
客户端A在给客户端B发送一个syn表示要和客户端B建立连接,此时客户端B也想和客户端A建立连接,于是客户端B就给客户端A发送了一个syn+ack报文,表示我同意和你建立连接,但是你也得和我建立连接,此时客户端A收到之后,欣然应允,于是就发送了一个ack确认报文。
至此客户端A和客户端B就经过上述的三次握手之后,就正式的建立起来了连接。
在我们的TCP报头中,有这6个特殊的bit位,默认为0,当SYN字段为1时,就表示是一个SYN报文,当ACK字段为1时,就表示是一个ACK报文,当他们两个全部为1时,就表示是一个syn+ack报文。
那么为什么需要三次握手呢?
三次握手这个过程,其实就是在检测通信双方的发送能力和接收能力是否正常,是后续进行数据可靠传输的基础。
断开连接:四次挥手
四次挥手和三次握手是非常相似的。
通信双方,各自给对方发送一个FIN(结束报文),然后在各自给对方返回一个ack报文。
在三次握手中syn和ack是合并成一个数据包进行发送的,但是在四次挥手这里,通常情况下是不能合并的。
因为三次握手的syn和ack是在同一时机触发的(由系统内核触发的)。
四次挥手这里FIN和ACK的触发时机是不同的,ACK是由系统内核触发的,在收到FIN报文的第一时间就返回ACK报文。但是FIN是由应用程序代码控制的,在调用socket的close方法时,就会触发FIN报文。
不能合并就在于你的代码逻辑是咋样的,是在发现客户端A断开连接之后,客户端B这边立马进行close操作,那么就触发了第二个FIN,如果这是ACK报文还没有发出去,那么就可以合并,如果在发现对方断开连接之后,不是立马进行close操作,如果是中间还隔了很久,那么就不能合并。
需要注意的是连接是由系统内核进行维护的,如果某个进程结束了,但是内核还是会维护TCP的连接,直到完成四次挥手。
FIN报文就是当FIN字段为1时,就表示此报文就是一个FIN报文。
滑动窗口,也叫批量传输
上述的确认应答机制是对每发送的一个数据报,都要等待一个ack确认报文,收到ack确认报文之后在发下一个数据报,但是这样的话性能就较差。
滑动窗口就是一次发送多个数据报,就可以大大提高性能。
批量不是无限发送,是发送到一定的程度,就等待ack,不等待ack就能发送的数据报是有限制的,这个限制就是窗口大小。
而且是批量发送完成之后,返回一个ack就立即发送下一条。
上图中批量发送4条数据,发完之后,统一等待ack,注意是每收到一个ack之后,就发送下一条数据,不是等收到4个ack之后在发下一组。
上述批量传输的过程就是滑动窗口。
窗口的大小就是指无需等待ack就能发送的数据报的最大值。上图就是400个字节,4个段。
发送前4个段的时候,无需等待ack就能直接发送数据,等收到第一个ack之后,窗口向后滑动,继续发送第五个数据段。操作系统为了维护这个窗口,需要开辟一个发送缓冲区来记录当前还有哪些数据没有应答,只有确认应答过的数据,才能从发送缓冲区中删除掉。
图1中批量发送了4条数据,图1中的白色区域,相当于是等待ack的应答。
图2中当主机B给主机A返回一个ack之后,说明1001-2000已经收到,此时就会发送5001-6000的数据。
可以看到窗口是向后挪动了一个格子,如果此时收到的ack报文速度非常快,那么这个窗口就会快速的向后挪动。这也就是滑动窗口的由来。
如果在批量发送的过程中出现丢包之后,滑动窗口的处理机制也是非常灵活的。
TCP协议一定是先保证可靠性,其次才是效率。
如果出现丢包,无外乎2种情况:
数据在发送的过程中丢了
可以看到数据1001-2000在发送的过程丢了,接收方会一直向发送方索要1001-2000的数据,当发送方连续收到3个同样的索要1001的ack时,发送方就反应过来1001-2000是丢了,就会重新发送1001-2000这个数据,当发送方把1001-2000这个数据发送过去之后,接收方返回的确认ack是7001 ,而不是2001,因为此时7001之前的数据已经发送过来了,全部在接收方接收缓冲区中。
上述的重传过程没有任何的冗余,只有发送没有接收的数据,不会有重复发送的情况。整体的速度是比较的快的。也被称为快速重传。
数据已经抵达,但是确认ack丢了
这个情况其实在TCP中是没有必要担心的,因为确认序号的原因,后一个ack能覆盖前一个ack,也就说当当收到2001的ack时,发送方就已经知道2001之间的数据是成功接收到的。
如果是最后一个ack丢了,其实也没有事情,这时TCP的超时重传就会起作用。在等待一段时间之后,如果没有ack报文出现,就会重新发送最后一个的数据。
流量控制也是TCP中的一个安全机制。
接收端所能接受的数据是有限的,如果发送端发送的太快,导致瞬间就填满了接收端的接收缓冲区之后,后面发来的数据接收端就会直接丢弃。
流量控制就是通过控制发送方的发送速度,来解决上述问题。
在ack携带的报文中,有16位的窗口大小这个字段,这里的值就是建议发送端发送的窗口大小。
接收方计算窗口大小是非常简单粗暴的,是直接拿剩余的接收缓冲区中的大小作为窗口大小。
从上图就可以看出,当发送方第一次接收到接收方的ack时,此时接收方的窗口大小是已经在ack报文中携带了,也就说接收方剩余的接收缓冲区大小为3000,于是发送方就按照这个大小进行批量发送,当发送到接收方缓冲区大小为0时,就停止发送,因为接收方的接收缓冲区已经满了,此时发送方就会每隔一段时间发送一个窗口探测报文,如果探测一会发现接收方的剩余空间不是0了,此时就可以继续发送。当接收端剩余空间腾出来了之后,也会发送各一个窗口更新通知,通知发送方按照这个窗口大小进行发送。
滑动窗口的大小取决于流量控制和拥塞控制。
流量控制衡量了接收端的处理能力。拥塞控制衡量了传输路径的处理能力。
如果网络当前就很拥堵,此时要是贸然的发送大量的数据,就会造成数据和网络的同时损失。
因为网络上有很多的结点,拥塞控制就是衡量中间路径,中间路径上有多少个结点,每个结点的当前情况。
TCP引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
开始的时候,先按照一个非常小的发送速率进行发送,如果不丢包,就扩大发送速率(窗口大小),如果出现丢包,就立即把速率降小。
然后重复上述的过程。
可以看出来上述的拥塞窗口的增长速度是非常快的。是指数级别的增长。
可以看到刚开始传输是按照一个非常小的阈值进行发送,然后在按照指数增长的的速度进行发送,短时间让窗口大小达到一个比较大的值。
指数增长到达了一定阈值 ssthresh,就会变成线性增长,避免拥塞。
当增长到一定的程度,出现了网络拥塞,就认为此时的窗口大小已经到了当前网络的传输上限了,然后立即把窗口大小调整为较小的初始值,然后重复上述过程。
保证TCP可靠性的核心是确认应答。
ack是一定要发的,但是不是立刻就发送ack报文,而是磨蹭会在发送,因为TCP中决定发送效率的是窗口大小,窗口大小就是接收方的接收缓冲区剩余大小。如果是等会在回复ack报文的话,那么此时应用程序可能会消费一些数据,消费数据之后,接收缓冲区中就把消费的数据删除了,这样接收缓冲区中大小就会变大,窗口大小也会变大。下次就能发送更多的数据过来。
延时应答的效果就是通过延时发送ack报文,让接收方的应用程序更可能的多消费一些数据,此时返回的窗口大小就会大一点。发送方发送的速度也会快一点。同时也能满足让接收方能处理过来。
基于延时应答。
客户端和服务器之间的通信模式是一问一答的形式的。
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 "一发一收" 的。意味着客户端给服务器说了 "How are you",服务器也会给客户端回一个 "Fine, thank you";
那么这个时候ACK就可以搭顺风车,和服务器回应的 "Fine,thank you" 一起回给客户端
合并为一个包比分成两个包发送是效率较高的。
粘包问题
当发送端给接收端发送多个数据之后,这些数据全部都在接收端的接收缓冲区中,紧紧的挨在一起,此时接收端读数据的时候,就难以区分从哪里到哪里是一个完整的数据,就会导致读出半个包/一个半包的情况出现。
此时我们可以定义个一个分隔符来区分数据包。
package netWork.netWork02;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TcpEchoServer {
//serverSocket 就是在外场拉客的
//ClientSocket 就是服务于拉来的客人的
//serverSocket 只有一个,但是clientSocket会给每个客户端都分配一个
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port); //进行端口号绑定
}
public void start() throws IOException {
//还可以使用线程池的方式
ExecutorService executorService = Executors.newCachedThreadPool();
System.out.println("服务器启动!!!");
while (true) {
//accept() 接收一个连接 clientSocket 针对具体的客户端进行服务 通过这个clientSocket和客户端进行通信
Socket clientSocket = serverSocket.accept(); //accept是会阻塞的,如果没有客户端连接的话,就会阻塞
//频繁的创建和销毁线程对系统资源的消耗也是很大的
/*如果直接调用这个方法,会影响这个循环的二次执行。到时accept就不及时了,
采用创建新的线程来调用processConnection这个方法
每次来一个新的客户端都创建一个新的线程
主线程就是while循环,只做两件事,accept 和创建线程,当线程创建好了之后,就下一次调用accept,
刚刚创建好的线程,去处理请求
处理连接和处理请求之间是并发的,没有关联关系*/
/* Thread t = new Thread(()->{
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
t.start();*///这个循环和线程中的任务就会并发的执行
//使用线程池的方式
//一个连接的所有请求处理完,这个线程不会立即销毁,而是放在线程池里面,下次直接使用。
executorService.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
//通过这个方法来处理一个连接
//读取请求
//根据请求计算响应
//把响应返回给客户端
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
//通过clientSocket拿到一对Stream对象,inputStream 输入,从网卡读
//outputStream 输出,往网卡写
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
//把inputStream和outputStream进行包装
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while (true) {
// 1 读取请求
if(!scanner.hasNext()) {
System.out.printf("[%s:%d] 客户端下线\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break; //读取的流到结尾了,没有数据了,(对端关闭连接了)
}
//直接使用scanner来读取一段字符串 使用换行来区分数据包
String request = scanner.next();
// 2 根据请求计算响应
String response = process(request);
// 3 把响应写回客户端 响应也是要带上换行的
printWriter.println(response);
printWriter.flush(); //刷新缓存区 冲刷 数据立即写入网卡中
System.out.printf("[%s:%d] req: %s,resp: %s\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort(),request,response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
clientSocket.close(); //只给一个连接提供服务的,时刻都有新的连接,所有要释放
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
可以看出在上述的TCP服务器代码中我们就是使用分隔符来区分的。
还可以约定长度来区分数据包。
这两个解决方案都是在我们的代码是自己定义实现的。
进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别。连接是在操作系统内核中维护的。
机器重启:和进程终止的情况相同。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
TCP之所以这么复杂,就在于TCP既想要提高传输可靠性,又尽可能的保证传输的效率。
可靠性:
提高效率: