当前位置: 首页 > java >正文

网络基础1(应用层、传输层)

目录

一、应用层

1.1 序列化和反序列化        

1.2 HTTP协议        

1.2.1 URL

1.2.2 HTTP协议格式       

1.2.3 HTTP服务器示例       

二、传输层        

2.1 端口号       

2.1.1 netstat       

2.1.2 pidof

2.2 UDP协议

2.2.1 UDP的特点        

2.2.2 基于UDP的应用层协议

2.3 TCP协议

2.3.1 确认应答(ACK)机制       

2.3.2 超时重传机制    

2.3.3 连接管理机制        

2.3.4 TIME_WAIT状态       

2.3.5 CLOSE_WAIT 状态       

2.3.6 滑动窗口       

2.3.7 流量控制        

2.3.8 拥塞控制

2.3.9 延迟应答        

2.3.10 面向字节流

2.3.11 粘包问题

2.3.12 TCP异常情况

2.3.13 TCP小结

extra 用UDP实现可靠传输

1. 引入序列号

2. 引入确认应答

3. 引入超时重传

4. 数据包的格式

5. 重传机制

6. 简单示例代码(C++)



一、应用层

        在 OSI 七层模型中,应用层是最顶层,它直接与用户应用程序互动。程序员写的网络应用程序,像是 HTTP 客户端、FTP 客户端等,都工作在应用层,解决具体的业务需求。而协议是通信双方在应用层交换数据时所遵循的规则和约定,它定义了数据如何组织、如何发送、如何接收。通常,网络通信中的数据是以字节流的形式传输的,这些字节流需要在发送端和接收端之间按某种约定进行解析。网络接口(如 socket API)在发送和接收数据时,默认处理的是“字节流”或“字符串”格式。但是如果我们需要传输更复杂的结构化数据(比如一个对象、一个数组,或者包含多个字段的复杂数据)该怎么办呢?     

1.1 序列化和反序列化        

        例如,我们需要实现一个服务器版的加法器。我们需要客户端把要计算的两个加数发过去,然后由服务器进行计算,最后再把结果返回给客户端。

方案一:直接发送表达式字符串

        在这个方案中,客户端将加法表达式(例如 "1+1")发送给服务器,服务器解析字符串并计算结果。然后,服务器将结果以某种格式返回给客户端。

方案二:使用结构体序列化和反序列化

        在这个方案中,我们不直接传输字符串,而是定义一个结构体来表示加法操作的信息。然后,我们将结构体序列化为字符串进行传输,接收方接收到字符串后进行反序列化,恢复原始数据结构进行加法计算。

struct AddRequest {int num1;  // 第一个加数int num2;  // 第二个加数
};struct AddResponse {int result;  // 计算结果
};// 客户端代码
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <arpa/inet.h>struct AddRequest {int num1;int num2;
};int main() {int sockfd;struct sockaddr_in server_addr;AddRequest request = {1, 1};  // 客户端传递的加法请求// 创建 socketsockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0) {std::cerr << "Error creating socket!" << std::endl;return 1;}// 设置服务器地址server_addr.sin_family = AF_INET;server_addr.sin_port = htons(12345);server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");// 连接到服务器if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "Connection failed!" << std::endl;return 1;}// 发送结构体数据send(sockfd, &request, sizeof(request), 0);// 接收结果char buffer[1024];int n = recv(sockfd, buffer, sizeof(buffer), 0);buffer[n] = '\0';std::cout << "Server result: " << buffer << std::endl;close(sockfd);return 0;
}// 服务器端代码
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>struct AddRequest {int num1;int num2;
};struct AddResponse {int result;
};int main() {int sockfd, new_sockfd;struct sockaddr_in server_addr, client_addr;socklen_t addr_size;AddRequest request;AddResponse response;// 创建 socketsockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0) {std::cerr << "Error creating socket!" << std::endl;return 1;}// 设置服务器地址server_addr.sin_family = AF_INET;server_addr.sin_port = htons(12345);server_addr.sin_addr.s_addr = INADDR_ANY;// 绑定服务器地址if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "Bind failed!" << std::endl;return 1;}// 监听端口if (listen(sockfd, 10) == 0) {std::cout << "Server listening on port 12345..." << std::endl;} else {std::cerr << "Listen failed!" << std::endl;return 1;}// 接受客户端连接addr_size = sizeof(client_addr);new_sockfd = accept(sockfd, (struct sockaddr*)&client_addr, &addr_size);// 接收数据recv(new_sockfd, &request, sizeof(request), 0);// 进行加法运算response.result = request.num1 + request.num2;// 发送结果send(new_sockfd, &response, sizeof(response), 0);close(new_sockfd);close(sockfd);return 0;
}

        无论是采用方案一、方案二,还是其他方案,只要确保通信双方在发送和接收数据时遵循一定的规则(即协议),就能够确保数据在两端能够正确地解析和理解。这个规则或约定就被称为 应用层协议。        

                

1.2 HTTP协议        

        虽然作为程序员,我们可以自定义应用层协议,但其实很多情况下,已经有很多成熟且经过广泛使用的应用层协议可以供我们直接参考和使用,例如,HTTP(HyperText Transfer Protocol)就是当前互联网应用中最广泛使用的协议之一。

