数据传输与MSS分段
TCP是一个面向字节流的协议,它不限应用层传输消息的长度,但是实际上,在TCP之下的网路层和数据链路层,由于它们发送报文时,所使用到的内存是有效的,所以它们会限制报文的长度。因此,TCP必须把它从应用层那里接收到的任意长度的字节流,切分成许多个报文段,那么拆分成报文段的依据,是什么呢?又是怎样进行拆分的呢?
这节课将讲解如何使用MSS(最大报文段大小)来进行TCP数据报文的拆分。
TCP应用层编程示例 首先,我们如果直接使用TCP的socker这样的库去编程的话,是怎样的呢?
+------------------+ +-------------------+
|int s = socket(2) | |int s = socket(2) |
| | |bind(2) |
|gethostbyname(3) | |listen(2) |
|connect(2) ---------------->|accept(2) |
+------------------+ +-------------------+
|read/write(2) | |read/write(2) |
| <------------------> |
+------------------+ +-------------------+
|close(2) | |close(2) |
| | | |
+------------------+ +-------------------+
client server
上面,client和server间,连理TCP连接的时候,client需要建立一个socket,调用connet方法;而我们的server,在accept中,会在成功建立连接之后,也就是连接的状态为ESTABLISHED以后,进入下一个阶段。也就是传输数据的阶段。在传输数据的时候呢,我们调用write,就是发送消息,write中我们可以传输一个字节,也可以传输100M;read呢,我们可以向已经分配好的内存中,去接收任意长度的消息。
那么实际上在操作系统中,会发生什么样的事情呢?
TCP流的操作
- read
- write
client server
| <---------handler |
+--------------+ +--------------+
|read write | |read write |
|{ } { } | |{ } { } |
|{ } | | socket layer |{ } | |
|{ } | | |{ } | |
| ^ | | r/w queues | ^ | |
| | | | | | | |
+--|-------|---+ +--|-------|---+
| | | | | | | |
| | | | tcp stack | | | |
+--|---|---V---+ +--|---|---V---+
|---------------------------------|
操作系统的TCP内核栈,会把我们发送这样的消息拆分成许多个segment报文段
TCP流与报文端
- 流分段的依据
- MSS: 防止IP层分段
- 流控:接收端的能力
+--------------------+ +--------------------+
|Application Protocol| |Application Protocol|
+--------------------+ +--------------------+
| ^
22 |
21 Byte Stream BytesUnpackaged 1
20 Sent By FromTCP Segment 2
19 Application And Passed to 3
| Application ^
V |
+---------------------+ +---------------------+
|Transmission Control | |Transmission Control |
| Protocol(TCP) | | Protocol(TCP) |
+---------------------+ +-----------^---------+
| |
+---------V-------------+BytesPackaged +---------------+Segment
|[H]{14}{15}{16}{17}{18}|IntoSegment |[H]{4}{5}{6}{7}|RemovedFrom
+-----------------------+ +--------^------+IP Datagram
| |
+---------------------+ +---------------------+
|InternetProtocol(IP) | |InternetProtocol(IP) |
+---------------------+ +------------^--------+
| |
+-+-------V-----------+ |
|H|[H]{10}{11}{12}{13}|Segment Encapsulated |
+-+-------------------+Into IP Datagram |
| |
| +------------+ |
Client --------->|H| [H]{8}{9}|----------->Server
+------------+
上面的图中,应用程序是直接发送了从1到22的字节的,也就是实际发送的是一个流,但是 被拆分成了很多个Segment的段,那么这个流分段的依据是什么呢?两个依据,一个是MSS(最大报文段大小), 如果TCP层不对报文分层的话,IP层一定会对报文进行分层,我们要避免IP层分层,因为IP层的分层是非常没有效率的; 第二就是流控,比如说我们的接收端,现在只能接受3个字节了,所以它只能发送三个字节过来,或者说现在 只能接受4个字节了,因为我这台服务器可能非常繁忙,我的内存可能不够用了,或者说我们应用程序 没有及时的去把我们的缓存区的内容读取出来,所以我现在,需要你进行流控。那么这个时候也会影响我们分段的大小。
MSS:Max Segment Size
- 定义:仅指TCP承载数据,不包含TCP头部的大小,参见RFC879
- MSS选择目的
- 尽量每个Segment报文段携带更多的数据,以减少头部空间占用比率
- 防止Segment被某个设备的IP层基于MTU拆分
- 默认MSS: 536字节(默认MTU576字节,20字节IP头部,20字节TCP头部)
- 握手阶段协商MSS
- MSS分类
- 发送方最大报文段SMSS: SENDER MAXIMUM SEGMENT SIZE
- 接收方最大报文段RMSS: RECEIVE MAXIMUM SEGMENT SIZE
我们握手阶段如何协商出MSS的值呢?
TCP握手常用选项
类型 | 总长度(字节) | 数据 | 描述 |
---|---|---|---|
0 | - | - | 选项列表末尾标识 |
1 | - | - | 无意义,用于32位对齐使用 |
2 | 4 | MSS值 | 握手时发送端告知可以接收的最大报文段大小 |
3 | 3 | 窗口移位 | 指明最大窗口扩展后的大小 |
4 | 2 | - | 表明支持SACK选择性确认中间报文段功能 |
5 | 可变 | 确认报文段 | 选择性确认窗口中间的Segments报文段 |
8 | 10 | Timestamps时间戳 | 用于更精准的计算RTT,及解决PAWS问题 |
14 | 3 | 校验和算法 | 双方认可后,可使用新的校验和算法 |
15 | 可变 | 校验和 | 当16位标准校验和放不下时,放置在这里 |
34 | 可变 | FOC | TFO中Cookie |
上面介绍了TCP如何将应用消息拆分成不同的Segment段,那么把这些Segment发送给对方的时候,如何确认对方已经收到了,如果对方没有收到,又如何进行重传呢?
重传与确认
TCP必须保证每个Segment段一定传给了对方,如何保证呢?就是重传与确认
下面将会介绍重传与确认,是如何演化为“滑动窗口的”,滑动窗口以后,就会定义TCP的序列号,以及确认序列号,我们也将介绍序列号的设计理念是什么。
如何解决报文丢失问题呢
- 方法一: PAR: Positive Acknowledgment with Retransmission(带重传的积极确认) 问题:效率低
Device A Device B
start SendMessage
| andStartTimer \
| *-----Message------> Receive Message
|
| Send Acknowledgment
V /
green Acknowledgment<-------Ack-------*
Received
start SendMessage
| andStartTimer \ X
| *-----Message------> MessageLost
| (Acknowledgment Not Sent)
|
V (Ack Not Receive)
red Timer Expiration
start Re-SendMessage
| andStartTimer \
| *-----Message------> Receive Re-Sent Message
|
| Send Acknowledgment
V /
green Acknowledgment<-------Ack-------*
Received
简单的使用一个定时器来重传,比如我发送第一个消息的时候,我会启动一个定时器,第一个消息我没有发送完的时候,我是不能发送第二个消息的,直到我收到了DeviceB发送过来的确认帧以后呢,而且在超时计时器之内,收到了,那么我就可以发送第二个消息了。
发送第二个消息的时候呢,我又重新启动了一个定时器,如果在规定的时间内呢,我没有收到第二个消息的确认,我就会重发第二个消息,重发的时候呢,我又重新去重启定时器。
这样我就完成了一个简单的重发与确认。
- 提升并发能力的PAR改进版
- 接收缓冲区的管理
- Limit限制发送方
Device A Device B
S Send #1 \
| *Message #1------------> Receive #1 and
| Send #2: _/ Send Acknowledgment
#1 S MustStop \ /
| | (SendLimitIs2) *Message #2---------/--> Receive #2 and
V | / _/ Send Acknowledgment
G #2 Ack#1Received:<----Ack#1 Limit=2-* /
S | CanNowSend#3 \ / Receive #3,Send
| V *Message #3--------/---> Acknowledgment and
| G Ack#2Received:<----Ack#2 Limit=2-* _/ Lower sendLimit
#3 S CanNowSend#4 \ /
| | *X Message#4 / > #4 Not Received
V | Ack#3Received: /
G #4 Cannot Send <----Ack#3 Limit=1-*
|(SendLimitNow1)
V
R TimerExpiration,\
S Re-Send #4 \
| *-Message #4--------> Receive #4, Send
| / Acknowledment and
#4 / Raise Limit Back to 2
| /
V Ack#4Received, <----Ack#4 Limit=2--*
S G Send#5And#6 \
| S(SendLimitNow2)\ \
| | \ *-Message#5----------> Receive #5, Send
#5 ... \ / Acknowledgment
| *-Message#6---------/>
/
<----Ack#5 Limit=2--*
在发送第一个消息的时候,我给消息加了一个#1的标识,当我的设备B来回传消息的时候呢,它也要说,我现在确认了是第一个消息。每个消息都有个标识,这样就可以并发的传大量的消息。
比如说,到了第4号的时候,结果丢失了,这时候呢,我们的定时器就会发现没有收到4号的确认帧,就会重发4号。那么后面5号6号依次类推。
但是这里有个问题,就是我的Device B的内存和处理能力都是有限的,我必须要限制Device A不能无限制的去发送,所以我们要通过Limit这种方式来告诉它,我现在还有一个缓存区。 那么,我现在还有两个缓存区,你再给我发送两个消息,你不能给我发送第三个消息。
这种改进版的PAR呢,它的并发能力是比较强的,但是和滑动窗口的区别还是非常大的。 因为这里只是在标识消息,每一条报文或者叫Segment段,有一个编号,但是呢,TCP中的序列号是针对每一个字节的。
Sequence序列号/Ack序列号
- 设计目的:解决应用层字节流的可靠发送
- 跟踪应用层的发送端数据是否送达
- 确认接收端有序的接收到字节流
- 序列号的值针对的是字节而不是报文
TCP序列号 TCP序列号其实是存在一个复用的问题的,比如说我们最初的时候,ISN(Initial Sequence Number),然后我们大概发送2^32次方字节,也就是4G多,这么多字节以后呢,如果我们这个连接仍然在使用的话,我们就要复用最初的ISN了。
那么这个复用有没有问题呢,在常非网络下,是有问题的。这个问题我们通常称之为PAWS。
PAWS(Project Against Wrapped Sequence numbers)
- 防止序列号回绕
The TCP Timestamps option can disambiguate segments with the same sequence numbers by providing an extra 32 bits of effective sequence number space.
Time Bytes Sent Send Seq.No. Send Timestamp Receive A 0G:1G 0G:1G 1 OK B 1G:2G 1G:2G 2 OK,but noe segment lost and retransmitted C 2G:3G 2G:3G 3 OK D 3G:4G 3G:4G 4 OK E 4G:5G 0G:1G 5 OK F 5G:6G 1G:2G 6 OK,but retransmitted segment reappears
比如说A时间点呢,我们发送了0G到1G这么多字节,那么这时候我们的相对序列号就是0G到1G;那么,B时间点,我们发送了1G到2G,我们相对序列号就是1G到2G,但是大家注意到,B时间发送的这个报文丢失掉了,那么可能在后面的F时间点又重传了。
C时间点,我发了个2G到3G,D时间点我发送了3G到4G;E时间点,我发送了个4G到5G,注意,这个时候,已经发生了序列号的回绕了,因为2^32只有4G多,我没有办法表达5G这样的一个字节了,所以又使用到了0G到1G了。
F时间点呢,我再发送5G到6G,就用到1G到2G了,但是B时间点重发的那个1G到2G,和F时间点发的新的1G到2G,那么同时发送到我们的接收端的时候,接收端就无法去判断,到底哪个才是真实的那个消息。可能就把消息给覆盖掉了。怎么解决这样的一个问题呢?通过TCP Timestamps就可以解决了,因为我们把这个发送这个消息的时候呢,把我们的时间戳也带上,比如A时间戳带上,B时间戳带上,这样我们在接收放的时候就知道这个1G到2G是B时间戳重发的,这个1G到2G,是F时间戳所发的,F大于B,那么就可以正常的去处理了。
BDP网路中的问题 当然我们的时间戳不仅仅应用于PAWS中,它还可以应用于更精准的计算RTO
- TCP timestamp
- 更精准的计算RTO
- PAWS
Kind: 8 //标记唯一的选项类型,比如window scale是3 Length: 10 bytes //标记Timestamps选项的字节数 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | Kind=8 | Length=10 | TS Value (TSval) | TS ECho Reply (TSecr) | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 1 4 4
重传的时候呢,需要设置一个定时器,这个定时器的时间,究竟应该设置为多大,是非常有技术含量的。
RTO重传定时器的计算
如何测量RTT?
Server Client
| /|-
-|<--------SYN-----------* |
|\ | RTT(Round-trip time)
RTT | *-----SYN,ACK---------->|-
| /|
-|<----------------------* |
V V
计算这个RTT,其实有一个最简单的方法,因为我们每次发送的报文都有一个Sequence序列号,当我们收到回来的那个ACK的时候呢,这个ACK的确认报文的Sequence,跟我们之前发送的Sequence是可以对照起来的,而我们发送的每个Sequence在我们的kernel中呢,都会有一个数据结构叫做TCB(Transmission Control Block),这个模块是有一个时间的,我们一减就可以算出这个RTT了。
但实际上呢,RTT的计算更为复杂,因为这里涉及到重传。
如何在重传下有效测量RTT?
Sender Receiver Sender Receiver
-|- | |- |
1*RTT | \ | | \ |
| OriginalTransmission-->| | OriginalTransmission-->|
| | | /|
|- | -|\_ / |
| \ | | \.-------ACK--------* |
| Retransmission-------->| 1*RTT | /\ |
| /| -|</ *-Retransmission---->|
-|<------ACK-------------* | | |
Sender Receiver Sender Receiver
RTO| RTT1| |- | | | -|- |
| | | \ | RTO | RTT1| | \ |
| | | *------------>| V | | *------>X |
| | | /| | -|- |
V | -|\_ / | | | | \ |
|RTT2| | \----------* | |RTT2| *------------>|
V V | /\ | V V | /|
-|<* *---------->| -|<-------------* |
a)RTT1 is the correct RTT value b)RTT2 is the correct RTT value
- RTT测量的第二种方法
为了照顾上面的两种情况,需要使用到Timestamp
- 发送时间
- 数据包中Timestamp选项的回显时间
- 发送时间
Kind: 8 //标记唯一的选项类型,比如window scale是3
Length: 10 bytes //标记Timestamps选项的字节数
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
| Kind=8 | Length=10 | TS Value (TSval) | TS ECho Reply (TSecr) |
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 1 4 4
使用Timestamp可以精准的测量RTT之后,我们再来讨论RTO应该设置多大
RTO(Retransmission TimeOUT)应当设多大
- RTO应当略大于RTT
Sender Receiver Sender Receiver
| |- | | -|- |
RTO| | \ | | | \ |
V | *--------------->| RTO| | *----------------->|
|\__ /| | | /|
| \.------------* | | | X<------* |
| /\ | V _| |
|<-* *------------>| |\ |
| *------------------>|
a) RTO too small b) RTO too large
unnecessary retransmission slow reaction on losses
RTO的总体策略应该是略大于RTT,但是实际上这个RTT是经常变化的,因为我们网络是经常变化的, 我们的网络路径也可能重新选择。
由于RTT是经常波动的,所以RTO应该考虑平滑性
ROT应当更平滑
- 平滑RTO: RFC793,降低瞬时变化
- SRTT (smoothed round-trip time) =
(α*SRTT) + ((1-α)*RTT)
- α从0到1 (RFC推荐0.9),越大越平滑
- RTO =
min[UBOUND, max[LBOUND,(β*SRTT)]]
- 如UBOUND为1分钟,LBOUND为1秒钟,β从1.3到2之间
- 不适用于RTT波动大(方差大)的场景 在RTT波动大(方差大)时,那么这个RTO就总是慢了一步,那么因为过小因为过大,都会造成网络效率下降的问题,所以现在实际上我们大部分操作系统并没有采用这种方案,Linux中采用的方案是追踪RTT方差
- SRTT (smoothed round-trip time) =
追踪RTT方差
- RFC6298(RFC2988),其中α=1/8,β=1/4,K=4,G为Kernel最小时间颗粒:
- 首次计算RTO,R为第一次测量出的RTT
- SRTT (smoothed round-trip time) = R
- RTTVAR (round-trip time variation) = R/2
- RTO = SRTT + max(G, K*RTTVAR)
- 后续计算RTO,R’为最新测量出的RTT(瞬时RTT)
- SRTT = (1-α)SRTT + αR'
- RTTVAR = (1-β)RTTVAR + β |SRTT-R'|
- RTO = SRTT + max(G,K*RTTVAR)
- 首次计算RTO,R为第一次测量出的RTT
α,β,K的值,来源于大量的测试数据中所统计总结出来的,其实没有什么理论基础,但是实际上效果却不错,所以我们大部分的操作系统都在使用这样的一个方式来计算我们的RTO。
发送数据已经重传数据时,我们还必须考虑接收方的一个处理能力:基于滑动窗口来处理数据。
滑动窗口:发送窗口与接收窗口
滑动窗口分为发送窗口和接收窗口 滑动窗口:发送窗口快照
- 已发送并收到Ack确认的数据: 1-31字节
- 已发送未收到Ack确认的数据:32-45字节
- 未发送但总大小在接收方处理范围内:46-51字节
- 未发送但总大小超出接收方处理范围:52-字节
可用窗口/发送窗口
- 可用窗口: 46-51字节 / 发送窗口:32-51字节 1-31: Left Edge of Send Window 32-45: Window Already Sent(14 bytes) -| |- Send Window(20 bytes) 46-51: Usable Window(6 bytes) -| 52-: Right Edge of Send Window
当我们把刚刚可用窗口中的46-51字节发送出去的时候,我们可用窗口就耗尽了。 但是我们发送窗口其实是没有变化的。
我们之前的发送窗口是32-51字节,如果32-36字节,这5个字节我们收到了ACK的确认, 那么此时我们发送窗口的大小没有发生变化的时候呢,我们发送窗口就可以右移5个字节, 因为我收到了5个字节,所以接下来52-56字节又变成了可用窗口,所以我们接下来又可以 发送这5个字节了。
发送窗口
- SND.WND : 发送窗口的大小
- SND.UNA : 指针,指向第一个已发送但还没有收到ACK确认的地方(Unacknowledged)
- SND.NXT : 指针,指向已发送未确认的下一个字节,也就是可用窗口的第一个字节。
{#1 已发送已接收}{#2 已发送未接收}{#3 未发送且在发送窗口内}{#4 未发送且在发送窗口外}
所以可用窗口的计算公式为:Usable Window Size = SND.UNA+SND.WND-SND.NXT=32+20-46=6
约等于对端发送窗口的接收窗口
- RCV.WND
- RCV.NXT : Receive Next Pointer (接收窗口的最左端指针)
为什么是约等于呢?实际上我们的滑动窗口并不是一层不变的,比如说我们的接收窗口,如果我们的应用进程读取的速度非常快的话,我可能我的接收窗口可以很快空出来,而空出来以后呢,我怎样传递给对端呢?是需要通过TCP报文中的Windows字段来传递的,那么这个传递是有时间延迟的,所以他们其实是约等于的一个关系。
窗口的滑动与流量控制
下面窗口滑动示例的两个前提条件:MSS不产生影响,窗口不变 因为MSS与网络路径相关,而我们TCP连接中的网络路径是有可能发生变化的,那么这里我们简化,让MSS不产生影响。 不管是发送窗口还是接受窗口,都与我们操作系统的缓冲区是相关的,也与我们进程读取我们缓冲区的速度有关,这里为了方便理解,也假定窗口不会发生变化。
客户端 服务器
-------------------------------- -------------------------------
步骤 SND.UNA SND.NXT SND.WND RCV.NXT RCV.WND 步骤 SND.UNA SND.NXT SND.WND RCV.NXT RCV.WND
(初始设置) 1 1 360 241 200 (初始设置) 241 241 200 1 360
1.发送140字节请 1 141 360 241 200 (等待) 241 241 200 1 360
求
(等待) 1 141 360 241 200 2.接收请求,发送80 241 321 200 141 360
字节响应及ACK
3.接收响应ACK, 141 141 360 321 200 4.发送响应文件第1 241 441 200 141 360
发送ACK 部分120字节
5.接收响应文件第1 141 141 360 441 200 6.接收到第2步80字 321 441 200 141 360
部分,发送ACK 节的ACk
(等待) 141 141 360 441 200 7.接收到第4步ACK 441 441 200 141 360
(等待) 141 141 360 441 200 8.发送文件第2部分 441 601 200 141 360
160字节
9.接收到文件第2部 141 141 360 601 200 (等待) 441 601 200 141 360
分,发送ACK
完成 141 141 360 601 200 10.接收到第8步的 601 601 200 141 360
ACK
上面的例子中,有一个客户端和一个服务器,客户端的发送窗口是360字节,所以我们服务器端的接收窗口也是360字节。 而我们客户端的接收窗口是200字节,因此我们服务器的发送窗口也是200字节。注意,我们客户端的初始Sequence是1, 服务器呢,我们让它从241开始。首先客户端要发送一个请求,我们可以把它理解为http的一个GET请求,这个请求呢, 是140字节,因此它的SND.NXT要从1编程141。
那么发送到服务器呢,服务器在第二步终于接收到了,接收到以后呢,它要 返回一个响应,我们假定它的响应首先是由一个80字节的Header头部然后还有一个280字节的body(HTTP响应太长了,使用两次发送)。 首先我们发送这个80字节的头部,并且告诉客户端这140字节我已经收到了,我还要发送一个ACK,要完成这样两件事呢, 它的发送窗口和接收窗口都需要发生变化,首先接收窗口发生变化,RCV.NXT由1变成了141, 因为,我们收到了140个字节,但是我的接收窗口仍然是360,因为这个时候我们假定我们的应用进程快速的从 我们kernel中的缓冲区把这140个字节读取出来了,所以我们360没有发生变化。 然后我们发送了80字节的Header,因此我们SND.NXT,从241变成了321,而我们的发送窗口仍然没有发生变化。
第三步的时候呢,我们客户端收到了这80个字节,因此它的接收窗口从241变成了321,它同时也收到了那个ACK,那个ACK说 我收到了140个字节,所以要把SUD.UNA改为141。第三步同时还会发送一个ACK,就是告诉服务器说,你发送这80个字节的Header, 我已经收到了,那第三步这个ACK什么时候到呢?需要到第六步才能到。第四步的时候还没有到。
第四步的时候,服务器说我现在终于把文件读取出来了,这个文件是280字节,那我现在能不能发送280字节呢?这时候流控就 发生影响了。我们现在先看服务器的发送窗口,发送窗口有一个可用窗口的概念,那么此时的可用窗口是多大呢?我们可以 按照我们上一节课中所介绍的公式来看,因为我此时的发送窗口是200字节,但是我已发送但未被确认的字节是多少呢?是80字节, 此时我们第三步发送的ACK还没有到达,我们可以通过SND.NXT减去SND.UNA得到80,200减80,我还有120自己的一个可用窗口, 所以虽然我要发的是280字节,但我第4步只能发送120字节,发送完120字节以后呢,那我就把我的SND.NXT加上120字节得到441, 然后把文件的第一部分的120字节发送出去了。
到了第5步呢,假设客户端先收到了第一部分的文件数据,这个时候呢,它的接收窗口增加120字节,也就是从321加到了441,同时 又发送了一个ACK。
到第六步的时候,服务器先收到了第三步的ACK,第五步的ACK现在还没有收到,这时候呢,它就知道那80字节已经被确认了, 所以呢,它的SND.UNA就是发送窗口中的已发送未确认,移动80个字节,241加80,等于321,事实上,第六步的时候呢,我们 可用窗口已经从0涨到了80字节了,但是此时,我们假设服务器端还没有准备发送第二部分
到第七步的时候呢,我们终于收到了第五步中返回的ACK了,那么又腾出了120字节,所以此时服务器的可用窗口是200字节,因为SND.NXT减去SND.UNA是等于0,所以我们可用窗口是200字节。
所以第八步的时候,我们就会发送文件的剩余的160字节,那么发送完以后呢,我们可用窗口就剩40字节了,此时我们的SND.NXT变成了601,SND.UNA还是441。
客户端终于在第九步收到了我们这一部分,所以它把它的接收窗口的RCV.NXT由441加上160字节,变成了601字节,并且发送了ACK。
第10步,服务器收到了160字节的ACK,所以此时它的可用窗口又恢复到了200字节。
上面演示了流量控制与滑动窗口的关系,这里假定发送窗口与接收窗口都是不变的,但是实际上他们与操作系统的缓存区却是相关的。
操作系统缓冲区与滑动窗口的关系
在上节课中我们假定发送窗口和接收窗口,都是不变的,但是实际上发送窗口与接收窗口中,所存放的字节数都是放在操作系统的缓存区的,而操作系统的缓冲区会被操作系统调整,而当我们的应用进程没有办法及时的读取缓冲区的内容时,也会对我们的缓冲区造成影响,那么这节课中呢,我们将来看一看操作系统的缓冲区是如何影响我们发送窗口和接收窗口的。
窗口与缓存
- 应用层没有及时读取缓存
Client Server
+-------------------------+ +-------------------------+
|SND.UNA=1 SND.WND=360 | | RCV.WND=360|
| V Usable=360 | | +------------+ |
| +----------V | | | | |
| | | | | ^------------+ |
| ^----------+ | | RCV.NXT=1 |
|SND.NXT=1 | +-------------------------+
+-------------------------+
1.Send 140-Byte Request--Request
+-------------------------+ Length=140
|SND.UNA=1 SND.WND=360 | SeqNum=1 ----------.
| V Usable=220 | \
| +---+------V | *>2.Receive Request;Send Ack,
| |140| | | *-AckNum=141----<-Reduce Window By 100
| +---^------+ | / Window=260 +-------------------------+
|SND.NXT=141 | / | RCV.WND=260|
+-------------------------+ / | +----+-------+ |
3.Reduce Send Window to 260<--* | |140 | | |
+-------------------------+ | +----^-------+ |
|SND.UNA=141 SND.WND=260 | | RCV.NXT=141 |
| V Usable=260 | +-------------------------+
| +---+---------V |
| |140| | |
| +---^---------+ |
|SND.NXT=141 |
+-------------------------+
4.Send 180-Byte Request--Request
+-------------------------+ Length=180
|SND.UNA=141 SND.WND=260 | SeqNum=141---------.
| V Usable=80 | \
| +---+------+--V | *>5.Receive Request;Send Ack,
| |140| 180 | | | *--AckNum=321---<-Reduce Window By 180
| +---+------^--+ | / Window=80 +-------------------------+
| SND.NXT=321 | / | RCV.WND=80 |
+-------------------------+ / | +----+----+--+ |
6.Reduce Send Window to 80<--* | |140 |180 | | |
+-------------------------+ | +----+----^--+ |
|SND.WND=80 SND.UNA=321 | | RCV.NXT=321|
|Usable=80 V | +-------------------------+
| +---+------+--+ |
| |140| 180 | | |
| +---+------^--+ |
| SND.NXT=321 |
+-------------------------+
7.Send 80-Byte Request--Request
+-------------------------+ Length=80
|SND.WND=80 SND.UNA=321 | SeqNum=321---------.
|Usable=0 V | \
| +---+------+--+ | *>8.Recieve Request;Send Ack,
| |140| 180 |80| | *--AckNum=401---<-Reduce Window By 80(to 0)
| +---+------+--^ | / Window=0 +-------------------------+
| SND.NXT=321 | / | RCV.WND=0 |
+-------------------------+ / | +----+----+--+ |
9.Reduce Send Window to 0<--* | |140 |180 |80| |
+-------------------------+ | +----+----+--^ |
|SND.WND=0 SND.UNA=401 | | RCV.NXT=401|
|Usable=0 V | +-------------------------+
| +---+------+--+ |
| |140| 180 |80| |
| +---+------+--^ |
| SND.NXT=401 |
+-------------------------+
我们客户端的发送窗口是360字节,那么它此时的可用发送窗口也是360字节,而我们服务器端的接收窗口此时也是360字节。
第一步,客户端发送了140字节给服务器,所以客户端的发送窗口仍然是360字节,但是它的可用窗口已经从360字节减到了220字节,我们服务器收到了这140字节以后呢,它的接收窗口却变为了260字节,为什么呢,我们的接收窗口不应该是不变的吗,因为此时我们应用进程其实只把这140字节读取了40字节,还有100字节我们进程没有读取,因此我们本来窗口仍然应该是360字节的,但是由于我们的缓冲区中的100字节没有被应用进程读取,所以被占用了,导致我们的接收窗口从360减100得到了260,这时候我们接收窗口的第一次收缩,当我们收缩完以后,我们会通过TCP报文中的Window字段,也就是把Window的值260带给了我们客户端。 我们客户端此时就会把它的发送窗口也从360减为了260。
到了第四步,我们Client又要发送180字节,这180字节呢是小于我们当前窗口的260字节的,所以我们把180字节发送完以后,我们的可用窗口从260变成了80,但是我的发送窗口仍然是260字节。
到第五步,我们的Server收到了这180字节以后呢,我们假定我们的应用进程仍然没有去读取新来的180字节,所以我们的接收窗口又得收缩了,我们原先是260字节,我们减掉这180字节,我们只剩下80字节了。所以我们在我们的ACK报文中,又去告诉我们的客户端,接收窗口从原先的280字节,减为80字节
所以,在第六步,我们的发送窗口已经减为80字节了。
所以,第七步,我们发送完80字节以后呢,就已经可用窗口为0了
发送给服务器以后呢,服务器在收到这80个字节以后呢,这个时候它的接收窗口突然收缩为0。为什么呢,仍然是之前相同的道理,我们应用进程也没有去读取这80个字节,所以我们现在缓存已经全部被应用缓存占满了。因此我们接收窗口收缩为0。然后在ACK报文中告诉Client,现在接收窗口已经为0了。
- 收缩窗口导致的丢包
- 先收缩窗口,再减少缓存
- 窗口关闭后,定时探测窗口大小
Client Server
+-------------------------+ +-------------------------+
|SND.UNA=1 SND.WND=360 | | RCV.WND=360|
| V Usable=360 | | +------------+ |
| +----------V | | | | |
| | | | | ^------------+ |
| ^----------+ | | RCV.NXT=1 |
|SND.NXT=1 | +-------------------------+
+-------------------------+
1.Send 140-Byte Request--Request
+-------------------------+ Length=140
|SND.UNA=1 SND.WND=360 | SeqNum=1 ----------.
| V Usable=220 | \ 2.Receive Request; Send Ack,
| +---+------V | *>Reduce Window by 260 to
| |140| | | .-Shrink Buffer from 360 to 240
| +---^------+ | / +-------------------------+
|SND.NXT=141 | AckNum=141 | RCV.WND=100|
+-------------------------+ Window=100 | +----+---+ |
3.Send 180-Byte Request -. / | |140 | | |
+-------------------------+ \ / | +----^---+ |
|SND.UNA=1 SND.WND=360 | Request / | RCV.NXT=141 |
| V Usable=40 | Length=180 / +-------------------------+
| +---+------V | SeqNum=141 /
| |140| 160 | | \ /
| +---+------^ | *-----/-------.
| SND.NXT=141 | / \
+-------------------------+ / *>4.Receive Request;Too Large
5.Receive Ack;Try to Reduce / To Fit Into Buffer
Window Size to 100,But Too <--------* +-------------------------+
Much Data Already Sent | RCV.WND=100|
+-------------------------+ | +----+---+---+ |
|SND.UNA=1 SND.WND=100 | | |140 | 180 |???|
| V Usable=-80 | | +----^---+---+ |
| +---+---+--+ | | RCV.NXT=141 |
| |140| 180 | | +-------------------------+
| +---+---+--^ |
| SND.NXT=321 |
+-------------------------+
Right Edge of Send Window
Moves to Left
首先客户端发送一个140字节的请求,当然客户端发送窗口和服务器的接收窗口都是360字节。 发送完这140字节以后呢,到了我们服务器上,服务器上突然发生了缓存收缩的行为,比如说 我们服务器本来运行的是100个进程,如果此时变为500个进程以后,这时候我们的内存是 紧张的,内存紧张以后呢,我们就可以把每一个socket或者说每个TCP连接的缓存往下降一点, 此时我们把原先360自己的缓存降为了240字节,这时候我们收到了这140字节,但是呢我们的 应用进程仍然没有及时的去读取这140字节,所以此时我们的接收窗口就会直接变为100字节, 也就是240减140,得到100字节。然后在我们ACK中,我们回了一个Window为100字节。但是我们 的ACK到达是比较久的,所以在第三步的时候,那么我们的Client,并不知道此时我Server的 接收窗口只有100字节了,那么在它看来仍然是360减140,我们的可用窗口还是220字节。 所以它此时又去发送了一个180字节的请求,这180字节其实已经大于100字节了。 所以它发送到,我们第四步收到的时候看,已经超出了缓冲区的大小,所以我们只能把这个 报文丢掉,而到了第五步我们的Client才终于收到了之前的ACK报文,告诉客户端,我们现在 发送窗口只有100字节了,但是此时我已发送但未确认的字节是180字节,因此必须把我们窗口的 右端向左收缩80个字节。
当然我们实际的操作系统是不会让这种情况发生的,通常呢,他们不会既收缩窗口,也同时 减少缓存,而是采用先收缩窗口,过一段时间我再减少缓存,就避免了这种丢包情况。
上一个例子和这一个例子,都发生了窗口关闭,那什么是窗口关闭呢?也就是我们的发送窗口等于0了。 那这个时候,我们是不能被动的等待对方发送一个TCP报文,其中报文中带上一个Window,且值大于0, 告诉我们窗口打开了,因为此时我们Server可能不再发送报文了,或者说也不需要发送ACK,因为它之前 所有的ACK都已经发送给Client了。所以这时候我们Client要定时的发送一种探测窗口的报文给Server, 如果Server此时的Window从0变为整数,打开以后,我们Client就可以及时的知道,那么我们从操作系统 中的关于这个接收缓存到底应该配置多大是比较合适的呢?
首先我们应该搞清楚,接收窗口或者发送窗口中的最大值应该是多少比较合适,这个时候我们就需要考虑实际上飞行中的报文数量,需要多少比较合适。 飞行中报文的适合数量
bps*RTT
比如说,我们现在有一个100M的带宽,它的时延,也就是说我们的RTT,是1s的话,那么实际上最佳的发送窗口和接收窗口就应该是100M*1s
,最后我们得到的值,就是最适合的发送窗口和发送窗口,我们也把它叫做带宽时延积。
当然带宽时延积,只能指导我们设置接收窗口与发送窗口的大小,但是实际上我们操作系统分配的缓存不止用于接收窗口和发送窗口,它还用在我们的应用缓存中上。
Linux下调整接受窗口与应用缓存
net.ipv4.tcp_adv_win_scale=1
应用缓存=buffer/(2^tcp_adv_win_scale)
Linux中对TCP缓冲区的调整方式
net.ipv4.tcp_rmem = 4096 87380 6291456
- 读缓存最小值、默认值、最大值,单位字节,覆盖
net.core.rmem_max
- 读缓存最小值、默认值、最大值,单位字节,覆盖
net.ipv4.tcp_wmem = 4096 16384 4194304
- 写缓存最小值、默认值、最大值,单位字节,覆盖
net.core.wmem_max
- 写缓存最小值、默认值、最大值,单位字节,覆盖
net.ipv4.tcp_mem = 1541646 2055528 3083292
- 系统无内存压力、启动压力模式阈值、最大值,单位为页的数量
net.ipv4.tcp_moderate_rcvbuf = 1
- 开启自动调整缓存模式
如何减少小报文提高网络效率
当我们TCP segment报文段中所承载的信息数据非常小的时候,例如只有几个字节,那么整个网路的效率是很低的,为什么呢,因为每个TCP Segment中都会有固定的20个字节的TCP头部,也会有固定的20字节的IP头部,在整个报文中,有效信息所占的比重就会非常小,所以我们应当尽量在合理的范围内,避免大量的传输小报文。 这节课中我们将介绍减少小报文发送的几种策略。
SWS(Silly Window syndrome)糊涂窗口综合征
- 小窗口通知
Client Server
+-------------------------+ +-------------------------+
|SND.UNA=1 SND.WND=360 | | RCV.WND=360|
| V Usable=360 | | +------------+ |
| +----------V | | | | |
| | | | | ^------------+ |
| ^----------+ | | RCV.NXT=1 |
|SND.NXT=1 | +-------------------------+
+-------------------------+
1.Send 360-Byte Segment--Segment
+-------------------------+ Length=360
|SND.UNA=1 SND.WND=360 | SeqNum=1 ----------.
| V Usable=0 | \
| +----------+ | *>2.Receive Segment;Send Ack,
| |360 | | *-AckNum=361----<-Reduce Window By 120
| +----------^ | / Window=120 +-------------------------+
| SND.NXT=361 | / | RCV.WND=120|
+-------------------------+ / | +-------+-----+ |
3.Reduce Send Window to 120<--* | |240/360| | |
Send 120-Byte Segment--Segment | +-------^-----+ |
+-------------------------+ Length=120 | RCV.NXT=361 |
|SND.WND=120 SND.UNA=361 | SeqNum=361----------. +-------------------------+
| Usable=0 | | \
| +----------V---+ | *>4.ReceiveSegment;Send Ack,
| |360 |120| | *-AckNum=481-----<-Reduce Window To 80
| +----------+---^ | / Window=80 +-------------------------+
| SND.NXT=481 | / | RCV.WND=80 |
+-------------------------+ / | +-------+--+--+ |
3.Reduce Send Window to 80:<--* | |240/360|80| | |
Send 80-Byte Segment--Request | +-------+--^--+ |
+-------------------------+ Length=80 | RCV.NXT=481|
|SND.WND=80 SND.UNA=481 | SeqNum=481---------. +-------------------------+
| Usable=0 | | \
| +----------V---+ | *>4.Receive Request;Send Ack,
| | 360 |120| | *--AckNum=561---<-Reduce Window To 67
| +----------+---^ | / Window=67 +-------------------------+
| SND.NXT=481 | / | RCV.WND=80 |
+-------------------------+ / | +-------+--+-++ |
6.Reduce Send Window to 80<--* | |240/360|80| || |
Send 80-Byte Segmnt | +-------+--+-++ |
+-------------------------+ | RCV.NXT=561|
|SND.WND=80 SND.UNA=481 | +-------------------------+
|Usable=0 V |
| +----------+---+--+ |
| | 360 |120|80| |
| +----------+---+--^ |
| SND.NXT=561|
+-------------------------+
它假设的场景是,Server现在非常的繁忙。Client的发送窗口是360字节,Server的接收窗口也是360字节。现在Client要发送一个很大的文件给Server,首先它肯定是把发送窗口的360字节全部占用,发送一个360字节的报文,到达了我们Server以后呢,我们Server其实此时是很繁忙的,这个时候我们Server端的应用进程,它只有能力读取完120字节,剩余的240字节仍然停留在我们linux kernel中的缓冲区中,因此此时的发送窗口就从360字节减低为120字节了。我们把120字节的接收窗口通过ACK传递给Client,Client在第三步,因为它有大量的数据要发送,此时呢,它只能发送120字节了。这120字节到达Server以后呢,我们Server原先处理报文的速度就已经很慢了,此时Server只能再次把40字节传递给我们的应用进程,所以此时接收窗口就从120字节降低为80字节,并通过ACK传递给我们的Client。而我们Client呢,发现可用窗口又编程80字节了,它于是急不可耐的又发了80字节给我们的Server。而我们的Server呢,此时可能只处理了13个字节,接下来就把剩下的接收窗口降为67字节,又告诉了我们的Client。以此往复下去,那么每次发送的字节数都会非常的少,整个网络的效率很低。而实际上更好的解决方案是应该等一等Server,让它有时间让它的应用进程把这些缓冲区的数据全部处理掉,这样就不会出现反复的去传输网络效率非常低下的这样的TCP Segment了。
SWS避免算法
- 接收方
- David D Clark算法:窗口边界移动值小于
min(MSS,缓存/2)
时,通知窗口为0
- David D Clark算法:窗口边界移动值小于
- 发送方
- Nagle算法:
TCP_NODELAY
用于关闭Nagle算法- 没有已发送未确认报文段时,立刻发送数据
- 存在未确认报文段时,直到:1-没有已发送未确认报文段,或者2-数据长度达到MSS时再发送
- Nagle算法:
Nagle's Alg
+-----------+
"H" |\ |
"E" | *->"H"->. |
"L" | \|
"L" | /|
"O" | .<-ACK-<* |
|/ |
|\ |
| *->ELL->. |
| \|
| /|
| .<-ACK-<* |
|/ |
|\ |
| *->"O"->. |
| \|
| /|
| .<-ACK-<* |
|/ |
| |
比如说,我们现在在telnet中,或者ssh中,我们打HELLO,如果一个字符一个字符的发送过去,相对来说网络效率就比较低。Nagle算法呢,它认为可以这么来做,就是没有已发送未确认报文段时,立刻发送数据。比如说我现在输了一个“H”,那么此时呢,没有已发送未确认报文段,所以我就把“H”立即发送出去了。如果存在未确认报文段时呢,直到两种情况下,我们才会再发送,第一,没有已发送未确认报文段,第二数据长度达到MSS再发送。比如,在发送完"H"后呢,就不能马上发送"ELL"了,需要等一下,两个条件,等ACK回来以后呢,我们的已发送未确认报文段就没有了,这个时候就把ELL又发送出去了。
事实上没有携带数据的ACK,它的网络效率也是很低的。因为也有40个字节的IP/TCP头部,实际上我只是传输了一个ACK的信息的含义在里面。我们衍生出了TCP delayed acknowledgment。
TCP delayed acknowledgment延迟确认
- 当有响应数据要发送时,ack会随着响应数据立即发送给对方
- 如果没有响应数据,ack的发送将会有一个延迟,以等待看是否有响应数据可以一起发送
- 如果在等待发送ack期间,对方的第二个数据段又到达了,这时要立即发送ack
HZ是时钟频率,可以通过下面的命令查询:
Nagle's Alg Nagle's Delay
+-----------+ +-----------+
"H" |\ | "H" |\ |
"E" | *->"H"->. | "E" | *->"H"->. |
"L" | \| "L" | \|
"L" | /| "L" | ||
"O" | .<-ACK-<* | "O" | ACK ||
|/ | | Delay-||
|\ | | 500ms ||
| *->ELL->. | | ||
| \| | ||
| /| | /|
| .<-ACK-<* | | .<-ACK-<* |
|/ | |/ |
|\ | |\ |
| *->"O"->. | | *->ELLO>. |
| \| | \|
| /| | /|
| .<-ACK-<* | | .<-ACK-<* |
|/ | |/ |
| | | |
但是Nagle算法和延迟确认是会产生一个问题的,比如我们Nagle算法中,本身是要发送一个"H", 因为此时有一个已发送未确认了,所以我们"ELL"要等一会儿才能发,等到"ELL"的ACK返回了以后, 才能发"O"。但是如果接收方有一个延迟确认的话,就会有问题了。比如说,我们发送"HELLO"的时候, 因为"H"已经发送出去了,这是一个小报文,小报文呢因为长时间我们没有发送ELL过去,所以它的 延迟确认会等上很长的时间,比如说我们等500ms,才会发ACK,这样我整个网络效率就比较低。
Nagle VS delayed ACK
App TCP/IP Network TCP/IP App
| | | |
W1:write() less | | | |
------------->| | | |
than MSS |--->| | |
| |\ TCP segment | |
W2:write() less | | *------------->| |
------------->| | with W1 |---->|read()W1
than MSS | | |^ |
| | || |
| | |200ms|
| | |ACK |
| | |Delay|
| | || |
| | |V |
| | .<-ACKforW1---<| |
| |/ | |
|--->| | |
| |\ TCP segment | |
| | *------------->| |
| | with W2 |---->|read() W2
那么我们怎样解决Nagle算法下一定会有一个小报文在网络中发送,那么后续的报文都在等这个小报文 的ACK,才能继续发送的时候,而我们的延迟确认又导致我们这个ACK Delay一定会发生,其实这个时候 只有两种办法。
- 关闭delayed ACK:
TCP_QUICKACK
- 关闭Nagle:
TCP_NODELAY
Linux上更为激进的"Nagle": TCP_CORK
- 结合sendfile零拷贝技术使用
因为Nagle算法中,还允许网络中有一个已发送未确认的小报文,而
TCP_CORK
呢,则要求所有网络中的报文都必须是大报文,当然TCP_CORK
有使用限制,它必须结合sendfile零拷贝技术。
什么叫做sendfile零拷贝技术呢,也就是说,我们本身在Linux上需要把一个磁盘中的文件通过TCP发给客户端,那么在这样一个场景下呢,如果不使用sendfile的时候,我们需要先把这个文件读取到用户态的内存中,再同用户态的内存中呢,发给Linux kernel中的TCP缓冲区,经过发送窗口,再发送给我们的Client。但是有了sendfile,就可以直接由内核把磁盘中的文件读入到TCP的发送缓冲区中,直接进行发送,那么这样就减少了两次拷贝,所以我们把它叫做零拷贝技术。
那么使用sendfile的时候呢,我们才能打开TCP_CORK
这样的一个功能。
由于TCP协议向应用层提供不定长的字节流发送方法,使得TCP协议先天性的就有意愿去占满网络中的整个带宽,但是当网络中许多TCP连接同时视图去占满整个带宽的时候,就有可能发生恶性拥塞事件,而TCP的拥塞控制算法呢,则是非常有必要的,可以有效地降低网络中的拥塞,提升所有TCP连接的发送速度。
链路吞吐量 ^ 理想情况
1000M/s|----+----------------------------------
| /
| / . * * .->实际情况
| /.* | .
|/* | | \ 死锁
+---------------V------------------------->
|< >|< >| 输入负载
轻度 拥塞
拥塞
R1
(X)
\700M/s
\ R3 1000M带宽 R4
(X)-------------------(X)
R2 /
(X) /600M/s
在上面的网络拓扑图中,昨天的Client向右边的Server进行链路通信的时候,中间的这条链路它 实际的带宽应该是1000M/s,理想中的情况,应该是这样的,当它还没有达到1000M的时候,它是缓慢上升的,到了1000M以后就保持在1000M的一个水平。但是实际上是达不到的,比如,我现在从R1过来的流量是700M/s,从R2过来的流量是600M/s,但是实际上我们R3能够传出的流量是1000M/s,所以一定会有300M/s的数据被R3所丢掉了,当R3发生丢弃过载的数据包的时候,那么R3上的队列,其实也会非常的长,使得我们每一个报文它所在网路中待得的时间也会更长,我们的RTT时延也会过长。
所以实际的情况是这样的,当轻度拥塞的时候,我们的吞吐量是有些下降的,当重度拥塞的时候,实际上我们的网络是非常糟糕的。
拥塞控制历史
- 以丢包作为依据
- New Reno: RFC6582
- BIC: Linux2.6.8 - 2.6.18
- CUBIX (RFC8312) : Linux2.6.19 - 4.9
- 以探测带宽作为依据
- BBR: Linux4.9
实际上以丢包作为依据的算法是有很多问题的,所以谷歌提出了以探测带宽作为依据的BBR算法,Linux4.9内核中就已经引入了BBR算法。
本文发表于 0001-01-01,最后修改于 0001-01-01。
本站永久域名「 jiavvc.top 」,也可搜索「 后浪笔记一零二四 」找到我。