【传输层】TCP -- 三次握手四次挥手 | 可靠性与提高性能策略
- 超时重传机制
- 连接管理机制
- 三次握手
- 四次挥手
- 滑动窗口
- 拥塞控制
- 延迟应答
- 捎带应答
- 面向字节流
- 粘包问题
- TCP异常情况
- TCP小结
- 基于TCP应用层协议
- 理解 listen 的第二个参数
超时重传机制
- 主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B;
- 如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发
发送方如何判定丢包了呢?
其实真正有没有丢包,发送方其实不知道。定的策略,超时了,就判定丢包了
但是,主机A未收到B发来的确认应答,也可能是因为ACK丢失了
因此主机B会收到很多重复数据(这也是不可靠的一种),那么TCP协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。这时候我们可以利用前面提到的序列号,就可以很容易做到去重的效果
思考点:发送端,发出去数据,被发出的数据,并不想我们想的那样立马移除,而是必须被维持一段时间,维持在发送窗口或发送缓冲区
如果超时的时间如何确定?
- 最理想的情况下,找到一个最小的时间,保证 “确认应答一定能在这个时间内返回”。
- 但是这个时间的长短,随着网络环境的不同,是有差异的。
- 如果超时时间设的太长,会影响整体的重传效率;
- 如果超时时间设的太短,有可能会频繁发送重复的包;
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
- Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时
- 时间都是500ms的整数倍。
- 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
- 如果仍然得不到应答,等待 4*500ms 进行重传,依次类推,以指数形式递增。
- 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接
连接管理机制
在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接
三次握手
为什么要三次握手?
- 一次握手:只有一次握手,那么连接的建立就无法得到确认,无法确定双方的发送和接收能力,也无法建立可靠的连接(SYN洪水)
- 二次握手:只有两次握手,虽然可以确认一方的发送和接收能力,但无法保证另一方的接收能力。这可能导致双方在不同的时间窗口内发送数据,从而导致数据的不同步和丢失(SYN洪水)
为什么不四次握手?
- 可靠性:TCP 的三次握手已经足够确保连接的可靠性。通过三次握手,客户端和服务器可以确保彼此的发送和接收能力正常,并达到同步的状态。在第三次握手中,服务器端已经确认了客户端的连接请求,并准备好接收数据。
- 效率和延迟:增加握手的次数会引入额外的延迟和开销。每次握手都需要来回的数据交换和确认,增加了连接建立的时间。由于 TCP 的设计目标是提供高效的数据传输,因此三次握手被认为是一个合理的折衷方案,既保证了可靠性,又尽量减少了握手的次数。
- 避免冗余或无效连接:通过三次握手,可以防止过期的连接请求和无效的连接建立。如果握手次数增加,可能会导致更多无效连接的产生,浪费网络资源和服务器资源
三次握手是用最小成本验证全双工通信信道是通畅的,三次握手可以有效防止单机进行对服务器进行攻击(服务器收到攻击本身就不是tcp握手该解决的,但是如果握手有明显漏洞,那就是握手的问题了)
三次握手不一定非得成功,最担心的起始是最后一个ACK丢失,但是有配套的解决方案
链接是需要被管理起来的,被OS管理起来的,先描述,在组织。维护一个连接是有成本的[时空]
(三次握手也可称为四次握手,因为服务器在应答的SYN+ACK也可以分开进行,但是它们是不同的标志位,所以可以一次发送过去)
SYN 洪水(SYN Flood)是一种网络攻击方式,旨在通过发送大量的伪造 TCP 连接请求(SYN 包)来超载目标服务器的资源,使其无法正常处理合法的连接请求。
SYN 洪水攻击利用了 TCP 协议三次握手的过程中的漏洞。攻击者发送大量的带有伪造源 IP 地址的 SYN 包给目标服务器,使得服务器在接收到这些 SYN 包后会为每个连接请求分配一定的资源,包括内存和连接表项。然而,由于伪造的源 IP 地址,服务器在等待客户端发送后续的 ACK 包进行连接的建立时,无法得到响应,导致资源被浪费
为什么要连接?因为要保证可靠性
tcp连接本身并不能直接保证可靠性,实际上tcp建立连接的时候,你怎么知道哪些报文丢了?你怎么知道它是处于什么状态是通信状态还是断开状态?哪些报文需要重传?下一次重传的时间又是多长?当前服务端收到了多少报文,又发送了多少报文?这些上面的特征都是要维护在tcp连接结构体中的,正是有三次握手这样的机制,所以才建立了双方连接结构体这样的共识,正是有了连接结构体才能完成所谓的超时重传、流量控制等等策略。所以连接结构体是保证数据可靠性的数据结构基础,而三次握手是创建结构体的基础,所以tcp三次握手就间接保证了可靠性。
四次挥手
- 主动断开连接的一方,最终状态是TIME_WAIT状态
- 被动断开连接的一方,两次挥手完成,会进入CLOSE_WAIT状态(这个与双方是服务器还是客户端无关,因为TCP是地位对等的协议)
- 四次挥手动作完成,但是主动断开连接的一方要维持一段时间的TIME_WAIT
如果我们的服务器出现了大量的CLOSE_WAIT?
- 服务器有bug,没有做close文件描述符的动作
- 服务器有压力,可能一直在推送消息给client,导致来不及close
为什么主动断开连接的一方要维持一段时间的TIME_WAIT?
一般维持2*MSL的时间,目的是:
- 保证最后一个ACK尽可能的被对方收到
- 双方在断开连接的时候,网络中还有滞留的报文,为了保证滞留报文进行消散(教材给的理由)
服务器有时候可以立即重启,有时候无法立即重启原因是?bind_error
服务器主动断开,服务器要维持TIME_WAIT状态,维持该状态期间,该端口与连接依旧存在,所以你无法绑定该端口
解决TIME_WAIT状态引起的bind失败的方法
- 在server的TCP连接没有完全断开之前不允许重新监听,某些情况下可能是不合理的服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求)。
- 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产生大量TIME_WAIT连接。
- 由于我们的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip、源端口、目的ip、目的端口、协议)。其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了,就会出现问题
解决方法:使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符
滑动窗口
我们确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答,收到ACK后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候
既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)
发送方怎么在第一次就知道对方的接受能力呢?在通信之前,早就做过了三次握手,交换窗口大小
如果我们发送数据,没有收到应答之前,我们必须将自己的已经发送的数据暂时保存起来,为了支持超时重传
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值,上图的窗口大小就是4000个字节(四个段)。
- 发送前四个段的时候,不需要等待任何ACK,直接发送;
- 收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据;依次类推;
- 操作系统内核为了维护这个滑动窗口,需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答;只有确认应答过的数据,才能从缓冲区删掉;
- 窗口越大,则网络的吞吐率就越高
窗口的开始大小是怎么设定的?未来怎么变化?
目前:滑动窗口的大小,和对方的接受能力有关
win_start = 0; win_end = win_start + tcp_win – 未来无论怎么滑动,都要保证对方能够进行正常接受
滑动窗口大小=对方通告给我的自己的接受能力大小【目前】
确认序号的定义:ACK seq X +1 ,标识X+1之前的所有数据全部都收到了。这也是支持我们滑动窗口的滑动规则设定
1000已经丢包了,那么我们肯定不能返回2000,因为返回2000就代表1000已经应答了。
当TCP客户端发送序号为1000和2000的数据包时,如果序号1000的数据包丢失了,服务端并没有收到该数据包。因此,它期望接收序号为1000的数据包。所以,服务端会返回确认序号(ACK)为1000的ACK包。这意味着服务端告诉客户端:“我已经成功接收了直到序号999的所有数据,现在期待接收序号为1000的数据”。
简单地说,服务端会返回序号为1000的ACK。
一直向后滑动?如果空间不够了怎么办?
其实发送缓冲区被内核组织成了环形结构,上面的线性结构只是方便一开始的理解
拥塞控制
就如学校期末考试一样,全班就你没及格,你会觉得是你自己的问题,但是全班就一个人及格,可能就会认为是教学事故或其他原因。
这种发送10000条报文,丢失了9999条的情况,我们一般认为是网络出现了问题,因为我们与Server端有可靠性策略
那么这种网络出现问题我们还进行超时重传吗?不应该,我们以前的各种超时重传,互动窗口都是基于端对端的,网络已经出现问题了,我们如果继续发送,会让网络中又出现大量的报文,只会加重网络的故障问题。你可能觉得你的报文不多,发一下对网络不会有太大问题,但是如果TCP连接都这样,那么整体而言会造成重大问题。
TCP的可靠性不仅仅考虑了双方主机的问题,也考虑了路上网络的问题 – 拥塞控制背景
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。
TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据
此处引入一个概念程为拥塞窗口
- 发送开始的时候,定义拥塞窗口大小为1;
- 每次收到一个ACK应答,拥塞窗口加1;
- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口;
- 我(滑动窗口),网络(拥塞窗口),对端(窗口大小:自己的适应能力) — 我→网络→对端 — 滑动窗口大小 = min(拥塞窗口,窗口大小)
像上面这样的拥塞窗口增长速度,是指数级别的。“慢启动” 只是指初使时慢,但是增长速度非常快。
- 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。
- 此处引入一个叫做慢启动的阈值
- 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长
- 当TCP开始启动的时候,慢启动阈值等于窗口最大值;
- 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1;
少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞;
当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降;
拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案
延迟应答
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。
- 假设接收端缓冲区为1M,一次收到了50OK的数据;如果立刻应答,返回的窗口就是500K;
- 但实际上可能处理端处理的速度很快,10ms之内就把50OK数据从缓冲区消费掉了;
- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;
- 如果接收端稍微等一会再应答,比如等待200ms再应,那么这个时候返回的窗口大小就是1M;
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;
那么所有的包都可以延迟应答么?肯定也不是
- 数量限制:每隔N个包就应答一次
- 时间限制:超过最大延迟时间就应答一次
具体的数量和超时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms;
捎带应答
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 “一发一收” 的。意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine,thank you”;
那么这个时候ACK就可以搭顺风车,和服务器回应的 “Fine, thank you” 一起回给客户端
面向字节流
创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区;
- 调用write时,数据会先写入发送缓冲区中
- 如果发送的字节数太长,会被拆分成多个TCP的数据包发出
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区;然后应用程序可以调用read从接收缓冲区拿数据
- 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。这个概念叫做全双工
由于缓冲区的存在,TCP程序的读和写不需要——匹配,例如:
- 写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次
粘包问题
首先要明确,粘包问题中的"包",是指的应用层的数据包。比如你前一个序列的报文少读取一部分,同时也会影响下一个序列号的报文读取,这就是粘宝。
- 在TCP的协议头中,没有如同UDP一样的"报文长度"这样的字段,但是有一个序号这样的字段。
- 站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中
- 站在应用层的角度,看到的只是一串连续的字节数据。
- 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包
那么如何避免粘包问题呢?
归根结底就是一句话,明确两个包之间的边界
- 对于定长的包,保证每次都按固定大小读取即可。例如上面的Request结构,是固定大小的,那么就从缓冲区从头开始按sizeof(Request)依次读取即可
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置
- 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可)
思考:对于UDP协议来说,是否也存在"粘包问题”呢?
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在,同时,UDP是一个一个把数据交付给应用层,就有很明确的数据边界
- 站在应用层的站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现"半个"的情况。
TCP异常情况
进程终止:进程终止会释放文件描述符,仍然可以发送FIN和正常关闭没有什么区别(OS会正常自动四次挥手,跟close没有区别)
机器重启:和进程终止的情况相同
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset,即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在,如果对方不在,也会把连接释放。另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态,例如QQ,在QQ断线之后,也会定期尝试重新连接。(OS没有时间反应,客户端识别到网络变化,但是服务器不知道,客户端想要告诉服务端也没有办法,因为先拔的网线。这个时候就会。服务端认为连接是好的,客户端认为连接已经关闭了。服务端可能采取某些策略,过一段时间询问一下客户端还在不在。)
TCP小结
为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能
可靠性:
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
其他:
- 定时器(超时重传定时器,保活定时器,TIME_WAIT定时器等)
基于TCP应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
当然,也包括你自己写TCP程序时自定义的应用层协议;
理解 listen 的第二个参数
客户端状态正常,但是服务器端出现了 SYN_RECV 状态,而不是 ESTABLISHED 状态
这是因为,Linux内核协议栈为一个tcp连接管理使用两个队列:
- 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)
- 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)
而全连接队列的长度会受到 listen 第二个参数的影响.
全连接队列满了的时候,就无法继续让当前连接的状态进入 established 状态了
这个队列的长度通过上述实验可知,是 listen 的第二个参数 + 1
这个队列本质上就是给服务器维护的一个短暂的缓冲区,用来随时填补服务器上层服务完毕的时候直接从队列拿取新连接。
如有错误或者不清楚的地方欢迎私信或者评论指出🚀🚀
- 定时器(超时重传定时器,保活定时器,TIME_WAIT定时器等)