1.2.1 URL

        平时我们常说的“网址”其实就是指 URL(统一资源定位符,Uniform Resource Locator)。URL是用来表示互联网上资源位置的字符串,它告诉浏览器如何找到某个特定的网页、文件或其他网络资源。 例如:

        在 URL 中,某些字符具有特定的意义。例如,/ 用于路径分隔,? 用于分隔查询参数,& 用于连接多个查询参数,# 用于锚点。因此,当这些字符出现在 URL 的某个部分时,如果需要作为数据的一部分而不是特殊意义的分隔符,就必须进行 转义,以确保它们不会引起歧义。urlencodeurldecode 是常见的两个操作,它们用于处理 URL 中的特殊字符。                  urlencode 是将字符串中的特殊字符转换为 URL 编码格式。它的规则是将字符转换为 百分号编码(Percent Encoding),也称为 URL 编码。这个过程将字符转化为它们对应的 ASCII 码的十六进制表示,并在前面加上 % 符号。urldecode 是将 URL 编码的字符串解码回原始的字符串。这个过程会把 % 后跟着的十六进制值转换为对应的字符。

原字符:Hello World!
URL编码:Hello%20World%21空格" " 转换为 %20/ 转换为 %2F? 转换为 %3F: 转换为 %3A! 被编码为 %21
编码规则:取字符的 ASCII 码值(例如字符 "A" 的 ASCII 码是 65,十六进制是 41)。将该 ASCII 码值转为两位十六进制表示(即 %41)。对每个字符进行类似的编码。

        

1.2.2 HTTP协议格式       

HTTP请求格式

  • 首行: [方法] + [url] + [版本]
  • Header: 由多个键值对组成,每一组键值对由冒号(:)分隔,且每一组属性之间使用换行符(\n)分隔,遇到空行表示Header部分结束
  • Body: 空行后面的内容都是Body,Body允许为空字符串,如果Body存在,则在Header中会有一个Content-Length属性来标识Body的长度
POST /submit HTTP/1.1    //首行
Host: www.example.com
User-Agent: Mozilla/5.0
Accept: text/html, application/xhtml+xml
Content-Length: 45name=John&age=30

HTTP响应格式        

  • 首行: [版本号] + [状态码] + [状态码解释]
  • Header: 由多个键值对组成,每一组键值对由冒号(:)分隔,且每一组属性之间使用换行符(\n)分隔,遇到空行表示Header部分结束
  • Body: 空行后面的内容都是Body,Body允许为空字符串,如果Body存在,则在Header中会有一个Content-Length属性来标识Body的长度;如果服务器返回了一个html页面,那么html页面内容就是在body中
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Server: Apache/2.4.1<html><head><title>Example</title></head><body><h1>Hello, World!</h1></body>
</html>

HTTP的方法

方法说明支持的HTTP协议版本
GET获取资源1.0、1.1
POST传输实体主体1.0、1.1
PUT传输文件1.0、1.1
HEAD获得报文首部1.0、1.1
DELETE删除文件1.0、1.1
OPTIONS询问支持的方法1.1
TRACE追踪路径1.1
CONNECT要求用隧道协议连接代理1.1
LINK建立和资源之间的联系1.0
UNLINE断开连接关系1.0

HTTP的状态码        

类别原因短语
1XXInformational(信息性状态码)
2XXSuccess(成功状态码)
3XXRedirection(重定向状态码)
4XXClient Error(客户端错误状态码)
5XXServer Error(服务器错误状态码)

        常见的状态码, 比如 200(OK), 404(Not Found), 403(Forbidden), 302(Redirect, 重定向), 504(Bad Gateway).

        
HTTP常见Header

  • Content-Type: 数据类型(text/html等);
  • Content-Length: Body的长度;
  • Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
  • User-Agent: 声明用户的操作系统和浏览器版本信息;
  • referer: 当前页面是从哪个页面跳转过来的;
  • location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问;
  • Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能

1.2.3 HTTP服务器示例       

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void Usage() {printf("usage: ./server [ip] [port]\n");
}
int main(int argc, char* argv[]) {if (argc != 3) {Usage();return 1;}int fd = socket(AF_INET, SOCK_STREAM, 0);if (fd < 0) {perror("socket");return 1;}struct sockaddr_in addr;addr.sin_family = AF_INET;addr.sin_addr.s_addr = inet_addr(argv[1]);addr.sin_port = htons(atoi(argv[2]));int ret = bind(fd, (struct sockaddr*)&addr, sizeof(addr));if (ret < 0) {perror("bind");return 1;}ret = listen(fd, 10);if (ret < 0) {perror("listen");return 1;}for (;;) {struct sockaddr_in client_addr;socklen_t len;int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);if (client_fd < 0) {perror("accept");continue;}char input_buf[1024 * 10] = { 0 }; // 用一个足够大的缓冲区直接把数据读完.ssize_t read_size = read(client_fd, input_buf, sizeof(input_buf) - 1);if (read_size < 0) {return 1;}printf("[Request] %s", input_buf);char buf[1024] = { 0 };const char* hello = "<h1>hello world</h1>";sprintf(buf, "HTTP/1.0 200 OK\nContent-Length:%lu\n\n%s", strlen(hello), hello);write(client_fd, buf, strlen(buf));}return 0;
}

        如果是在云服务器上,可以先复制ssh渠道,在一端启动服务器,另一端使用curl命令进行测试。        

        


二、传输层        

