TCP 协议
TCP 协议
TCP 概述
TCP 是面向连接的、可靠的、基于字节流的全双工传输层通信协议
- 面向连接 - 单个发送方与单个接收方进行连接,在发送数据前两个进程需要先通过握手(交换控制报文)建立连接(初始化发送方、接收方的状态变量)
- 可靠 - 无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端,TCP 报文是有序的,当前一个 TCP 报文没有收到的时候持续等待,同时对重复的 TCP 报文自动丢弃
- 基于字节流 - TCP 将数据看成一个无结构的、有序的流,用户消息通过 TCP 协议传输时可能会被操作系统分组成多个的 TCP 报文进入流中,接收方程序必须知道消息的边界以读出一个有效的用户消息
报文格式
TCP 首部信息:
- 源端口号 (16bit)
- 目标端口号 (16bit)
- 序列号 (32bit) - 报文段首字节在字节流中的编号
- 确认号 (32bit) - 期望从另一方收到的下一个字节的序号
- 接收窗口 (16bit) - 愿意接收的字节数量
- 首部长度/数据偏移 (4bit) - TCP 首部长度(以4bytes(32bits) 的字为单位)
- 保留字段 (6bit) - 保留为今后使用,不使用
- 标志字段 (6bit)
- ACK - 指示确认应答字段值的有效性
- RST、SYN和FIN - 用于连接建立和断开
- URG - 紧急数据,不使用
- PSH - 马上推出(立即上交)数据,不使用
- 校验和 (16bit)
- 紧急数据指针 (16bit) - 不使用
- 选项 (不定长)
- 填充 - 使整个首部长度是 32bit(字大小)的整数倍
报文编号
TCP隐式地对字节流中的每一个字节编号,序列号是当前报文段首字节的字节流编号,确认号为希望对方主机发送的下一报文段所含数据的首字节的编号
序号到达2^32-1后重新从0开始
一条 TCP 连接的双方均可随机地选择初始序列号,这样做可以减少两台主机的新建连接受到仍在网络中存在的过期连接的报文段的影响
序列号 = 上一次发送的序列号 + len(上一报文的数据长度)
特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为上一次发送的序列号 + 1
确认号 = 上一次收到的报文中的序列号 + len(上一报文的数据长度)
特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为上一次收到的报文中的序列号 + 1
TCP 采用累积确认/累积应答模式,接收方发送确认号 X 代表 X 之前的所有数据都已收到
没有携带数据的 ACK 报文也有 40 个字节的 IP 头和 TCP 头,单独发送的效率很低
为了解决 ACK 传输效率低问题,衍生出了 TCP 延迟确认
TCP 延迟确认的策略:
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
- 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
拆包&粘包
TCP 传输协议是面向流的,没有数据包界限,客户端向服务端发送数据时,可能将一个完整的报文拆分成多个小报文进行发送,也可能将多个报文合并成一个大的报文进行发送,因此就有了拆包和粘包
产生原因:
- 拆包:网络通信每次可以发送的数据包大小受 MTU 传输单元大小、MSS 最大分段大小、滑动窗口等多种因素限制,如果一次传输的网络包数据大小超过传输单元大小,那么数据可能会拆分为多个数据包发送出去
- 粘包:当两个消息的某个部分内容被分到同一个 TCP 报文时,接收方如果不知道消息的边界就无法读出有效的消息
- 如果每次请求的网络包数据都很小,一共请求了 10000 次,TCP 并不会分别发送 10000 次,因为 TCP 采用的 Nagle 算法对此作出了优化,Nagle 算法可以理解为批量发送,在数据未得到确认之前先写入缓冲区,等待数据确认或者缓冲区积攒到一定大小再把数据包发送出去
拆包解决方案:
TCP 将数据依照最大报文段长度(MSS)分割,加上 TCP 首部后形成多个 TCP 报文段,然后下发至网络层
MSS通常根据最初确定的由本地发送主机发送的最大链路层帧长度(即所谓的最大传输单元(MTU))来设置
TCP 与 IP 首部长度之和通常为40bytes,以太网和 PPP 链路层协议都具有1500字节的 MTU,因此 MSS 的典型值为1460字节
TCP 通过 MSS 保证一个完整 TCP 报文段在 IP 层不被分片,以免包含部分 TCP 报文段的单个 IP 分片丢失时需要重传整个 TCP 报文,开销过大
为了达到最佳的传输效能, TCP 协议在建立连接的时候通常要协商双方的 MSS 值
粘包解决方案:定义应用层的通信协议
- 消息长度固定:每个数据报文长度固定,当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息,当发送方的数据小于固定长度时空位补齐,缺点在于固定长度值难以确定
- 特定分隔符:在每次发送报文的尾部加上特定分隔符,接收方根据分隔符进行消息拆分
- 消息长度+消息内容(最常用):消息头中存放消息的总长度,消息体存放实际的二进制的字节数据,接收方在解析数据时,首先读取消息头的长度字段 Len,然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文
连接管理
可靠性传输
TCP 的可靠数据传输为 GBN 和 SR 的混合体
超时重传
TCP 采用超时/重传机制来处理报文段的丢失问题,在发送数据时设定定时器,指定时间内未收到对方的 ACK 报文则重传
超时重传时间 RTO 根据往返延时(RTT)动态确定,应该略大于报文往返 RTT 的值,取得无效重传和等待重传时间过长问题间的平衡
推荐的初始 RTO 值为1秒,在第一次收到确认报文前的超时都将使 RTO 值成倍增加,以保证接收到首个确认报文段并更新 RTO
在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4
此外如果超时重发的数据再次超时又需要重传时,TCP 加倍超时间隔
快速重传
快速重传指发送方在定时器到期之前收到对同一报文段的3个额外 ACK,则立刻重传该报文段
快速重传避免了超时重传等待时间过长的问题
SACK
快速重传因为不确定丢失报文的数量,面临重传一个还是重传所有报文的问题
SACK 方法通过确认已收到的数据信息解决该问题
选择性确认 SACK在 ACK 报文的 TCP 头部选项字段里加入已收到的数据的信息
发送方由此可以知道哪些数据已被收到,只重传丢失的数据
D-SACK
Duplicate SACK 又称 D-SACK 使用 SACK 来告诉发送方有哪些数据被重复接收
用途:
- 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了
- 可以知道是不是「发送方」的数据包被网络延迟了
- 可以知道网络中是不是把「发送方」的数据包给复制了
流量控制 - 滑动窗口
滑动窗口指定发送方无需等待 ACK 报文而可以继续发送数据的最大值
通常窗口大小由接收方的窗口大小决定,依靠 TCP 头部接收窗口字段交流控制
发送方滑动窗口 swnd
SND.WND:表示发送窗口的大小(由接收方指定)
SND.UNA (Send Unacknowledged):一个绝对指针,指向已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节
SND.NXT:一个绝对指针,指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节
指向 #4 的第一个字节是个相对指针,值为 SND.UNA + SND.WND
可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)
接收方滑动窗口 rwnd
RCV.WND:表示接收窗口的大小,会被通告给发送方
RCV.NXT:一个绝对指针,指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节
指向 #4 的第一个字节是个相对指针,值为 RCV.NXT + RCV.WND
SND.NXT 即为下一 TCP 报文的序列号首部字段
RCV.NXT 即为下一 TCP 报文的确认号首部字段
滑动窗口的大小受系统内存缓冲区的限制,当缓冲区不够的情况下,如果服务器应用程序没有及时读取接收数据,接收窗口会不断收缩并通告客户端
当服务端系统资源非常紧张时,操作系统可能会直接减少接收缓冲区大小,进而减少接收方窗口大小,这时如果应用程序无法及时读取缓存数据,会出现数据包丢失的现象
为了防止这种情况发生,TCP 规定不允许同时减少缓存和收缩窗口,采用先收缩窗口,过段时间再减少缓存的方式避免丢包情况
零窗口
当发送方收到接收窗口为 0 的 ACK 报文时,将通过持续计时器间断发送仅1字节的窗口探测报文段,接收方收到后通过 ACK 报文反馈当前接收窗口大小
窗口探测的次数一般为 3 次,每次大约 30-60 秒
如果 3 次过后接收窗口还是 0 的话,部分 TCP 实现就会发 RST 报文来中断连接
糊涂窗口综合症
糊涂窗口综合症指接收方窗口过小导致发送方每个 TCP 报文都传输小数据,浪费资源
解决方案:
- 让接收方不通告小窗口给发送方
当接收窗口小于min(MSS,缓存空间/2)时,向发送方通告窗口大小为0 - 让发送方避免发送小数据
使用 Nagle 算法,该算法的思路是延时处理,只有满足以下条件中的任意一个,才可以发送数据:- 窗口大小 >= MSS 并且数据大小 >= MSS
- 收到之前发送数据的 ACK 响应报文
拥塞控制
网络所需传输的数据超过了网络的处理能力即引发网络拥塞
拥塞的代价:
- 为了达到一个有效输出,网络需要做更多的工作(重传)
- 由于传输速度大幅减慢出现超时引起的没有必要的重传
- 某一分组丢失时,任何用于传输这个分组的上游传输能力都被浪费
常见的拥塞控制方法包括端到端拥塞控制和网络辅助拥塞控制
端到端拥塞控制中端系统通过对网络行为的观察推断网络拥塞
网络辅助拥塞控制中路由器向发送方端系统提供网络拥塞的显式反馈信息
IP层不向端系统提供显式的网络拥塞反馈,因此TCP使用端到端拥塞控制,每一个发送方根据所感知到的网络拥塞程度来限制其能向连接发送流量的速率
TCP 连接的每一端都是由一个接收缓存、一个发送缓存和几个变量组成,运行在发送方的 TCP 拥塞控制机制跟踪一个额外的变量拥塞窗口 cwnd
发送窗口的值 swnd = min(cwnd, rwnd)
TCP 通过报文的超时感知网络拥塞,同时采用四种算法控制拥塞:
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
TCP 拥塞控制算法
TCP使用ACK来触发(或计时)增大它的拥塞窗口长度,感知到丢包时降低其发送速率
TCP拥塞控制算法使用AIMD(线性增、乘性减少),包括三个部分
- 慢启动(slow-start, SS)阶段
- 接收到的每个 ACK 报文使
cwnd += 1,因此每经过1RTT 后cwnd *= 2(指数型增长)
- 接收到的每个 ACK 报文使
- 拥塞避免(congestion-avoidance, CA)阶段
- 接收到的每个 ACK 报文使 cwnd 增加1/cwnd,也即每经过1RTT 后
cwnd += 1
- 接收到的每个 ACK 报文使 cwnd 增加1/cwnd,也即每经过1RTT 后
- 快速恢复(fast recovery,FR)阶段
- 对于每个冗余 ACK,
cwnd += 1,当丢失报文段的 ACK 到达时,令cwnd = ssthresh,进入 CA 阶段
- 对于每个冗余 ACK,
- 连接刚建立时,cwnd 通常置为1(单位为 MSS),连接处于 慢启动(slow-start, SS)阶段
- 接收到的每个 ACK 报文使
cwnd += 1,因此每经过1RTT 后cwnd *= 2(指数型增长)
- 接收到的每个 ACK 报文使
- 当拥塞窗口 cwnd 大小超过慢启动门限
ssthresh时进入拥塞避免(congestion-avoidance, CA)阶段- 接收到的每个 ACK 报文使 cwnd 增加1/cwnd,也即每经过1RTT 后
cwnd += 1
- 接收到的每个 ACK 报文使 cwnd 增加1/cwnd,也即每经过1RTT 后
- 发生超时重传时,
ssthresh = cwnd/2,cwnd 重设为初始值(1),再次进入 SS 阶段 - cwnd 增长至 ssthresh 值后再次进入 CA 阶段
- 发生快速重传时,
ssthresh = cwnd/2,cwnd = ssthresh+3(将3个冗余 ACK 计算在内),进入 FR 阶段- 对于每个冗余 ACK,
cwnd += 1- 如缺失报文段最终超时,则 cwnd 重设为初始值,进入 SS 阶段
- 收到缺失报文段的 ACK 后,令
cwnd = ssthresh,进入 CA 阶段
- 对于每个冗余 ACK,
一般来说
ssthresh的初始大小是65535bytes
快速恢复阶段分析:
ssthresh = cwnd/2,cwnd = ssthresh+3- 快速恢复首先减小 cwnd 以避免网络拥塞- 对于每个冗余 ACK,
cwnd += 1- 使得每个收到的重复的 ACK 包不占用拥塞窗口,目的是尽快将丢失的数据包发给目标
公平性
- 相同 RTT 下两条 TCP 连接基本实现平等地共享链路带宽
- 较小 RTT 能够享用更高吞吐量
- 无拥塞控制的 UDP 连接会压制 TCP 流量
- 应用程序可使用多个 TCP 连接来抢占带宽
TCP-Socket 编程
- 服务端和客户端初始化
socket,得到文件描述符 - 服务端调用
bind,将 socket 绑定在指定的 IP 地址和端口 - 服务端调用
listen,通过监听 socket 进行监听 - 服务端调用
accept,等待客户端连接 - 客户端调用
connect,向服务端的地址和端口发起连接请求 - 服务端
accept返回用于传输的已连接 socket 的文件描述符 - 客户端调用
write写入数据;服务端调用read读取数据 - 客户端断开连接时,会调用
close,那么服务端read读取数据的时候,就会读取到了EOF,待处理完数据后,服务端调用close,表示连接关闭
accpet 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列取出一个已经建立连接的 socket
客户端可以自己连自己形成连接(TCP 自连接),也可以两个客户端同时向对方发出请求建立连接(TCP 同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有 listen,就能 TCP 建立连接







