目录
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的报文格式
我们先来简单认识一下各个部分:
1. 源端口号,目的端口号 和 udp 相同(前面文章介绍了udp)
2. 4位首部长度 —— TCP的报头长度
(数据报 = 首部(报头 header)+ 载荷 (UDP))
TCP 的报头长度是不固定的(变长的) ,报头最短20字节(没有选项),报头最长是60字节(选项最多是 40 字节)
注意:这个长度范围 是 0 ~ 15,那是怎么表示 60 的呀?
这里有一个很巧妙的设定 —— 这个长度的单位是 “4字节”
换句话来说,选项都是4字节一个单位的(最小也是4字节的),
所以60字节就是有15个选项 :15(x4字节)= 60(字节)
选项是什么?我们来介绍 一下这一部分:
3. 选项 —— option (可选的:可以有,可以没有)
选项也是报头的一部分,也就是说,有选项,报头就更长,没有选项,报头就更短
4.保留(6)位
前面介绍了udp 数据报最长 64kb 且固定,就很难受,TCP的设计大佬就搞了保留位,
保留位:就是虽然现在不用,但是先占个位置,留下了扩展的余地
5. 16位校验和
和udp 一样
剩下的,我们在后续 TCP协议 的相关特性那里介绍。
TCP协议 的相关特性
TCP协议 的特性 : 有连接,可靠传输,面向字节流,全双工
我们结合代码来看(完整代码最下面有)
1.有连接
我们在服务器这边就要通过 accept 的方式来接受 内核的连接,建立连接的过程,在代码中并不能感受到,因为内核都帮我们处理好了,但是我们可以通过 accept 把内核里建立好的连接 拿上来,这就体现了 tcp 的有连接
包括在后续传入数据的时候,也不用指定对方的地址了,因为已经在 tcp 的连接里记录下来了。
2.面向字节流 和 全双工
这两个就是 字节流
一个 Socket 既可以读 又可以写 —— 全双工
2.可靠传输
在代码里体现不出来
可靠传输,是TCP中最核心的特性(初心)
这里的可靠传输,不是说,发送方把数据能够 100%的传输给接收方, 这样要求太高了
我们退而求次:
1)发送方发出去的数据之后,能够知道接收方是否收到数据
2)一旦发现对方没收到,就可以通过一系列的手段来 “补救”
TCP可靠传输是如何达成的?
这就要涉及到TCP中的以下机制了
1.确认应答机制
发送方 把数据 发给 接收方 之后,接收方 收到数据 就会给 发送方返回一个 应答报文(acknowledge -> 简写成 ack)
此时,发送方如果收到这个应答报文了,就知道自己的数据是否发送成功了
在网络传输数据时,可能会出现 “后发先至” 这样的情况,一个数据包在进行传输的过程中走的路径可能是非常复杂的,不同的数据包,可能走不同的路线
—— 那如何避免这种“后发先至”的情况呢?
TCP在此处要完成一下两个工作:
1.确保应答报文和发出去的数据,能对上号,不要出现歧义。
2.确保在出现“后发先至” 的现象时,能够让应用程序这边仍然按照 正确的顺序 来理解数据。
——那TCP是如何完成这两个工作的?
根据下面的 32位序号 和 32位确认序号来完成。
意思是,我们可以把发出去的数据编上序号,与此同时,我们的应答报文就可以针对刚才那条数据的序号进行应答。而发送方也可以根据应答报文的确认序号对应到之前发送的数据,应答报文还可以根据确认序号的大小 进行重新排序。
总结来说,这个序号就是一个整数,根据它的大小关系,来描述数据的先后顺序。
举个例子:
上面的图,其实还不够严谨,更准确的说,序号不是按照 “一条两条” 的方式来进行编号的,而是按照 字节 来编号的。(TCP是面向字节流的,没有一条两条的概念)
——那具体TCP是如何编号的呢?
我们看下图:
(ps:TCP传输数据的时候,初始序号一般不是从1开始,上图的序号只是假设)
我们再看一个图:传输数据的时候就可以这样表示
1.首先我们来看第一条数据:
这条数据表示 这一个TCP数据包里一共有1000个字节的载荷数据,其中第一个字节的 序号是1,就是在TCP报头的序号字段中,写“1”,
由于一共是1000个字节,此时最后一个字节的序号自然就是1000了,但是1000这样的数据并不在TCP报头中记录。
(TCP报头中只记录这一次传输的载荷数据的 第一个字节的序号,剩下其他字节的序号,都需要依次的推出)
2. 我们接下来来看确认应答那一条:
在 应答报文中,就会在 确认序号字段中 填写 1001 ,因为收到的数据是 1~1000,所以1001之前的数据,就都被主机B收到了,或者也可以理解成,B接下来要向主机A索要1001开始的数据,
之后依次类推 发送,应答...
通过特殊的 ack 数据包,里面携带的“确认序号”来告诉发送方,哪些数据已经被确认收到了,此时发送方,就知道了自己刚发的数据是到了还是没到, 这就是可靠传输
——那如何区分一个数据包是普通的数据,还是 ack 应答数据呢?
我们还是看报文格式那张图:
下图画红圈的那一位为 1 ,则表示 当前数据包是一个应答报文 ,此时该数据包中的 “确认序号字段” 就能生效
这一位 为 0 ,则表示当前数据包是一个普通报文,此时数据包中的 "确认序号字段" 是不生效的。
TCP的初心,就是为了实现可靠传输,而达成可靠传输的 最核心 的机制,就是 确认应答。
(ps:至于为什么确认序号用收到的最后一个字节的序号 + 1表示?我们讲到滑动窗口那里再介绍。)
2. 超时重传机制
上述的确认应答,描述的是一个比较理想的情况, 那如果网络传输的过程中,出现丢包了,这时候该怎么办?
那发送方,势必无法收到 ack(应答报文)啦,这就出bug了,
那此时就 使用 超时重传机制 来针对确认应答,进行补充。
——首先,我们要了解,为什么会丢包?
我们可以把 网络想象成 错综复杂的公路网,在公路上就会有很多很多的收费站,
平时,车少,收费站的车都会快速通过,很少会出现堵车情况 ;
但是在一些 节假日的时候,收费站就经常会堵车,
然后在网络中,“收费站” 可以理解成一些 “路由器/交换机”,如果数据包太多了,就会在这些路由器/交换机 上出现 “堵车”,但是 路由器 针对 “堵车” 的处理,往往是比较粗暴的,它不会保存积压的数据包,而是会把其中的大部分数据包直接丢掉。(这些被丢掉的数据包就从网络上消失了,这就是丢包)
—— 由于丢包是一个“随机” 的事件,因此在上述 tcp 传输的过程中,丢包就存在两种情况:
1.传输的数据丢了
2.返回的 ack 丢了
但是站在发送方的角度,其实无法区分这两种情况。所以,无论出现上诉那种情况,发送方都会进行 “重新传输”。
重传操作,大幅度提升了数据能够被传过去的概率,是一个很好的丢包补救措施。
–– 那发送方是何时进行重传呢?
这里有一个 等待时间
我们的发送方,在发出去数据之后,会等待一段时间,如果这个时间之内,ack来了,此时就自然视为 数据到达;
如果达到这个时间之后,数据还没有到,就会触发 重传机制。
超时重传––超过了等待时间 再重传。
––那这个等待时间是多少呢?
不确定。
1.初始的等待时间,是可以配置的,不同的系统上都不一定一样,也可以通过修改内核参数来引起这个时间变化。
2.等待的时间,也会动态变化,每多经历一次超时,等待时间都会变长,但也不是一直变长,重传若干次时,时间拉长到一定程度,会认为数据再怎么重传也没用了,就会放弃 tcp连接(会触发TCP的重置连接操作)
––但是这里就有个问题了,我们看一下第二种丢包情况:
站在主机B 的视角,就收到了两条一样的数据,很明显,这就出bug了,就比如你买东西给商家转账,然后ack丢了,触发重传,又发了一次钱。
但是这个不用担心,TCP已经帮我们解决了,TCP会有一个“接收缓冲区”,就是一个内存空间,会保存当前已经收到的数据请,以及数据的序号。接收方如果发现,当前发送方发来的数据,已经在接收缓冲区中存在了,接收方就会直接把这个后来的数据丢掉。确保应用程序进行 read 的时候,读到的只有一条数据。
而且,到了缓冲区,不仅可以去重 ,还能进行重新排序,确保发送的顺序,和应用程序读取的顺序是一致的。
3.连接管理
建立连接+断开连接
这就来到了,面试中,最经典的问题了:
三次握手(建立连接) 和 四次挥手(断开连接)
##建立连续(三次握手)##
TCP这里的握手,是给对方传输一个简短的,没有业务数据的数据包,通过这个数据包,来唤起对方的注意,从而触发后续的操作
TCP的三次握手––TCP在建立连接的过程中,需要通信双方一共“打三次招呼”才能完成连接的建立
––那具体是怎么打招呼的,我们画图来解释:
A想和B建立连接,A就会主动发起握手操作,在实际开发中,主动发起的一方,就是所谓的“客户端”,被动接受的一方就是“服务器”。
syn:同步报文段,也是一个特殊的TCP数据包,没有载荷(就是不携带业务数据)(业务数据就是应用层数据包)
上图画圈那一位(syn),如果是1,就表示这个报文是一个同步报文段,如果这一位是0,就不是同步报文段。
上诉了解完,我们就可以画握手的图了∶
此时,握手完成,A和B记录了对方的信息,也就是 构成了“逻辑”上的连接。
但是,这怎么是四次呢?不是三次握手吗?
这是因为,在建立连续的过程,通信双方都要给对方发起syn,也都要给对方反馈ack,虽然一共是4次握手,但是中间两次,恰好可以合并成一次。(ACK和第二个syn都是内核触发的,是同一时间的,所以可以合并)
––那为什么要握手呢?
这于“可靠传输”密切相关。
在进行确认应答 和 超时重传有个大前提
–>当前的网络环境是基本可用的,通畅的
而“三次握手”的核心作用:
1.投石问路,确认当前网络是否是通畅的
2.要让发送方和接收方 都能确认自己的发送能力和接收能力正常的
上诉,是“可靠传输”的前提条件。
3.让通信双方,在握手过程中,针对一些重要的参数,进行协商。
握手这里要协商的信息, 其实是有好几个的, 但是此处不做过多讨论.
但是至少要知道, tcp 通信过程中的序号从几开始, 是双方协商出来的(一般不是从 1 开始的)
每次连接建立的时候,都会协商出一个比较大的, 和上次不太一样的值.
这种设定方式是避免前朝的剑,本朝的官,有的时候网络如果不太好,客户端和服务器之间可能会断开连接,再重新建立连接,重连的时候就可能在新的连接好了之后,就连接的数据姗姗来迟,而这种迟到的数据,应该要丢掉,不应该让这个数据影响到现在的数据,
——那如何区分这个是否是上一个数据?
就是通过上述序号的设定规则来实现,如果发现收到的数据序号和当前正常数据的序号差异非常大,就可以判定为是上一个数据,就可以直接丢掉了。
好,接下来我们介绍一下这张图:
——LISTEN(listen)
服务器端的状态.
服务器这边socket 创建好 并且把端口号绑定好,此时就会进入listen状态。
此时就允许客户端随时来建立连续了。
——ESTABLISHED(establshed)
客户端,服务器都会有的状态。
连接建立完成,接下来可以进行正常通信了。
##连接断开(四次挥手)##
建立连接,一般都是客户端主动发起的,断开连接,客户端和服务器都可以主动发起。
我们画图来看:
这个FIN是什么?
FIN: 结束报文段
这一位如果为 1, 那他就是一个结束报文段,然后就和对方断开连接。
然后∶
此时连接就断开了,这个时候,就相当于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 合并在一起了。(概率性问题)
这个大图也画出了四次挥手的过程,我们来看看:
——CLOSED:
连接已经彻底断开,可以释放了
※※TIME_WAIT※※:
哪一方,主动断开连接,哪一方就会进入TIME_WAIT(等待),
TIME_WAIT状态就是为了处理最后一个ACK丢失 这种情况:
如果最后一个ACK丢了,站在B的角度,没收到应答报文,B就会触发超时重传,重新把刚才的FIN传一遍,但是已经不会有人再响应了,B也就永远也收不到ACK了。
所以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通过 确认应答 为核心,借助其他机制辅助,最终完成可靠传输。
错误答案:三次握手/四次挥手保证了可靠传输(错误❌!!!)