传输层负责数据能够从发送端传输到接收端。        

2.1 端口号       

        端口号(Port)标识了一个主机上进行通信的不同的应用程序; 在TCP/IP协议中, 用 "源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过
netstat -n查看).

端口号范围划分

  • 0 - 1023: 常见端口号, HTTP, FTP, SSH等这些广为使用的应用层协议, 它们的端口号都是固定的. 我们写一个程序使用端口号时, 要避开这些知名端口号
    • ssh服务器, 使用22端口
    • ftp服务器, 使用21端口
    • telnet服务器, 使用23端口
    • http服务器, 使用80端口
    • https服务器, 使用443
  • 1024 - 65535: 操作系统动态分配的端口号.  客户端程序的端口号, 就是由操作系统从这个范围分配的

注意:

        一个进程可以 bind 多个端口号。你可以通过创建多个套接字,每个套接字绑定到不同的端口来实现。

        一个端口号通常只能被一个进程绑定。但是,在某些特定的条件下,多个进程可以绑定到相同的端口,尤其在高并发或多进程/多线程环境下。

        

2.1.1 netstat       

        netstat是一个用来查看网络状态的重要工具.

语法:netstat [选项]        

功能:查看网络状态

常用选项:

  • -t:显示 TCP 连接。
  • -u:显示 UDP 连接。
  • -l:仅显示在监听状态的端口。
  • -p:显示哪个进程在使用相应的端口。
  • -n:以数字方式显示地址和端口号,而不是将其解析为主机名和服务名。
  • -a:显示所有的连接和监听端口。
  • -r:显示路由信息。
  • -i:显示网络接口的信息。
  • -s:显示网络统计信息。
//netstat 的输出示例如下(部分):
Proto  Recv-Q  Send-Q   Local Address    Foreign Address    State       PID/Program name
tcp       0       0     0.0.0.0:22       0.0.0.0:*          LISTEN      1234/sshd
tcp6      0       0     :::80            :::*               LISTEN      5678/nginx

        

2.1.2 pidof

        pidof 是一个用于查找给定程序名称对应的进程 ID (PID) 的命令

语法:pidof [进程名]
功能:通过进程名, 查看进程id

        有时,你可能希望查看某个端口正在被哪个进程使用,这时候 netstatpidof 可以结合使用。例如,假设你想要查看端口 8080 被哪个进程占用,你可以使用以下步骤:        

$ netstat -tulnp | grep :8080
tcp6       0      0 :::8080    :::*    LISTEN      1234/nginx$ pidof nginx
1234

        

2.2 UDP协议

        源端口号(Source Port):16 位,表示发送端的端口号;如果不需要返回信息,源端口可以设置为 0。

        目的端口号(Destination Port):16 位,表示接收端的端口号。此字段由应用程序根据目标服务来设置。

        长度(Length):16 位,表示 UDP 数据报的总长度(包括头部和数据部分);UDP 最小长度为 8 字节(只包含头部),最大长度为 65535 字节(最大 16 位长度)。

        校验和(Checksum):16 位,用于错误检测;如果校验和出错, 就会直接丢弃。

        数据(Data):数据部分是可变长度的,具体的大小由 Length 字段指定;包含应用程序需要传输的内容。

        

2.2.1 UDP的特点        

  • 无连接:UDP 在数据传输前不需要建立连接,发送方知道接收方的 IP 地址和端口号后,就可以直接发送数据;
  • 不可靠:UDP 不提供数据传输的确认机制,也没有重传机制,若数据包丢失或发生错误,UDP 不会向应用层报告错误;
  • 面向数据报:UDP 以独立的数据报形式发送数据,每个数据报都是一个独立的单位,不能控制数据的读取次数和数量,且数据长度固定。

        在 UDP 中,应用层的数据是作为一个整体(数据报)直接传输的,UDP 协议会原样发送应用层传递的报文,并不会对数据进行拆分或合并,具体来说:如果应用层发送 100 字节的数据,UDP 就会以 100 字节的完整数据报形式发送出去。UDP 不会将其拆分成多个小数据包,且接收端必须接收与发送端相同大小的数据。例如,发送端调用 sendto 发送 100 字节数据,那么接收端必须通过 recvfrom 一次性接收 100 字节数据,不能分多次调用 recvfrom 来接收数据。        

        

2.2.2 基于UDP的应用层协议

  • NFS:允许客户端通过网络访问和共享远程服务器上的文件;
  • TFTP:用于通过网络传输小文件,通常用于设备启动和固件升级;
  • DHCP:自动为网络中的设备分配 IP 地址和其他配置信息;
  • BOOTP:为无盘工作站等设备提供启动所需的网络配置信息;
  • DNS:将域名解析为 IP 地址,便于用户访问互联网资源。

        

2.3 TCP协议

字段长度说明
源端口16位表示发送端的端口号
目标端口16位表示接收端的端口号
序列号32位包含数据流中的字节序列号,表示数据段的开始位置
确认号32位如果ACK标志位设置为1,确认号表示接收到的下一个期望字节的序列号
数据偏移(首部长度)4位表示TCP头部的长度,单位是4字节
保留字段6位保留为0,未来扩展使用
标志位(Flags)6位URG(紧急指针有效)、ACK(确认序列号有效)、PSH(推送功能)、RST(重置连接)、SYN(同步序列号)、FIN(结束连接)
窗口大小16位用于流量控制,表示接收窗口的大小
校验和16位用于数据的校验,确保数据传输过程中没有发生错误
紧急指针16位如果URG标志位为1,紧急指针指出紧急数据的结束位置
选项可变长度可选字段,通常用于TCP的扩展功能(如最大段大小MSS、时间戳等)
数据可变长度TCP段中实际传输的应用数据

        

