前言
小到基于应用层做网络开发,大到生活中无处不在的网络。我们在享受这个便利的时候,没有人会关心它如此牢固的底层基石是如何搭建的。而这些基石中很重要的一环就是tcp协议。翻看一下“三次握手”和“四次挥手”,本以为这就是tcp了,其实不然。它仅仅解决了连接和关闭的问题,传输的问题才是tcp协议更重要,更难,更复杂的问题。回头看tcp协议的原理,会发现它为了承诺上层数据传输的“可靠”,不知要应对多少网络中复杂多变的情况。简单直白列举一下:
- 怎么保证数据都是可靠呢?---连接确认!关闭确认!收到数据确认!各种确认!!
- 因为网络或其他原因,对方收不到数据怎么办?--超时重试
- 网络情况千变万化,超时时间怎么确定?--根据RTT动态计算
- 反反复复,不厌其烦的重试,导致网络拥塞怎么办?---慢启动,拥塞避免,快速重传,快速恢复
- 发送速度和接收速度不匹配怎么办?--滑动窗口
- 滑动窗口滑的过程中,他一直告诉我处理不过来了,不让传数据了怎么办?--ZWP
- 滑动窗口滑的过程中,他处理得慢,就理所当然的每次让我发很少的数据,导致网络利用率很低怎么办?---Nagle
其中任何一个小环节,都凝聚了无数的算法,我们没有能力理解各个算法的实现,但是需要了解下tcp实现者的思路历程。
梳理完所有内容,大概可以知道:
tcp提供哪些机制保证了数据传输的可靠性?
tcp连接的“三次握手”和关闭的“四次挥手”流程是怎么样的?
tcp连接和关闭过程中,状态是如何变化的?
tcp头部有哪些字段,分别用来做什么的?
tcp的滑动窗口协议是什么?
超时重传的机制是什么?
如何避免传输拥塞?
一. 概述
1. tcp连接的特点
- 提供面向连接的,可靠的字节流服务
- 为上层应用层提供服务,不关心具体传输的内容是什么,也不知道是二进制流,还是ascii字符。
2. tcp的可靠性如何保证
- 分块传送:数据被分割成最合适的数据块(UDP的数据报长度不变)
- 等待确认:通过定时器等待接收端发送确认请求,收不到确认则重发
- 确认回复:收到确认后发送确认回复(不是立即发送,通常推迟几分之一秒)
- 数据校验:保持首部和数据的校验和,检测数据传输过程有无变化
- 乱序排序:接收端能重排序数据,以正确的顺序交给应用端
- 重复丢弃:接收端能丢弃重复的数据包
- 流量缓冲:两端有固定大小的缓冲区(滑动窗口),防止速度不匹配丢数据
3. tcp的首部格式
3.1 宏观位置
- 从应用层->传输层->网络层->链路层,每经过一次都会在报文中增加相应的首部。参考之前的文章http协议
- TCP数据被封装在IP数据报中
3.2 首部格式
- tcp首部数据通常包含20个字节(不包括任选字段)
- 第1-2两个字节:源端口号
- 第3-4两个字节:目的端口号
源端口号+ip首部中的源ip地址+目的端口号+ip首部中的目的ip地址,唯一的确定了一个tcp连接。对应编码级别的socket。
- 第5-8四个字节:32位序号。tcp提供全双工服务,两端都有各自的序号。编号:解决网络包乱序的问题
序号如何生成:不能是固定写死的,否则断网重连时序号重复使用会乱套。tcp基于时钟生成一个序号,每4微秒加一,到2^32-1时又从0开始
- 第9-12四个字节:32位确认序列号。上次成功收到数据字节序号加1,ack为1才有效。确认号:解决丢包的问题
- 第13位字节:首部长度。因为任选字段长度可变
- 后面6bite:保留
- 随后6bite:标识位。控制各种状态
- 第15-16两个字节:窗口大小。接收端期望接收的字节数。解决流量控制的问题
- 第17-18两个字节:校验和。由发送端计算和存储,由接收端校验。解决数据正确性问题
- 第19-20两个字节:紧急指针
3.3 标识位说明
- URG:为1时,表示紧急指针有效
- ACK:确认标识,连接建立成功后,总为1。为1时确认号有效
- PSH:接收方应尽快把这个报文交给应用层
- RST:复位标识,重建连接
- SYN:建立新连接时,该位为0
- FIN:关闭连接标识
3.4 tcp选项格式
- 每个选项开始是1字节kind字段,说明选项的类型
- kind为0和1的选项,只占一个字节
- 其他kind后有一字节len,表示该选项总长度(包括kind和len)
- kind为11,12,13表示tcp事务
3.5 MSS 最长报文大小
- 最常见的可选字段
- MSS只能出现在SYN时传过来(第一次握手和第二次握手时)
- 指明本端能接收的最大长度的报文段
- 建立连接时,双方都要发送MSS
- 如果不发送,默认为536字节
二. 连接的建立与释放
1. 连接建立的“三次握手”
1.1 三次握手流程
- 客户端发送SYN,表明要向服务器建立连接。同时带上序列号ISN
- 服务器返回ACK(序号为客户端序列号+1)作为确认。同时发送SYN作为应答(SYN的序列号为服务端唯一的序号)
- 客户端发送ACK确认收到回复(序列号为服务端序列号+1)
1.2 为什么是三次握手
- tcp连接是全双工的,数据在两个方向上能同时传递。
- 所以要确保双方,同时能发数据和收数据
- 第一次握手:证明了发送方能发数据
- 第二次握手:ack确保了接收方能收数据,syn确保了接收方能发数据
- 第三次握手:确保了发送方能收数据
- 实际上是四个维度的信息交换,不过中间两步合并为一次握手了。
- 四次握手浪费,两次握手不能保证“双方同时具备收发功能”
2. 连接关闭的“四次挥手”
2.1 为什么是四次挥手
- 因为tcp连接是全双工的,数据在两个方向上能同时传递。
- 同时tcp支持半关闭(发送一方结束发送还能接收数据的功能)。
- 因此每个方向都要单独关闭,且收到关系通知需要发送确认回复
2.2 为什么要支持半关闭
- 客户端需要通知服务端,它的数据已经传输完毕
- 同时仍要接收来自服务端的数据
- 使用半关闭的单连接效率要比使用两个tcp连接更好
2.3 四次握手流程
- 主动关闭的一方发送FIN,表示要单方面关闭数据的传输
- 服务端收到FIN后,发送一个ACK作为确认(序列号为收到的序列号+1)
- 等服务器数据传输完毕,也发送一个FIN标识,表示关闭这个方向的数据传输
- 客户端回复ACK以确认回复
3. 连接和关闭对应的状态
3.1 状态说明
- 服务端等待客户端连接时,处于Listen监听状态
- 客户端主动打开请求,发送SYN时处于SYN_SENT发送状态
- 客户端收到syn和ack,并回复ack时,处与Established状态等待发送报文
- 服务端收到ack确认后,也处于Established状态等待发送报文
- 客户端发送fin后,处于fin_wait_1状态
- 服务端收到fin并发送ack时,处于close_wait状态
- 客户端收到ack确认后,处于fin_wait_2状态
- 服务端发送fin后,处于last_ack状态
- 客户端收到fin后发送ack,处于time_wait状态
- 服务端收到ack后,处于closed状态
3.2 time_wait状态
- 也称为2MSL等待状态,MSL=Maximum Segment LifetIme,报文段最大生存时间,根据不同的tcp实现自行设定。常用值为30s,1min,2min。linux一般为30s。
- 主动关闭的一方发送最后一个ack所处的状态
- 这个状态必须维持2MSL等待时间
3.2.1 为什么需要这么做?
- 设想一个场景,最后这个ack丢失了,接收方没有收到
- 这时候接收方会重新发送fin给发送方
- 这个等待时间就是为了防止这种情况发生,让发送方重新发送ack
- 总结:预留足够的时间给接收端收ack。同时保证,这个连接不会和后续的连接乱套(有些路由器会缓存数据包)
3.2.2 这么做的后果?
- 在这2MSL等待时间内,该连接(socket,ip+port)将不能被使用
- 很多时候linux上报too many open files,说端口不够用了,就需要检查一些代码里面是不是创建大量的socket连接,而这些socket连接并不是关闭后就立马释放的
- 客户端连接服务器的时候,一般不指定客户端的端口。因为客户端关闭然后立马启动,按照理论来说是会提示端口被占用。同样的道理,主动关闭服务器,2MSL时间内立马启动是会报端口被占用的错误
- 多并发的短连接情况下,会出现大量的Time_wait状态。这两个参数可以解决问题,但是它违背了tcp协议,是有风险的。参数为:tcp_tw_reuse和tcp_tw_recycle
- 如果是服务端开发,可设置keep-alive,让客户端主动关闭连接解决这个问题
4. 复位报文段
一个报文段从源地址发往目的地址,只要出现错误,都会发出复位的报文段,首部字段的RST是用于“复位”的。这些错误包括以下情况
- 端口没有在监听
- 异常中止:通过发送RST而不是fin来中止连接
5. 同时打开
- 两个应用程序同时执行主动打开,称为“同时打开“
- 这种情况极少发生
- 两端同时发送SYN,同时进入SYN_SENT状态
- 打开一条连接而不是两条
- 要进行四次报文交换过程,“四次握手”
6. 同时关闭
- 双方同时执行主动关闭
- 进行四次报文交换
- 状态和正常关闭不一样
7. 服务器对于并发请求的处理
- 正等待连接的一端有一个固定长度的队列(长度叫做“积压值”,大多数情况长度为5)
- 该队列中的连接为:已经完成了三次握手,但还没有被应用层接收(应用层需要等待最后一个ack收到后才知道这个连接)
- 应用层接收请求的连接,将从该队列中移除
- 当新的请求到来时,先判断队列情况来决定是否接收这个连接
- 积压值的含义:tcp监听的端点已经被tcp接收,但是等待应用层接收的最大值。与系统允许的最大连接数,服务器接收的最大并发数无关
三. 数据的传输
1. tcp传输的数据分类
- 成块数据传输:量大,报文段常常满
- 交互数据传输:量小,报文段为微小分组,大量微小分组,在广域网传输会增加拥堵的出现
- tcp处理的数据包括两类,有不同的特点,需要不同的传输技术
2. 交互数据的传输技术
2.1 经受时延的确认
- 概念:tcp收到数据时,并不立马发送ack确认,而是稍后发送
- 目的:将ack与需要沿该方向发送的数据一起发送,以减少开销
- 特点:接收方不必确认每一个收到的分组,ACk是累计的,它表示接收方已经正确收到了一直到确认序号-1的所有字节
- 延时时间:绝大多数为200ms。不能超过500ms
2.2 Nagle算法
- 解决什么问题:微小分组导致在广域网出现的拥堵问题
- 核心:减少了通过广域网传输的小分组数目
- 原理:要求一个tcp连接上最多只能有一个未被确认的未完成的分组,该分组的确认到达之前,不能发送其他分组。tcp收集这些分组,确认到来之前以一个分组的形式发出去
- 优点:自适应。确认到达的快,数据发送越快。确认慢,发送更少的组。
- 使用注意:局域网很少使用该算法。且有些特殊场景需要禁用该算法
3. 成块数据的传输
- 主要使用滑动窗口协议
四. 滑动窗口协议
1. 概述
- 解决了什么问题:发送方和接收方速率不匹配时,保证可靠传输和包乱序的问题
- 机制:接收方根据目前缓冲区大小,通知发送方目前能接收的最大值。发送方根据接收方的处理能力来发送数据。通过这种协调机制,防止接收端处理不过来。
- 窗口大小:接收方发给发送端的这个值称为窗口大小
2. tcp缓冲区的数据结构
- 接收端:
- LastByteRead: 缓冲区读取到的位置
- NextByteExpected:收到的连续包的最后一个位置
- LastByteRcvd:收到的包的最后一个位置
- 中间空白区:数据没有到达
- 发送端:
- LastByteAcked: 被接收端ack的位置,表示成功发送确认
- LastByteSent:发出去了,还没有收到成功确认的Ack
- LastByteWritten:上层应用正在写的地方
3. 滑动窗口示意图
3.1 初始时示意图
- 黑框表示滑动窗口
- #1表示收到ack确认的数据
- #2表示还没收到ack的数据
- #3表示在窗口中还没有发出的(接收方还有空间)
- #4窗口以外的数据(接收方没空间)
3.2 滑动过程示意图
- 收到36的ack,并发出46-51的字节
4. 拥塞窗口
- 解决什么问题:发送方发送速度过快,导致中转路由器拥堵的问题
- 机制:发送方增加一个拥塞窗口(cwnd),每次受到ack,窗口值加1。发送时,取拥塞窗口和接收方发来的窗口大小取最小值发送
- 起到发送方流量控制的作用
5. 滑动窗口会引发的问题
5.1 零窗口
- 如何发生: 接收端处理速度慢,发送端发送速度快。窗口大小慢慢被调为0
- 如何解决:ZWP技术。发送zwp包给接收方,让接收方ack他的窗口大小。
5.2 糊涂窗口综合征
- 如何发生:接收方太忙,取不完数据,导致发送方越来越小。最后只让发送方传几字节的数据。
- 缺点:数据比tcp和ip头小太多,网络利用率太低。
- 如何解决:避免对小的窗口大小做响应。
- 发送端:前面说到的Nagle算法。
- 接收端:窗口大小小于某个值,直接ack(0),阻止发送数据。窗口变大后再发。
五. 超时与重传
1. 概述
- tcp提供可靠的运输层,使用的方法是确认机制。
- 但是数据和确认都有可能丢失
- tcp通过在发送时设置定时器解决这种问题
- 定时器时间到了还没收到确认,就重传该数据
2. tcp管理的定时器类型
- 重传定时器:等待收到确认
- 坚持定时器:使窗口大小信息保持不断流动
- 保活定时器:检测空闲连接崩溃或重启
- 2MSL定时器:检测time_wait状态
3. 超时重传机制
3.1 背景
- 接收端给发送端的Ack确认只会确认最后一个连续的包
- 比如发送1,2,3,4,5共五份数据,接收端收到1,2,于是回ack3,然后收到4(还没收到3),此时tcp不会跳过3直接确认4,否则发送端以为3也收到了。这时你能想到的方法是什么呢?tcp又是怎么处理的呢?
3.1 被动等待的超时重传策略
- 直观的方法是:接收方不做任何处理,等待发送方超时,然后重传。
- 缺点:发送端不知道该重发3,还是重发3,4,5
- 如果发送方如果只发送3:节省宽度,但是慢
- 如果发送方如果发送3,4,5:快,但是浪费宽带
- 总之,都在被动等待超时,超时可能很长。所以tcp不采用此方法
3.2 主动的快速重传机制
3.2.1 概述
- 名称为:Fast Retransmit
- 不以实际驱动,而以数据驱动重传
3.2.2 实现原理
- 如果包没有送达,就一直ack最后那个可能被丢的包
- 发送方连续收到3相同的ack,就重传。不用等待超时
- 图中发生1,2,3,4,5数据
- 数据1到达,发生ack2
- 数据2因为某些原因没有送到
- 后续收到3的时候,接收端并不是ack4,也不是等待。而是主动ack2
- 收到4,5同理,一直主动ack2
- 客户端收到三次ack2,就重传2
- 2收到后,结合之前收到的3,4,5,直接ack6
3.2.3 快速重传的利弊
- 解决了被动等待timeout的问题
- 无法解决重传之前的一个,还是所有的问题。
- 上面的例子中是重传2,还是重传2,3,4,5。因为并不清楚ack2是谁传回来的
3.3 SACK方法
3.3.1 概述
- 为了解决快速重传的缺点,一种更好的SACK重传策略被提出
- 基于快速重传,同时在tcp头里加了一个SACK的东西
- 解决了什么问题:客户端应该发送哪些超时包的问题
3.3.2 实现原理
- SACK记录一个数值范围,表示哪些数据收到了
- linux2.4后默认打开该功能,之前版本需要配置tcp-sack参数
- SACK只是一种辅助的方式,发送方不能完全依赖SACK。主要还是依赖ACK和timout
3.3.3 Duplicate SACK(D-SACK)
- 使用SACK标识的范围,还可以知道告知发送方,有哪些数据被重复接收了
- 可以让发送方知道:是发出去的包丢了,还是回来的ack包丢了
4. 超时时间的确定
4.1 背景
- 路由器和网络流量均会变化
- 所以超时时间肯定不能设置为一个固定值
- 超时长:重发慢,效率低,性能差
- 超时短:并没有丢就重发,导致网络拥塞,导致更多超时和更多重发
- tcp会追踪这些变化,并相应的动态改变超时时间(RTO)
4.2 如何动态改变
- 每次重传的时间间隔为上次的一倍,直到最大间隔为64s,称为“指数退避”
- 首次重传到最后放弃重传的时间间隔一般为9min
- 依赖以往的往返时间计算(RTT)动态的计算
4.3 往返时间(RTT)的计算方法
- 并不是简单的ack时间和发送时间的差值。因为有重传,网络阻塞等各种变化的因素。
- 而是通过采样多次数值,然后做估算
- tcp使用的方法有:
- 被平滑的RTT估计器
- 被平滑的均值偏差估计器
4.4. 重传时间的具体计算
- 计算往返时间(RTT),保存测量结果
- 通过测量结果维护一个被平滑的RTT估计器和被平滑的均值偏差估计器
- 根据这两个估计器计算下一次重传时间
5. 超时重传引发的问题-拥塞
5.1 为什么重传会引发拥塞
- 当网络延迟突然增加时,tcp会重传数据
- 但是过多的重传会导致网络负担加重,从而导致更大的延时和丢包,进入恶性循环
- 也就是tcp的拥塞问题
5.2 解决拥塞-拥塞控制的算法
- 慢启动:降低分组进入网络的传输速率
- 拥塞避免:处理丢失分组的算法
- 快速重传
- 快速恢复
六. 其他定时器
1. 坚持定时器
1.1 坚持定时器存在的意义
- 当窗口大小为0时,接收方会发送一个没有数据,只有窗口大小的ack
- 但是,如果这个ack丢失了会出现什么问题?双方可能因为等待而中止连接
- 坚持定时器周期性的向接收方查询窗口是否被增大。这些发出的报文段称为窗口探查
1.2 坚持定时器启动时机
- 发送方被通告接收方窗口大小为0时
1.3 与超时重传的相同和不同
- 相同:同样的重传时间间隔
- 不同:窗口探查从不放弃发送,直到窗口被打开或者进程被关闭。而超时重传到一定时间就放弃发送
2. 保活定时器
2.1 保活定时器存在的意义
- 当tcp上没有数据传输时,服务器如何检测到客户端是否还存活
参考
- 《tcp/ip详解 卷1:协议》
- coolshell.cn/articles/11…
- coolshell.cn/articles/11…