Linux:TCP协议
TCP是一个面向连接的、可靠的、基于字节流的传输层协议。文次我们会通过介绍TCP的报头并通过分析各字段的用途来进一步解释其核心特性:
可靠传输: 有确认应答、超时重传、确保有序。
流量控制和拥塞控制: 动态调节发送速率,防止丢包与拥塞。
面向连接: 通过“三次握手”建立连接,“四次挥手”断开连接。
TCP报头
源/目的端口号:
标识唯一的发送方和接收方进程
32 位序号Seq:
体现基于字节流特性和可靠性:表示本报文段所携带数据的第一个字节在整个字节流中的编号。TCP将应用层交付的数据视为连续的字节流,并为每个字节编号。接收端TCP利用序列号将可能乱序到达的报文段重新排序,确保数据按正确的顺序交付给应用层。
32 位确认号Ack:
(1)体现可靠性 (确认机制): 当ACK
标志位为1时,该字段才有效。它表示接收方期望收到的下一个字节的序列号。
(2)也用于流量控制
4 位 TCP 报头长度:
表示该 TCP 头部有多少个 4 字节,最小值为5(对应20字节标准报头),最大值为15(对应60字节报头)
4位保留未用:必须置为0
6 位标志位:
○ URG: 紧急指针是否有效,一般没用
○ ACK: 确认号是否有效,绝大多数报文段都携带ACK
○ PSH: 提示接收端应用程序立刻从 TCP 缓冲区把数据读走
○ RST: 对方要求重新建立连接,通常在发生严重错误或拒绝连接请求时使用; 我们把携带 RST 标识的称为复位报文段
○ SYN: 请求建立连接,在三次握手中用于同步序列号; 我们把携带 SYN 标识的称为同步报文段
○ FIN: 表示发送方数据已发送完毕,请求终止连接,用于四次挥手关闭连接; 我们称携带 FIN 标识的为结束报文段
16 位窗口大小:
用于流量控制: 表示接收方当前愿意接收的数据量(以字节为单位),即接收窗口的大小。发送方根据这个值动态调整自己发送数据的速率,确保不会淹没接收方,防止接收缓冲区溢出。这是TCP实现端到端流量控制的关键机制。
16 位校验和:
发送端填充, CRC 校验.
接收端校验不通过, 则认为数据有问题
此处的检验和不光包含 TCP 首部, 也包含 TCP 数据部分.
16 位紧急指针:
标识哪部分数据是紧急数据;
40 字节头部选项: 暂时忽略;
可靠传输
确认应答
在进行tcp通信时,发送方每发送一个报文,接收方就必须给接收方发一个ACK应答报文,表示“Ack序号之前的数据我收到了”(ACK是一个标识位;Ack就是确认序号,可以认为是缓冲区上的一个指针,而序号Seq同样是一个指针),这保证了发送方能掌握接收方的接收状态,避免盲目发送(如接收方网络中断,发送方还在发送),保证了通信时的可靠性,同时还与下文的滑动窗口、超时重传、快速重传有密切关系(通过Ack来判断哪一部分数据丢包)
快速重传
如果连续三次接收方都给发送方应答同一个ACK,那么接收方就会认为该ACK对应的报文丢失,会进行重发
超时重传
如果发送方发送报文后一段时间内接收方没有应答,接收方就会认为发送的报文丢失,会进行重复发送。那么如何确定该过多长时间没收到应答才认为是丢包呢,这个时间如果太短会导致网络环境较差时频繁发送重复数据,太长又降低了整体的重传效率,因此一般会动态计算超时时间:
Linux 中(BSD Unix 和 Windows 也是如此), 超时以 500ms 为一个单位进行控制, 每次判定超时重发的超时时间都是 500ms 的整数倍.
如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
累计到一定的重传次数, TCP 认为网络或者对端主机出现异常, 强制关闭连接
滑动窗口
一方面,由于发送方每发送一个报文都需要接收方进行应答,这使得高延迟网络下双方通信效率较低;另一方面,如果发送方发送数据过快,接收方来不及将新报文放入接收缓冲区,只能将其丢弃。为此就需要一种机制动态地调节传输速率——滑动窗口
滑动窗口首先允许发送方连续发送多个报文(无需逐个等待ACK),显著提高了传输效率。
这个过程中即使应答报文丢失了也没关系,因为还会有后续的应答报文。
随后又将发送/接收缓冲区进行划分:
发送方:
已确认发送|发送窗口|未发送
SND.WND: 表示发送窗口的大小, 上图虚线框的格子数是 10 个,即发送窗口大小是 10。
SND.NXT:下一个发送的位置,它指向未发送但可以发送的第一个字节的序列号。
SND.UNA: 一个绝对指针,它指向的是已发送但未确认的第一个字节的序列号。
发送方在收到应答报文时,需要将确认号Ack与自己的窗口范围进行对比:
若
Ack
不在[SND.UNA,SND.NXT]
范围内→ 无效ACK,直接忽略。若
Ack > SND.UNA
→ 新数据被确认,更新SND.UNA = Ack。
若
Ack == SND.UNA
→ 重复ACK(可能数据丢失或乱序),累计重复次数:若重复ACK ≥ 3次 → 触发快速重传(重传
SND.UNA
对应的数据包)。若
Ack < SND.UNA
→ 过期的ACK(确认已确认的数据),直接忽略。
那么接收方的Ack又是如何更新的呢:
接收方:
已确认收到|接收窗口|未收到
REV.WND: 表示接收窗口的大小, 上图虚线框的格子就是 9 个。
REV.NXT: 下一个接收的位置,它指向未收到但可以接收的第一个字节的序列号。
当Seq<RCV.NXT时(重复数据),直接丢弃报文,窗口不变,Ack不变
当Seq==RCV.NXT时(按序到达),Ack=RCV.NXT=RCV.NXT+len,窗口缩小
当Seq>RCV.NXT时(乱序数据),RCV.NXt和Ack不变,将数据缓存,窗口缩小,REV.WND-=len
而当应用层将数据从缓冲区取出时,窗口则会变大
流量控制
有了滑动窗口机制,我们就可以通过控制窗口大小来限制传输速率
接收方通过 TCP 报头的窗口大小字段,动态告知发送方其剩余接收缓冲区容量;
窗口大小字段越大, 说明网络的吞吐量越高;
接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值;
发送端接收到这个窗口之后, 就会减慢自己的发送速度;
如果接收端缓冲区满了, 就会将窗口置为 0;
这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 强制接收方应答并把接窗口大小告诉发送端
拥塞控制
由于可能同时有大量的计算机在网络上进行通信,大家同时发送大量的数据, 很有可能导致甚至加重网络拥堵;为此,TCP引入慢启动机制, 先发少量的数据,摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
此处引入一个概念称为拥塞窗口
发送开始的时候, 定义拥塞窗口大小为 1;
每次收到一个 ACK 应答, 拥塞窗口大小乘2;
每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口
像上面这样的拥塞窗口增长速度, 是指数级别的
"慢启动" 只是指初使时慢, 但是增长速度非常快.
为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
此处引入一个叫做慢启动的阈值
当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长
当 TCP 开始启动的时候, 慢启动阈值等于窗口最大值;
在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1;
少量的丢包, 我们仅仅是触发超时重传;
大量的丢包, 我们就认为网络拥塞;
当 TCP 通信开始后, 网络吞吐量会逐渐上升;
随着网络发生拥堵, 吞吐量会立刻下降;
拥塞控制, 归根结底是 TCP 协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.
应答的优化策略
延迟应答
由于接收方收到消息时,窗口会变小,此时如果立刻应答,会使得发送方发过来的报文变小(进行流量控制),可是很多时候,接收方的处理速度很快,可能窗口虽然在接收报文时变小了,但很快就会恢复。因此,实际上发送方可能会低估接收方的接收能力,进而降低传输效率。
所以,为了提高传输效率,采用延迟应答机制:减少应答次数和接收到消息后过一定时间才应答
那么这个延迟应答时间应该是多少呢:显然不能超过500ms,因为这会与超时重传产生冲突,导致接收方明明收到消息了却被发送方认为是丢包了。一般来说,这个时间是200ms。
捎带应答
很多时候tcp通信不是一方发送一方接收,而是双方都在发送都在接受,这时候双方不仅要发自己的消息,还要频繁应答对方(而发送的信息仅仅是一个ACK和确认号和窗口大小,这显然有些浪费),因此,为了提高通信效率,当进行双向通信时,发送方(同时也是接收方)会把这次要发的报文和上次接收数据的应答报文合二为一发送给对方,这也是为什么大多数报文都会携带ACK应答
面向连接
三次握手
tcp通信在连接时需要先进行“三次握手”,其目的为:
1.确保双方的接收能力和发送能力正常
2.同步双方序列号seq
第一次握手前,双方均处于CLOSED状态,表示断开;服务端调用listen()后进入LISTEN状态,表示等待连接
第一次握手:
客户端调用connect()向指定服务端发起连接请求并向服务端发送SYN报文请求连接并发送Seq序(假设值为x,因为并没有发送任何数据,所以这个是随机生成的)
客户端进入SYNC-SENT状态
此时服务端已知晓自己的接收功能正常,对方的发送功能正常,服务端进入SYNC-RCVD状态
第二次握手:
服务端向客户端发送SYN报文表示同意连接,并捎带应答ACK报文;发送Seq序号(假设值为y,由于没有发送任何数据,也是随机生成的)和Ack确认序号(值为x+1,你可能疑惑不是没有发送数据吗,为什么确认序号还要加1,这是因为TCP 协议规定SYN报文虽然不携带数据, 但是也要消耗1个序列号)
此时,客户端已知晓自己的发送和接收功能正常(因为收到了服务端应答,说明自己的消息成功发出,也说明自己能收到服务端的消息),也知晓服务端的发送和接收功能正常(因为服务端收到了自己的连接请求并成功应答);
但服务端尚不知道自己的发送功能和对方的接收功能是否正常(因为对方还没有应答)因此需要第三次握手:
第三次握手:
客户端发送ACK报文应答,并发送Seq序号(值为x+1)和Ack确认序号(值为y+1)
至此服务端收到消息,确认了自己的发送功能和对方的接收功能,双方都进入ESTABLISHED状态,表示已连接可以正常通信
四次挥手
tcp在断开连接时,要进行四次挥手,其目的为:确保双方数据完整传输并安全释放资源
其中FIN信号表示不再发送数据,但仍可以接收数据
由于更多情况下是客户端主动断开连接(如关闭浏览器),所以这里我们认为客户端是主动断开连接的一方,服务端是被动断开连接的一方,当然服务端也有可能是主动都断开的连接的一方,下文会提到
第一次挥手:
客户端调用close()函数,向服务端发送FIN报文表示准备断开连接,并发送Seq序号(由于没有数据,因此是随机生成的,假设值为u)
客户端进入FIN_WAIT-1状态,关闭应用层动作(不再发送应用层的数据)
第二次挥手:
服务端发送ACK应答报文,并发送Ack确认序号(值为u+1,你可能疑惑不是没有发送数据吗,为什么确认序号还要加1,这是因为TCP 协议规定FIN报文虽然不携带数据, 但是也要消耗1个序列号)和Seq序号(由于没有数据,因此是随机生成的,假设值为v)
服务端进入CLOSE_WAIT状态,关闭内核动作(无法读取缓冲区中的数据)
客户端进入FIN_WAIT_2状态(这是一个半关闭状态,不能发送,但可以接收)
第三次挥手:
服务端调用close()函数,向服务端发送FIN报文,并发送Ack确认序号(值为u+1)和Seq序号(假设值为w,若第二次挥手后到第三次挥手前,服务端向客户端发送了数据,则w>v;否则w=v)
服务端进入LAST_ACK状态,关闭应用层动作(不再发送应用层的数据)
第四次挥手:
客户端发送ACK应答报文并发送确认序号Ack(值为w+1)和序号Seq(值为u+1)
客户端进入TIME_WAIT状态,关闭内核动作(无法读取缓冲区中的数据),并等待一段时间确保对方收到应答报文
服务端收到报文后进入CLOSE状态,连接断开
TIME_WAIT
为什么客户端在第四次挥手后,还要等待一段时间呢:这是因为,如果该报文丢失,服务端会超时重发第三次挥手的报文,客户端收到后又会发送第四次挥手的报文;这样确保了服务端能收到第四次报文
这里还存在一种情况:大量TIME_WAIT状态堆积
服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是每秒都有很大数量的客户端来请求)
这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务端主动清理掉), 就会产生大量 TIME_WAIT 连接
由于我们的请求量很大, 就可能导致 TIME_WAIT 的连接数很多, 每个连接都会占用一个通信五元组(源 ip, 源端口, 目的 ip, 目的端口, 协议). 其中服务器的 ip 和端口和协议是固定的
如果新来的客户端连接的 ip 和端口号和 TIME_WAIT 占用的重复了, 就会出现问题,造成服务端bind失败,一个解决方法使用 setsockopt()设置 socket 描述符的 选项 SO_REUSEADDR 为 1, 表示允许创建端口号相同但 IP 地址不同的多个 socket 描述符
CLOSE_WAIT
CLOSE_WAIT表示: 本地(已经收到了对端发来的 FIN
包(关闭请求),并且已经回复了 ACK
(确认收到)。本地已经知道对端没有数据要发送了。
它在等待: 本地应用程序(通常是服务器端的服务进程)执行 close()
系统调用,关闭自己的套接字。只有应用程序调用了 close()
,操作系统内核才会发送 FIN
包给对端。
这里可能存在一个问题:大量CLOSE_WAIT状态堆积
如果服务端无法正常调用close函数关闭连接,可能会导致大量CLOSE_WAIT状态堆积,占用文件描述符和内存,这时就需要调试寻找没有正常关闭连接的原因。
为什么是四次挥手
你可能会疑惑,为什么断开连接时,第二次挥手(服务端应答)和第三次挥手(服务端发送FIN报文)为什么不能合二为一(像三次握手中的第二次握手一样,发送SYN报文的同时捎带应答ACK报文)。这是因为客户端发送FIN报文是表示自己不想再发送数据了,但此时服务端可能还有数据没有发完,需要一些时间。因此,第二次挥手和第三次挥手有一定时间差,不能合二为一;同时,如果在这期间服务端又向客户端发送了数据的话,两次的确认序号也可能不同。
面向字节流
TCP的面向字节流是通过发送缓冲区和接收缓冲区来实现的,这使得其相较于UDP具有以下优势:
有序性:尽管字节流在传输过程中会被分割成多个报文段,并且这些报文段可能乱序到达、丢失、重传,TCP 协议保证了接收缓冲区中的字节流顺序与发送缓冲区中的字节流顺序完全一致。接收方应用读取到的字节顺序就是发送方写入的字节顺序。
可靠性:TCP 保证,只要连接没有异常中断,发送方写入发送缓冲区的每一个字节最终都会按顺序出现在接收方的接收缓冲区中,不会丢失、不会重复、不会出错(通过校验和、确认、重传、序列号等机制实现)。
缓冲区机制平滑了应用层读写速度与网络传输速度之间的差异(可以进行流量控制)。
但这也导致其没有消息边界(UDP是面向报文发送,有天然的消息边界),进而催生了粘包和拆包两个问题:
粘包问题:发送方多次写入的较小数据块,可能被 TCP 合并成一个较大的报文段发送
拆包问题:发送方写入的一个较大数据块,可能被 TCP 拆分成多个较小的报文段发送
这里举一个例子:
发送方分别发送"Hello"和"World"
接收方读取缓冲区中的内容, 返回 “HelloWorld”。
又或者是发送方分别发送 “HelloWorld
接收方读取缓冲区中的内容, 返回”"Hello"和"World"。
为了解决这一问题,需要明确两个包之间的边界:
对于定长的包, 保证每次都按固定大小读取即可;
对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议来定, 只要保证分隔符不和正文冲突即可);