所属专栏:Java学习
1. TCP 的简单示例
同时,由于 TCP 是面向字节流的传输,所以说传输的基本单位是字节,接受发送都是使用的字节流
方法签名 | 方法说明 |
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接时,返回一个服务端 Socket 对象,并基于 Socket 建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
accept 操作是内核已经完成了建立连接的操作,进行“确认”的动作
public void start() throws IOException {
System.out.println("服务器启动...");
while (true) {
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
启动之后需要再次创建一个专门操作的 socket
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线\n", clientSocket.getInetAddress(), clientSocket.getPort());
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
while (true) {
//读取请求并进行遍历
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
if (!scanner.hasNext()) {
break;
}
String request = scanner.next();
//根据请求计算响应
String response = process(request);
//把响应写回客户端
//outputStream.write(response.getBytes());
printWriter.println(response);
//打印日志
System.out.printf("[%s:%d] req=%s; resp=%s\n", clientSocket.getInetAddress(),
clientSocket.getPort(), request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
System.out.printf("[%s:%d] 客户端下线\n", clientSocket.getInetAddress(), clientSocket.getPort());
clientSocket.close();
}
}
与 UDP 不同的是,这里所有的传输都是用过字节流来完成的,首先读取客户端的请求,然后根据请求计算出对应的响应,再把响应写回客户端
接下来看客户端的主要功能:
public void start(){
System.out.println("客户端启动...");
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
Scanner scanner = new Scanner(inputStream);
Scanner scannerIn = new Scanner(System.in);
PrintWriter printWriter = new PrintWriter(outputStream);
while (true){
//控制台读取数据
System.out.print("->");
String request = scannerIn.next();
//把请求发送给服务器
printWriter.println(request);
//从服务器读取响应
if(!scanner.hasNext()){
break;
}
String response = scanner.next();
//打印响应结果
System.out.println(response);
}
}catch (IOException e){
e.printStackTrace();
}
}
客户端启动之后,从控制台上读取要发送的请求,接着把数据发送给服务器,然后从服务器读取响应,再打印相应的结果
上面服务端和客户端的代码其实还有 3 个 bug :
- 当我们运行之后发现,客户端发送了数据之后,服务端并没有任何响应,也就是客户端并没有把数据发送出去,原因就是客户端的请求是存放到了内存的缓冲区中,引入缓冲区之后进行写入数据的操作并不会立即触发 IO,由于此时要发送的数据比较少,所以需要存一会。
解决办法就是调用 flush() 方法,刷新缓冲区
- 当前的服务器代码,针对 clientSocket 没有进行 close 操作:ServerSocket,DatagramSocket 的生命周期都是伴随整个进程的,但是代码中的 clientSocket 是“连接级别的”数据,随着客户端断开连接,这个 socket 也就不再使用了(即使是同一个客户端,断开之后重新连接,也是一个新的 socket)因此这样的 socket 就需要手动关闭,防止文件资源泄露
- 服务器不能同时给多个客户端提供服务:当一个客户端连接上服务器之后,服务器代码就会进入 processConnect 内部的 while 循环,此时第二个客户端尝试连接服务器时就不能执行到第二次accept 所有的客户端发送的请求就会积攒到操作系统内核的缓冲区里,第一个客户端退出时,其他客户端才能连接
这里的问题本质上是代码结构的问题:采用了双重 while 循环的写法,就会导致进入里层 while 的时 候,外层无法执行,解决办法就是把双重 while 换成单重的:
就可以使用之前学到的多线程来解决这个问题
对于上述的代码,其实还是可以优化的,如果一段时间内有大量的客户端发送请求,就会给服务器带来比较大的压力,对于这种情况,可以通过使用线程池来优化:
通过使用线程池,解决了短时间内有大量客户端发送请求之后就断开了的问题,但是如果说客户端持续的发送请求处理响应,那么连接就会保持很久,这样的场景下使用多线程 / 线程池就不太合适了
针对上述问题,可以通过 IO 多路复用来解决,相比于处理请求的时间,大部分时间可能都是在阻塞等待,如果可以让一个线程同时给多个客户提供服务就可以了,IO 多路复用就是在操作系统内部提供的功能(IO 多路复用具体实现的方案有多种),例如 Linux 下的 epoll ,就是在内核中创建了一个数据结构,可以把多个 socket (每一个 socket 对应一个客户端)放到这个数据结构中,同一时刻,大部分的 socket 都是处于阻塞等待,少部分收到数据的 socket ,epoll 就会通过回调函数的方式通知应用程序,应用程序就可以使用少量的线程针对这些 socket 进行处理
2. 长连接和短连接
长连接:长连接是指客户端和服务器端建立连接后,在较长时间内保持连接状态,以便进行多次数据传输。这类似于建立了一条专用的通信线路,可以随时进行数据交互,直到一方主动关闭连接或者由于某些异常情况导致连接中断
短连接:短连接是指在每次数据传输时,客户端和服务器端建立连接,数据传输完成后立即关闭连接。这种连接方式就像打一次电话,通话(数据传输)结束后就挂断(关闭连接)
3. UDP 协议结构
报文格式:
UDP 的报文分为报头,正文/载荷(完整的应用层数据包),其中报头部分又分为四个部分,每一个部分都是固定的四个字节,分别存储源端口,目的端口,UDP 报文长度(报文长度 = 报头长度 + 载荷长度),校验和(检验和),每一个部分都是固定的两个字节存储,由于是两个字节存储 UDP 报文长度,所以最大值就是 65535 ,也就是 64KB ,这个时候就会出现一个问题,如果要表示的内容不止是 64KB ,就需要换用 TCP 来表示了
关于校验和:由于网络传输过程中是比较容易出现错误的,传输的电信号/光信号/电磁波等信息容易受到环境的干扰,使这里的传输信号发生转变,校验和的目的就是能够“发现”或者“纠正”这些错误,同时,如果只是发现错误,那么校验和携带的信息就可以很少,如果想要纠正错误,就需要再携带额外的信息(消耗更多的带宽)
在 UDP 协议中使用的简单有效的校验和是 CRC 校验和(循环冗余校验):对 UDP 数据报整个进行遍历,分别取出每一个字节,往一个字节或是两个字节的变量上进行累加,即使溢出之后也继续加,主要关注的是校验和的结果是否会在传输中改变
如果说传输的数据,在网络通信中没有发生任何改变,此时计算出来的就是 checksum1 == checksum2 反之,如果不相等,就代表数据传输中数据发生了改变,就会丢弃这次传输
此外还可能会发生传输过程中校验和的信息也发生改变了,也就是传输过程中校验和变成了 checksum3,此时接收方重新计算校验和得到了 checksum4 ,这种情况下两个校验和大概率是不相等的,所以影响也不大,还有可能出现两组不同的数据计算出相同的校验和,这种概率也是非常低的,所以上面这两种极端情况一般不考虑
MD5 算法:
本质上是一个“字符串 hash 算法”,特点:
- 定长:无论输入多长的字符串,得到的结果都是固定长度(适合做校验算法)
- 分散:输入的内容只要发生一点改变,得到的结果也是相差很大的(适合做哈希算法)
- 不可逆:根据输入的内容计算 md5 对计算机来说是不复杂的,但是如果根据 md5 的值来计算原始值,理论上是不可以的(适合做加密算法)
4. TCP 协议结构
4.1. 确认应答
在之前提到过 TCP 的核心机制是确认应答,可以确认对方是否收到数据,在数据传输的过程中,如果有多条请求,并且返回对应的响应,但是此时可能会出现这样的问题:最先发送的请求可能并不会最先收到响应,也就是收到响应的顺序会不一样。
针对这样的问题的解决方案就是给每一个字节都进行编号(TCP 的传输是面向字节流的),并且编号是连续且递增的,按照字节编号这样的机制就称为“TCP 的序号”,在应答报文中,针对之前收到的数据进行对应的编号,称为“TCP 的确认序号”
上面的 32 位序列号和确认序列号就是这种,由于序号是递增的,知道了第一个字节的序号,后续每一个字节的序号都能知道
假如 TCP 发送了的数据标记为了 1~1000,那么确认应答的序号应该是收到的数据最后一个字节序号的下一个序号,也就是1001,表示小于 1001 序号的数据都收到了
并且之后的六位标志位中的第二位(ack)就会设为 1(默认是0)
4.2. 丢包
丢包的原因:
- 数据传输过程中发生了 bit 翻转,收到这个数据的接收方/中间的路由器等,计算校验和发现不匹配,就会把当前数据包丢掉,不再交给应用层
- 数据传输到某个节点(路由器/交换机)时,当前节点负载过高,例如某个路由器单位时间内只能发送n 个包,但是遇到了高峰期,单位时间内需要发送的包超过了 n ,后续传输过来的数据就可能被路由器丢掉了
4.3. 超时重传
TCP 对抗丢包的方法:其实丢包是不可能避免的,TCP 感应到丢包之后就会再重新发一次数据,第二次再发生丢包的概率就会减小很多,TCP 感应丢包是通过应答报文来区分的,收到应答报文之后就说明没有丢包,没有收到应答报文就说明数据丢包了,但是也不能排除当时没收到后续收到了的情况,所以就需要设置一个时间限制,在时间限制内来判断是否丢包,不过还有一个特殊情况:
第一种就是正常的数据没有发送到丢包了,第二种是数据没有丢,但是 ack 丢了,不过无论是哪种情况都会认为是丢包并且进行数据重传,这时就会出现一个问题,第一种情况是没问题的,数据丢了重新传,但是第二种情况数据没有丢,再次发送就意味着主机2收到了两份同样的数据,如果是转账的请求,让你转两次账肯定也不合理
针对上述问题 TCP 也进行了处理,接收方会有一个接收缓冲区,收到的数据会先进入缓冲区中,后续再收到数据就会根据序号在缓冲区中找对应的位置,如果发现当前序号 1~1000 已经存在了,就会把新收到的数据丢弃了,以此来确保读取到的数据是唯一的
重传的时间设定:
这里的时间不是固定的,而是动态变化的,例如发送方第一次重传,超时时间为 t1,如果重传之后仍然没有 ack ,还是继续重传,第二次重传超时时间为 t2,,t2 是大于 t1 的,每多重传一次,超时时间的间隔就会变大
经过一次重传之后,就能让数据到达对方的概率显著提示,反之,如果重传几次都没有顺利到达,说明网络的丢包率已经达到了一个很大的程度
重传也不会无休止的进行,当重传到达一定次数的时候,TCP 就不会尝试重传了,就认为这个链接已经G了,此时先进行“重置/复位 连接”,发送一个特殊的数据包“复位报文”,如果网络恢复了,复位报文就会重置连接,使通信继续进行,如果网络还是有问题,复位报文没有得到回应,此时 TCP 就会单方面放弃连接
确认应答和超时重传这两个核心机制共同构建了 TCP 的“可靠传输机制”