1. tcp协议介绍
TCP协议是一个安全的、面向连接的、流式传输协议,所谓的面向连接就是三次握手,对于程序猿来说只需要在客户端调用connect()函数,三次握手就自动进行了。
TCP头部字段解释
- 源端口号 (16位): 指示发送数据的源端口号。
- 目的端口号 (16位): 指示接收数据的目的端口号。
- 序列号 (32位): 用于标识发送方向接收方发送的数据的第一个字节的序列号。每次发送新的数据时,序列号会根据发送的数据量递增相应的数值。这是TCP可靠传输的基础之一,用于确保数据按正确的顺序到达。
- 确认号 (32位): 指示接收方期望接收到的下一个数据字节的序列号。当ACK标志被置位时,这个字段才有效,用于确认已接收的数据。
- 首部长度(数据偏移)(4位): 指示TCP头部的长度,以32位(即4字节)为单位。例如,如果数据偏移字段的值为5,则表示TCP头部长度为20字节(5 * 4字节)。
- 保留字段 (6位): 保留字段,目前应设置为0。
- 标志位 (6位):
URG (1位): 如果置位,表示紧急指针字段有效,用于标记数据中的紧急数据部分,应优先处理这部分数据。
ACK (1位): 如果置位,表示确认号字段有效,用于确认接收到的数据,TCP规定除了最初建立连接时的SYN包之外改位必须置为1。
PSH (1位): 如果置位,表示接收方应尽快将数据推送到应用程序,而不是等待缓冲区填满。
RST (1位): 如果置位,表示重置连接,通常用于响应无效的分组或异常情况。
SYN (1位): 如果置位,表示连接请求或响应,用于建立连接,并在序列号的字段进行初识序列号值的设定。
FIN (1位): 如果置位,表示发送方已完成发送数据,用于关闭连接。- 窗口大小 (16位): 表示接收方可用的缓冲区大小,用于流量控制。接收方可以通过这个字段告诉发送方它可以接收多少数据。
- 校验和 (16位): 用于验证TCP头部和数据的完整性。这是计算出来的值,用于检测传输过程中的错误。
- 紧急指针 (16位): 指示紧急数据的结束位置,相对于序列号字段所表示的数据起始位置。仅当URG标志被置位时有效。
选项字段 (可变长度): 包含可选的功能,如窗口缩放、时间戳等,用于提高性能或提供额外的功能。
填充字段 (可变长度): 用于确保整个头部是32位的倍数,保持对齐。
这些字段协同工作,确保TCP连接的可靠性和高效性。
2. TCP协议的特点
- 面向连接
- 一定是端到端(点到点)才能连接
- 应用程序在使用TCP协议之前,必须T先建立TCP连接。在传输数据完毕后,必须释放已经建立的TCP连接。
- 可靠传输
- TCP 提供可靠的数据传输服务。它通过序号、确认和重传机制来确保数据的可靠性。如果发现数据包丢失或损坏,TCP 会重新传输数据。
- 面向字节流
- TCP 是面向字节流的协议,它不关心数据的边界。
- 发送方将数据划分为小的数据块,而接收方会根据需要重组这些数据块。
3. 可靠性保障—序号/应答号
TCP的特点中了解到TCP是可靠的,那究竟是如何保证可靠性的呢?其实和TCP报文协议中的32位序列号和32位确认应答号来保证的。
- 序列号 (32位): 用于标识发送方向接收方发送的数据的第一个字节的序列号。每次发送新的数据时,序列号会根据发送的数据量递增相应的数值。这是TCP可靠传输的基础之一,用于确保数据按正确的顺序到达。
- 确认号 (32位): 指示接收方期望接收到的下一个数据字节的序列号。当ACK标志被置位时,这个字段才有效,用于确认已接收的数据。
在TCP中,序列号是用来标识每个数据段中数据的第一个字节的编号。序列号是递增的,每次发送新的数据时,序列号会根据发送的数据量递增相应的数值。
假设客户端向服务器发送数据,数据分为三个数据段:数据段A、数据段B和数据段C,每个数据段的长度均为100字节。
- 客户端发送数据:
- 客户端发送数据段A,序列号为100。
- 客户端发送数据段B,序列号为200。
- 客户端发送数据段C,序列号为300。
- 数据段乱序:
- 假设数据段B先到达服务器,然后是数据段A,最后是数据段C。
- 服务器会根据序列号将数据段B暂存,并等待数据段A到达。
- 当数据段A到达后,服务器会将数据段A和B按序重组。
- 最后,当数据段C到达后,服务器会将数据段C按序重组。
- 确认应答:
- 服务器发送ACK确认号为201(确认已接收到序列号100到199的数据)。
- 服务器发送ACK确认号为301(确认已接收到序列号200到299的数据)。
- 服务器发送ACK确认号为401(确认已接收到序列号300到399的数据)。
通过这种方式,即使数据段在网络中被打乱顺序,接收方也能根据序列号将数据段按序重组,从而确保数据字节能够按序到达。
2. 三次握手
TCP(Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。在TCP中,建立一个连接需要通过一个被称为“三次握手”的过程来完成,以确保双方都已经准备好进行数据交换。以下是三次握手的具体步骤:
- 第一次握手(SYN):
- 客户端发送一个带有SYN标志的TCP段到服务器,请求建立连接。这个TCP段还包含了一个随机生成的初始序号(ISN),记作X。
- 服务器端:接收客户端发送的请求数据,解析tcp协议,校验SYN标志位是否为1,并得到序号 x
- 第二次握手(SYN+ACK):
- 服务器接收到客户端的SYN后,会发送一个带有SYN和ACK标志的TCP段作为响应。
- 服务器也会选择一个自己的初始序号Y,并且在ACK字段中设置客户端的初始序号X加1的值,即X + 1,以确认收到了客户端的SYN。
- 这个步骤中,服务器不仅同意了客户端的连接请求,而且还指定了自己的初始序号。
- 第三次握手(ACK):
- 客户端接收到服务器的SYN+ACK后,会发送一个带有ACK标志的TCP段,确认收到了服务器的SYN。
- 客户端会在ACK字段中设置服务器的初始序号Y加1的值,即Y + 1。
- 这个步骤确保了服务器知道客户端已经接收到它的SYN+ACK,并且现在连接已经完全建立。
完成这三次握手后,客户端和服务器都认为连接已经建立,可以开始双向的数据传输了。每一个方向的数据传输都会使用序列号和确认号来确保数据的可靠传输,即发送方可以追踪哪些数据包已经被接收方确认。
三次握手的设计确保了以下几点:
- 防止已经失效的连接请求报文突然又传送到了服务端,从而产生错误。
- 确保了客户端和服务端都已准备好进行数据传输。
- 确认了双方的发送能力和接收能力。
3. 四次挥手
TCP协议中,当数据交换完成后,连接的双方可以通过“四次挥手”(也称为“四次握手”)的过程来关闭连接。与“三次握手”用于建立连接不同,“四次挥手”用于确保连接的双方都能够平滑地关闭连接,避免数据丢失或半开连接。以下是四次挥手的具体步骤:
- 第一次挥手(FIN):
- 假设客户端完成了数据发送任务,它会向服务器发送一个带有FIN标志的TCP段,请求关闭客户端到服务器这个方向上的连接。
- 这个TCP段中还包含一个序列号,记作Z。
- 第二次挥手(ACK):
- 服务器接收到客户端的FIN后,会发送一个带有ACK标志的TCP段作为响应,确认收到了客户端的FIN。
- 服务器在ACK字段中设置客户端的序列号Z加1的值,即Z + 1,表明所有之前的数据都已被正确接收。
- 这时,客户端到服务器方向的连接被关闭,但服务器仍然可以继续向客户端发送数据。
- 第三次挥手(FIN):
- 当服务器也完成了数据发送任务后,它会发送一个带有FIN标志的TCP段给客户端,请求关闭服务器到客户端方向上的连接。
- 这个TCP段中同样包含一个序列号,记作W。
- 第四次挥手(ACK):
- 客户端接收到服务器的FIN后,会发送一个带有ACK标志的TCP段作为响应,确认收到了服务器的FIN。
- 客户端在ACK字段中设置服务器的序列号W加1的值,即W + 1。
- 至此,服务器到客户端方向的连接也被关闭,整个TCP连接彻底终止。
四次挥手的原因是,由于TCP连接是全双工的,所以每个方向都必须单独关闭。这意味着,当一方发送FIN后,它仍然可以接收来自另一方的数据。因此,连接的关闭需要在两个方向上分别完成,这就是为什么需要四次挥手的原因。
值得注意的是,在实际应用中,服务器在接收到客户端的FIN之后,并不一定会立即发送自己的FIN,它可能还会利用仍处于打开状态的连接来发送额外的数据。只有当服务器确实没有数据要发送时,它才会发送自己的FIN,然后等待客户端的最终ACK确认。
4. 端口复用
端口复用是指在服务器程序中,多个连接可以共享同一个端口的技术。这种技术在某些场景下非常有用,尤其是在需要处理大量短暂连接的情况下。下面是对端口复用的详细解释:
一个常见的场景是
端口复用的概念:
端口复用允许服务器上的多个连接使用相同的端口。通常情况下,每个连接都需要一个唯一的端口,以避免冲突。然而,在某些情况下,特别是当服务器需要处理大量的短暂连接时,端口资源可能会成为瓶颈。端口复用可以缓解这个问题。
端口复用的场景:
- 短连接密集型服务:例如,Web服务器经常需要处理大量的短暂HTTP连接,每个连接只交换少量的数据后即关闭。
- 资源受限环境:在资源有限的环境中,如嵌入式系统或移动设备,端口复用可以帮助节省端口资源。
- 高并发服务:在需要支持高并发连接的服务中,端口复用可以减少端口分配的压
- 服务器进程主动关闭连接后,端口进入
TIME_WAIT
状态,此时端口并不会立即被释放供其他进程使用。TIME_WAIT状态是为了确保所有在网络中可能存在的、尚未到达的包能够被接收和处理。
实现端口复用的方法
函数原型如下:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
-
sockfd:用于监听的文件描述符
-
level:设置端口复用需要使用 SOL_SOCKET 宏
-
optname:要设置什么属性(下边的两个宏都可以设置端口复用)
- SO_REUSEADDR:允许一个端口在服务器进程重启或异常终止后立即被重新绑定,即使仍有某些连接处于TIME_WAIT状态。这有助于快速重启服务,而不必等待操作系统自然释放端口。
- SO_REUSEPORT:更进一步,允许多个进程或线程同时绑定到同一个端口。操作系统负责将传入的连接均匀分配给这些监听者。这在高并发场景下特别有用,可以显著提升网络服务的吞吐量和响应速度。
-
optval:设置是去除端口复用属性还是设置端口复用属性,实际应该使用 int 型变量
0:不设置
1:设置 -
optlen:optval指针指向的内存大小 sizeof(int)
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
int sockfd;
struct sockaddr_in serv_addr;
int optval = 1;
// 创建套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 设置SO_REUSEADDR选项
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
// 初始化地址结构
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(8080);
// 绑定套接字
bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
// 开始监听
listen(sockfd, 5);
// 接受连接
while (1) {
int connfd;
struct sockaddr_in cli_addr;
socklen_t cli_len;
connfd = accept(sockfd, (struct sockaddr *)&cli_addr, &cli_len);
// 处理连接...
}
close(sockfd);
return 0;
}
5.TCP数据粘包问题
由于TCP协议本身的特性,连续发送的多个数据包可能会被合并成一个大的数据包发送,或者一个大数据包可能会被分割成多个小数据包发送。这种情况可能导致接收方难以确定每个数据包的边界,从而产生数据粘连的问题。下面是一些处理TCP数据粘包的常见方法:
- 添加固定长度的消息头
在发送数据之前,先发送一个固定长度的消息头,其中包含后续数据的长度。接收方根据消息头中的长度信息来判断数据包的边界。
// 发送方:
char message[1024] = "Hello, world!";
// 将消息长度写入消息头
unsigned int header = htonl(strlen(message));
// 发送消息头
send(sockfd, &header, 4, 0);
// 发送数据
send(sockfd, message, strlen(message), 0);
//接收方:
unsigned int header;
ssize_t bytes_received = recv(sockfd, &header, sizeof(header), 0);
// 解析消息长度
size_t message_length = ntohl(header);
// 接收数据
recv(sockfd, buffer, message_length, 0);
- 使用分隔符
在数据之间插入特定的分隔符,接收方可以根据分隔符来确定数据包的边界。
有缺陷: 效率低, 需要一个字节一个字节接收, 接收一个字节判断一次, 判断是不是那个特殊字符串 - 使用消息队列
发送方将消息放入队列中,接收方从队列中读取消息。这种方法通常与消息头或其他方法结合使用,以确保消息的完整性和边界。 - 使用自定义协议
定义一个自定义协议,包括消息的格式、消息头、消息体、消息结束标志等。 - 使用标准的应用层协议(比如:http、https)来封装要传输的不定长的数据
- 可以使用protobuf等序列化框架来处理数据边界(后面讲到protobuf后,再讨论)