您现在的位置是:首页 >技术交流 >详解TCP原理网站首页技术交流

详解TCP原理

陌上 烟雨齐 2023-05-27 08:00:03
简介详解TCP原理

目录

前言

TCP协议

TCP协议的格式

TCP原理

1、确认应答

2、超时重传

3、连接管理

4、滑动窗口

5、流量控制

6、拥塞控制

7、延时应答

8、捎带应答

9、面向字节流

10、异常情况

总结


前言

TCP协议是一种传输层协议,也是TCP/IP协议栈中非常重要的一个协议,它提供了面向连接,可靠性传输,面向字节流等特性。使传输的效率和可靠性大大提高。可靠性是TCP协议的最核心的机制。它可以在数据传输时提供各种控制和错误恢复机制,确保数据在网络中可靠传输。

TCP协议

TCP,即Transmission Control Protocol,传输控制协议。人如其名,要对数据的传输进行一个详细的控制。

TCP协议的格式

 可以看出TCP的报文格式还是比UDP比较复杂的。

  • 源端口 : 和UDP的源端口一致,标识数据从那个进程来的。
  • 目的端口 :标识数据要到那个进程中去。
  • 32位序号:用于标识数据从发送方发送到接收方的字节流。
  • 32位确认序号:只有当接收方给发送方返回ack时,确认序号才有用。表示接收方希望发送方发送下一编号的字节数据。
  • 4位首部长度:占用4位,以4个字节为单位计算。标识TCP的头部有多少个4字节。标识TCP头部的长度。固定长度为20个字节。
  • 保留6位:不做讨论。
  • 6个标志位:占用6位,包含URG、ACK、PSH、RST、SYN和FIN等标志位,分别表示紧急数据、确认、推送数据、重置连接、同步序列号和结束连接等功能。
  • 16位窗口大小:表示接收方可以接收的数据量的大小。
  • 16位的校验和:用于检测数据的完整性,包括TCP的报头和数据部分。
  • 16位紧急指针:当URG为1时,标识那部分数据是紧急数据。

上述就是TCP报文的报头结构。

下面进行详细的介绍。

TCP原理

TCP对数据传输的管控机制,主要在两个方面,一是效率,而是安全,保证在数据传输安全的前提下,尽可能的提高传输效率。

1、确认应答

实现可靠性最核心的机制。

TCP发送方将每个字节的数据进行编号,即为序号。

接收端在接收到这个带有序号的数据之后,就进行发送ack报文,这个ack报文里面带有接收端给发送方的确认序号,确认序号就是从接收到的数据中最后一个字节序号的下一个序号,就是告诉发送端,我已经收到了你发的数据,下一次你从哪里开始发送。

在网络中由于网络的原因,有可能会有后发先至的情况。

在发送方发送的数据中,由于是TCP是对每一个字节进行编号的,在网络传输的过程中,可能有某个字节因为网络不稳定的原因延迟到达,其他字节已经在TCP的接收缓冲区中了,这是数据就会产生紊乱。但是因为TCP的序号的作用,可以针对序号在接收缓冲区中进行排序,使数据变得有序。这样上层的应用层就不会读到错误的数据了。

2、超时重传

如果网络稳定,数据也没有产生错误,那么数据就很顺利的发送并接收,但是如果网络在发送数据的时候,因为某个结点出了问题,这样数据就不能发送到接收端了,就会产生丢包问题。

丢包之后,接收端肯定就收不到数据,就不能发送ack确认报文。

此时发送方就在等接收方给它的确认报文,等待一段时间之后,还是没有收到接收方发来的确认报文,发送方就会视为刚才发送的数据丢包了,就会进行重发。

如果说发送的数据接收端收到了,但是接收端发送的ack丢包了,发送方也就等不到ack报文了。

发送方也是一样在等待一定的时间之后,如果还是没有ack报文,那么发送方就认为数据丢包了,此时发送方就会再次发送一次之前发送的数据。

上述过程客户端B就会接收到重复的数据,但是TCP协议因为序号的存在,是能够帮我们自动识别出哪些是重复数据,并且把重复的数据丢弃。

如果有多个数据都丢包了,那么TCP会继续进行超时重传,但是每丢包一次,超时重传的时间就变长了,也就说重传的频率变低了。

如果多次重传,都没有得到ack回应,那么TCP就会尝试重置连接,如果重置连接也失效,那么TCP此时就之间断开连接。

TCP实现可靠性是由确认连接和超时重传实现的。

3、连接管理

建立连接 :三次握手

握手:是通信双方建立一次网络交互,相当于客户端和服务器之间通过三次交互,互相建立了连接关系(双方各自记录对方的信息)。

 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报文。

4、滑动窗口

滑动窗口,也叫批量传输

