目录
一、什么是TCP
如同前文的UDP一样,TCP也是传输层提供给应用层的一组API,其特点是
- 有连接
- 可靠传输
- 面向字节流
- 全双工
其特点的具体说明可移步前文
1)核心的两个类
ServerSocket
- ServerSocket(int port) : 和UDP的方法类似,也是在构造时指定一个具体的端口
- accept():TCP是一个有连接的协议,在双方建立关系时需要互相记录双方的信息,在建立连接时,在系统内核实际上已经建立完成了,但是在应用层,仍需要使用accept()来同意建立连接。
而accept()这个方法还会返回一个Socket,表示双方已经确定连接,在服务端-客户端这个模型中,服务端可以同时为多个客户端提供服务,服务端会存在一个ServerSocket,而客户端和服务端建立连接后,客户端通过调用accept()方法又会返回一个Socket,这个返回的Socket就是为这个客户端服务的。
也就是说,ServerSocket只有一个,而服务端和客户端每建立一个连接,都会有一个Socket对这个客户端服务
可以理解为售前和售后,售前用来拉客,用户购买产品之后,服务器会为每一个用户分配一个售后来进行后续服务。
Socket
在构造Socket时,需要设置好服务器的IP和端口号
TCP的特征:面向字节流
getInputStream():从网卡中接收数据
getOutputStream():从网卡中发送数据
二、基于TCP的回显服务器
服务端:
逻辑为:
- 构造serverSocket
- 通过accept()和客户端建立连接,并通过processConnection这个方法来处理和客户端的连接,这里需要使用多线程的方式来调用processConnection这个方法,因为一个服务器是要对多个客户端进行服务的,而一个客户端的连接什么时候结束是无法确定的,所以需要设置多个线程,每个线程来对一个客户端负责,这样就不会影响别的客户端了。
- 在processConnection()中使用try with catch 创建好IO流,并使用Scanner和PrintWriter包装好IO流,读请求,计算响应,把响应写回客户端
package network;
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) {
Socket clientSocket = serverSocket.accept();
// 如果直接调用, 该方法会影响这个循环的二次执行, 导致 accept 不及时了.
// 创建新的线程, 用新线程来调用 processConnection
// 每次来一个新的客户端都搞一个新的线程即可!!
// 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());
// try () 这种写法, ( ) 中允许写多个流对象. 使用 ; 来分割
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
// 没有这个 scanner 和 printWriter, 完全可以!! 但是代价就是得一个字节一个字节扣, 找到哪个是请求的结束标记 \n
// 不是不能做, 而是代码比较麻烦.
// 为了简单, 把字节流包装秤了更方便的字符流~~
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();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
客户端
逻辑为:
- 构造Socket用于和服务器进行连接
- 在processConnection()中使用try with catch 创建好IO流,并使用Scanner和PrintWriter包装好IO流,输入数据,发送请求,获取响应,把响应显示到控制台
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIp, int port) throws IOException {
// 这个操作相当于让客户端和服务器建立 tcp 连接.
// 这里的连接连上了, 服务器的 accept 就会返回.
socket = new Socket(serverIp, port);
}
public void start() {
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
PrintWriter printWriter = new PrintWriter(outputStream);
Scanner scannerFromSocket = new Scanner(inputStream);
while (true) {
// 1. 从键盘上读取用户输入的内容.
System.out.print("-> ");
String request = scanner.next();
// 2. 把读取的内容构造成请求, 发送给服务器.
// 注意, 这里的发送, 是带有换行的!!
printWriter.println(request);
printWriter.flush();
// 3. 从服务器读取响应内容
String response = scannerFromSocket.next();
// 4. 把响应结果显示到控制台上.
System.out.printf("req: %s; resp: %s\n", request, 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的可靠传输
在上述案例中,已经可以简单感受TCP的四大特性的其三了
- 有连接:通过accept()建立连接
- 面向字节流:通过InputStream和OutputStream来读写数据
- 全双工:一个socket即可以读数据也可以写数据
但还有一点:可靠传输,是无法直接感受的
可靠传输:这可以说是TCP设计的初心,一个数据,我们无法保证他100%可以发送成功,但是,我们起码要做到尽力传输过去,并且如果传输失败,发送方要知道结果
确认应答
这是实现可靠性的核心机制,本质上在于接收方,是否接收到数据,给一个应答
现在假设你是一个joker,你要开始舔你的女神了,对话如下
可以看到,你分别发送了两条信息,而对方也针对你的信息分别作出了应答。小姐姐发出的信息我们称为 “应答报文” 。
但在错综复杂的网络环境中,是很容易出现后发先至的情况的,也就是会出现这种情况
这下你可就乐了,虽然她不愿意和你吃麻辣烫,但是她愿意做你女朋友啊,比起这个,吃不吃麻辣烫还重要吗?
但是现实是残酷的,实际上她并不愿意当你女朋友,当你兴高采烈的去找她时,你的小丑行为会引得众人哄堂大笑,所以为了维护我们的尊严,我们决定引入一个编号机制来解决这种情况。
这样,我们就不会误会啦~
现在回到TCP上,在TCP中,编号机制是非常极端的,它会将每个字节的数据都进行编号
也就是说,在TCP中是没有一条信息两条信息这种说法。
那么TCP的应答机制又是如何体现的呢,可以对照上面TCP报文结构的图来看
在TCP报文中存在一个32位序号和一个32位确认序号,发送方发送数据时,会填写序号一栏,如果发送了一条1000个字节的数据,那么序号填写的就是TCP数据中这条数据的起始编号。
而在应答报文中,会填写确认序号一栏,因为发送方发送了一条1000字节的数据且起始编号为1,表示这条消息的编号为1-1000,那么确认序号就会填写1000的下一个序号即1001,表示1001之前的序号已经接收完毕
为什么会出现后发先至的情况
现实中的网络环境是错综复杂的,信息的传输不可能只通过一条路线进行,在网络传输后面有着复杂的算法,数据是实时寻找最优路线进行传输的,所以不同的信息通过走不同的路线传输,就无法保证数据的到达时刻和发送时刻一一对应,这是很普遍的现象。
如何解决后发先至
在TCP中,为了解决这个问题,TCP中就存在一块接收缓冲区,在数据经过复杂的网络环境传输完成之后,会堆积在这块接收缓冲区中,并且会根据序号进行整队,然后在将整理好的数据发给应用程序,这就保证了在应用方面接收到的数据总是有序的
超时重传
如果网络环境良好,一切传输顺利,接收方也就可以正常完成确认应答,这样就保证了传输的可靠性,但是凡事皆有例外。
网络上存在丢包的概率,且丢包是一个概率性事件,为了解决丢包问题,发送方在发送数据后,如果一段时间内没有接收到确认应答,会视为数据丢包了,随后就会重新再发一遍数据包。这就是超时重传
理想情况下,触发超时重传是发送方发送的数据丢了。接收方没有返回ACK,触发超时重传。但还存在一种情况,那就是发送方发送的数据没丢,但是接收方返回的ACK丢了。这种情况下,接收方已经接收到数据,但仍触发了超时重传,此时接收方可能会接收到两份同样的数据,这其实是一个比较严重的问题,就好像你去买包子,吃了一份包子却要让你付两份钱
为了解决这个问题,TCP还配备了自动去重功能,它会根据序号,在缓冲区整队时剔除序号重复的数据,确保数据唯一且有序
判断网络环境的机制
连续丢包
连续的两份数据,有没有可能都丢了呢?其实是可能的
假设一个数据的丢包率为10%,那么连续两个数据丢包的概率就是10%*10% = 1%,三份数据都丢的概率就是0.1%,如果多份数据包都连续丢失,TCP就会认为此时的网络环境存在严重问题。
应对措施
TCP针对多个数据包丢失,应对思路是:
- 1.超时重传,每丢包一次,就进行一次超时重传,但是每次超时重传的等待时间都会变长,当等待时间超过一个值时,取消超时重传
- 2.尝试重新建立连接,多次超时重传仍然无法解决,TCP就会尝试重新建立连接
- 3.关闭连接,当无法重新建立起连接后,TCP直接关闭连接,放弃通信
总结:确认应答和超时重传是TCP保证可靠传输的重要基石!!
连接管理
TCP协议中,通信双方互相建立连接和结束连接的过程是较为复杂的,简单可以分为以下两种
三次握手
三次握手指的是,客户端和服务端之间,通过三次交互,完成建立连接的过程
syn:同步报文段,是一方向另一方发起申请建立连接的请求
简单来说可以这样理解,你向你的女朋友表白
(syn)你->女朋友:我喜欢你,可以做我的唯一吗?
(ack)女朋友->你:我愿意
(syn)女朋友->你:你也原因做我的唯一吗?
(ack)你->女朋友:我也愿意
注意:这里服务端向客户端发出的syn+ack是被视为一条信息的,所以说客户端和服务端只交互了三次就确认了连接关系
三次握手这个过程就相当于投石问路,此时客户端和服务端都确认了双方的发送能力和接收能力,并且记录了互相的信息
四次挥手
四次挥手指的是:客户端和服务器断开连接,通过四次交互接触连接的过程
FIN:结束报文段,一方 向另一方发起的接触连接的请求
可以简单这么理解
(fin)你->女朋友:我们分手吧,你不是我的唯一了
(ack)女朋友->你:ok
(fin)女朋友->你:你也不是我的唯一了
(ack)你->女朋友:ok
为什么三次挥手中的ack和syn可以合并成一条,而四次挥手中的ack和fin不行呢?
这是因为:ack和syn是在同一时机触发的(都是由系统内核完成的),而ack和fin不是,fin是程序控制的,是在调用到socket的close方法才会触发的。所以ack和fin无法合并成一条信息。
但也并不唯一,fin和ack仍然有可能合并
close的调用时机有可能是立马调用,也有可能是隔了很久,如果是立马调用,就有可能趁着ack还没发送出去,顺带把fin一起捎上一起发过去,这样就可以将fin和ack视为同一个信息了。
注意:建立连接的过程,一定是客户端向服务端发起的,而取消连接的请求,有可能是服务器向客户端发起,也有可能是客户端向服务端发起。
TCP的传输效率
TCP在除了要保证可靠性的前提下,还要保证传输效率,下面来介绍一些TCP确保传输效率的机制
滑动窗口
在这种传输方式中。A向B发送消息,每发送一次,A都要等待B的ACK报文返回后才发送下一条,这无疑是很低效的。A花费了大量时间在等待B。
优化思路:同时发多条数据,同时等多条ack
这里并不是同时发四条数据,等待四个ack都返回完毕后,在发送四条数据,而是在接收到一个ack后,立刻发送下一份数据,这样就花费了一份的等待时间发送了多条数据。
传输在宏观上是很快的,上述的传输过程总的来看,就是一个窗口一直在往下滑动,所以我们称为:滑动窗口
这里批量等待数据的数量,就是窗口大小。
那么效率保证了,如果还是出现了丢包问题,如何解决?
这里就要申明一下TCP的原则了:可靠性第一,其他的都给我靠边站!
丢包问题大概可以分两类
ACK丢了:
这张图中,接近一半的ACK都丢了,此时网络环境已经是很差了。但是,这仍然不影响可靠性。因为ack有一条特性:只要接收到后一条应答报文,就说明前面的数据都已经接收到了
所以说,即使前面的ack都丢了,只要后一条ack没丢,仍然可以说明前面的数据都正常接收到了,而对于后面的ack也没有接收到的情况,仍然会触发超时重连
syn丢了:
这里主机A向B发送数据
- 1-1000数据正常发送,ack正常返回
- 1001-2000数据丢包,此时B没有收到数据,所以仍然会返回1001的ack
- A此时没有意识到丢包,接着发送2001-7000的数据,而由于B没有收到1001-2000这个数据,所以多次重复返回1001的ack
- 多次受到重复的ACK之后,A意识到1001这个数据丢包了,重新发送1001-2000这个数据
- B收到了数据,返回7001的ack,因为只是1001-2000的数据丢失了,后面的数据仍然正常接收,所以直接返回7001,表示前面的数据都正常接收
流量控制
在上面的滑动窗口机制中,窗口越大,整体的传输效率就越快,但并非越快越好,数据发送的太快,瞬间就把对方的缓冲区给塞满了,剩下的数据无法处理,就会发生丢包,这就得不偿失了。
为了限制发送方的传输速率,就需要接收方对发送方做出一定的限制
在应答报文(ACK)中,会携带一个窗口大小这样的字段,这里面是根据自身的信息接收能力填入的一个值,发送方就会根据这个值来调节滑动窗口的大小
拥塞控制
拥塞控制衡量了传输路径的处理能力
在主机与主机之间的通信中,数据会经过很多的路由器和交换机。而在这条传输链路中,是存在木桶效应的,即整体传输效率取决于效率最慢的那个设备。
而拥塞控制做的事情,就是衡量中间节点的传输能力
控制方法:
通过实验的方式,在开始的时候,按一个小的速率发送,如果不丢包,就提高速率,当提升到出现丢包的时候,就把速率调小,然后在尝试加快速率,一直重复这个过程,此时,速率就不会是一个恒定的值,而是处于动态平衡的状态
延时应答
延时应答比较抽象,举一个例子来理解
老师布置了十张卷子作为作业,但是你没有写,于是老师就在钉钉里质问你,为什么你没有写这十张卷子
此时你有两种选择:
1.立刻答复,老师我十张卷子都没有写,我尽快补上(心里没底)
2.装作没看见,与此同时立马开始补,补了五张之后在给老师答复:老师我只有5张没写,很快就补上了(心里有点底了)
第二种选择,就是延时应答提高效率的原理
因为在TCP中,窗口大小是通过流量控制和拥塞控制共同控制的,A向B发送数据,如果B接收到数据后立马返回ACK,设返回的窗口大小为n、如果等待一小会之后在返回ACK,那么等待一会之后返回的窗口大概率会比n大,因为在等待的这段时间中,应用程序已经从缓冲区中拿了一批数据了
面向字节流
粘包问题
说到面向字节流,就不得不提到粘包问题
这是一段你和小姐姐的对话
此时,在你的接收缓冲区中,数据是这样的
而在小姐姐的接收缓冲区中,数据是这样的
可以看到,由于你的说话习惯,在每句话之前都带上宝宝两个字,所以站在缓冲区的角度来看小姐姐是可以将这么一串字符分成完成的几句话的
而由于小姐姐没有固定的格式,所以你就无法分辨出从哪到哪是一句话
因此在读的时候,就可能将两条信息视为一条信息来读,这就是粘包问题
解决问题的思路:定义分隔符
只要在发送信息的时候双方都约定好特定的格式就可以了.
异常情况
在服务器运行过程中,难免会遇到突发情况,可以大致分为以下情况
进程关闭/进程崩溃
进程没了,socket是文件,随之被关闭,但是连接仍然存在,仍然可以四次挥手
主机关机(正常流程)
先杀死所有用户进程(在这个过程中触发四次挥手),但是这个四次挥手不一定能挥完,比如对方发的fin过来了,但是咱们没来得及ack就关机了,此时对端会重传fin,重传几次过后仍然没有ack,对端也就随之释放连接了。
主机掉电(拔电源/停电)/网线断开
a)对端是发送方:对端收不到ack->超时重传->重置连接->断开连接
b)对端是接收方:对端不知道你掉线。
对于b的情况,TCP引入了一个机制:心跳包保活机制
即:每隔一段时间,对端就会发一个心跳包(ping),然后接收方就会返回一个(pong),这样一来一回,对端就会知道你还活着,进程仍然存在。但是如果发送出的心跳包没有得到回应,那么对端就会视为进程被关闭,会自动释放连接