2.3.1 确认应答(ACK)机制       

        确认应答(ACK)机制是TCP协议用来确保数据可靠传输的方式。简单来说,就是接收方在收到数据后,会给发送方一个确认信号(ACK),表示它已成功接收到数据。

2.3.2 超时重传机制    

        那TCP如何确定超时的时间呢?TCP的超时重传机制动态调整超时时间,以适应不同的网络环境。理想情况下,超时时间应确保确认应答能够在指定时间内返回,但实际应用中,网络条件的变化会影响这一时间。如果超时时间设置过长,可能降低重传效率;如果设置过短,可能导致重复包的发送。为保证高效的通信,TCP动态计算最大超时时间。在Linux(以及BSD Unix和Windows)系统中,超时以500ms为单位,每次重传的超时时间按2的指数倍递增(500ms、1000ms、2000ms等)。如果多次重传后仍未收到应答,TCP会认为网络或对端主机存在问题,最终强制关闭连接。        

2.3.3 连接管理机制        

服务端状态转化:

  • [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接;
  • [LISTEN -> SYN_RCVD] 一旦监听到连接请求, 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文, 响应客户端的连接请求;
  • [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行读写数据了;
  • [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT;
  • [CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN);
  • [LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接.

客户端状态转化:

  • [CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段;
  • [SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据;
  • [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时, 向服务器发送结束报文段, 同时进入FIN_WAIT_1;
  • [FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认, 则进入FIN_WAIT_2, 开始等待服务器的结束报文段;
  • [FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段, 进入TIME_WAIT, 并发出LAST_ACK;
  • [TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life, 报文最大生存时间)的时间, 才会进入CLOSED状态.        

2.3.4 TIME_WAIT状态       

        做一个测试,首先启动server,然后启动client,接着用Ctrl-C使server终止,最后马上再运行server,结果会报出这条错误信息:

        bind error: Address already in use
        出现该错误的原因是,尽管服务器应用程序已经终止,但TCP协议层的连接未完全断开,导致端口仍然被占用。这通常是因为TCP连接关闭后,主动关闭连接的一方会进入 TIME_WAIT 状态,等待一定时间(通常为2个最大报文生存时间,MSL),以确保网络中的延迟报文被清除。在此期间,端口无法重新绑定或监听。可以使用 netstat 命令查看端口占用情况,确认是否有连接仍在 TIME_WAIT 状态,或是否有其他进程在使用该端口。

        根据TCP协议,主动关闭连接的一方会进入 TIME_WAIT 状态,并必须等待两个MSL后才能返回 CLOSED 状态。当通过Ctrl-C终止服务器时,服务器是主动关闭连接的一方,因此在 TIME_WAIT 状态期间,无法重新监听相同的端口。默认的MSL值在RFC 1122中规定为两分钟,但不同操作系统的实现有所不同。例如,在CentOS 7中,默认的MSL值为60秒。

可以通过命令

        cat /proc/sys/net/ipv4/tcp_fin_timeout 查看当前MSL的配置值。
        注意,TIME_WAIT状态持续2个MSL(最大报文生存时间),是为了确保所有可能迟到的报文段在两个传输方向上都已经消失。MSL定义了TCP报文在网络中的最大生存时间,因此,在TIME_WAIT期间,能够确保即使服务器重启,也不会收到来自上一个连接的过期数据,这些数据很可能是无效的。其次,TIME_WAIT状态还保证了最后一个报文的可靠到达。如果最后一个ACK丢失,服务器会重新发送FIN报文,即使客户端的进程已经结束,TCP连接仍然存在,允许重发LAST_ACK,以确保连接的正常关闭。        

        

        在服务器的TCP连接未完全断开之前,无法重新监听端口,但在某些情况下,这种限制可能不合理。例如,服务器需要处理大量客户端连接,虽然每个连接的生存时间较短,但请求量非常大,且每秒都有大量客户端请求。在这种情况下,如果服务器主动关闭连接(如清理不活跃的客户端),会产生大量的 TIME_WAIT 连接。

        由于请求量庞大,TIME_WAIT 状态的连接数量可能很高,每个连接占用一个通信五元组(源IP、源端口、目标IP、目标端口和协议)。当新客户端连接时,如果其目标IP、目标端口与某个 TIME_WAIT 连接的五元组重复,就会出现端口占用问题。

        为了解决这个问题,可以通过使用 setsockopt() 设置 socket 描述符的选项 SO_REUSEADDR 为 1,允许创建端口号相同但IP地址不同的多个 socket 描述符,从而避免端口被 TIME_WAIT 状态占用。

int opt = l;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

2.3.5 CLOSE_WAIT 状态       

        在作者的《网络编程套接字》博客中的4.3实现了一个TCP通用服务器,如果在代码中删去 new_sock.Close(); 这条语句,然后再编译并运行服务器,启动客户端进行连接,检查 TCP 状态,发现客户端和服务器均处于 ESTABLISHED 状态,正常运行。当关闭客户端程序时,观察到服务器进入 CLOSE_WAIT 状态。结合四次挥手的流程图分析,可以推测四次挥手未能正确完成。

        服务器出现大量 CLOSE_WAIT 状态的原因是服务器未正确关闭 socket,导致四次挥手未能完成。这个问题是一个 BUG,通过在代码中添加适当的 close 操作即可解决。

//tcp_server.hpp#pragma once
#include <functional>
#include "tcp_socket.hpp"// 定义一个 Handler 类型,用于处理客户端请求和生成响应
typedef std::function<void(const std::string& req, std::string* resp)> Handler;class TcpServer {
public:// 构造函数,初始化服务器的 IP 和端口TcpServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {}// 启动服务器,处理客户端请求bool Start(Handler handler) {// 1. 创建监听用的 socketCHECK_RET(listen_sock_.Socket());// 2. 绑定服务器 IP 和端口到监听 socketCHECK_RET(listen_sock_.Bind(ip_, port_));// 3. 设置监听队列大小为 5CHECK_RET(listen_sock_.Listen(5));// 4. 进入事件循环,不断接受客户端连接for (;;) {// 5. 等待并接受客户端的连接TcpSocket new_sock;std::string ip;uint16_t port = 0;if (!listen_sock_.Accept(&new_sock, &ip, &port)) {continue;  // 接受失败则跳过}// 输出客户端连接信息printf("[client %s:%d] connect!\n", ip.c_str(), port);// 6. 进入与客户端的读写循环for (;;) {std::string req;// 7. 从客户端接收请求数据,若失败则断开连接bool ret = new_sock.Recv(&req);if (!ret) {printf("[client %s:%d] disconnect!\n", ip.c_str(), port);// [注意!] 客户端断开连接时需要关闭与客户端的 socket// new_sock.Close();    // !!!!!! 删除这条语句 break;  // 退出循环,处理下一个客户端}// 8. 处理请求并生成响应std::string resp;handler(req, &resp);// 9. 将响应数据发送回客户端new_sock.Send(resp);// 输出请求和响应数据printf("[%s:%d] req: %s, resp: %s\n", ip.c_str(), port, req.c_str(), resp.c_str());}}return true;  // 启动成功}private:TcpSocket listen_sock_;  // 监听用的 TcpSocket 对象std::string ip_;         // 服务器 IP 地址uint64_t port_;          // 服务器端口号
};

2.3.6 滑动窗口       

        在刚才的讨论中,我们提到了一种确认应答策略,即每发送一个数据段都需要等待一个 ACK 确认应答,然后再发送下一个数据段。该策略的一个主要缺点是性能较差,尤其是在数据传输往返时间较长时。

        为了提高性能,我们可以一次性发送多个数据段,而不需要等待每个数据段的确认应答。通过重叠等待时间,能够显著提升吞吐量。图中的窗口大小表示无需等待确认应答即可继续发送数据的最大量(例如,4000 字节,即四个数据段)。在发送前四个数据段时,无需等待任何 ACK,直接发送;在收到第一个 ACK 后,滑动窗口向后移动,继续发送下一个数据段,依此类推。

        操作系统内核会通过发送缓冲区来维护滑动窗口,记录哪些数据段还没有收到 ACK。只有在接收到对应的 ACK 后,数据才会从缓冲区中删除。窗口越大,能够并行发送的数据就越多,从而提高网络的吞吐率。

        

当发生丢包时,重传机制可以通过以下两种情况进行处理:

情况一:ACK丢失
        如果数据包已成功到达接收端,但部分 ACK 丢失,通常不会造成问题,因为发送端可以通过后续的 ACK 确认已收到的数据包。

情况二:数据包丢失
        当某一数据包丢失时,接收端会持续向发送端发送相同的 ACK(例如 "1001"),表示接收端期待重新接收该数据。若发送端连续收到三次相同的 ACK(如 "1001"),则会重新发送丢失的数据段(例如 1001 至 2000)。接收端在收到重传数据后,会返回新的 ACK(如 "7001"),这表示接收端已成功接收到之前的 2001 至 7000 的数据,这些数据已被存储在接收缓冲区中。

        这种机制被称为“快重传”或“高速重发控制”,它通过快速识别丢包并触发重传,有效提高数据传输的可靠性和效率。

2.3.7 流量控制        

        接收端的处理速度是有限的。如果发送端发送数据过快,可能导致接收端的缓冲区被填满,从而引发丢包、重传等一系列问题。为了避免这种情况,TCP采用流量控制机制(Flow Control),根据接收端的处理能力来调整发送端的发送速率。

        在流量控制机制中,接收端将自身可用的缓冲区大小放在 TCP 头部的“窗口大小”字段中,通过 ACK 报文通知发送端。窗口大小越大,表示接收端能够处理的数据量越多,网络的吞吐量也越高。

        当接收端发现缓冲区快满时,会将窗口大小设置为较小的值并通知发送端,要求发送端减缓数据发送速度。如果缓冲区已满,接收端会将窗口大小设置为0,发送端此时暂停数据发送。然而,发送端仍会定期发送窗口探测数据段,以便接收端告知当前的窗口大小,并恢复正常的数据传输。

        接收端通过 TCP 头部中的 16 位窗口字段来向发送端告知窗口大小。该字段存储的是窗口大小的值,最大可表示 65535。然而,这并不意味着 TCP 窗口的最大大小就是 65535 字节。实际上,TCP 头部的 40 字节选项部分还包含一个窗口扩大因子 M。实际的窗口大小是通过将窗口字段的值左移 M 位来计算的。        

2.3.8 拥塞控制

        拥塞控制的核心目标是 在保证可靠性和高效性的同时,尽量避免过载网络。通过慢启动算法快速探索带宽,避免一开始就向网络发送过多数据;通过慢启动阈值、指数增长和线性增长相结合的方式,平衡网络的吞吐量和稳定性;在发生丢包或超时时,及时调整拥塞窗口,以适应当前的网络状况。

        

  1. 拥塞窗口(Congestion Window,cwnd):拥塞窗口是 TCP 协议中控制数据流量的一个重要参数。它决定了发送方每次可以发送多少数据。该值动态变化,根据网络的拥塞状态来调整。

  2. 慢启动:TCP 连接初始阶段,拥塞窗口从 1 开始,每收到一个确认应答(ACK),拥塞窗口大小增加 1(或按某些实现为增长一段更大的值),以指数方式增长。初期阶段,TCP 慢启动算法尝试快速探索网络的带宽容量,以便尽快达到一个适合的发送速度。

  3. 慢启动阈值(ssthresh):在慢启动过程中,当拥塞窗口达到一个阈值(ssthresh)时,窗口增长方式会发生变化:从指数增长转为线性增长。此时,拥塞窗口的增加速度减慢,避免网络过度拥堵。该阈值的设置非常关键,合理的阈值有助于平衡吞吐量和网络拥堵的关系。如果网络中发生了丢包或超时重传,通常会触发调整阈值的操作。每次超时后,慢启动阈值会减半,并且拥塞窗口会重新设置为 1。

  4. 拥塞控制的其他机制快重传和快恢复:当接收端收到重复的 ACK 时,TCP 会认为某些数据包丢失,立即触发快速重传,减少拥塞的延迟,并尽快恢复正常的发送速率。拥塞避免:当拥塞窗口大于慢启动阈值时,TCP 会逐渐增加拥塞窗口的大小,通常是每经过一个 RTT(往返时延),拥塞窗口会增加 1(线性增长)。这种方式避免了指数增长带来的过度拥堵。

  5. 网络拥堵的感知丢包:如果发送的数据包丢失,通常是因为网络的拥塞。TCP 通过检测丢包情况来判断网络是否发生拥塞,并相应调整发送速率。超时重传:如果数据包的确认 ACK 在预定时间内没有到达,TCP 会重传该数据包并触发拥塞控制机制,减缓数据发送速率。

2.3.9 延迟应答        

        当接收端接收到数据后,通常会立即返回 ACK 来确认接收情况。但如果每次都立刻发送 ACK,网络上的 ACK 包数量就会增加,造成带宽浪费。在延迟应答中,接收端会在一定时间内或在收到一定数量的数据后再发送 ACK。这可以让窗口的大小更大,从而提高吞吐量和传输效率。

        延迟应答有两种限制数量限制:即在接收到一定数量的数据包后才进行 ACK 应答。例如,接收端可能设定每接收到 2 个数据包才返回一次 ACK。时间限制:即如果接收端在某段时间内没有收到新的数据包,它会在设定的最大延迟时间(如 200ms)内发送 ACK。

        延迟应答可以使接收端能够在一个 ACK 中传递更大的窗口大小。由于接收端在延迟应答时已经处理掉一部分数据,它可以返回一个更大的窗口大小,允许发送方发送更多的数据。更大的接收窗口意味着可以发送更多数据,从而增加吞吐量。但这必须在不导致网络拥塞的情况下进行,否则会产生反效果。

        

        捎带应答(Piggybacking)是一种优化策略,通常在延迟应答的基础上进行。它通过将 ACK 消息和数据消息一起发送,从而减少网络中独立 ACK 的数量。具体来说,当客户端发送请求并等待服务器的回应时,服务器不仅仅会响应请求的数据,还可以在回应数据的同时,返回客户端的 ACK 消息。

        

2.3.10 面向字节流

        创建一个 TCP 套接字时,内核会同时为该套接字分配一个发送缓冲区和一个接收缓冲区。当调用 write 函数时,数据首先会被写入发送缓冲区。如果待发送的数据字节数较大,系统会将数据分割成多个 TCP 数据包发送;如果数据量较小,则会在缓冲区中等待,直到积累到一定的长度或其他合适的时机,再进行发送。

        接收数据时,数据通过网卡驱动程序传入内核的接收缓冲区,然后应用程序可以调用 read 函数从接收缓冲区读取数据。

        TCP 连接的特点是,每个连接都有独立的发送缓冲区和接收缓冲区,因此在同一连接中,既可以进行数据的读取,也可以进行数据的写入,这种机制被称为全双工通信。

        由于缓冲区的存在,TCP 的读写操作不需要严格匹配。例如,向缓冲区写入 100 字节数据时,可以通过一次 write 操作完成,也可以通过 100 次 write 操作,每次写入一个字节。同样,读取 100 字节数据时,应用程序不需要关心数据是如何写入的,可以一次性调用 read 读取 100 字节,也可以调用 100 次 read,每次读取一个字节。

2.3.11 粘包问题

        在 TCP 中,由于其流式传输的特性,数据以字节流的方式进行传送,并没有明确的“边界”来分隔不同的数据包。这就导致了应用层无法直接区分哪些字节属于同一个数据包,哪些字节属于另一个数据包。为了解决这个问题,应用层需要通过以下方式来明确边界:

  • 固定长度包:对于定长的数据包,应用层可以按照固定大小读取缓冲区的内容。例如,如果每个数据包都是 1024 字节,应用程序可以每次读取 1024 字节来获得一个完整的数据包。
  • 包头包含数据长度:对于变长数据包,应用层可以在包头中预留一个字段来存储该包的总长度。这样,在接收数据时,就可以通过读取包头来知道整个数据包的长度,从而确保正确读取完整数据包。
  • 特殊分隔符:应用层也可以设计特定的分隔符来分隔不同的数据包,只要这些分隔符在数据正文中不会出现。

       在 UDP 中,由于每个 UDP 数据包都是独立传输的,并且 UDP 本身是面向数据报的协议,存在一个很明确的数据边界。因此,UDP 不存在类似 TCP 中的粘包问题。

2.3.12 TCP异常情况

        进程终止:当一个进程终止时,操作系统会释放它所持有的文件描述符,但这并不意味着 TCP 连接立刻关闭。系统会发送一个 FIN 包,表示连接的另一端可以开始进行正常的连接关闭流程。与正常的 TCP 连接关闭类似,进程终止不会导致连接立即被重置。

        机器重启:机器重启和进程终止的情况类似,所有与该机器相关的网络连接都会被丢弃。当机器重启时,连接会被强制断开,接收方依然认为连接存在,直到它尝试进行数据写入时才会发现连接已经不存在,通常会收到 RST 包(重置连接)作为响应。

        机器掉电/网线断开:机器掉电或者网线断开时,接收端会认为连接依然存在,并不会立刻察觉到连接已中断。等到接收端尝试进行写入操作时,它会发现连接已经断开,这时 TCP 会发送 RST 包来重置连接。在这种情况下,接收端可能通过定期探测来判断连接的存活状态。比如 TCP 有内置的 保活定时器,用于检查连接是否还存活。如果检测到连接已经断开,它会自动释放该连接。

        应用层协议检测:很多应用层协议(例如 HTTP 长连接)也会实现定期的心跳检测机制,用于确保连接的有效性。例如,HTTP 长连接会定期发送 ping/pong 消息,或者通过设置超时时间来检测连接是否仍然有效。即使在即时通讯软件(如 QQ)中,断开连接后,客户端通常会尝试周期性地重新连接。

2.3.13 TCP小结

        既要保证可靠性,同时又尽可能的提高性能,这就注定了TCP非常复杂。

        可靠性机制:校验和、序列号(按序到达)、确认应答、超时重发、连接管理、流量控制、拥塞控制;

        提高性能:滑动窗口、快速重传、延迟应答、捎带应答;

        基于TCP应用层协议:HTTP、HTTPS、SSH、Telnet、FTP、SMTP。

extra 用UDP实现可靠传输

        使用 UDP 实现可靠传输协议的核心思路是参考 TCP 的可靠性机制,包括顺序控制、确认应答、超时重传等。因为 UDP 本身并不保证数据的可靠传输,所以我们可以在应用层设计和实现这些机制。

        

1. 引入序列号

        为了保证数据按顺序传输,需要给每个数据包分配一个序列号。接收方根据序列号来确定数据包的顺序。

  • 每个数据包都包含一个 序列号
  • 接收方接收到数据包后,检查序列号,如果序列号顺序正确,进行处理;否则,丢弃或请求重发。

2. 引入确认应答

        每个数据包传输后,接收方会发送一个确认应答(ACK)给发送方,表示该数据包已成功接收。如果发送方在超时前没有收到确认,应当进行重传。

  • 每个数据包发送后,发送方等待接收方的确认。
  • 如果接收方收到数据包且顺序正确,发送一个 ACK 确认消息给发送方。
  • 如果发送方未在规定时间内收到确认,则重新发送数据包。

3. 引入超时重传

        超时重传机制是可靠传输协议的核心。发送方会设置一个 定时器,在一定时间内等待确认应答。如果超时没有收到确认,重新发送数据包。

  • 每发送一个数据包后,启动定时器。
  • 如果在定时器超时前收到了确认应答,停止定时器。
  • 如果超时仍未收到确认,重发数据包。

4. 数据包的格式

        每个数据包的格式需要包括以下字段:

  • 序列号:标识数据包的顺序。
  • 数据:传输的实际数据。
  • 确认号(可选):如果是确认应答包,表示接收到的数据包的序列号。
  • 校验和:保证数据的完整性。

5. 重传机制

        我们还需要一个 滑动窗口 机制,用来控制哪些数据包已经成功接收,并允许发送方根据接收方的确认信息进行有序重传。

6. 简单示例代码(C++)

#include <iostream>
#include <string>
#include <chrono>
#include <thread>
#include <iomanip>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>#define PORT 12345
#define MAX_RETRIES 5
#define TIMEOUT 2  // 超时设置为2秒// 发送方
void send_data(const std::string &data, const std::string &receiver_ip) {int sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0) {std::cerr << "Socket creation failed!" << std::endl;return;}struct sockaddr_in receiver_addr;receiver_addr.sin_family = AF_INET;receiver_addr.sin_port = htons(PORT);receiver_addr.sin_addr.s_addr = inet_addr(receiver_ip.c_str());int seq_num = 0;int retries = 0;while (retries < MAX_RETRIES) {// 构造数据包:序列号 + 数据std::string packet = std::to_string(seq_num) + ":" + data;// 发送数据包ssize_t sent = sendto(sockfd, packet.c_str(), packet.size(), 0, (struct sockaddr*)&receiver_addr, sizeof(receiver_addr));if (sent < 0) {std::cerr << "Failed to send data!" << std::endl;break;}std::cout << "Sent: " << packet << std::endl;// 设置接收超时struct timeval tv;tv.tv_sec = TIMEOUT;tv.tv_usec = 0;setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv));// 等待确认char ack_buffer[1024];socklen_t len = sizeof(receiver_addr);ssize_t recv_len = recvfrom(sockfd, ack_buffer, sizeof(ack_buffer), 0, (struct sockaddr*)&receiver_addr, &len);if (recv_len >= 0) {ack_buffer[recv_len] = '\0';  // 确保字符串结束int ack_seq_num = std::stoi(ack_buffer);  // 获取确认号std::cout << "Received ACK: " << ack_seq_num << std::endl;// 如果确认号与发送的序列号一致,表示确认成功if (ack_seq_num == seq_num) {std::cout << "Data successfully acknowledged." << std::endl;break;  // 数据发送成功,退出重传} else {std::cout << "Incorrect ACK received." << std::endl;}} else {std::cout << "Timeout, resending..." << std::endl;retries++;seq_num++;  // 增加序列号,准备重发}}if (retries == MAX_RETRIES) {std::cout << "Max retries reached. Failed to send data." << std::endl;}close(sockfd);
}// 接收方
void receive_data() {int sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0) {std::cerr << "Socket creation failed!" << std::endl;return;}struct sockaddr_in server_addr;server_addr.sin_family = AF_INET;server_addr.sin_port = htons(PORT);server_addr.sin_addr.s_addr = INADDR_ANY;if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "Bind failed!" << std::endl;close(sockfd);return;}int expected_seq_num = 0;while (true) {char buffer[1024];struct sockaddr_in client_addr;socklen_t len = sizeof(client_addr);ssize_t recv_len = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)&client_addr, &len);if (recv_len < 0) {std::cerr << "Failed to receive data!" << std::endl;continue;}buffer[recv_len] = '\0';  // 确保字符串结束std::string received_data(buffer);size_t colon_pos = received_data.find(":");if (colon_pos != std::string::npos) {int seq_num = std::stoi(received_data.substr(0, colon_pos));std::string content = received_data.substr(colon_pos + 1);// 如果序列号匹配,处理数据并发送确认if (seq_num == expected_seq_num) {std::cout << "Data received: " << content << std::endl;expected_seq_num++;  // 更新期望的序列号// 发送确认消息std::string ack = std::to_string(seq_num);sendto(sockfd, ack.c_str(), ack.size(), 0, (struct sockaddr*)&client_addr, len);} else {std::cout << "Out-of-order packet. Expected " << expected_seq_num << ", but got " << seq_num << "." << std::endl;std::string ack = std::to_string(expected_seq_num - 1);  // 发送最后一次成功的确认sendto(sockfd, ack.c_str(), ack.size(), 0, (struct sockaddr*)&client_addr, len);}}}close(sockfd);
}int main() {std::thread receiver_thread(receive_data);std::this_thread::sleep_for(std::chrono::seconds(1));  // 等待接收方启动send_data("Hello, UDP!", "127.0.0.1");receiver_thread.join();return 0;
}

        


        

        

        

        

        

        

http://www.xdnf.cn/news/5655.html

相关文章:

  • Android CountDownTimer重写
  • RDMA核心组件 的总结表格
  • RSA算法详解一:初识RSA
  • Python爬虫如何获取JavaScript动态渲染后的网页内容?
  • VUE3基础样式调整学习经验
  • yarn workspace使用指南
  • 配置集群(yarn)
  • 消息队列如何保证消息可靠性(kafka以及RabbitMQ)
  • MySQL全量、增量备份与恢复
  • Qt创建项目
  • 基于千眼狼高速摄像机与三色掩模的体三维粒子图像测速PIV技术
  • 前苹果首席设计官回顾了其在苹果的设计生涯、公司文化、标志性产品的背后故事
  • CentOS下安装MySQL数据库
  • node .js 启动基于express框架的后端服务报错解决
  • WEB安全--RCE--webshell bypass2
  • NestJS 知识框架
  • 区块链大纲笔记
  • 人脸识别deepface相关笔记
  • 物联网无线传感方向专业词汇解释
  • git|gitee仓库同步到github
  • JDK动态代理和CGLIB动态代理的区别?
  • 《Head First 设计模式》第一章 - 笔记
  • 关于nextjs中next-sitemap插件生成文件样式丢失问题及自定义样式处理
  • 开启WSL的镜像网络模式
  • git和gdb
  • 《Flutter社交应用暗黑奥秘:模式适配与色彩的艺术》
  • hashCode()和equals(),为什么使用Map要重写这两个,为什么重写了hashCode,equals也需要重写
  • Decimal.js 的常用方法
  • HNUST软件测试B考前最终复习
  • 密码学--仿射密码