上述的确认应答机制是对每发送的一个数据报,都要等待一个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报文出现,就会重新发送最后一个的数据。

5、流量控制

流量控制也是TCP中的一个安全机制。

接收端所能接受的数据是有限的,如果发送端发送的太快,导致瞬间就填满了接收端的接收缓冲区之后,后面发来的数据接收端就会直接丢弃。

流量控制就是通过控制发送方的发送速度,来解决上述问题。

 在ack携带的报文中,有16位的窗口大小这个字段,这里的值就是建议发送端发送的窗口大小。

接收方计算窗口大小是非常简单粗暴的,是直接拿剩余的接收缓冲区中的大小作为窗口大小。

 从上图就可以看出,当发送方第一次接收到接收方的ack时,此时接收方的窗口大小是已经在ack报文中携带了,也就说接收方剩余的接收缓冲区大小为3000,于是发送方就按照这个大小进行批量发送,当发送到接收方缓冲区大小为0时,就停止发送,因为接收方的接收缓冲区已经满了,此时发送方就会每隔一段时间发送一个窗口探测报文,如果探测一会发现接收方的剩余空间不是0了,此时就可以继续发送。当接收端剩余空间腾出来了之后,也会发送各一个窗口更新通知,通知发送方按照这个窗口大小进行发送。

6、拥塞控制

滑动窗口的大小取决于流量控制和拥塞控制。

流量控制衡量了接收端的处理能力。拥塞控制衡量了传输路径的处理能力。

如果网络当前就很拥堵,此时要是贸然的发送大量的数据,就会造成数据和网络的同时损失。

因为网络上有很多的结点,拥塞控制就是衡量中间路径,中间路径上有多少个结点,每个结点的当前情况。

TCP引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。

开始的时候,先按照一个非常小的发送速率进行发送,如果不丢包,就扩大发送速率(窗口大小),如果出现丢包,就立即把速率降小。

然后重复上述的过程。

 可以看出来上述的拥塞窗口的增长速度是非常快的。是指数级别的增长。

 可以看到刚开始传输是按照一个非常小的阈值进行发送,然后在按照指数增长的的速度进行发送,短时间让窗口大小达到一个比较大的值。

指数增长到达了一定阈值 ssthresh,就会变成线性增长,避免拥塞。

当增长到一定的程度,出现了网络拥塞,就认为此时的窗口大小已经到了当前网络的传输上限了,然后立即把窗口大小调整为较小的初始值,然后重复上述过程。

7、延时应答

保证TCP可靠性的核心是确认应答。

ack是一定要发的,但是不是立刻就发送ack报文,而是磨蹭会在发送,因为TCP中决定发送效率的是窗口大小,窗口大小就是接收方的接收缓冲区剩余大小。如果是等会在回复ack报文的话,那么此时应用程序可能会消费一些数据,消费数据之后,接收缓冲区中就把消费的数据删除了,这样接收缓冲区中大小就会变大,窗口大小也会变大。下次就能发送更多的数据过来。

延时应答的效果就是通过延时发送ack报文,让接收方的应用程序更可能的多消费一些数据,此时返回的窗口大小就会大一点。发送方发送的速度也会快一点。同时也能满足让接收方能处理过来。

8、捎带应答

基于延时应答。

客户端和服务器之间的通信模式是一问一答的形式的。

在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 "一发一收" 的。意味着客户端给服务器说了 "How are you",服务器也会给客户端回一个 "Fine, thank you";
那么这个时候ACK就可以搭顺风车,和服务器回应的 "Fine,thank you" 一起回给客户端
合并为一个包比分成两个包发送是效率较高的。

9、面向字节流

粘包问题

当发送端给接收端发送多个数据之后,这些数据全部都在接收端的接收缓冲区中,紧紧的挨在一起,此时接收端读数据的时候,就难以区分从哪里到哪里是一个完整的数据,就会导致读出半个包/一个半包的情况出现。

此时我们可以定义个一个分隔符来区分数据包。

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] 客户端上线
",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] 客户端下线
",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
",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服务器代码中我们就是使用分隔符来区分的。

还可以约定长度来区分数据包。

这两个解决方案都是在我们的代码是自己定义实现的。

10、异常情况

进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别。连接是在操作系统内核中维护的。
机器重启:和进程终止的情况相同。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。

总结

TCP之所以这么复杂,就在于TCP既想要提高传输可靠性,又尽可能的保证传输的效率。

可靠性:

  • 校验和
  • 序列号(按序到达)
  • 确认应答
  • 超时重发
  • 连接管理
  • 流量控制
  • 拥塞控制

提高效率:

  • 滑动窗口
  • 快速重传
  • 延迟应答
  • 捎带应答
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。