1.TCP连接的11种状态
实际上,TCP的整个通信过程就是下面这样子,我们先学他的11种状态。
- CLOSED:初始状态,表示TCP连接是“关闭着的”或“未打开的”。
- LISTEN :表示服务器端的某个SOCKET处于监听状态,可以接受客户端的连接。
- SYN_RCVD :表示服务器接收到了来自客户端请求连接的SYN报文。在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂,基本上用netstat很难看到这种状态,除非故意写一个监测程序,将三次TCP握手过程中最后一个ACK报文不予发送。当TCP连接处于此状态时,再收到客户端的ACK报文,它就会进入到ESTABLISHED 状态。
- SYN_SENT :这个状态与SYN_RCVD 状态相呼应,当客户端SOCKET执行connect()进行连接时,它首先发送SYN报文,然后随即进入到SYN_SENT 状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT 状态表示客户端已发送SYN报文。
- ESTABLISHED :表示TCP连接已经成功建立。
- FIN_WAIT_1 :这个状态得好好解释一下,其实FIN_WAIT_1 和FIN_WAIT_2 两种状态的真正含义都是表示等待对方的FIN报文。而这两种状态的区别是:FIN_WAIT_1状态实际上是当SOCKET在ESTABLISHED状态时,它想主动关闭连接,向对方发送了FIN报文,此时该SOCKET进入到FIN_WAIT_1 状态。而当对方回应ACK报文后,则进入到FIN_WAIT_2 状态。当然在实际的正常情况下,无论对方处于任何种情况下,都应该马上回应ACK报文,所以FIN_WAIT_1 状态一般是比较难见到的,而FIN_WAIT_2 状态有时仍可以用netstat看到。
- FIN_WAIT_2 :上面已经解释了这种状态的由来,实际上FIN_WAIT_2状态下的SOCKET表示半连接,即有一方调用close()主动要求关闭连接。注意:FIN_WAIT_2 是没有超时的(不像TIME_WAIT 状态),这种状态下如果对方不关闭(不配合完成4次挥手过程),那这个 FIN_WAIT_2 状态将一直保持到系统重启,越来越多的FIN_WAIT_2 状态会导致内核crash。
- TIME_WAIT :表示收到了对方的FIN报文,并发送出了ACK报文。 TIME_WAIT状态下的TCP连接会等待2*MSL(Max Segment Lifetime,最大分段生存期,指一个TCP报文在Internet上的最长生存时间。每个具体的TCP协议实现都必须选择一个确定的MSL值,RFC 1122建议是2分钟,但BSD传统实现采用了30秒,Linux可以cat /proc/sys/net/ipv4/tcp_fin_timeout看到本机的这个值),然后即可回到CLOSED 可用状态了。如果FIN_WAIT_1状态下,收到了对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。(这种情况应该就是四次挥手变成三次挥手的那种情况)
- CLOSING :这种状态在实际情况中应该很少见,属于一种比较罕见的例外状态。正常情况下,当一方发送FIN报文后,按理来说是应该先收到(或同时收到)对方的ACK报文,再收到对方的FIN报文。但是CLOSING 状态表示一方发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况呢?那就是当双方几乎在同时close()一个SOCKET的话,就出现了双方同时发送FIN报文的情况,这是就会出现CLOSING 状态,表示双方都正在关闭SOCKET连接。
- CLOSE_WAIT :表示正在等待关闭。怎么理解呢?当对方close()一个SOCKET后发送FIN报文给自己,你的系统毫无疑问地将会回应一个ACK报文给对方,此时TCP连接则进入到CLOSE_WAIT状态。接下来呢,你需要检查自己是否还有数据要发送给对方,如果没有的话,那你也就可以close()这个SOCKET并发送FIN报文给对方,即关闭自己到对方这个方向的连接。有数据的话则看程序的策略,继续发送或丢弃。简单地说,当你处于CLOSE_WAIT 状态下,需要完成的事情是等待你去关闭连接。
- LAST_ACK :当被动关闭的一方在发送FIN报文后,等待对方的ACK报文的时候,就处于LAST_ACK 状态。当收到对方的ACK报文后,也就可以进入到CLOSED 可用状态了。
事实上,TCP的11种状态的关系如下
2.再次理解三次挥手
2.1.再次理解三次握手
- 三次握手
TCP 建立连接的过程叫做握手,握手需要在客户和服务器之间交换三个 TCP 报文段
连接建立过程
- 最初客户/服务器的 TCP 进程都处于
CLOSED(关闭)
状态。在本实例中,A主动打开连接
,而 B被动打开连接
- B 的 TCP 服务器进程先创建
传输控制块
TCB,并处于LISTEN(收听)
状态,等待客户的连接请求 - A 的 TCP 客户进程创建
传输控制模块
TCB。并向 B 发出连接请求报文段,首部中的同部位 SYN = 1,选择一个初始序号 seq = x。TCP 客户端进程进入SYN-SENT(同步已发送)
状态。TCP 规定,SYN 报文段(即 SYN = 1 的报文段)不能携带数据,但要消耗一个序号
- B 收到连接请求报文段后,如同意建立连接,则向 A 发送确认。在确认报文段中应把 SYN 位和 ACK 位都置1,确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y。这时 TCP 服务器进程进入
SYN-RCVD(同步收到)
状态。这个报文段也不能携带数据,但同样要消耗掉一个序号
- TCP 客户进程收到 B 的确认后,还要向 B 给出确认。确认报文段的 ACK 置1,确认号 ack = y + 1,而自己的序号 seq = x + 1。TCP 的标准规定,ACK 报文段可以携带数据。但
如果不携带数据则不消耗序号
,在这种情况下,下一个数据报文段的序号仍是 seq = x + 1。这时,TCP 连接已经建立,A 进入ESTABLISHED(已建立连接)
状态 - 当 B 收到 A 的确认后,也进入
ESTABLISHED
状态
传输控制块
TCB(Transmission Control Block)存储了每一个连接中的一些重要信息,如:TCP 连接表,指向发送和接收缓存的指针,指向重传队列的指针,当前的发送和接收序号等等
只有双方都处于 ESTABLISHED 状态,才能认为 TCP 的连接是成功的,双方才能正常发送数据。TCP 的第三次握手发送的 ACK 报文是没有响应的,因为它只是用来确认对方的 SYN+ACK 报文,而不是用来请求建立连接。
- 对于客户端而言,一旦发送了这个 ACK 报文后,它就处于 ESTABLISHED 状态,因为它已经完成了三次握手的过程。
- 对于服务端而言,只有当它收到了这个 ACK 报文以后才会处于 ESTABLISHED 状态,因为它需要等待客户端的确认才能确定连接已经建立。
这样,服务端和客户端在 TCP 的连接成功的认知上存在着时间差,如果服务端并未收到第三次握手发送的 ACK 报文,会出现什么情况?
- 如果服务端没有收到第三次握手发送的 ACK 报文,服务端的 TCP 连接状态会保持为 SYN_RECV,并且会根据 TCP 的『超时重传机制』,会等待 3 秒、6 秒、12 秒后重新发送 SYN+ACK 包,以便客户端重新发送 ACK 包。
- 客户端在接收到 SYN+ACK 包后,就认为 TCP 连接已经建立,状态为 ESTABLISHED。如果此时客户端向服务端发送数据,服务端将以 RST 包响应,用于强制关闭 TCP 连接。
- 如果服务端收到客户端重发的 ACK 包,会先判断全连接队列是否已满,如果未满则从半连接队列中拿出相关信息存放入全连接队列中,之后服务端 accept() 处理此请求。如果已满,则根据 tcp_abort_on_overflow 参数的值决定是扔掉 ACK 包还是发送 RST 包给客户端。
- 半连接和全连接队列
tcp_abort_on_overflow 是一个布尔型参数,当服务端的监听队列满时,新的连接请求会有两种处理方式,一是丢弃,二是拒绝连接(通过向服务端发送 RST 报文实现)。通过哪种方式处理,取决于这个参数:
- tcp_abort_on_overflow 为 0,丢弃服务端发送的 ACK 报文,不建立连接。
- tcp_abort_on_overflow 为 1,发送 RST 报文给客户端,拒绝连接。
另外, 服务端的监听队列有两种:
TCP 半连接队列和全连接队列是服务端在处理 TCP 连接时维护的两个队列,它们的含义如下:
- 半连接队列,也称SYN 队列,是存放已收到客户端的 SYN 报文,但还未收到客户端的 ACK 报文的连接请求的队列(即完成了前两次握手)。服务端会向客户端发送 SYN+ACK 报文,并等待客户端的回复。
- 全连接队列,也称accept 队列,是存放已完成三次握手,但还未被应用程序 accept 的连接请求的队列。服务端会从半连接队列中移除连接请求,并创建一个新的 socket,然后将其放入全连接队列。
半连接队列和全连接队列都有最大长度限制,如果超过限制,服务端会根据 tcp_abort_on_overflow 参数的值来决定是丢弃新的连接请求还是发送 RST 报文给客户端。
它们和 socket 的关系是:
- 服务端通过 socket 函数创建一个监听 socket,并通过 bind 函数绑定一个地址和端口,然后通过 listen 函数指定监听队列的大小。
- 当客户端发起连接请求时,服务端会根据 TCP 三次握手的进度,将连接请求放入半连接队列或全连接队列。
- 当应用程序调用 accept 函数时,服务端会从全连接队列中取出一个连接请求,并返回一个新的 socket 给应用程序,用于和客户端通信。
2.2.应用层和三次握手的关系
事实上,应用层和三次握手的关系如下
- 服务器调用listen函数,服务器进入LISTEN状态
- 客户端调用connect 函数,操作系统会开始三次握手,客户端发送完ACK之后,connect函数就会返回
- 服务端调用accept函数就会处理客户端发来的ACK,如果没有调用accept函数,就不会处理客户端发来的ACK函数
- 没有被accept的连接叫半连接,被accept的的连接叫全连接
我们上面讲了这么多,总要见见吧!!!
Sock.hpp
// Sock.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
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);
}
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() {}
};
main.cc
// main.cc
#include "Sock.hpp"
int main()
{
Sock sock;
int listensock = sock.Socket();
sock.Bind(listensock, 8877);
sock.Listen(listensock);
while(true)
{
;
}
}
注意这里我们不执行到accept啊!!
makefile
Tcpserver:main.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf Tcpserver
我们运行一下
我们发现服务器的状态是LISTEN状态
现在我们修改代码,执行accept
main.cc
// main.cc
#include "Sock.hpp"
int main()
{
Sock sock;
int listensock = sock.Socket();
sock.Bind(listensock, 8877);
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(100);
close(sockfd);
std::cout << sockfd << " had closed" << std::endl;
}
}
netstat -ntp | head -2 && netstat -ntp | grep '127.0.0.1:8877'
我们运行一下
我们发现服务器确实没有建立连接
我们使用telnet进行测试
客户端和服务端都是确认了连接的
我们再加一个telnet
很好啊 ,只有执行了connect才能进行三次握手
2.2.1.listen的参数
还记得listen函数吗?
- listen函数
#include<sys/socket.h> int listen(int sockfd, int backlog);
listen() 函数的主要作用就是将套接字( sockfd )变成被动的连接监听套接字(被动等待客户端的连接)。所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。
我们看看参数
int sockfd:
服务端的socket,也就是socket函数创建的
标识绑定的,未连接的套接字的描述符。
这第2个参数我们看看
我们将listen的第二个参数改成1
Socket.hpp
class Sock
{
private:
const static int gbacklog = 1;
...
};
注意我们没有使用accept函数
main.cc
// main.cc
#include "Sock.hpp"
int main()
{
Sock sock;
int listensock = sock.Socket();
sock.Bind(listensock, 8877);
sock.Listen(listensock);
while(1)
{
;
}
}
我们编译运行一下,使用telnet连接它
我们发现这个Recv-Q变成了1,其实就是 Request Queue里面的有了一个连接
Recv-Q:含义:表示接收队列中的字节数。这是指已经到达本地计算机但尚未被应用程序读取的数据量。对于TCP连接,这个值通常应该很小或为零,因为TCP会尽量保持接收队列为空。
我们再查看一下链接情况
我们发现这个客户端就处于:ESTABLISTEN状态了!!!
我其实想说,其实我们不调用accept,就能建立客户端-》服务端方向的链接。链接建立成功和上层有没有调用accept没有关系。
- 三次握手是操作系统自动完成的
- 就算服务端没有调用accept函数,我的三次握手也实现了。
- 客户端已经发送了ACK,进入了ESTABLISTEN状态。实现了服务端-》客户端的连接。
- 只不过由于服务端没有调用accept,所以直接将其忽略了,客户端->服务端的连接没建立成功。
我们再加一个telnet
我们看看它们的链接状态
都链接成功了
我们再加一个telnet,我们发现它却卡在这里了
我们查看一下
我们看看链接状态
我们却发现了一条SYN_SENT状态的telnet ,链接怎么没有成功啊?
这说明客户端没有发送ACK给服务端啊!!
我们把之前的一个telnet取消掉,我们发现这个新的telnet就能连接了!!
为了解决这种问题,我们就创建出来了listen的第2个参数了。
int backlog:
backlog 为请求队列的最大长度。
比如有100个用户链接请求,但是系统一次只能处理20个,那么剩下的80个不能不理人家,所以系统就创建个队列记录这些暂时不能处理,一会儿处理的连接请求,依先后顺序处理,那这个队列到底多大?就是这个参数设置,比如2,那么就允许两个新链接排队。这个不能无限大,那内存就不够了。
当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。
缓冲区的长度(能存放多少个客户端请求)可以通过 listen() 函数的 backlog 参数来指定,但究竟为多少并没有什么标准,可以根据你的需求来自定义设定,并发量小的话可以是10或者20。
如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。
当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。
它这个队列也算是半连接
- 半连接队列,也称SYN 队列,是存放已收到客户端的 SYN 报文,但还未收到客户端的 ACK 报文的连接请求的队列(即完成了前两次握手)。服务端会向客户端发送 SYN+ACK 报文,并等待客户端的回复。
- 为什么要listen的这个第二个参数不能设置得太大?
因为这个队列需要维护,会消耗资源,完全没有必要
- 为什么要有这个listen这第二个参数?
为了提高效率!
2.3.TCP三次握手的优点
在前面几个小节中,我们知道了什么是连接,也了解了 TCP 的三次握手过程和 TCP 状态的变化。在了解这些前提后,我们再来谈谈 TCP 为什么是三次握手。
TCP 连接除了要保证建立连接的效率、验证全双工之外,虽然它不保证 100%的可靠性,但是它是用于保证可靠性和流量控制维护的某些状态信息(包括 Socket、序列号和窗口大小)的前提。
那么问题就转化为:为什么只有三次握手才可以初始化 Socket、序列号和窗口大小并建立 TCP 连接?
结论:
- 阻止重复历史连接的初始化(主要)
- 同步双方的初始序列号
- 避免资源浪费
2.3.1.阻止重复历史连接的初始化
三次握手的首要原因是防止旧的重复连接初始化造成混乱。
首先谈谈什么是『历史连接』。
有这样一个场景:假如客户端先发送了 SYN 报文(Seq=90),然后它突然关机了,好巧不巧,SYN(Seq=90)也被网络阻塞了,导致服务端并未收到。当客户端重启后,又向服务端发送了 SYN 报文(Seq=100)以重新发起连接。这里的 SYN(Seq=90)就被称为历史连接。
注意,这里的 SYN 不是后面要讲的『重传』SYN,因为序列号不同。
TCP 的三次握手通过序列号和确认号的机制来防止旧的重复连接初始化造成混乱。
TCP的三次握手过程中,通过序列号和确认号的机制来确保连接的可靠性和防止旧的重复连接初始化造成的混乱。这一机制的核心在于序列号和确认号的形成与使用。
序列号的形成
序列号(seq)在TCP连接中扮演着至关重要的角色,它用于标记数据段的顺序,确保数据的正确传输和接收。序列号是一个占4个字节的字段,用来对TCP连接中发送的每一个字节进行编号。
- 随机初始化:
- 在TCP连接的建立过程中,每个端点(客户端和服务器)都会随机生成一个初始序列号。这个初始序列号用于标记该端点发送的第一个数据字节。
- 例如,在三次握手的第一次中,客户端会随机生成一个序列号(seq=x),并将其置于TCP头部的“序列号”字段中,然后发送一个SYN报文给服务器。
- 递增性:
- 在数据传输过程中,每当发送方发送一个数据段时,它都会将其序列号增加该数据段的字节长度。这样,接收方就可以通过序列号来确定数据的顺序,并检查是否有数据丢失。
- 接收方在接收到数据段后,会回复一个带有“确认号(ack)”的ACK报文,确认号字段表示接收方期望从发送方接收到的下一个字节的序列号。
确认号的形成
确认号(ack)是TCP头部中的另一个重要字段,用于表示接收方期望从发送方接收到的下一个字节的序列号。
- 确认机制:
- 在TCP连接中,每当接收方成功接收到一个数据段时,它都会发送一个ACK报文给发送方,其中确认号字段设置为接收到的数据段的最后一个字节的序列号加1。
- 例如,在三次握手的第二次中,服务器在收到客户端的SYN报文后,会发送一个SYN+ACK报文给客户端。在这个报文中,确认号字段被设置为客户端的初始序列号加1(ack=x+1),表示服务器期望从客户端接收到的下一个字节的序列号是x+1。
- 重传机制:
- 如果发送方在规定的超时时间内没有收到接收方的ACK报文,它会认为该数据段可能已丢失,并会重新发送该数据段。
- 重传时,发送方会保持序列号不变,以便接收方能够识别出这是一个重传的数据段,并相应地更新其状态。
综上所述,TCP通过序列号和确认号的机制来确保数据的顺序性和可靠性。在三次握手过程中,每个端点都会随机生成一个初始序列号,并在数据传输过程中递增地使用它。同时,接收方会通过发送带有确认号的ACK报文来告知发送方其期望接收的下一个字节的序列号。这种机制有效地防止了旧的重复连接初始化造成的混乱,确保了TCP连接的稳定性和可靠性。
具体来说:
- 在第一次握手中,客户端发送一个 SYN 报文,携带一个随机的初始序列 Seq=x,表示客户端想要建立连接,并告诉服务端自己的序列号。
- 在第二次握手中,服务端回复一个 SYN+ACK 报文,携带一个随机的初始序列号 Seq=y,表示服务端同意建立连接,并告诉客户端自己的序列号。同时,服务端也确认了客户端的序列号,将确认号 ack 设置为 x+1,表示期待收到客户端下一个字节的序列号。
- 在第三次握手中,客户端回复一个 ACK 报文,将确认号 ack 设置为 y+1,表示确认了服务端的序列号,并期待收到服务端下一个字节的序列号。至此,双方都同步了各自的初始序列号,并确认了对方的初始序列号,连接建立成功。
这样的过程可以防止旧的重复连接初始化造成混乱,因为:
- 第一次握手:如果客户端发送的 SYN 报文是旧的重复报文,那么它携带的初始序列号 Seq=x 可能已经被服务端使用过或者超出了服务端期待的范围。这样,服务端收到这个旧的 SYN 报文后,会认为它是无效的或者已经过期的,不会回复 SYN+ACK 报文,也不会建立连接。
- 第二次握手:如果服务端回复的 SYN+ACK 报文是旧的重复报文,那么它携带的初始序列号 Seq=y 可能已经被客户端使用过或者超出了客户端期待的范围。这样,客户端收到这个 SYN+ACK 报文后,会认为它是无效的或者已经过期的,不会回复 ACK 报文,也不会建立连接。
- 第三次握手:如果客户端回复的 ACK 报文是旧的重复报文,那么它携带的确认号 ack 可能已经被服务端使用过或者超出了服务端期待的范围。这样,服务端收到这个 ACK 报文后,会认为它是无效的或者已经过期的,不会分配资源给这个连接,也不会进行数据传输。
代入上面假设的场景,如果在 SYN(Seq=100)正在发送的途中,原先 SYN(Seq=90)刚好被服务端接收,那么服务端会返回 ACK(Seq=91),客户端觉得自己应该收到的是 ACK(Seq=101)而不是 ACK(Seq=91),此时客户端就会发起 RST 报文以终止连接。服务端收到后,释放连接。
经过一段之间后,新的 SYN(Seq=100)被服务端接收,服务端返回 ACK(Seq=101),客户端检查确认应答号是正确的,就会发送自己的 ACK 报文,连接成功,且避免了旧的重复连接初始化造成混乱。
因此,通过序列号和确认号的机制,TCP 可以在三次握手中验证双方是否是当前有效的连接请求,并且同步双方的初始序列号。这样可以防止旧的重复连接初始化造成混乱。
- 上面的例子是服务端先收到了『旧 SYN』报文的情况,如果服务端先收到了『新 SYN』报文再收到『旧 SYN』报文时,会发生什么?
从数据结构的角度理解这个过程:
如果服务端在收到 RST 报文之前,先收到了「新 SYN 报文」,那么服务端会认为客户端想要建立一个新的连接,而不是继续之前的连接。
服务端会为新的 SYN 报文分配一个新的 TCB,并发送 SYN+ACK 报文给客户端。同时,服务端会保留旧的 TCB,直到收到 RST 报文或者超时。这样,服务端就可以同时处理两个不同的连接请求,而不会混淆它们。
2.3.2.为什么两次握手不能防止旧的重复连接初始化造成混乱呢?
如果只有两次握手,那么客户端发送的 SYN 报文可能会在网络中延迟,导致服务端收到一个过期的连接请求,从而建立一个无效的连接,浪费资源。
这是因为在『两次握手』的情况下,服务端只要收到了客户端发送的第一个报文,就认为它已经建立好了这个方向的连接,立即处于 ESTABLISHED 状态。然而客户端只有当收到服务端发送的 ACK+SYN 报文后,才会认为它处于 ESTABLISHED 状态。
问题就在于,客户端和服务端切换到 ESTABLISHED 状态的时机不论多少次握手,都会有时差,这是由机制本身决定的。如果在『服务端处于 ESTABLISHED 状态,客户端处于 SYN_SENT 状态并将要切换到 ESTABLISHED 状态之前』这个时间段内,报文的传输出现了问题,那么整个连接就会失败。
在这个时间段内,如果客户端发送的旧 SYN(Seq=100)较新 SYN(Seq=200)更先被服务端收到,服务端进入 ESTABLISHED 状态,像客户端发送 SYN+ACK(Seq=101)报文。客户端通过校验发现,ACK(Seq=101)不是自己期望的 ACK(Seq=201),于是向服务端发送 RST 报文以终止连接。直到新 SYN(Seq=200)被服务端接收到以后,才能正常建立连接。
但是这个过程中(注意在两次握手的情况下),服务端已经和客户端的建立了一个旧连接,这个旧连接因为双方的确认应答序号不一致而被迫终止,造成的后果不仅是终止了这个连接,更在于白白浪费了建立连接和发送数据的资源(图中 RST 之前),我们知道建立连接是有成本的。
三次握手可以保证客户端在收到服务端的 SYN+ACK 报文后才确认连接,如果客户端没有回复 ACK 报文,那么服务端会认为连接请求无效,不会建立连接。简单地说,两次握手只能 100%地建立一个方向的通信信道(客户端<-服务端),但是三次握手就能建立双方向的通信信道。
- 到底该如何理解呢?
你发现了吗?不论是上面分析三次握手还是两次握手,最后一次总是单方面的报文,TCP 协议是无法 100%保证这最后一个报文能被对方收到的,那么分析问题时,就把最后一次当做不存在。那么问题就变得简单了,既然 TCP 是全双工的,那么就要建立双方向的通信信道。两次握手中只有一次握手能 100%建立通信信道,只有一个方向,不满足 TCP 的全双工通信要求,当然不行了。
- 双方向具体如何理解?
我们知道,只有处于 ESTABLISHED 状态的一端才能发送数据,例如第一次握手后,服务端处于 ESTABLISHED 状态,那么意味着客户端<-服务端这个方向的通信信道连接成功,而不是指发送 SYN 这个方向(图中的箭头)。
- 问:为啥这么确定地说 100%?
因为没有第一次握手,就没有第二次握手。
2.3.3.同步双方初始序列号
序列号是 TCP 协议实现可靠传输的一个重要机制,它可以帮助双方识别和处理重复、丢失、乱序、延迟的数据包。
初始序列号是建立 TCP 连接时双方协商的一个随机数,它可以防止历史连接的干扰和恶意攻击。
通过三次握手,双方可以互相确认对方的初始序列号,并在此基础上递增序列号来发送后续的数据包。这样一来一回,才能确保双方的初始序列号能被可靠的同步。
2.3.4.避免资源浪费
刚才在介绍两次握手时,说明了两次握手只能确保建立单方向的通信信道(客户端->服务端),这个过程对客户端是无感知的,只要它没有收到第二次握手服务端发送的 SYN+ACK 报文,就会根据超时重传机制发送若干 SYN 报文以请求连接。
例如,如果客户端发送的 SYN 报文在网络中阻塞了,重复发送多次 SYN 报文,那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。即两次握手会造成消息滞留情况下,服务端重复接受无用的连接请求 SYN 报文,而造成重复分配资源。
- 两次握手不能根据上下文 SYN 的序列号来丢弃历史请求报文吗
两次握手只能在客户端端阻止历史连接,而不能在服务端阻止历史连接。
因为:
两次握手可以根据 SYN 的序列号来丢弃历史报文,但是不能阻止历史连接。也就是说,如果客户端收到了一个过期的 SYN+ACK 报文(比如之前网络延迟导致的),它可以根据序列号判断这是一个历史连接,并发送 RST 报文来拒绝连接。
但是服务端在收到客户端的 SYN 报文后,就进入了 ESTABLISHED 状态,并没有『中间状态』来阻止历史连接。也就是说,如果服务端收到了一个过期的 SYN 报文(比如之前网络延迟导致的),它无法根据序列号判断这是一个历史连接,并可能建立一个无效的连接,并向客户端发送数据。
3.再次理解四次挥手
3.1.再次理解四次挥手
上面看到的是最简单的四次挥手,但事实上我们需要深入了解一下
在这个断开连接的过程中,实质就是四次挥手
- 四次挥手
连接释放过程
- A 的应用进程先向其 TCP 发出连接释放报文段,并停止再发送数据,主动关闭 TCP 连接。A 把连接释放报文段首部的终止控制位 FIN 置1,其序号 seq = u,它等于前面已传送过的数据的最后一个字节的序号加1。这时 A 进入 FIN-WAIT-1(终止等待1) 状态,等待 B 的确认。TCP 规定,FIN 报文段即使不携带数据,也消耗一个序号
- B 收到连接释放报文段后即发出确认,确认号是 ack = u + 1,而这个报文段自己的序号是 v,等于 B 前面已传送过的数据的最后一个字节的序号加1。B随即进入 CLOSE-WAIT(关闭等待) 状态。TCP 服务器进程这时应通知高层应用进程,因而从 A 到 B 这个方向的连接就释放了,这时的 TCP 连接处于 半关闭(half-close) 状态,即 A 已经没有数据要发送了,但 B 若发送数据,A 仍要接收。也就是说,从 B 到 A 这个方向的连接并未关闭,这个状态可能会持续一段时间
- A 收到来自 B 的确认后,就进入 FIN-WAIT-2(终止等待2) 状态,等待 B 发出的连接释放报文段
- 若 B 已经没有要向 A 发送的数据,其应用进程就通知 TCP 释放连接。这时 B 发出的连接释放报文段必须使 FIN = 1。现假定 B 的序号为 w(在半关闭状态 B 可能又发送了一些数据)。B 还必须重复上次已发送过的确认号 ack = u + 1。这时 B 就进入 LAST-ACK(最后确认)状态,等待 A 的确认
- A 在收到 B 的连接释放报文段后,必须对此发出确认。在确认报文段中把 ACK 置1,确认号 ack = w + 1,而自己的序号是 seq = u + 1(根据 TCP 标准,前面发送过的 FIN 报文段要消耗一个序号)。然后进入到 TIME-WAIT(时间等待)状态。此时 TCP 连接还没有释放掉。必须经过时间等待计时器(TIME-WAIT timer)设置的时间2MSL后,A 才进入到 CLOSED 状态
- 当 A 撤销相应的传输控制块 TCB 后,就结束了这次的 TCP 连接
时间 MSL 叫做
最长报文段寿命
(Maximum Segment Lifetime),RFC 793建议设为2分钟。但这完全是从工程上来考虑的,对于现在的网络,MSL = 2分钟可能太长了一些
注意:
- 四次挥手:左->右和左<-右两个方向上,都各自有 FIN 请求关闭连接报文(红色),和一个 ACK 确认关闭连接报文(蓝色)。
- **主动关闭连接的一方才有 TIME_WAIT **状态。
- 为什么有两个FIN_WAIT 状态?
两个 FIN_WAIT 状态的区别是:
- FIN_WAIT_1 状态表示:服务端处于主动关闭方(客户端或服务器)发送了 FIN 包,等待被动关闭方(服务器或客户端)的 ACK 包这个时候的状态。
- 而 FIN_WAIT_2 状态表示:主动关闭方收到了被动关闭方的 ACK 包后,等待被动关闭方的 FIN 包的这个状态。
一般情况下,FIN_WAIT_1 状态持续的时间很短,因为被动关闭方会马上回复 ACK 包。
但是,如果被动关闭方没有及时回复 ACK 包,或者网络链路出现故障,导致主动关闭方收不到 ACK 包,那么主动关闭方就会一直处于 FIN_WAIT_1 状态,直到超时或者重传达到一定次数后,放弃连接并进入 CLOSED 状态。
3.2.四次挥手和应用层的关系
- 客户端主动调用close(fd), 调用了之后系统会开始四次挥手,
- 客户端操作系统会构造FIN报文,然后发送FIN给服务端,客户端进入FIN_WAIT_1状态。
- 服务端操作系统收到FIN之后,立马发送ACK过去,然后进入CLOSE_WAIT状态
- 过一会儿服务端会自动调用服务端应用层的close(fd),构造一个FIN报文发送给客户端,服务端进入LAST_ACK状态
FIN报文的形成和close(fd)有关
3.2.1.测试CLOSE_WAIT
如果要测试CLOSE_WAIT状态,则可以把服务端的close(sockfd)去掉,保留客户端的close(sockfd),这样子实现了一方关闭的情况。
这样子服务器并不会调用close(sockfd),也就是不会完成后两次挥手,则服务器状态会一直持续CLOSE_WAIT状态。
下面用一个例子来测试当客户端主动关闭连接时,会出现什么情况。
Sock.hpp
// Sock.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
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);
}
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() {}
};
main.cc
// main.cc
#include "Sock.hpp"
int main()
{
Sock sock;
int listensock = sock.Socket();
sock.Bind(listensock, 8877);
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;
}
}
}
makefile
Tcpserver:main.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf Tcpserver
我们运行一下
我们看到服务端处于监听状态
netstat -nltp
通过指令 netstat 查看,这个服务器进程确实已经被运行起来了,并且正处于监听状态。
现在用另一个会话用 telnet 工具在本地进行测试:
注意到,此时服务端和客户端都处于 ESTABLISHED 状态,表示连接创建成功。
telnet 相当于客户端,那么下面这个客户端主动关闭连接会发生什么呢?
我们看到服务端是进入了COLSE_WAIT状态,客户端则是进入了 FIN_WAIT_2 状态
一般情况下,FIN_WAIT_1 状态持续的时间很短,因为被动关闭方会马上回复 ACK 包。
而 FIN_WAIT_2 状态表示:主动关闭方收到了被动关闭方的 ACK 包后,等待被动关闭方的 FIN 包的这个状态。
如果我们不服务器调用close函数,那么就不会发FIN报文,这样子服务器就处于COLSE_WAIT状态,客户端就会处于FIN_WAIT_2状态 。
注意,由于我只有一台主机可以用来测试,实际上如果用其他主机作为客户端连接到这个 8080 的监听端口的话,再用这个命令查看相关信息,IP 地址可能和服务器运营商提供的公网 IP 不同,这是因为后者提供的是虚拟 IP。
注意到在服务器上,这个连接的状态变化为了 CLOSE_WAIT。这是因为我们的代码中没有在关闭连接时关闭文件描述符,造成了在这段时间内占用了这个文件描述符。如果你在短时间内重复连接的话,会发现文件描述符会一直递增,同时也会出现 CLOSE_WAIT 状态的连接:
我们知道文件描述符是有上限的,而且连接本身也会占用资源,如果客户端主动关闭连接后,服务端却没有关闭文件描述符,最终会导致进程崩溃。
服务器出现大量 CLOSE_WAIT 状态连接的原因有哪些?
CLOSE_WAIT 状态表示一个 TCP 连接已经结束,但是仍有一方在等待关闭连接的状态。这一方是被动关闭的一方,也就是说它已经接收到了对方发送的 FIN 报文,但是还没有发送自己的 FIN 报文。
当出现大量处于 CLOSE_WAIT 状态的连接时,很大可能是由于没有关闭连接,即『代码层面上』没有调用 close() 关闭 sockfd 文件描述符。也可能是由于响应太慢或者超时设置过小,导致对方不耐烦直接 timeout,而本地还在忙于耗时逻辑。还有一种可能是 BACKLOG 太大,导致来不及消费的请求还在队列里就被对方关闭了。
3.2.2.测试TIME_WAIT
只要让服务器是被动断开连接的一方,并且四次挥手全部完成,服务器最终状态就会是TIME_WAIT状态
注意:我们这里服务器绑定的端口号一直是8877 ,可以看看上面的main函数里面
在服务端中增加10秒后自动关闭连接操作:
#include "Sock.hpp"
int main()
{
// ...
while(true)
{
// ...
sleep (10);
close(sockfd);
std::cout << sockfd << " had closed" << std::endl;
}
}
在 sleep 的 10s 内,服务端连接处于正常连接状态:
10秒后,服务端进程退出。 当服务端主动调用 close,关闭连接时,虽然四次挥手已经完成,但是作为主动断开连接的一方,要维持一段时间的 TIME_WAIT 状态。
在这个状态下,连接其实没有关闭,但其地址信息 IP 和 端口号8877依旧是被占用的。只有CLOSE状态才是真正的关闭了。
我们这个时候退出重新启动服务器,再去绑定上次的IP和端口号8877看看
我们发现启动不了服务器。
再过了一会,我们发现这个服务端的就不见了 ,但是我们的客户端的连接还在
过了一分钟后,服务器能启动了!!
值得注意的是,作为服务器,一旦启动后无特殊需求(如维护)是不会主动关闭连接的,上面代码模拟的通常是服务端进程因为异常而终止的情况。
文件描述符的生命周期随进程,不论服务端进程是正常退出还是异常退出,只要服务端进程退出,此时就应该立即重启服务器。但问题在于,由于是服务端主动关闭请求,此时服务器必然存在大量处于 TIME_WAIT 状态的连接,而它们在一段时间内占用了 IP 和端口8877。如果是双 11 这样的场景,发生这种是被称之为事故,是要被定级的。
一, 服务器出现大量 TIME_WAIT 状态连接的原因有哪些?
首先要知道,TIME_WAIT 状态是主动关闭连接的一方才会出现的状态。服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接。
问题就转化为,什么原因会导致服务端主动断开连接:
- HTTP 没有使用长连接。即服务器使用了短连接,这意味着每次请求都需要建立一个新的 TCP 连接,而且在响应完毕后,服务端会主动关闭连接,导致产生大量的 TIME_WAIT 状态的连接,占用系统资源(端口号+CPU+内存),影响新连接的建立。
- HTTP 长连接超时。如果客户端在一段时间内没有发送新的请求,服务端会认为客户端已经不需要继续使用该连接,就会主动关闭连接,以释放资源。这个超时时间可以由服务端配置。
- 服务器收到了客户端异常或重复的 FIN 包,导致进入 TIME_WAIT 状态等待对方的 ACK 包,但是没有收到,只能等待超时后关闭。
- HTTP 长连接的请求数量达到上限。如果一个连接上发起的请求数量超过了服务端设定的最大值,服务端会主动关闭连接,以防止客户端占用过多的资源。
- 服务端设置了过长的 MSL(报文最大生存时间),导致 TIME_WAIT 状态持续时间过长,无法及时回收资源。
二,什么是长连接/短连接?
长连接和短连接是指在 TCP 协议中,连接的建立和关闭的方式。简单来说:
- 长连接:客户端和服务器建立一次连接后,可以连续发送多个数据包,不会主动关闭连接,除非出现异常或者双方协商关闭。长连接适合于操作频繁,点对点的通信,可以减少建立和关闭连接的开销,提高网络效率。
- 短连接:客户端和服务器每次通信都要建立一个新的连接,发送一个数据包后就关闭连接。短连接适合于并发量大,请求频率低的通信,可以节省服务器的资源,防止过多的无效连接。
3.2.3.如何解决服务器主动断开连接,无法立马重启bind原来端口号的问题
以前在socket套接字编程的时候,我们会遇到服务器有时立即重启无法bind原来的端口号,但有时却又可以bind原来的端口号,其实就是因为TIME_WAIT状态。
- 如果你先关闭客户端,后关闭服务器,则服务器最终状态是CLOSED状态,此时你立即重启服务器bind原来的端口号,就不会出现bind error的问题,因为端口号并没有被占用着。
- 如果你先关闭服务器,则服务器的最终状态是TIME_WAIT状态,此时你立即重启服务器bind原来的端口号,这一定是bind不成功的,因为原来的服务器进程还占用着8877端口号(拿8080举例),你现在重启服务器,又bind8877号端口,当然就会报错bind error了。因为一个端口号只能被一个进程绑定,一个进程是可以bind多个端口号的,因为一个进程可以打开多个文件描述符sockfd。
服务器立即重启无法bind原来端口号是一个很严重的问题,比如京东618期间,服务器挂满了大量的连接,如果由于连接数的不断增多,服务器不小心挂掉了,那服务器是需要立马重启的,如果此时服务器无法bind原来的端口号,而导致被迫等待2MSL的时间,也就是120s,那所有的用户此时就无法进行购物,京东无法提供服务,618期间,1s就是上百万的营业额,要是等120s,公司得亏损多少啊,所以服务器必须能够立即重启且能够bind原来的端口号,大型公司的服务器他们bind的基本都是知名端口号,在公司内部一旦一个服务器bind了一个端口号,轻易是不会换端口号的。
解决的方式也并不困难,只需要设置sockfd选项为重用本地地址SO_REUSEADDR,即使服务器(主动断开连接)的sockfd对应的连接结构体处于TIME_WAIT状态,与sockfd绑定的socket地址(struct sockaddr_in local)也可以立即被重用,这样就可以实现服务器立即重启依旧能bind原来的端口号了。
这样服务器一旦挂掉重启后,虽然存在大量处于 TIME_WAIT 状态的连接,但是这个选项可以绕过 TIME_WAIT 限制,直接复用原先使用的地址。只需要在 Socket 初始化时设置选项:
// Sock.hpp::Sock
int Socket()
{
// ...
int opt = 1;
setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
// ...
}
并且将刚才在 main.cc 中增加的代码删除,方面手动终止和重启服务端进程。
建立一个连接并主动关闭服务端,重启服务端进程,并尝试重新建立连接:
即使此时这个 端口号 对应的连接处于 TIME_WAIT 状态,由于设置了地址复用选项,可以无视它的存在,跳过这段占用时间。
除了设置SO_REUSEADDR选项外,还可以修改内核参数/proc/sys/net/ipv4/tcp_tw_recycle来快速回收被关闭的sockfd,回收其相关的所有资源,例如连接结构体,socket地址等等,从而使得主动关闭TCP连接的一方根本就不进入TIME_WAIT状态,进而允许服务器进程能够立即重用本地socket地址。
——摘自:《Linux高性能服务器编程》
3.2.4.为什么客户端不会受TIME_WAIT的影响?
- 1.为什么客户端不会受TIME_WAIT的影响?
首先我们要明白,我们客户端和服务端进行通信的过程是通过互相绑定端口号和IP地址来实现的。而TIME_WAIT的问题是端口资源占用。
- 服务端的端口就那些固定的端口,所以影响特别大。但是我客户端用来通信的端口可是随机生成的
- 但是客户端的端口号通常是随机生成的
具体来说,客户端在发起网络请求时,操作系统会为其分配一个临时端口号,这个端口号通常属于动态端口范围(49152-65535),以确保与服务器端的知名端口号(如HTTP的80端口、HTTPS的443端口等)区分开来。这些随机生成的端口号在客户端完成网络请求后会被释放,以便后续的网络请求可以重新使用。
- 2.其次,我们客户端不是不受TIME_WAIT状态影响,而是更不容易受影响。
在TCP连接中,客户端通常使用随机生成的端口号来建立连接,而服务端则使用固定的端口号来监听来自客户端的连接请求。这种设计导致了服务端在处理大量连接时更容易受到TIME_WAIT状态的影响,原因如下:
端口重用限制:由于服务端的端口号是固定的,当连接关闭并进入TIME_WAIT状态时,该端口在TIME_WAIT持续时间内(通常是2MSL)无法被重新用于新的连接。这限制了服务端在同一时间内能够处理的并发连接数。
连接频率:服务端往往需要处理来自多个客户端的频繁连接请求。如果每个连接都经历TIME_WAIT状态,那么在高连接频率下,服务端可能会积累大量的TIME_WAIT连接,从而占用更多的系统资源,并可能影响新连接的建立。
资源占用:TIME_WAIT状态虽然不占用太多资源,但在大量连接的情况下,每个连接都占用一小部分资源,这些资源的累积可能会变得显著。此外,TIME_WAIT连接还需要被系统维护,这也会增加一定的管理开销。
网络状况:网络延迟和丢包可能会导致TIME_WAIT状态的持续时间延长,进一步加剧服务端受TIME_WAIT状态的影响。
由于服务端的端口号是固定的,且需要处理来自多个客户端的频繁连接请求,因此服务端更容易受到TIME_WAIT状态的影响。但通过合理的配置和优化措施,可以有效地减轻这种影响。
3.2.5.TIME_WAIT
什么是 TIME_WAIT 状态?
处于 TIME_WAIT 状态的一端,说明:
它正在等待一段时间,以确保对方收到了最后一个 ACK 包,或者处理可能出现的重复的 FIN 包。
也处于一个半关闭的状态,即它已经发送了 FIN 包,表示不再发送数据,但是还可以接收对方的数据,直到对方也发送了 FIN 包。
- **主动关闭连接的一方才有 TIME_WAIT **状态。
**TIME_WAIT **状态也称为 2MSL 等待状态,在这个状态下,TCP 将会等待两倍于 MSL(最大段生存期)的时间,有时也被称为加倍等待。每个实现都必须为 MSL 设定一个数值,它代表任何报文段在被丢弃前在网络中被允许存在的最长时间。
什么是半关闭?
- 半关闭状态是一种单向关闭的状态,它只关闭了某个方向的连接,即数据传输。另一个方向的连接,即数据接收,还是保持打开的。
- 半关闭状态的作用是让一方可以继续发送数据,直到把所有数据都发送完毕,再发送 FIN 包。这样可以避免数据的丢失或者重复发送。
因此,TIME_WAIT 状态存在的目的有两个:
- 可靠地实现 TCP 全双工连接的终止,防止最后一个 ACK 丢失而导致对方无法正常关闭。
- 允许老的重复报文段在网络中消逝,防止新的连接收到旧的报文段而导致数据错乱。
但是,TIME_WAIT 状态的缺点是:
- 它会占用端口资源,如果有大量的 TIME_WAIT 状态存在,可能会导致端口资源耗尽,无法建立新的连接。
- 它会延长连接的释放时间,如果有新的连接请求到来,需要等待 TIME_WAIT 状态结束后才能使用相同的端口。
- TIME_WAIT 状态的持续时间是 2 倍的 MSL(报文最大生存时间),通常为 2 分钟或 4 分钟。在这段时间内,该连接占用的端口不能被再次使用。
为什么 TIME_WAIT 状态的持续时间是 2 倍的 MSL?
为了保证客户端发送的最后一个ACK报文段能够到达服务器。因为这个ACK有可能丢失,从而导致处在 LAST-ACK 状态的服务器收不到对 FIN-ACK 的确认报文。服务器会超时重传这个FIN-ACK,接着客户端再重传一次确认,重新启动时间等待计时器。最后客户端和服务器都能正常的关闭。假设客户端不等待2MSL,而是在发送完ACK之后直接释放关闭,一但这个ACK丢失的话,服务器就无法正常的进入关闭连接状态。即:
- (1)保证客户端发送的最后一个ACK报文段能够到达服务端。
这个ACK报文段有可能丢失,使得处于 LAST-ACK 状态的B收不到对已发送的 FIN+ACK 报文段的确认,服务端超时重传 FIN+ACK 报文段,而客户端能在2MSL时间内收到这个重传的FIN+ACK 报文段,接着客户端重传一次确认,重新启动2MSL计时器,最后客户端和服务端都进入到 CLOSED 状态,若客户端在 TIME-WAIT 状态不等待一段时间,而是发送完ACK报文段后立即释放连接,则无法收到服务端重传的 FIN+ACK 报文段,所以不会再发送一次确认报文段,则服务端无法正常进入到 CLOSED 状态。
- (2)防止“已失效的连接请求报文段”出现在本连接中。
客户端在发送完最后一个ACK报文段后,再经过2MSL,就可以使本连接持续的时间内所产生 的所有报文段都从网络中消失,使下一个新的连接中不会出现这种旧的连接请求报文段。
在网络中,可能存在着多个相同源 IP 地址和目的 IP 地址的连接。如果不等待一段时间,就重新使用相同的源端口和目的端口,可能会导致之前连接的报文被误认为是新连接的一部分。等待 2 MSL 的时长可以确保之前连接的所有报文都已经在网络中消失,从而避免新连接与之前连接的混淆。
MSL是Maximum Segment Lifetime的英文缩写,可译为“最长报文段寿命”,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。
在 CentOS 7 中,MSL 为 60s:
4.TCP的效率策略
4.1.流量控制
流量控制我们在上面讲过
- 什么是流量控制
我们知道,TCP协议有发送缓冲区和接收缓冲区,无论是用户端还是服务端都是。
假设有一天,服务端太忙了,客户端在向服务端发送数据,但是服务端来不及调用read或者recv这样的接口来拿取数据,但是客户端并不清除服务端那边的情况,就一直向服务端发,服务端已经被写满了,依旧再发的话,那么就会导致出现大面积丢包现象。
因此,为了规避这种情况,当服务端的接收缓冲区空间紧张的时候,我们应该想办法让用户端发送数据的速度慢点,或者直接不发了。
所以,这种通过控制客户端发送数据的速度,以便能让服务端来得及处理数据,从而规避大面积丢包的情况,这种策略就叫做流量控制。
- 如何实现流量控制?
答案就是服务端在返回给客户端的响应中,16位窗口大小就是服务端的接收缓冲区当前还剩多少空间。客户端就可以根据这个剩余的空间来制定合理的发送数据的速度。
并且,我们要能想到,服务端也可能会给客户端发消息,那么客户端也会给服务端响应,那么此时这个响应中的16位窗口大小就是客户端的接收缓冲区还剩多少空间。
说白了这个16位窗口大小就是对方的接收缓冲区还剩多少空间
也就是说,双方都可以进行流量控制。
那么我想问:
- 建立连接后,第一次发送数据的时候,如何保证发送数据量是合理的?
不用理解三次握手只是为了建立连接,双方也交互了报头,已经协商了双方的接收能力
实际上,当发送方第一次发送数据给接收方时,它是通过 TCP 的三次握手过程来知道对方接收数据的能力的。具体来说,发送方在第一次握手时,会发送一个 SYN 报文,其中包含了自己的初始序列号(ISN)和最大段大小(MSS)。接收方在第二次握手时,会回复一个 SYN+ACK 报文,其中包含了自己的 ISN 和 MSS,以及一个『窗口』大小,表示自己当前可以接收的数据量。
发送方在第三次握手时,会回复一个 ACK 报文,确认接收到了对方的 SYN+ACK 报文。这样,三次握手完成后,双方就知道了彼此的序列号、段大小和窗口大小,从而可以根据这些信息来调整自己的发送速度和接收能力。
- 事实上,三次握手的时候,前两次不能携带数据,第3次握手可以携带数据
流量控制的工作过程如下:
- 当前我们假定接收主机 B 的接收缓冲区是 4000 这么大.
- 首先主机A发送一个数据报文(大小1000), B 收到 1 ~ 1000 的数据后, 发送 ACK 给 A, 并且通过报文的16位窗口大小中告知 A 自己目前剩余空间大小.
- 主机 A 收到 B 的 ACK 后, 按照 3000 的窗口大小来进行发送数据.
- 以此类推……A一直给B发信息,直到B的接收缓冲区满了
- 假定接收方 B 在这个过程中没有消耗数据, 接收缓冲区满了, 发送给 A 最新的 ACK 表示窗口大小已为 0.
- 此时发送方A就会暂停发送, 在等待过了超时重传的时间以后还没有收到 B 窗口更新的通知, 就会发送一个窗口探测的包(不携带具体数据, 只是为了让 B 回应 ACK, 反应当前接受缓冲区的空间大小).
- A 一旦发现接收方剩余空间大小不是 0 了, 就可以继续发送了.
- 根据这样的机制, 接收方就可以实现通过窗口大小, 反向限制发送方传输速度.
光考虑接收方还是不够的, 我们还需要考虑中间链路的处理能力, 下面讲解一下拥塞控制机制.
『窗口大小』字段在报头中占 16 位,也就是
,这意味着窗口大小最大是 65535(字节)吗?
默认情况是这样子。但是不一定
TCP 窗口大小字段本身是 16 位的,所以最大值是 65535 字节。但是,TCP 还支持一种叫做窗口缩放的选项,它可以在 TCP 三次握手期间协商一个缩放因子,用于将窗口大小乘以一个 2 的幂,从而扩大窗口的范围。窗口缩放选项的值可以从 0 到 14,所以最大的缩放因子是,这样最大的窗口大小就可以达到65535 × 16384 = 1 GB。
当然,这个值也受限于操作系统缓冲区的大小和网络状况的影响。
4.2.滑动窗口
4.2.1.推导滑动窗口的由来
提出问题:在我们滑动窗口协议之前,我们如何来保证发送方与接收方之间,每个包都能被收到。并且是按次序的呢?
发送方发送一个包1,这时候接收方确认包1。发送包2,确认包2。就这样一直下去,知道把数据完全发送完毕,这样就结束了。那么就解决了丢包,出错,乱序等一些情况!
同时也存在一些问题。问题:吞吐量非常的低。我们发完包1,一定要等确认包1.我们才能发送第二个包。
提出问题:那么我们就不能先连发几个包等他一起确认吗?这样的话,我们的速度会不会更快,吞吐量更高些呢?
我们根据序列号的作用,区分各种数据。
如图,这个就是我们把两个包一起发送,然后一起确认。可以看出我们改进的方案比之前的好很多,所花的时间只是一个来回的时间。
接下来,我们还有一个问题:改善吞吐量的问题
问题:我们每次需要发多少个包过去呢?发送多少包是最优解呢?
我们能不能把第一个和第二个包发过去后,收到第一个确认包就把第三个包发过去呢?
而不是去等到第二个包的确认包才去发第三个包。
这样就很自然的产生了我们"滑动窗口"的实现。
在图中,我们可看出
- 灰色1号2号3号包已经发送完毕,并且已经收到Ack。这些包就已经是过去式。
- 4、5、6、7号包是黄色的,表示已经发送了。但是并没有收到对方的Ack,所以也不知道接收方有没有收到。
- 8、9、10号包是绿色的。是我们还没有发送的。这些绿色也就是我们接下来马上要发送的包。
- 可以看出我们的窗口正好是11格。
- 后面的11-16还没有被读进内存。要等4号-10号包有接下来的动作后,我们的包才会继续往下发送。
正常情况
可以看到4号包对方已经被接收到,所以被涂成了灰色。
- “窗口”就往右移一格,这里只要保证“窗口”是7格的。
- 我们就把11号包读进了我们的缓存。进入了“待发送”的状态。
- 8、9号包已经变成了黄色,表示已经发送出去了。
接下来的操作就是一样的了,确认包后,窗口往后移继续将未发送的包读进缓存,把“待发送“状态的包变为”已发送“。
丢包情况
有可能我们包发过去,对方的Ack丢了。
也有可能我们的包并没有发送过去。从发送方角度看就是我们没有收到Ack。
发生的情况:一直在等Ack。
如果一直等不到的话,我们也会把读进缓存的待发送的包也一起发过去。
但是,这个时候我们的窗口已经发满了。
所以并不能把12号包读进来,而是始终在等待5号包的Ack。
如果我们这个Ack始终不来怎么办呢?
超时重发
这时候我们有个解决方法:超时重传
这里有一点要说明:这个Ack是要按顺序的。必须要等到5的Ack收到,才会把6-11的Ack发送过去。这样就保证了滑动窗口的一个顺序。
这时候可以看出5号包已经接受到Ack,后面的6、7、8号包也已经发送过去而且已收到Ack。窗口便继续向后移动。
4.2.1.滑动窗口是怎么划分和工作的。
在TCP中,发送方维护一个发送窗口swnd,接收方则会维护一个接收窗口rwnd,它们是一个连续的字节序列,表示发送方可以发送的数据范围大小。窗口由两个参数定义:窗口的起始字节和窗口的大小。
发送方将数据分成多个数据段,按顺序发送到接收方。每个数据段都包含一个序列号,标识数据在发送方发送窗口中的位置。接收方使用ack确认应答报文来通知发送方已成功接收数据 ,随后发送方通过ack报文窗口滑动。
(ack字段值表示接收方期望接收的下一个字节的序列号)
- 我们发送的一批数据存在哪里?
存在发送方的发送缓冲区里面,数据的发送其实就是把数据拷贝到底层硬件去运输。
拷贝完后我们只需要在发送缓冲区画个边界就好。
其实我们可以在逻辑上将发送缓冲区分为4个部分,从左到右依次为:
- 已经发送同时被ACK的数据(这部分数据可以被新数据覆盖),
- 已经发送但没有被ACK的数据(这部分数据不能被新数据覆盖),
- 尚未被发送但可发送的数据(刚刚从应用层缓冲区中拷贝下来的数据),
- 未发送且不可发送(其实开辟空间时,有初始化的数据)
滑动窗口就是已经发送但没有被ACK,尚未被发送但可发送那两块!!也就是图中的发送窗口。
所以,随着滑动窗口的右移,右边的数据就会被逐渐发送,左边的数据会被应答,如果需要重传数据段,则重传的就是滑动窗口中的数据段。
- 理解缓冲区的划分——怎么画出滑动窗口的?
我们可以将缓冲区看作一个大的buffer,而实际上所谓的滑动窗口,其实就是buffer中win_start和win_end两个数组下标之间构成的空间,
- 如何理解窗口的滑动
滑动窗口移动,其实就是win_start++和win_end++,当滑动窗口内的数据被ACK确认应答了,滑动窗口就会右移。
但我们对滑动窗口的理解就止步于此了吗?当然不是!这仅仅只是一个开始而已!
事实上,滑动窗口的工作原理如下:
- 协商初始化窗口大小。在建立TCP连接时,双方协商并初始化流量控制的参数。其中包括窗口大小(通常是以字节为单位的接收缓冲区大小)和初始的拥塞窗口cwnd大小(swnd=min(cwnd, rwnd))。
- 发送窗口滑动:发送方发送一个数据段并收到ACK确认应答后,将发送窗口向前滑动,使其离开已确认的数据。这样,发送方可以继续发送新的数据,只要它在滑动窗口范围内。
- 接收方更新确认号:接收方根据接收到的报文段的序列号确定已成功接收的数据字节范围,并将确认号设置为下一个期望接收的字节的序列号(通常为接受到的报文段下一位)。
- 接收方更新、通告接收窗口大小:接收方根据已成功接收的数据字节数和初始窗口大小计算可用的接收窗口大小。并接收方将新的接收窗口大小通过 TCP 报文段中的窗口大小字段通告给发送方。这个值告诉发送方接收方的当前可用缓冲区空间。
- 动态调整窗口大小:接收方通过ACK确认号通知发送方已成功接收的数据。发送方可以根据接收方通告的窗口大小进行数据发送控制——如果接收方的窗口变大,发送方可以发送更多的数据;如果接收方的窗口变小,发送方需要适应减少的窗口大小。
- 流量控制:通过滑动窗口机制,接收方可以动态调整窗口大小以限制发送方的数据发送速率。接收方通过通告窗口大小,告知发送方自己的可用缓冲区空间。发送方根据接收方的窗口大小调整发送速率,确保不会超出接收方的处理能力。
4.2.3.滑动窗口的分类
TCP 滑动窗口分为两种: 发送窗口和接收窗口。
发送端的滑动窗口包含四大部分,如下:
已发送且已收到 ACK 确认
已发送但未收到 ACK 确认
未发送但可以发送
未发送也不可以发送
发送端滑动窗口
- 深蓝色框里就是发送窗口。
- SND.WND: 表示发送窗口的大小, 上图虚线框的格子数是 10 个,即发送窗口大小是 10。
- SND.NXT:下一个发送的位置,它指向未发送但可以发送的第一个字节的序列号。
- SND.UNA: 一个绝对指针,它指向的是已发送但未确认的第一个字节的序列号。
接收方的滑动窗口包含三大部分,如下:
已成功接收并确认
未收到数据但可以接收
未收到数据并不可以接收的数据
接收方滑动窗口
蓝色框内,就是接收窗口。
REV.WND: 表示接收窗口的大小, 上图虚线框的格子就是 9 个。
REV.NXT: 下一个接收的位置,它指向未收到但可以接收的第一个字节的序列号。
4.2.5.滑动窗口的 几个问题
滑动窗口的所有操作都是操作系统自己完成的!
- 怎么理解滑动窗口的边界?
- 这个win_start是根据确认序列号设置的!!!可以理解为win_start就是确认序号!
- 这个win_end是确认序号+滑动窗口大小
- 每收到一个报文,win_start就会根据确认序号来修改!!!!!
- (1)滑动窗口的大小是怎么设定的?未来大小又是怎么变化的?
滑动窗口的大小要始终和对方的接收能力挂钩,因为滑动窗口的大小=一次批量化发送数据段的多少,我们知道TCP有流量控制,而一次批量化发送数据段的多少,其实是由对方的16位窗口大小和网络的拥塞情况(下面的拥塞控制就会讲到,现在先提一嘴)共同决定的,所以滑动窗口的大小=min(16位窗口大小,拥塞窗口大小)。
如果随着网络情况变好,同时对方接收能力也提升上来,那滑动窗口自然就会变大,而无论是网络情况还是对方接收能力,只要有一个下降,滑动窗口自然就会变小,因为滑动窗口大小是取两者的最小值。
初始时,win_start=0,win_end=win_start+tcp_win(对方的接收能力),这么认为其实是不对的,因为还要考虑网络的拥塞情况,不过暂时这样理解是可以的,大部分情况下网络都是良好的
- (2)窗口一定会向右滑动吗?能不能向左滑动呢?
滑动窗口不一定向右滑动,有可能保持不动,比如发送端发送的报文都丢包了,或者发送报文的ACK丢包了,这两种情况滑动窗口都会保持不动。当然一般正常情况下,滑动窗口都会右移的,比如最左侧报文段收到ACK,那滑动窗口就是会右移的。
但滑动窗口一定不会向左滑动,左边的数据都是已经被ACK的,而滑动窗口内的数据是未被ACK的,向左滑动是不合理的!
- (3)滑动窗口大小会保持不变吗?会变大吗?会变小吗?变化的依据又是什么?
- 滑动窗口大小会保持不变,比如上面我们说的两种丢包的情况,滑动窗口的大小和位置都会保持不变。
- 滑动窗口是会变大的,比如对方应用层将socket缓冲区内的数据全部拿走,缓冲区的剩余空间一下子增多,同时网络情况也一直很良好,那么滑动窗口就可以增大,发送数据时就可以一次批量化的发送更多的数据了。
- 滑动窗口也是会变小的,比如对方的应用层就是不拿走传输层的数据,则随着对方接收缓冲区不断接收数据段,则对方的接收能力就会下降,而此时滑动窗口也会跟着下降。
- (4)收到ACK的报文如果不是窗口最左侧的报文,而是中间的,或者是右侧的,该怎么办?窗口还要滑动吗?
收到ACK的报文是中间的(右侧的也一样),一般有两种情况,一种是最左侧报文段丢失了,另一种是左侧报文段没丢失,但对应的ACK报文段丢失了。
对于第一种情况,在滑动窗口中,假设丢失报文段的序号是1000,发送成功的报文段的序号是2000,所以丢失的其实就是1000序号-1999序号这1000字节的数据,而后面发送的报文段都得到了ACK,但值得注意的是这些ACK报文段的确认序号是什么呢?我们之前学习确认应答机制的时候,知道确认序号表示的是,ack序号之前的所有数据都已经收到了,所以这些返回的ACK报文段的确认序号就全部是1000,此时发送端就知道1000号报文段在传输过程中丢包了!那就会触发超时重传机制。
- 当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 "我想要的是 1001" 一样;
- 如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
- 这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已 经收到了, 被放到了接收端操作系统内核的接收缓冲区中;
这种机制被称为 "高速重发控制"(也叫 "快重传“)
对于第二种情况,如果仅仅只是ACK报文段丢失了,那后面发送成功的报文段对应返回的ACK报文段的确认序号就会是正常的,而这种情况并不会产生任何问题,滑动窗口正常右移即可。
这种情况下, 部分ACK丢了并不要紧 比特科技 , 因为可以通过后续的ACK进行确认
- (5)滑动窗口会变为0吗?
会的,如果对方接收能力为0,则滑动窗口也会为0,比如对方缓冲区被打满,其上层还不取走缓冲区中的数据,则此时接收能力也就是16位窗口大小的值就会为0
- (6)滑动窗口一直向右滑动吗?如果剩余空间不够了该怎么办?
实际上发送缓冲区被内核维护成了一个环形结构,所以滑动窗口确实会一直向右滑动,而所谓的环形结构其实就是通过模运算来实现的。
当滑动窗口的位置发生变化时,win_start和win_end会随之增大或不变,在变化之后可以让win_start和win_end%=缓冲区的大小,防止下标越界,这样其实就维护好一个环形队列了。
4.2.4.滑动窗口工作示例
综上,举个发生流量控制和超时重传的滑动窗口例子,假设发送方需要发送的数据总长度为 400 字节,分成 4 个报文段,每个报文段长度是 100 字节: 1)
1.TCP三次握手连接建立时,接收方告诉发送方:我的接收窗口rwnd大小是 300 字节。
发送端会连续发送3个报文。
2.发送方发送第一个报文段(序号 1 - 100),还能再发送 200 个字节。
3.发送方发送第二个报文段(序号 101 - 200),还能再发送 100 个字节。
4.发送方发送第三个报文段(序号 201 - 300),还能再发送 0 个字节 此时,发送方的窗口中存了三个待收到ACK的报文段了。
序号为1-300的报文均进入已发送,未接受ACK区
5.此时接收方成功地接收了发送方发送的序号为1到100的报文段。接收方返回一个报文段: ack = 101, rwnd = 200,发送方收到了这个ACK(ACK的含义是收到了ACK之前序号的所有数据),这个时候发送方的滑动窗口往右边移动。序号为1-100的直接脱离发送方的滑动窗口,进入已经接收的序列。
发送方的滑动窗口的移动不是等所有报文都有ACK回应之后才一次性移动,而是根据接收到的每个ACK报文来逐步移动。
具体到你的场景:
发送第一个报文段(序号 1 - 100):发送方发送了第一个报文段,并等待ACK。此时,发送方的窗口包含了从序号1到300的字节,但已发送并等待确认的是1-100。
接收方发送ACK(ack = 101):当接收方成功接收并确认序号为1到100的报文段后,它会发送一个ACK报文,其中
ack = 101
表示它已经成功接收到了序号为100的字节,并期待下一个字节(即序号101)的发送。此时,发送方收到这个ACK后,会将其窗口中的已发送且已确认的部分(即序号1-100)从窗口中移除,并向右滑动窗口,使窗口的左边界移动到序号101。
6.假设在发送的过程中101-200的报文段丢失,接收方迟迟没有收到序号是101-200的报文段,反而先收到序号是201到300的报文段,但是我们要知道ACK的含义:(ACK的含义是收到了ACK之前序号的所有数据),我接受端上次发送给你发送端的ACK可是101,你这发过来的数据怎么是201呢?所以我接收方就会先暂时接受201-300的数据,但是接受端不会回应ACK=301的报文。
接收方不会仅仅因为收到了乱序的报文段就拒绝它们。相反,接收方会保留这些报文段,并等待缺失的报文段到达。只有当所有必要的报文段都到达并按顺序重组后,接收方才会将数据传递给上层应用程序。
TCP的接收方会按照序列号的顺序来重组数据。如果接收方先收到了序号为201到300的报文段,而序号为101到200的报文段尚未到达,那么接收方会将序号为201到300的报文段中的数据暂时存储在接收缓冲区中,但不会将这些数据传递给上层应用程序,直到序号为101到200的报文段也被成功接收并重组为止。
TCP的发送方会等待接收方对每个报文段的确认(ACK)。如果发送方没有收到序号为101到200的报文段的确认,并且超时计时器到期,发送方将会重传这些报文段。这个过程会一直重复,直到接收方成功接收到所有报文段并发送了相应的确认。
7. 同时假设这里发生流量控制,把接收窗口大小降到了 200。 此时的接收方滑动窗口右端正常来说应该右移一个,但是这里发生了流量控制,接收方希望缩小窗口大小,所以正好,这里就不需要向右扩展了:
发送方的滑动窗口会受接收方的滑动窗口的大小而改变
在TCP协议中,接收端(接收方)发送的ACK报文段的确认号(acknowledgment number)是基于它已经成功接收并准备接收的下一个字节的序列号。如果接收端先收到了序号为201到300的报文段,但序号为101到200的报文段丢失了,那么接收端在当前情况下不会发送ACK为301的报文。
原因是TCP的累积确认机制:接收端只确认它已经成功接收到的、按序排列的、最高序号的字节。在这个例子中,尽管接收端收到了序号为201到300的报文段,但由于序号为101到200的报文段丢失了,所以接收端无法确认序号为300(或更高)的字节,因为它还没有收到并确认序号为101到200之间的所有字节。
因此,接收端会继续发送
ack=101
的ACK报文段,直到它收到并确认序号为101到200的所有报文段为止。一旦这些报文段被成功接收并确认,接收端才会发送一个确认号更高的ACK,比如ack=301
(如果它此时已经收到了序号为300的报文段的话)。我们举个例子
- 当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 "我想要的是 1001" 一样;
- 如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
- 这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已 经收到了, 被放到了接收端操作系统内核的接收缓冲区中;
这种机制被称为 "高速重发控制"(也叫 "快重传“)
回归我们的例子,也就是说
- 接收方接受了序号为201-300的报文,但是没有给发送方发送ACK=301的报文。
- 接收方先给发送方发3次ACK为101的报文,发送端如果收到了3次ACK是101的报文,就会重新发送序列号是101的报文。
- 当服务端收到序号为101的报文后,下次发的ACK就会是301(代表301之前序列号的数据我都收到了)
8.发送方收到第一个包的ACK,并从窗口中删除第一个包段。但swnd的左端滑动到101的时候,还未收到第二个包的确认,无法继续滑动,然后等待收到该报文。
其中,每次swnd窗口都会根据ack报文进行调整、滑动(接收窗口rwnd大小变为200, 那发送方swnd也得变小)。此时的发送方滑动窗口swnd如下:
9.发送端没有收到第二个报文段的确认回复,等待超时后重新发送第二个报文段(序号 101 - 200),并且启动TCP拥塞控制(但在这里被省略了,只专注于窗口滑动)。
10.接收端成功接收到第二个包段(之前收到了第一、三个报文段),并返回一个报文段 ack = 301, rwnd = 100 给发送端(这里ACK是301的原因我们上面讲了),假设这里发生了流量控制,接收方将窗口大小减少到100)。
11.发送方窗口收到之前所有ACK,发送窗口根据最后那个ACK报文ack=301,rwnd=100进行调整,使得swnd=100,swnd左端向右滑动到ACK期待下一个位置301。
12.发送方发送第四个报文段(序号 301 - 400)。
4.3.延迟应答
一定要记得,窗口越大,网络吞吐量就越高,传输效率也就会越高(一次传输的数据更多了嘛),TCP提高效率的机制就是保证在网络不拥塞的前提下,尽可能提升传输效率。
延迟应答也是一个提高传输效率的机制, 是围绕滑动窗口琢磨产生的. 窗口大小越大, 传输效率越高. 那么是否有办法能在保证网络不拥堵的情况下, 尽可能的提升窗口大小呢?
如果接收数据的主机立刻返回 ACK 应答, 这时候返回的窗口可能比较小. 只需要在返回 ACK 时, 拖延一些响应的时间, 利用这个拖延的时间, 给接收方腾出来更多消费数据的时间, 那么接收缓冲区的剩余空间, 就会变大了, 窗口大小也可以变得更大了!
- 假设接收端缓冲区为1M. 一次收到了500K的数据. 如果立刻应答, 返回的窗口就是500K.
- 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了.
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来.
- 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M.
值得注意的是,并不是只要延迟了,上层就一定会在这段时间内拿走缓冲区中的数据,这是概率性事件,只是说大概率上层会拿走,如果拿走,那恰巧就可以提高效率,没拿走,那也就只能没拿走呗,这个世界上没有绝对的事情,任何事情都是概率性的,只不过分为大概率和小概率,延迟应答也一样,只不过他是较大概率的事件。
那么所有的包都可以延迟应答么?
肯定也不是的.
- 数量限制: 每隔N个包就应答一次.
- 时间限制: 超过最大延迟时间就应答一次.
具体的数量和超时时间, 依操作系统不同也有差异. 一般N取2, 超时时间取200ms.
4.4.捎带应答
捎带应答是在延迟应答的基础上进行的,也就是说,接收方在收到数据包后,并不立即发送确认应答,而是等待一段时间,看是否有其他数据要发送。如果有,就把确认应答和数据一起发送,这就是捎带应答。如果没有,就单独发送确认应答。
捎带应答的好处是可以减少网络上的小数据包和开销,提高网络利用率和传输效率。因为如果每次发送一个确认应答或一个数据包,都需要占用一个 TCP 包的报头空间,这些报头空间会占用网络资源,增加网络开销,降低网络性能。而如果把确认应答和数据一起发送,就可以节省一个 TCP 包的报头空间,减少网络资源的消耗,提高网络性能。
假设有两个主机 A 和 B,它们之间使用 TCP 协议进行通信,A 是发送方,B 是接收方。假设每个数据包的大小是 1000 字节,延迟应答的最大时间是 200 毫秒,每隔两个数据包就必须发送一个确认应答。下面是一个可能的通信过程:
- A 向 B 发送第一个数据包,编号为 1。
- B 收到第一个数据包,但不立即发送确认应答,而是等待一段时间,看是否有其他数据要发送。
- A 向 B 发送第二个数据包,编号为 2。
- B 收到第二个数据包,由于已经达到了数量限制,就必须发送一个确认应答。假设此时 B 有数据要发送给 A,就把确认应答和数据一起发送,这就是捎带应答。假设 B 要发送的数据包编号为 3,那么它就会在这个数据包中附加一个确认应答,编号为 2。
- A 收到捎带应答和数据包,知道前两个数据包已经被 B 正确接收,并处理 B 发来的数据包。
- A 向 B 发送第三个数据包,编号为 4。
- B 收到第三个数据包,但不立即发送确认应答,而是等待一段时间,看是否有其他数据要发送。
- A 向 B 发送第四个数据包,编号为 5。
- B 收到第四个数据包,由于已经达到了数量限制,就必须发送一个确认应答。假设此时 B 没有数据要发送给 A,就单独发送一个确认应答,编号为 5。
- A 收到确认应答,知道前四个数据包已经被 B 正确接收。
在这个过程中,在第二次和第四次通信时,B 都使用了捎带应答的机制,在同一个 TCP 包中即发送了确认应答又发送了数据。这样做可以减少网络上的小数据包和开销,并提高网络利用率和传输效率。
另外,捎带应答在保证发送数据的效率之外,由于捎带应答的报文携带了有效数据,因此对方收到该报文后会对其进行响应,当收到这个响应报文时不仅能够确保发送的数据被对方可靠的收到了,同时也能确保捎带的 ACK 应答也被对方可靠的收到了。
事实上,我们在三次握手的时候就见过捎带应答
三次握手的第二次握手阶段,server也想和client建立连接,则server会向client发送一个SYN报文段,而这个SYN报文段就可以捎带上ACK,应答上一个client给server发送的SYN报文段,所以捎带应答很简单,只要将这个报文段的ACK和SYN标志位都置为有效即可。
到现在,我们就知道三次握手其实会做下面这几件事
三次握手:
- 建立连接
- 双方协商起始序号
- 双方协商接受缓冲区大小
几乎所有的策略,都是在双方的机器上都起作用的!TCP不只考虑了双方主机的策略,还考虑了网络的情况。
4.5.拥塞控制
4.5.1.拥塞控制的前置理解
之前我们谈论的所有TCP策略和机制,其实都是在谈通信两端,没有谈论中间网络数据传输的环节,丢包除了因为双方的问题,还有可能因为中间环节网络出现了问题,而由于网络异常或压力过大导致的丢包,需要TCP进行拥塞控制。所以滑动窗口的大小不仅需要考虑对方的接收能力大小,还要考虑中间环节网络的情况如何。
TCP引入了许多的机制来保证网络数据传输的可靠性,例如,流量控制,超时重传,确认应答,连接管理,同时也引入了滑动窗口,拥塞控制等机制来保证网络数据传输的高效性,只不过TCP的可靠性过于耀眼,导致很多人忽略了TCP的高效性,但实际上TCP也是非常高效的。
所以你敢说UDP一定比TCP更高效吗?虽然网上有很多人这么说,但我不敢这么说。
如果clien给server发送一批数据段,只有几个数据段丢失了,那client并不会觉得怎么样,直接超时重传即可,但如果丢失了非常多的数据段,则client会认为此时是网络出现问题了,因为在流量控制机制的管理下,发送的一批数据段一定是符合对方接收能力的,此时如果出现大面积的丢包,则一定是网络环境出现了问题,而如果网络出现了问题,就需要TCP的拥塞控制来缓解网络压力。
所以TCP的可靠性不仅仅考虑了通信双方可能出现的问题,同时还考虑了中间环节网络可能出现的问题。
- 如果此时发生了大面积的网络丢包,那TCP还能采取超时重传的策略吗?
需要知道的是,网络中通信的可不止你和服务器这两台主机,还要其他主机也在通信,如果TCP采取超时重传策略,那所有的主机都会采取超时重传策略,本来网络的压力就已经够大了,结果所有的主机还在不停的向网络中疯狂的塞报文,那造成的结果就是又一次的大面积丢包,因为此时网络环境已经出问题了,比如带宽太窄,网络数据拥塞。所以重传只会加剧网络故障问题。
正确的做法应该是让所有的主机都遵守停下来的机制,网络是有自我恢复的能力的,只要所有主机都暂时不发送报文,或者仅仅只发送少量的报文,则等待网络恢复之后,再继续通信,这才是正解。
而当网络中出现大面积丢包,所有主机停下来(只是形象化的说法,实际是让主机只发送少量的探测报文),等待网络恢复的机制其实就是拥塞控制。
4.5.2.拥塞窗口
此处, 我们引入一个 “拥塞窗口” 的概念, 就是在拥塞控制机制下, 发送方采用的滑动窗口大小. 在 TCP 中, 拥塞控制具体就是依靠 “拥塞窗口” 来展开的:
当网络出现拥塞时,发送端会发送拥塞窗口大小的探测报文段,用于探测网络状况如何。
拥塞窗口和发送窗口的关系:
- 拥塞窗口是发送方维护的一个状态变量,它表示当前网络的拥塞程度,也就是发送方可以在没有确认的情况下发送的数据量。
- 发送窗口是发送方根据拥塞窗口和接收方通告的接收窗口计算出来的一个变量,它表示发送方在当前时刻可以发送的数据范围。
- 发送窗口的大小等于拥塞窗口和接收窗口(对方接受能力)中的较小值,即swnd=min(cwnd,rwnd)。
- 发送窗口的大小决定了发送方的传输速率和网络的吞吐量,因此发送方要根据网络反馈来调整拥塞窗口的大小,以达到最优的传输效率。也就是说,拥塞窗口随网络状况动态变化。
值得注意的是,即使是单台主机一次性向网络中发送大量数据,也可能会引发网络拥塞的上限值,所以发送窗口要尽可能小。
拥塞窗口 cwnd 变化的规则:
只要网络中没有出现拥塞,cwnd 就会增大;
但网络中出现了拥塞,cwnd 就减少;
- 重新认识ACK
对ACK的再认识,ack通常被理解为收到数据后给出的一个确认ACK,ACK包含两个非常重要的信息:
- 一是期望接收到的下一字节的序号n,该n代表接收方已经接收到了前n-1字节数据,此时如果接收方收到第n+1字节数据而不是第n字节数据,接 收方是不会发送序号为n+2的ACK的。举个例子,假如接收端收到1-1024字节,它会发送一个确认号为1025的ACK,但是接下来收到的是 2049-3072,它是不会发送确认号为3072的ACK,而依旧发送1025的ACK。
- 二是当前的窗口大小m,如此发送方在接收到ACK包含的这两个数据后就可以计算出还可以发送多少字节的数据给对方,假定当前发送方已发送到第x字节,则可以发送的字节数就是y=m-(x-n).这就是滑动窗口控制流量的基本原理.
- 滑动窗口和拥塞窗口的区别
- 滑动窗口:是接收端进行的流量控制。流量控制是为了控制发送方的发送速率,保证接收方来得及接收信息。发送方和接收方都有一个缓存队列,接收方发送确认报文的时候都会携带上要求发送方的流量窗口大小。当接收方的缓存队列已经满的时候,接收方在发送确认报文的时候,会减小窗口大小,是发送发下一次发送更少的数据。因为这个窗口时动态改变大小的,所以叫滑动窗口。
- 拥塞控制:也是对流量 的控制,是发送方主动发起的。拥塞控制主要是解决网络中的流量过大超过了资源所能利用的部分,对所有的主机、路由器 造成影响。就想一个红绿灯路口的车辆过多就会造成拥塞,这个时候就需要降级车辆的数量、增大数据传输速率。
拥塞窗口是决定任何时候可以发出的字节数的因素之一。
拥塞窗口由发送方维护,是阻止发送方和接收方之间的链路因流量过多而过载的一种手段。这不应与发送方维护的滑动窗口相混淆,滑动窗口的存在是为了防止接收方过载。拥塞窗口是通过估计链路上有多少拥塞来计算的。
4.5.3.慢启动策略+拥塞避免算法(阈值前指数增长,阈值后线性增长)
在 TCP 中, 拥塞控制具体就是依靠 “拥塞窗口” 来展开的:
- 慢启动, 刚开始通信的时候, 会使用一个非常小的窗口去打探情况. 如果一开始就发送一个很大的流量, 当碰到网络拥堵时, 就会让本来不富裕的网络带宽雪上加霜了.
- 指数增长, 在传输的过程中(只考虑网络通畅不堵塞), 拥塞窗口的大小就会指数增长. 这个指数增长的速度是极快的, 如果不加以限制, 就会出现非常大的值. 所以我们需要一个阈值来进行控制.
- 线性增长, 当窗口大小指数增长到一个阈值后, 就会从指数增长转换为线性增长. 此时线性增长虽然没有指数增长的那么快, 但是也会使发送速度越来越快. 当快到一定程度就会接近网络传输的极限, 就可能出现丢包.
- 拥塞窗口回归小窗口, 当传输出现大量丢包后, 就认为当前网络出现拥堵了, 此时就会把窗口大小调整到最初的小窗口. 接下来的操作就会继续回到之前 “指数增长” + “线性增长” 的过程. 另外也会根据当前出现丢包的窗口大小, 调整出一个新的阈值(该阈值指的是指数增长到线性增长那个阈值).
步骤中的指数增长和线性增长, 都是按照传输的轮次来进行的. 比如当前给定的窗口大小为 4000, 全部发送之后, 这一轮就结束了, 当收到 ACK 之后, 继续发送数据时为下一轮.
如图, 拥塞窗口会在传输过程中, 不断的变化, 以此来适应多变的网络环境.
像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快. 慢启动要着重注意下面几点.
- 当 TCP 开始启动的时候, 慢启动阈值等于窗口最大值.
- 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1.
- 少量的丢包, 我们仅仅是触发超时重传. 大量的丢包, 我们就认为网络拥塞.
所以在数据传输中, 实际发送方的窗口大小 = min (拥塞窗口大小, 流量控制窗口大小). 拥塞控制和流量控制共同的限制了滑动窗口机制, 可以使滑动窗口在保证可靠性的前提下, 进一步提高传输效率.