传输层协议——TCP协议 (详解!!!)

目录

TCP的报文格式 

1. 源端口号,目的端口号 和 udp 相同(前面文章介绍了udp) 

2. 4位首部长度 —— TCP的报头长度 

3. 选项 —— option (可选的:可以有,可以没有)

4.保留(6)位 

 5. 16位校验和

TCP协议 的相关特性 

1.有连接 

2.面向字节流 和 全双工

2.可靠传输

TCP可靠传输是如何达成的? 

 1.确认应答机制

 2. 超时重传机制

3.连接管理

##建立连续(三次握手)##

——LISTEN(listen)

——ESTABLISHED(establshed)

##连接断开(四次挥手)##

※※TIME_WAIT※※: 

TCP服务端代码 

TCP客户端代码

常见面试题:TCP是如何保证可靠传输的?


 

前言:本章节是网络编程的理论基础。是一个服务器开发程序员的重要基本功。是整个网络课程中的重点和难点。也是各大公司笔试面试的核心考点

TCP协议最大的特点,就是可靠传输!!!

TCP的报文格式 

 传输层协议——TCP协议 (详解!!!)_第1张图片

我们先来简单认识一下各个部分:

1. 源端口号,目的端口号 和 udp 相同(前面文章介绍了udp) 

2. 4位首部长度 —— TCP的报头长度 

 

(数据报 = 首部(报头 header)+ 载荷 (UDP))

TCP 的报头长度是不固定的(变长的) ,报头最短20字节(没有选项),报头最长是60字节(选项最多是 40 字节)

注意:这个长度范围 是 0 ~ 15,那是怎么表示 60 的呀?

这里有一个很巧妙的设定 —— 这个长度的单位是 “4字节”

换句话来说,选项都是4字节一个单位的(最小也是4字节的),

所以60字节就是有15个选项 :15(x4字节)= 60(字节)

传输层协议——TCP协议 (详解!!!)_第2张图片

选项是什么?我们来介绍 一下这一部分:

3. 选项 —— option (可选的:可以有,可以没有)

选项也是报头的一部分,也就是说,有选项,报头就更长,没有选项,报头就更短 

  

4.保留(6)位 

前面介绍了udp 数据报最长 64kb 且固定,就很难受,TCP的设计大佬就搞了保留位,

保留位:就是虽然现在不用,但是先占个位置,留下了扩展的余地 

 5. 16位校验和

和udp 一样 

 剩下的,我们在后续 TCP协议 的相关特性那里介绍。

TCP协议 的相关特性 

TCP协议 的特性 : 有连接,可靠传输,面向字节流,全双工

我们结合代码来看(完整代码最下面有) 

1.有连接 

我们在服务器这边就要通过 accept 的方式来接受 内核的连接,建立连接的过程,在代码中并不能感受到,因为内核都帮我们处理好了,但是我们可以通过 accept 把内核里建立好的连接 拿上来,这就体现了 tcp 的有连接

包括在后续传入数据的时候,也不用指定对方的地址了,因为已经在 tcp 的连接里记录下来了。

2.面向字节流 和 全双工

这两个就是 字节流 

传输层协议——TCP协议 (详解!!!)_第3张图片

 一个 Socket 既可以读 又可以写 —— 全双工

 传输层协议——TCP协议 (详解!!!)_第4张图片

2.可靠传输

在代码里体现不出来

可靠传输,是TCP中最核心的特性(初心)

这里的可靠传输,不是说,发送方把数据能够 100%的传输给接收方, 这样要求太高了

我们退而求次:

1)发送方发出去的数据之后,能够知道接收方是否收到数据

2)一旦发现对方没收到,就可以通过一系列的手段来 “补救”

TCP可靠传输是如何达成的? 

