作者:@阿亮joy.
专栏:《学会Linux》
座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
TCP 协议(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。那 TCP 协议如何对数据传输进行控制的呢?接下来我们一起来探讨。
TCP 报文由首部和数据两部分组成。首部一般由 20 到 60 字节(Byte)构成,长度可变。其中前 20 个字节格式固定,后 40 个字节为可选。
TCP 报文中每个字段的含义如下:
4 位首部长度的意义
4 位首部长度是用来表示 TCP 报头的长度的,也能表示数据相对于 TCP 报文起始位置的偏移量,其单位是 4 字节,所以其能表示的范围是 0 到 60 字节。因为 TCP 固定的报头长度是 20 字节,那么 4 位首部长度的最小值就是 5 了。
TCP 是如何解包并将有效载荷(数据)向上交付的呢?
首先提取 TCP 报文的前 20 个字节的固定报头,根据固定报头中的 4 位首部长度来判断报文中是否有可选项字段,如果有可选项字段,也需要将其提取出来。如果 4 位首部长度等于 5,则说明报文中没有可选项字段;如果 4 位首部长度不等于 5,则说明报文中的可选项字段占 (4 位首部长度大小 * 4 - 20) 个字节。将报头提取完后,剩下的就是有效载荷了。那么再根据固定报头中的目的端口,就可以向上交付数据给特定的进程了。
TCP 报文中是没有整个报文的大小或者有效载荷的大小,那是如何得知有效载荷的大小的呢?
当 TCP 报文被封装在 IP 报文中传输时,IP 首部中有一个字段叫做总长度,它表示整个 IP 报文的长度。由于 IP 报文包括 IP 首部和 TCP 报文,因此可以通过减去 IP 首部长度来得到 TCP 报文的总长度。然后再减去 TCP 首部长度,就可以得到 TCP 有效载荷的长度了。
我们都知道 TCP 协议是可以保证可靠性的协议,那我有两个问题就是:是什么原因导致传输的不可靠的呢?TCP 协议是如何可靠性的呢?
是什么原因导致传输的不可靠呢?
其实传输的不可靠单纯就是因为传输的距离变长了。就好比,两个人相隔百米互相喊话,那么这两个的通信就无法保证可靠,无法保证自己说的话已经被对法接收到了。
那 TCP 协议是如何保证可靠性的呢?
TCP 保证可靠性的一个重要的机制就是确认应答机制。当发送端发送数据时,它会等待接收端的确认应答(ACK)。如果接收端成功接收到数据,它会返回一个确认应答给发送端。如果发送端在一定时间内没有收到确认应答,它会认为数据丢失,并重新发送数据。那么这种机制就称为 TCP 协议的确认应答机制,而这种机制可以保证数据的可靠传输。
深入了解 TCP 的确认应答机制
客户端向服务端请求时,实际并不是只发送一个请求(注:只发送一个请求,效率过低),可能是多个请求,而客户端也可能会给客户端回复多个应答。需要注意的是,客户端一次向服务端发送多个请求时,发送的顺序不一定是服务端接收的顺序,也就是报文乱序问题。那么 TCP 是如何解决以上这两个问题的呢?想要知道这个,我们需要了解 TCP 报头中的序号和确认序号。
序号和确认序号
序号是发送方给每个发送的报文分配的一个编号(TCP 将每个字节的数据都进行了编号),用来标识该报文在数据流中的位置。接收方可以根据序号对接收到的报文进行排序,以确保数据按顺序传输。注:这里的报文指的是携带完整 TCP 报头的 TCP 报文。
确认序号是接收方返回给发送方的一个编号,表示该编号前的的所有报文都已经接收到了,也可以用来表示接收方期望接收的下一个数据包的序号。发送方可以根据确认序号判断哪些数据包已经被接收方成功接收,哪些数据包需要重新发送。
通过序号和确认序号,请求和应答就可以一一对应起来了。因为确认序号的含义是该序号前的所有报文都已经接收到了,所以就会出现部分确认应答和不给应答两种情况。
部分应答应答:假设客户端给服务端发送序号为 1000、2000 和 3000 三个报文,服务端也成功接收到了这三个报文,那服务端就可以给客户端回复一个确认序号为 3001 的报文,表示该序号前的报文都接收到了,而不需要回复 1001 或 2001 的报文,这就是部分确认应答。
不给应答:假设客户端给服务端发送序号为 1000、2000 和 3000 三个报文,服务端只接受到序号为 1000 和 3000 的报文,没有接收到序号为 2000 的报文。那么服务端只能给客户端回复一个确认序号为 10001 的报文,尽管服务端已经接收到了序号为 3000 的报文,这就是不给应答的情况。而这种情况就可能会涉及报文的重传了,稍后会介绍到。
只用序号一个字段就可以表示序号和确认序号两个字段,那么为什么只用序号一个字段呢?
TCP 协议是全双工的通信发送,也就是说 TCP 协议中的通信双方既可以收数据,也可以发数据。那么就可能会出现这种情况:服务端在给客户端应答的时候,也想给客户端发送数据。发送数据就需要有序号,那么这就要求了 TCP 报头中要有序号和确认序号两个字段。只有序号一个字段无法区分该序号究竟是序号还是确认序号,也无法实现全双工的通信方式。
TCP 的接收缓冲区和发送缓冲区
TCP 本身是具有接收缓冲区和发送缓冲区的:
TCP 发送缓冲区和接收缓冲区存在的意义
发送缓冲区和接收缓冲区的作用:
经典的生产者消费者模型:
窗口大小
当发送端将数据发送给对端时,其本质就是将自己发送缓冲区中的数据发送到对端的接收缓冲区中。但是缓冲区是有大小的,当接收端处理数据的速度小于发送端发送数据的数据,缓冲区就有可能会被填满。这时候,发送端再发送过来的数据就无法放入到接收缓冲区中导致数据丢失,从而引发数据重传等连锁反应。
为了解决这个问题,TCP 报头中包含了 16 位窗口大小,16 位窗口大小中填充的是自己的接收缓冲区剩余空间的大小,也就是通过 16 位窗口大小告知对方自己的接收能力。
接收端在对发送端发送过来的数据进行响应时,可以通过 TCP 的报头中的 16 位窗口大小来告知发送端自己当前接收缓冲区剩余空间的大小。此时,发送端就可以根据这个窗口大小来调整自己发送数据的速度。
理解本质:
为什么需要多个标记位?
SYN
FIN
ACK
RST
PSH
URG
双方使用 TCP 协议进行网络通信时,TCP 协议是保证报文按序发送和按序到达的,即便到达接收端的接收缓冲区的顺序和发送时的顺顺序不一样,也可以根据序列号来排序,从而实现按序到达。
一般情况下,TCP 按序到达是我们所希望的,要求接收方从接收缓冲区中按序读取数据。但是有些特殊情况,发送方会给接收方发送一些紧急数据,要求接收方优先读取这些数据。那该怎么办呢?
此时就需要用到 TCP 报头中的 URG 标记位和 16 位紧急指针了。
如何理解连接
TCP 协议是一种面向连接的可靠的通信协议。使用 TCP 协议通信前,客户端和服务器之间需要建立一个连接,并维护这个连接的状态,才能进行数据的传输。
客户端和服务端使用 TCP 协议通信前需要建立连接,是因为 TCP 的各种可靠性(如:超时重传、流量控制和拥塞控制等)都是建立在连接的基础之上的。因此,保证传输数据的可靠性的前提就是先建立号连接。
每个客户端将来都有可能连接同一个服务端,那么服务端中一定会存在大量的连接,此时操作系统就需要对这些链接进行管理。
三次握手的过程
客户端和服务端进行 TCP 通信前需要建立好连接,而建立的连接的过程就称之为三次握手。
以客户端和服务端为例,客户端想要和服务端进行通信。客户端主动向服务端发起建立连接的请求,然后客户端和服务端的 TCP 层就自动三次握手建立好连接。
三次握手完成后,客户端和服务端之间的连接就建立起来了,从此客户端和服务端就可以通过该连接进行通信,直至其中一方断开连接。
三次握手一定能保证成功吗?
三次握手不一定能够保证成功,因为在网络传输中可能会出现各种各样的问题,如:网络延迟、丢包、服务端关机等等。以第三次握手为例,如果服务端发出的 SYN + ACK 数据包超过一段的时间没有收到应答,服务端会认为该数据包丢失并进行数据包重传。重传次数根据 /proc/sys/net/ipv4/tcp_synack_retries
来指定,默认是 5 次。如果重传次数超过了这个值,服务端就会认为连接建立失败,关闭连接。
为什么是三次握手呢?而不是一次、两次、四次等次数呢?
客户端和服务端结束通信时需要断开连接,断开连接的过程就是四次挥手。
以客户端主动断开连接为例:
第一次挥手:客户端主动向服务端发送 FIN 报文,请求断开连接,表明客户端不会再向服务端发送数据了,但可以接收服务端发送过来的数据。
第二次挥手:服务端接收到客户端发送的 FIN 报文后,会给客户端发送 ACK 报文,表明服务端收到了客户端的 FIN 报文。而服务端可能还有数据需要进行处理和发送,连接并没有真正关闭。
第三次挥手:服务端处理完数据后,便向客户端发送 FIN 报文。
第四次挥手:客户单收到服务端的 FIN 报文后,向服务端发送 ACK 报文,表明确认关闭连接。服务端收到该 ACK 报文时,也就关闭了连接。
注:如果收到客户端的 FIN 报文时,服务端没有数据需要进行处理,那么 ACK 和 FIN 会在同一个报文中同时设置为 1,此时四次挥手就变成了三次挥手。
四次挥手的状态变化:
四次挥手一定能够成功吗?
四次挥手不能够保证一定成功。四次挥手可能会失败的情况包括:客户端或服务器发送的 FIN 数据包丢失,客户端或服务器发送的 ACK 应答报文丢失。如果出现这些情况,客户端或服务器会重传丢失的数据包,直到连接断开或达到最大重传次数。
在什么情况下,服务端会存在大量 CLOSE_WAIT 状态呢?
服务端会在被动关闭连接的情况下,在接收到 FIN 数据包但尚未发送自己的 FIN 数据包时,进入 CLOSE_WAIT 状态。通常,CLOSE_WAIT 状态在服务器上的停留时间应该很短。但是,如果服务器上出现大量的 CLOSE_WAIT 状态,那么可能意味着被动关闭的一方没有及时发出 FIN 数据包,也就是说应用层没有调用 close 函数关闭文件描述符。
验证 CLOSE_WAIT 状态
Sock.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
class Sock
{
private:
const static int gbacklog = 20;
public:
Sock() {}
int Socket()
{
int listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
exit(2);
}
int opt = 1;
// setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
return listensock;
}
void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
{
struct sockaddr_in local;
memset(&local, 0, sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
exit(3);
}
}
void Listen(int sock)
{
if (listen(sock, gbacklog) < 0)
{
exit(4);
}
}
int Accept(int listensock, std::string *ip, uint16_t *port)
{
struct sockaddr_in src;
socklen_t len = sizeof(src);
int servicesock = accept(listensock, (struct sockaddr *)&src, &len);
if (servicesock < 0)
{
return -1;
}
if(port) *port = ntohs(src.sin_port);
if(ip) *ip = inet_ntoa(src.sin_addr);
return servicesock;
}
bool Connect(int sock, const std::string &server_ip, const uint16_t &server_port)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0) return true;
else return false;
}
~Sock() {}
};
Test.cc
#include "Sock.hpp"
int main()
{
Sock sock;
int listensock = sock.Socket();
sock.Bind(listensock, 8080);
sock.Listen(listensock);
while(true)
{
std::string clientip;
uint16_t clientport;
int sockfd = sock.Accept(listensock, &clientip, &clientport);
if(sockfd > 0)
{
std::cout << "[" << clientip << ":" << clientport << "]# " << sockfd << std::endl;;
}
}
return 0;
}
验证流程:
验证 TIME_WAIT 状态
#include "Sock.hpp"
int main()
{
Sock sock;
int listensock = sock.Socket();
sock.Bind(listensock, 8080);
sock.Listen(listensock);
while (true)
{
std::string clientip;
uint16_t clientport;
int sockfd = sock.Accept(listensock, &clientip, &clientport);
if (sockfd > 0)
{
std::cout << "[" << clientip << ":" << clientport << "]# " << sockfd << std::endl;
;
}
sleep(10);
close(sockfd);
std::cout << sockfd << " closed" << std::endl;
}
return 0;
}
如果想让服务器能够立即重新启动,可以使用 setsocketopt 函数的 SO_REUSEADDR 选项。SO_REUSEADDR 选项允许在同一端口上快速重新启动服务器,而不必等待 TIME_WAIT 状态的套接字释放。
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
如果想让服务器能够立即重新启动,可以设置 SO_REUSEADDR 选项,并将 optval 参数设置为一个 int 类型的指针,指向一个值为 1 的整数,表示开启地址重用功能。代码示例如下:
int optval = 1;
if(setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0)
{
perror("setsockopt");
exit(EXIT_FAILURE);
}
这将允许在服务器退出后立即重新启动,而无需等待TIME_WAIT 状态的套接字释放。请注意,使用 SO_REUSEADDR 选项可能会导致套接字地址重用,因此应谨慎使用。
为什么要保持 TIME_WAIT 状态 2 MSL 的时间呢?
注:MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同,在 Centos7 上默认配置的值是 60s,可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout
查看 MSL 的值。
TCP 的超时重传机制是指在 TCP 协议中,当发送数据的一方在规定的时间内未收到接收方的确认信号时,就会触发超时重传机制。具体来说,TCP 将每个发送的数据包都标记一个时间戳,如果在规定的时间内未收到确认信号,就会重新发送该数据包。这个时间戳是根据之前的数据包的发送时间、传输距离以及网络拥塞程度等因素计算得出的。
超时重传机制可以保证数据的可靠传输,但也会影响网络的传输效率。因此,TCP 协议的实现通常会根据网络状况动态地调整超时时间,以达到最佳的传输效率和可靠性。
需要注意的是,TCP 的超时重传机制是通过代码逻辑来实现的,并不是 TCP 报头来实现的。当发送方发送数据包时,会启动一个定时器,如果在规定的时间内没有收到接收方的确认消息,发送方会认为该数据包已经丢失,并重新发送该数据包。超时重传机制的实现需要在代码中设置一个合适的超时时间,以及对超时事件的处理逻辑。
超时重传的两种情况
超时重传分为两种情况:一种是发送方发送的报文丢失了,此时发送方在一定的时间内无法收到对应的应答,会对该报文进行重传。
超时重传的另一种情况是:接收方收到了发送方的报文,但是接收方给发送方的应答报文丢失了,此时发送方也会因为一定时间内没有收到对应的应答,进而对该报文进行重传。
超时重传的时间
最理想的情况就是找到一个最小的时间,保证确认应答一定能在这个时间内返回。但是这个时间是不固定的,其应该根据网络状况和数据传输的要求进行调整。当网络状况好的时候,超时重传的时间可以设置成短一点,提高网络传输的效率;而当网络状况差时,超时重传的时间可以设置成长一点,降低重传的概率和避免网络拥塞。
TCP 为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。因此 TCP 支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)。
我们都知道 TCP 是每发送一个数据,都要进行一次应答。当上一个数据收到了应答,再发送下一个数据。这样的模式有非常明显的缺点就是性能较差。当数据往返时间越长时,网络的吞吐量会越低。
一次发送多条数据
双方在进行 TCP 通信时一次可以想对方发送多条数据,这样可以将等待多个响应的时间重叠在一起,进而提高通信效率。
需要注意的是,虽然双方在进行通信时可以一次向对方发送多条数据,但这样也是要考虑对方的接收能力的,发送数据的总量不能超过对方接收缓冲区剩余空间的大小。
发送方给对方发送多条数据时,这么多条数据当中会有部分数据是没有收到移动的。我们可以将发送缓冲区中的数据分为三部分:
如上图所示,发送缓冲区第二部分数据所占的空间就是滑动窗口的空间。滑动窗口的大小是指无需等待 ACK 应答而可以继续发送数据的数量的最大值。
滑动窗口存在的最大意义就是提高通信效率:
当发送方发送的数据陆陆续续地收到对应的应答时,此时可以将应答所对应的数据段移出滑动窗口,置于滑动窗口的左侧。而窗口是否会向右移动则取决于对方的接收能力(窗口大小)。如果对方可以接收更多的数据,那么窗口可以向右移动将位于窗口右侧的数据包含进窗口中,进行数据的发送。
TCP 超时重传机制要求发送缓存区暂时保存发送未被对方确认的数据,而这部分数据就是在滑动窗口当中。位于滑动窗口左侧的数据都是已经被对方确认收到的数据,这些数据能够被操作系统删除或者覆盖。因此,滑动窗口不仅能够保证能向对方一次性发送多条数据,而且还保证了数据的超时重传。
滑动窗口主要是解决数据传输的效率问题,顺带保证超时重传的可靠性。
滑动窗口的本质
滑动窗口的本质就是指针或者下标,而滑动窗口的移动就是指针或者下标增加。
滑动窗口一定能够向右移动吗?滑动窗口如果一直向右移动会造成越界问题吗?
滑动窗口不一定能够向右移动。因为滑动窗口的大小是受对方窗口大小的限制的,如果对方应用层没有从接收缓冲区中拿出数据,这样对方的窗口会越来越小,因此滑动窗口会越来越小,无妨向右移动。
滑动窗口并不一定是整体向右移动的,可能是窗口的左边界进行移动而右边界不移动,因为对方的窗口大小是不固定的,随时在变化,所以滑动窗口的大小也不是固定的,也是随时都在变化。
滑动窗口一直向右移动并不会造成越界问题。因为发送缓存区是被看成环形队列的,当滑动窗口向右移动时,如果超出了缓冲区的范围,那么就会进行模除运算,重新回到缓冲区的起始位置进行向右移动。
滑动窗口的大小可以为零吗?
滑动窗口的大小可以为零。当对方一直不从接收缓冲区中拿取数据,会导致发送方的滑动窗口越来越小直至为零。
丢包问题
情况一:数据已经被接收方接收,但是 ACK 丢失了
这种情况下,部分 ACK 丢了并不要紧,因为可以通过后续的 ACK 进行确认。
情况二:数据直接丢失了,未被接收方接收到
这种重传机制就是快重传机制。快速重传机制的实际方式是:当发送方收到三个相同的 ACK 时,就认为是前一个数据包已经丢了,并立即重传。这个机制提高重传的效率。
快重传 VS 超时重传
双方通过 TCP 进行通信时,出现丢包问题是非常正常的,此时就通过超时重传或者快重传进行数据的重传。但是如果双方进行通信时出现了大量的丢包,此时就不能认为这是正常现象了。
TCP 协议不仅考虑到了两个主机端到端的可靠性问题,还考虑了网络状态的问题。
当出现大量丢包问题时,就可能出现网络拥塞问题。当出现网络拥塞问题,就不能立即将这些数据进行重传。因为一个网络是多个主机进行共用的,并且这样主机使用的都是 TCP / IP 协议,你重传了,那别的主机要不要重传呢。所以当网络出现问题时,不应该再向网络中发送大量数据。
网络拥塞影响的不只是一台主机,影响的是该网络下的所有主机,此时所有使用 TCP 协议的主机都需要执行拥塞避免算法来缓解网络拥塞的状态,使得网络状态慢慢得以恢复。
拥塞控制
虽然 TCP 协议有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然地发送大量的数据是很有可能引起雪上加霜的。
TCP 协议引入了慢启动机制,先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
为了解决网络拥塞问题,就引入了一个概念拥塞窗口。
每收到一个 ACK 应答,拥塞窗口的大小就增加一,那么拥塞窗口的大小是以指数的形式进行增长的。指数形式增长只是初始时增长较慢,也就是所谓的慢启动,但是越往后增长就后越快,这时候就有可能短时间内又造成了网络拥塞的问题。
少量的丢包,我们仅仅是触发超时重传或快重传。大量的丢包,我们就认为网络拥塞。当 TCP 通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。拥塞控制,归根结底是 TCP 协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
如果接收数据的主机接收到数据立刻返回 ACK 应答,此时返回的窗口可能比较小。
接收方在接收到数据后,并不会立即发送 ACK 应答,而且是等候一段时间(一般是200ms),等接收方上层处理完数据后再给发送方发送一个更大的窗口大小,这种机制就是 TCP 协议的延迟应答机制。延迟应答机制并不是为了保证可靠性的,而是为了提高效率的一种策略。
有了延迟应答机制,发送给对方的窗口大小就会越大,网络吞吐量就越大,传输效率就越高。延迟应答机制的目标是在保证网络不拥塞的情况下尽量提高传输效率。
具体的数量和超时时间,依操作系统不同也有差异。一般 N 取2,超时时间取 200ms。
捎带应答是双方进行 TCP 通信时最常见的一种方式。TCP 协议的捎带应答机制就是发送的同一个 TCP 数据包中即包含数据又包含 ACK 应答的一种机制。
就好比,主机 A 给主机 B 发送一条消息,主机 B 收到该消息后就回复给出 ACK 应答。但如果主机 B 也有消息需要给主机 A 发送,那么这个 ACK 应答就可以和该消息放在同一个报文中,而不需要再单独发送一个 ACK 应答了。此时,主机 B 发送的这个既完成了对收到的数据的应答,又完成了自己数据的发送。
捎带应答机制很明显能够减小网络通信的开销,因为通信双方不再单独地发送 ACK 应答了。
创建一个 TCP 的套接字时,会在内核中创建一个发送缓冲区 和一个接收缓冲区。
由于缓冲区的存在,TCP 程序的读和写不需要一一匹配。
对应 TCP 来说,根本就不关心发送缓冲区中存储的是什么数据,在它看来就是一个个字节的数据,它只需要将这些数据可靠地发送到对方的接收缓冲区中就行了。而对方的上层也不会关心接收缓冲区的数据是什么数据,只需要将其一个字节一个字节地读取上来就行了。像这种不关心数据格式的数据通信流程,就被称为面向字节流。
而 UDP 的面向数据报就是将上层应用层交下来的数据看做一个独立的报文,既不进行拆分,也不进行合并,添加上 UDP 报头后向下交付给网络层处理。而上层应用层收到数据时,就认为该数据是一个完整的报文,可以直接对其进行处理。面向数据包就好像是我们平时收快递的样子,当我们收到一个快递时,我们就知道对方发送了一个快递。当我们收到五个快递时,对方就发送了五个快递。这种数据通信流程就被称为面向数据报。
什么是粘包?
如何解决粘包问题呢?
解决粘包问题的本质就是明确两个包之间的边界,只要知道包与包之间的边界,就能够正确读取一个数据包了。
UDP 协议是否存在粘包问题呢?
因此,UDP 协议是不存在粘包问题的,根本原因就是 UDP 报头中有 16 位 UDP 长度字段来明确数据之间的边界。而 TCP 是基于字节流,没有明确的数据边界,需要应用层定制协议来明确数据与数据之间的边界。
进程崩溃 / 进程退出
当客户端和服务端正常通信时,客户端进程突然崩溃了,那么建立好的连接会怎么办呢?
TCP 的连接信息是有内核维护的,所以当客户端进程崩溃后,内核需要挥手该进程的 TCP 连接资源。于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核中完成的,并不需要进程的参与。所以即使客户端进程退出了,还是能与服务器完成 TCP 四次挥手释放连接的。
机器重启
当客户端和服务端正常通信时,客户端机器重启,那么建立好的连接会怎么办呢?
当客户端选择机器重启时,操作系统会把正在运行的所有进程杀掉再进行机器重启,那么机器重启的情况就和进程崩溃、进程退出的情况一样了。操作系统自动帮客户端与服务器进行四次挥手,正确释放连接。
机器掉电 / 网线断开
当客户端和服务端正常通信时,客户端机器掉电或网线断开,那么建立好的连接会怎么办呢?
当客户端机器掉电或网线断开时,客户端的连接会自动关闭掉,但是服务端是无法感觉客户端已经关闭连接了,因此服务端还会保持连接。但是这个连接并不会一直保持,因为 TCP 协议具有保活机制。
应用层的某些协议,也有一些这样的检测机制。例如 HTTP 长连接中,也会定期检测对方的状态。例如 QQ,在 QQ断线之后,也会定期尝试重新连接。
TCP 协议之所以设计得这么复杂,是因为既要保证可靠性,同时也要尽可能地提高性能。
可靠性:
提高性能:
其他:
基于 TCP 协议的应用层协议有很多,常见的协议如下:
当然,也包括我们自己写 TCP 程序时自定义的应用层协议。
TCP 协议和 UDP 协议的区别主要有一下几个方面:
TCP 协议和 UDP 协议没有明显的好坏之分,我们需要根据具体的应用场景来选择合适的协议。TCP 协议和 UDP 协议的具体应用场景:
可以参考 TCP 协议保证可靠性的做法,在应用层实现类似的功能。例如:
accept 函数不参与三次握手的过程,而是当底层建立好连接后,然后 accept 从底层获取已经建立好的连接。也就是说。就算我不调用 accept 函数,底层的连接也可以建立好。
那如果上层来不及调用 accept 函数,并且对端还来了大量的连接请求,难道所有的连接都应该先建立好吗?如果上层都来不及获取连接了,那么就说明服务器现在已经很繁忙了。如果现在系统还有建立连接的话,这就会导致服务器的资源更加吃紧甚至挂掉。
那系统是如何解决这个问题的呢?系统为 TCP 连接管理维护了两个连接队列,来解决这个问题。而这两个队列不能没有,也不能太长。如果没有队列的话,服务器闲下来的话,没有连接可以获取,这样就无法使服务器的资源充分发挥处理。而如果队列太长的话,对端等的时间就会变长,并且这些资源可以划分给服务器,这样服务器就可以处理更多的连接请求,更能提高效率。
当对端来了大量的连接请求时,并不是所有的连接都会建立好。如果没有超过连接队列的长度,连接就会建立好,在队列中等待上层获取。而如果超过连接队列的长度,连接请求就会被拒绝。而连接队列的长度与 listen 函数的第二个参数有关。
现在我们把上面代码中的 gbacklog 从 20 改成 1,并且服务端不进行 accept 获取新连接。如下图所示:
listen 的第二个参数决定了底层全连接队列的长度,其长度等于 listen 第二个参数加一,而全连接队列就是用来保存处于 ESTABLISHED 状态,但是上层没有调用 accept 获取的连接请求。全连接队列也被称为 accept 队列。
还有一个队列就是半连接队列,用来保存处于 SYN_SENT 和 SYN_RECV 状态的连接请求。半连接队列有明显的生命周期,一段时间后,服务端还是没有向客户端发送 SYN+ACK 包,那么系统将该连接请求移除半连接队列。而如果服务端收到了客户端的 ACK 包后,系统会将连接请求移入到全连接队列。而全连接队列能够将连接长时间的维持在 ESTABLISHED 状态,等待上层来获取连接。当全连接队列满了时,就无法继续让当前连接的状态进入 ESTABLISHED 状态了。
本篇博客从 TCP 协议报头讲起,讲解了 TCP 协议的确认应答机制、窗口大小、六个标记位、连接管理机制、超时重传机制、流量控制、滑动窗口、拥塞控制、延迟应答、捎带应答、面向字节流、粘包问题等等。以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家啦!❣️