这就要涉及到TCP中的以下机制了 

 1.确认应答机制

 发送方 把数据 发给 接收方 之后,接收方 收到数据 就会给 发送方返回一个 应答报文(acknowledge -> 简写成 ack

此时,发送方如果收到这个应答报文了,就知道自己的数据是否发送成功了

 在网络传输数据时,可能会出现 “后发先至” 这样的情况,一个数据包在进行传输的过程中走的路径可能是非常复杂的,不同的数据包,可能走不同的路线

—— 那如何避免这种“后发先至”的情况呢?

TCP在此处要完成一下两个工作:

1.确保应答报文和发出去的数据,能对上号,不要出现歧义。

2.确保在出现“后发先至” 的现象时,能够让应用程序这边仍然按照 正确的顺序 来理解数据。

——那TCP是如何完成这两个工作的?

根据下面的 32位序号 和 32位确认序号来完成。

传输层协议——TCP协议 (详解!!!)_第5张图片

意思是,我们可以把发出去的数据编上序号,与此同时,我们的应答报文就可以针对刚才那条数据的序号进行应答。而发送方也可以根据应答报文的确认序号对应到之前发送的数据,应答报文还可以根据确认序号的大小 进行重新排序。

总结来说,这个序号就是一个整数,根据它的大小关系,来描述数据的先后顺序

举个例子:

传输层协议——TCP协议 (详解!!!)_第6张图片

​上面的图,其实还不够严谨,更准确的说,序号不是按照 “一条两条” 的方式来进行编号的,而是按照 字节 来编号。(TCP是面向字节流的,没有一条两条的概念)

——那具体TCP是如何编号的呢?

我们看下图:

传输层协议——TCP协议 (详解!!!)_第7张图片

(ps:TCP传输数据的时候,初始序号一般不是从1开始,上图的序号只是假设)

我们再看一个图:传输数据的时候就可以这样表示

传输层协议——TCP协议 (详解!!!)_第8张图片

1.首先我们来看第一条数据:

传输层协议——TCP协议 (详解!!!)_第9张图片

这条数据表示 这一个TCP数据包里​一共有1000个字节的载荷数据,其中第一个字节的 序号是1,就是在TCP报头的序号字段中,写“1”,

由于一共是1000个字节,此时最后一个字节的序号自然就是1000了,但是1000这样的数据并不在TCP报头中记录。

(TCP报头中只记录这一次传输的载荷数据的 第一个字节的序号,剩下其他字节的序号,都需要依次的推出)

2. 我们接下来来看确认应答那一条:

传输层协议——TCP协议 (详解!!!)_第10张图片

在 应答报文中,就会在 确认序号字段中 填写 1001 ,因为收到的数据是 1~1000,所以1001之前的数据,就都被主机B收到了,或者也可以理解成,B接下来要向主机A索要1001开始的数据,

之后依次类推  发送,应答...

通过特殊的 ack 数据包,里面携带的“确认序号”来告诉发送方,哪些数据已经被确认收到了,此时发送方,就知道了自己刚发的数据是到了还是没到, 这就是可靠传输

——那如何区分一个数据包是普通的数据,还是 ack 应答数据呢? 

 我们还是看报文格式那张图:

下图画红圈的那一位为 1 ,则表示 当前数据包是一个应答报文 ,此时该数据包中的 “确认序号字段” 就能生效

这一位 为 0 ,则表示当前数据包是一个普通报文,此时数据包中的 "确认序号字段" 是不生效的。

传输层协议——TCP协议 (详解!!!)_第11张图片

TCP的初心,就是为了实现可靠传输,而达成可靠传输的 最核心 的机制,就是 确认应答。 

(ps:至于为什么确认序号用收到的最后一个字节的序号 + 1表示?我们讲到滑动窗口那里再介绍。) 

 2. 超时重传机制

上述的确认应答,描述的是一个比较理想的情况, 那如果网络传输的过程中,出现丢包了,这时候该怎么办?

那发送方,势必无法收到 ack(应答报文)啦,这就出bug了,

那此时就 使用 超时重传机制 来针对确认应答,进行补充。 

——首先,我们要了解,为什么会丢包? 

        我们可以把 网络想象成 错综复杂的公路网,在公路上就会有很多很多的收费站,

平时,车少,收费站的车都会快速通过,很少会出现堵车情况 ;

        但是在一些 节假日的时候,收费站就经常会堵车,

然后在网络中,“收费站” 可以理解成一些 “路由器/交换机”,如果数据包太多了,就会在这些路由器/交换机 上出现 “堵车”,但是 路由器 针对 “堵车” 的处理,往往是比较粗暴的,它不会保存积压的数据包,而是会把其中的大部分数据包直接丢掉。(这些被丢掉的数据包就从网络上消失了,这就是丢包

—— 由于丢包是一个“随机” 的事件,因此在上述 tcp 传输的过程中,丢包就存在两种情况:

1.传输的数据丢了

 传输层协议——TCP协议 (详解!!!)_第12张图片

2.返回的 ack 丢了

传输层协议——TCP协议 (详解!!!)_第13张图片

但是站在发送方的角度,其实无法区分这两种情况。所以,无论出现上诉那种情况,发送方都会进行 “重新传输”。

重传操作,大幅度提升了数据能够被传过去的概率,是一个很好的丢包补救措施。

 –– 那发送方是何时进行重传呢?

这里有一个  等待时间

我们的发送方,在发出去数据之后,会等待一段时间,如果这个时间之内,ack来了,此时就自然视为 数据到达; 

如果达到这个时间之后,数据还没有到,就会触发 重传机制。

​超时重传––超过了等待时间 再重传。

––那这个等待时间是多少呢?

不确定。

1.初始的等待时间,是可以配置的,不同的系统上都不一定一样,也可以通过修改内核参数来引起这个时间变化。

2.等待的时间,也会动态变化,每多经历一次超时,等待时间都会变长,但也不是一直变长,重传若干次时,时间拉长到一定程度,会认为数据再怎么重传也没用了,就会放弃 tcp连接(会触发TCP的重置连接操作)

––但是这里就有个问题了,我们看一下第二种丢包情况:

传输层协议——TCP协议 (详解!!!)_第14张图片

站在主机B 的视角,就收到了两条一样的数据,很明显,这就出bug了,就比如你买东西给商家转账,然后ack丢了,触发重传,又发了一次钱。

但是这个不用担心,TCP已经帮我们解决了,TCP会有一个“接收缓冲区”,就是一个内存空间,会保存当前已经收到的数据请,以及数据的序号。接收方如果发现,当前发送方发来的数据,已经在接收缓冲区中存在了,接收方就会直接把这个后来的数据丢掉。确保应用程序进行 read 的时候,读到的只有一条数据。​

而且,到了缓冲区,不仅可以去重 ,还能进行重新排序,确保发送的顺序,和应用程序读取的顺序是一致的。

3.连接管理

建立连接+断开连接

这就来到了,面试中,最经典的问题了:

三次握手(建立连接) 和 四次挥手(断开连接)

##建立连续(三次握手)##

​TCP这里的握手,是给对方传输一个简短的,没有业务数据的数据包,通过这个数据包,来唤起对方的注意,从而触发后续的操作

TCP的三次握手––TCP在建立连接的过程中,需要通信双方一共“打三次招呼”才能完成连接的建立

––那具体是怎么打招呼的,我们画图来解释:

A想和B建立连接,A就会主动发起握手操作,在实际开发中,主动发起的一方,就是所谓的“客户端”,被动接受的一方就是“服务器”

syn:同步报文段,也是一个特殊的TCP数据包,没有载荷(就是不携带业务数据)(业务数据就是应用层数据包)

上图画圈那一位(syn),如果是1,就表示这个报文是一个同步报文段,如果这一位是0,就不是同步报文段。

上诉了解完,我们就可以画握手的图了∶

传输层协议——TCP协议 (详解!!!)_第15张图片

此时,握手完成,A和B记录了对方的信息,也就是 构成了“逻辑”上的连接。

但是,这怎么是四次呢?不是三次握手吗?

这是因为,在建立连续的过程,通信双方都要给对方发起syn,也都要给对方反馈ack,虽然一共是4次握手,但是中间两次,恰好可以合并成一次。(ACK和第二个syn都是内核触发的,是同一时间的,所以可以合并)

传输层协议——TCP协议 (详解!!!)_第16张图片

––那为什么要握手呢?

这于“可靠传输”密切相关。

在进行确认应答 和 超时重传有个大前提

–>当前的网络环境是基本可用的,通畅的

而“三次握手”的核心作用:

1.投石问路,确认当前网络是否是通畅的

2.要让发送方和接收方 都能确认自己的发送能力和接收能力正常的

上诉,是“可靠传输”的前提条件。

3.让通信双方,在握手过程中,针对一些重要的参数,进行协商。

握手这里要协商的信息, 其实是有好几个的, 但是此处不做过多讨论.

但是至少要知道, tcp 通信过程中的序号从几开始, 是双方协商出来的(一般不是从 1 开始的)

每次连接建立的时候,都会协商出一个比较大的, 和上次不太一样的值.

这种设定方式是避免前朝的剑,本朝的官,有的时候网络如果不太好,客户端和服务器之间可能会断开连接,再重新建立连接,重连的时候就可能在新的连接好了之后,就连接的数据姗姗来迟,而这种迟到的数据,应该要丢掉,不应该让这个数据影响到现在的数据,

——那如何区分这个是否是上一个数据?

就是通过上述序号的设定规则来实现,如果发现收到的数据序号和当前正常数据的序号差异非常大,就可以判定为是上一个数据,就可以直接丢掉了。

好,接下来我们介绍一下这张图:

传输层协议——TCP协议 (详解!!!)_第17张图片

——LISTEN(listen)

传输层协议——TCP协议 (详解!!!)_第18张图片

服务器端的状态.

服务器这边socket 创建好 并且把端口号绑定好,此时就会进入listen状态。

此时就允许客户端随时来建立连续了。

——ESTABLISHED(establshed)

传输层协议——TCP协议 (详解!!!)_第19张图片

客户端,服务器都会有的状态。

连接建立完成,接下来可以进行正常通信了。

##连接断开(四次挥手)##

建立连接,一般都是客户端主动发起的,断开连接,客户端和服务器都可以主动发起。

我们画图来看:

传输层协议——TCP协议 (详解!!!)_第20张图片

这个FIN​是什么?

FIN:  结束报文段​

这一位如果为 1, 那他就是一个结束报文段,然后就和对方断开连接。

然后∶

传输层协议——TCP协议 (详解!!!)_第21张图片

此时连接就断开了,这个时候,就相当于A和B​都把对端的信息删除了。

然后我们想一想,和三次握手相比,此处的四次挥手能否把中间的两次交互 合二为一?

–––不一定。

––不能合并的原因 ––> ACK 和 第二个FIN的触发时机是不同的。

ACK是内核响应的,B收到FIN,就会立即返回ACK,  而第二个 FIN 是应用程序的代码触发,B这边调用了 close方法,才会触发FIN。

从服务器收到FIN(同时返回ACK),再到执行到close,发起FIN,这中间要经历多久,是不确定的。

FIN会在socket对象close的时候,被发起,可能是手动调用 close,也可能是进程结束。

ps: 如果我这边代码 close没写或没执行到,是不是第二个FIN就一直发不出去?

–––有可能。

正常的四次挥手,就是正常的流程断开的连接,

不正常的挥手(没挥完四次),异常的流程断开连接。

––那什么时候可以合并呢?

TCP中还有一个机制–>延时应答(之后会介绍),能够拖延ACK的回应时间,一旦ACK滞后了,就有机会和下一个 FIN 合并在一起了。(概率性问题)

这个大图也画出了四次挥手的过程,我们来看看:

传输层协议——TCP协议 (详解!!!)_第22张图片

——CLOSED:

连接已经彻底断开,可以释放了

※※TIME_WAIT※※: 

哪一方,主动断开连接,哪一方就会进入TIME_WAIT(等待),

TIME_WAIT状态就是为了处理最后一个ACK丢失 这种情况:

如果最后一个ACK丢了,站在B的角度,没收到应答报文,B就会触发超时重传,重新把刚才的FIN传一遍,但是已经不会有人再响应了,B也就永远也收不到ACK了。

传输层协议——TCP协议 (详解!!!)_第23张图片

所以A这边使用TIME_WAIT状态进行等待,等待的这个时间,如果最后一个ACK丢失,然后B重传FIN, A就能接受到,然后返回ACK。

(TIME_WAIT等待时间是2MSL(MSL:可配置的参数))

ps∶  网络传输数据的基本单位:

       段–>segment   包–>packet    

       报–>datagram   帧–>frame

但是,当引入 “可靠性” 的时候,会 降低传输效率(多出了等待ack的时间,单位时间内的传输的数据就少了),提高复杂程度,(这也是UDP不被TCP完全取代的原因,当特别需要性能的场景,UDP肯定还是更胜一筹的。)

TCP服务端代码 

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;

/**
 * @Author: iiiiiihuang
 */

//字节流通信方式
public class TcpEchoServer {
    private ServerSocket serverSocket = null;
    public TcpEchoServer(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动!");
        ExecutorService service = Executors.newCachedThreadPool();
        while(true) {
            //通过 accept,把内核中已经建立好的连接拿到应用程序中
            //建立连接的细节流程是内核自动完成的,应用程序 “捡现成的” 就好
            Socket clientSocket = serverSocket.accept();
            //创建线程来调用processConnection,这样就可以并发执行了(好几个客户端同时处理)(多线程)
//            Thread t = new Thread(() -> {
//                processConnection(clientSocket);
//            });
//            t.start();

            service.submit(new Runnable() {
                @Override
                public void run() {
                    processConnection(clientSocket);
                }
            });
        }
    }

    //通过这个方法来处理 当前的连接
    public void processConnection(Socket clientSocket) {
        //先打印日志,表示当前有客户端连上了
        System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
        //接下来进行数据的交互
        try (InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()) {
            //使用try()方法,可以避免后续用完了流对象,忘记关闭
            //由于客户端发来的数据,可能是多条数据,所以针对对条数据,就得循环处理
            while(true) {
                Scanner scanner = new Scanner(inputStream);
                if(!scanner.hasNext()) {
                    //此时连接就断开了,循环就要结束
                    System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
                    break;
                }

                /**
                 *  1.读取请求并解析,此处就以 next 来作为读取请求的方式
                 */
                //next 的规则是读到“空白符” 就返回
                //后续客户端发起的请求,会以空白符作为结束的标记(此处约定为\n)
                String request = scanner.next();

                /**
                 * 2.根据请求,计算响应
                 */
                String response = process(request);

                /**
                 * 3.把响应写回到客户端
                 */
                //(1)可以把String 转为字节数组,写入到 OutputStream
                //(2)也可以使用 PrintWriter 把 OutputStream 包裹一下,来写入字符串
                PrintWriter printWriter = new PrintWriter(outputStream);
                //此处的打印就不是打印到控制台了,而是写入到 outputStream 对应的流对象中,也就是写入到 clientSocket 里面
                //这个数据自然就通过网络发送出去了(发给当前这个连接的另外一端)
                //此处使用 println (带有\n)也是为了后续 客户端那边 可以使用 scanner.next 来读取数据。
                printWriter.println(response);
                //此处还有一个操作 ———— 刷新缓冲区 (如果没这个操作,可能数据依然是在内存中的,没有被写入网卡)
                printWriter.flush();

                /**
                 * 4.打印这次请求交互过程的内容
                 */
                System.out.printf("[%s:%d] req = %s , resp = %s\n", clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
            }
        }catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                //在这里进行clientSocket 的关闭,防止文件资源泄露
                //这是因为本方法(processConnection)就是在处理一个连接,这个方法执行完毕,这个连接也就处理完了
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public String process(String request) {
        //回显服务器,响应和请求一样
        return request;
    }

    public static void main(String[] args) throws IOException {
       TcpEchoServer server = new TcpEchoServer(9090);
       server.start();
    }
}

TCP客户端代码


import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;

/**
 * @Author: iiiiiihuang
 */
public class TcpEchoClient {
    private Socket socket = null;
    public TcpEchoClient(String serverIp, int serverPort) throws IOException {
        //在创建Socket的同时,要和服务器 “建立连接”, 此时就得告诉 Socket 服务器在哪里 (如何连接,不需要我们手动干预,内核自动完成了)
        socket = new Socket(serverIp, serverPort);
    }

    public void start() {

        Scanner scanner = new Scanner(System.in);
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()){
            PrintWriter printWriter = new PrintWriter(outputStream);
            Scanner scannerNetwork = new Scanner(inputStream);
            while (true) {
                /**
                 * 1.从控制台读取用户输入的内容
                 */
                System.out.print("-> ");
                String request = scanner.next();
                /**
                 * 2.把字符串作为请求,发送给服务器
                 */
                printWriter.println(request);
                printWriter.flush();

                /**
                 * 3.从服务器读取响应
                 */
                String response = scannerNetwork.next();

                /**
                 * 4.把响应显示到界面上
                 */
                System.out.println(response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    public static void main(String[] args) throws IOException {
        TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
        client.start();
    }
}

常见面试题:TCP是如何保证可靠传输的?

正确答案:TCP通过 确认应答 为核心,借助其他机制辅助,最终完成可靠传输。

错误答案:三次握手/四次挥手保证了可靠传输(错误❌!!!)

你可能感兴趣的:(JavaEE,tcp/ip,网络协议,网络)