UDP
UDP的特点
无连接: 知道对端的IP和端口号就直接进行传输, 不需要建立连接;
不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息;
面向数据报: 不能够灵活的控制读写数据的次数和数量;
UDP报头
我们注意到,UDP协议首部中有一个16位的最大长度,也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部)。
面向数据报
应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并;
用UDP传输100个字节的数据,如果发送端调用一次sendto,发送100个字节,形成一个sk_buff类型的数据,那么接收端也必须调用对应的一次recvfrom,从这个sk_buff中取走所有100个字节的数据,而不能循环调用10次recvfrom,每次接收sk_buff中的10个字节。
UDP的缓冲区
udp的发送缓冲区
UDP没有真正意义上的发送缓冲区,udp的“发送缓冲区”,其实就是这个socket下的sk_write_queue队列及队列中每个sk_buff结构中data和tail之间的区域。
当你在应用层调用sendto函数时,系统通过更新struct sk_buff中指针char* data和char* tail指向的起始和结束位置,把用户级缓冲区中的数据拷贝到接收缓冲区中,形成有效载荷。
当需要把数据从传输层交给网络层时,系统会把struct udphdr中的报头信息,添加到有效载荷前面,更新char* data的位置,形成完整的udp报文。
发送队列中的sk_buff结构在sendto后马上形成并立即发送给网络层,形成一个发送一个,因此实际上也没有“队列”的说法。
udp的接收缓冲区
UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致,如果缓冲区满了,再到达的UDP数据就会被丢弃;
udp存在接收队列 sk_receive_queue;某个sockek从网络层收到属于它的sk_buff后,会把对应的sk_buff链接到接收队列中。这个sk_buff中data和tail之间的区域,是缓冲区。而多个sk_buff形成的接收队列 sk_receive_queue;就是我们所说的“udp的接收缓冲区”。
当应用层调用recvform时,再一次从接收队列中取走一整个sk_buff中的数据,把有效载荷提取出来返回给应用层。调用一次recvform,就取走一个sk_buff中的数据。
对于UDP套接字,数据报(datagram)的接收并不是在进程调用recvfrom之后才开始的。当UDP数据报到达网络接口时,操作系统会将其传递给网络栈,然后网络栈会查找是否有与该数据报的目的IP地址和端口号相匹配的UDP套接字。然后把数据,即整个sk_buff链接到UDP的接收缓冲区中。
如果接收缓冲区已满,则新到达的数据报可能会被丢弃,具体行为取决于socket的配置和系统的实现。
因此,每个UDP socket的接收缓冲区是独立的,不同的UDP socket之间不会共享接收缓冲区,因为它们下面的sk_buff是独立的。这意味着每个UDP socket都可以独立地接收和处理UDP数据报,而不会受到其他socket的影响。
sk_buff缓冲区中其实保存的不是UDP报头+有效载荷,或者仅有有效载荷,而是保存的是IP报头+UDP报头+有效载荷,因为struct sk_buff中的指针char* data和char* tail指向的一定是有效载荷,UDP层刚从IP层收到sk_buff时,因为对于IP来说,它的有效载荷是udp格式的报文,因此data指向的是UDP报头。在UDP层,系统需要对data强转成struct udphdr获取报头信息,从而知道有效载荷的位置,然后调整data指针的位置使它指向有效载荷(即用户数据)的起始位置。
当进程调用recvfrom系统调用来从UDP套接字接收数据时,操作系统会从套接字的接收队列中取出一个或多个sk_buff结构,然后把有效载荷复制到进程提供的用户级缓冲区中。recvfrom调用完成后,系统释放该struct sk_buff。
udp的报头只有源端口和目的端口号,没有ip地址,但是我们在应用层使用sendto时,要把对端的sockaddr填进去,这里的sockaddr有什么用,怎么体现在udp的报文中?
当你在应用层使用 sendto 系统调用发送 UDP 数据时,你提供的 sockaddr 结构体(通常是 sockaddr_in 或 sockaddr_in6)包含了目标地址(IP 地址)和端口号。这个 sockaddr 结构体并不直接体现在 UDP 报头或有效载荷中,但系统会使用其中的信息来构建 UDP 报头和 IP 报头。
具体来说:
- 应用层创建 sockaddr 结构体,并设置目标 IP 地址和端口号。
- 应用层调用 sendto 系统调用,并将 UDP 数据、目标 sockaddr 结构体以及数据长度作为参数传递给系统。
- 系统(内核)从 sockaddr 结构体中提取目标 IP 地址和端口号。
- 系统使用提取出的目标端口号来构建 UDP 报头中的目的端口字段。
- 系统使用提取出的目标 IP 地址来构建 IP 报头中的目的 IP 地址字段。同时,它还会根据网络配置和路由表来确定源 IP 地址,并填充到 IP 报头的源 IP 地址字段中。
- 系统将 UDP 报头和有效载荷封装成一个 UDP 数据包,并将其与 IP 报头一起封装成一个 IP 数据包。
- IP 数据包被发送到网络上,通过路由器和其他网络设备转发到目标地址。
在接收端,这个过程是相反的。当 IP 数据包到达时,系统首先解析 IP 报头,确定数据应该被传递给哪个本地 IP 地址和端口。然后,它解析 UDP 报头,将数据传递给在该端口上监听的 UDP 套接字。最后,应用层从套接字中读取数据。
基于UDP的应用层协议
NFS: 网络文件系统
TFTP: 简单文件传输协议
DHCP: 动态主机配置协议
BOOTP: 启动协议(用于无盘设备启动)
DNS: 域名解析协议
当然, 也包括你自己写UDP程序时自定义的应用层协议;
TCP
TCP的特点
以下是TCP的主要特点:
1.面向连接的传输:
TCP在传输数据之前,需要先建立连接,数据传输结束后需要释放连接。因为建立连接后,双方系统才会使用流量控制,拥塞控制等策略,并开辟必要的缓冲区,来保证数据的可靠传输。因此与UDP相比,TCP需要建立连接、维护连接状态、进行确认应答等操作,因此开销较大。在传输少量数据或实时性要求较高的场景中,UDP可能更为适合。
2.可靠传输:
TCP通过校验和、序列号(去重)、确认应答、超时重传、连接管理(三次握手和四次挥手)、流量控制(滑动窗口)和拥塞控制等机制实现可靠传输。
3.在保证可靠性的基础上提高性能
TCP通过滑动窗口、快速重传、延迟应答和捎带应答等机制提高性能。
4.基于字节流:
TCP将应用程序交下来的数据看成是一连串的无结构的字节流,它不保证接收方收到的数据块和发送方发送的数据块具有对应大小的关系(例如,发送方发送了100个字节的数据,但接收方可能会分成两次,每次50个字节来接收)。
5.全双工通信:
TCP允许通信双方的应用程序在任何时候都能发送数据,TCP连接的两端都设有发送缓存和接收缓存,用来临时存放双向通信的数据。
TCP报头
源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
32位序号:首次生成时是随机分配的序号,代表该段报文起始数据的字节序号;
32位确认号: 除了发起握手时是全0,其它时候是告诉对端已确认收到的对端报文的字节序号+1,即告诉对方,下次应该在我已确认收到的报文的字节序号+1的位置开始发送;
4位TCP报头长度: 表示该TCP头部有多少个32位bit(有多少个4字节); 所以TCP头部最大长度是15 * 4 = 60,而标准的TCP报头是20个字节。
6位标志位:
URG: 紧急指针是否有效
ACK: 确认号是否有效
PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走,接收端收到PSH会触发一个事件或中断,以通知应用程序有数据可供读取。然而,是否将应用程序从阻塞等待队列移动到运行队列,这取决于操作系统的调度策略和应用程序的当前状态。
RST: 当TCP连接的一方收到RST包时,它会关闭当前的连接,并清除与该连接相关的所有状态信息。这是因为RST包是TCP协议中用来异常终止连接的一种机制,它表示连接出现了某种错误或异常情况,导致连接无法继续。在收到RST包后,TCP连接的发送方会意识到连接已经中断,并且知道对方不再期望或能够继续这个连接。因此,发送方会关闭连接,并可能将错误报告给应用程序。
SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段识的为结束报文段
FIN: 通知对方, 本端要关闭了
16位窗口大小: 表示对端缓冲区能接收的最大的数据大小,这个数据指的是有效载荷的数据大小,不包括TCP报头的大小。
16位校验和: 发送端填充,CRC校验,接收端校验不通过, 则认为数据有问题。此处的检验和不光包含TCP首部(计算时16位校验和按0x0000填充), 也包含TCP数据部分,甚至还包含一个额外的伪报头(这里就不做讨论了);
16位紧急指针: 标识哪部分数据是紧急数据,表示紧急数据的位置在从起始位置开始偏移多少的偏移量,紧急数据只能是1个字节;
40字节头部选项: 报头选项的内容是标准TCP报头外的内容。
选项中比较重要的两个选项是Maximum segment size (MSS):最大分段大小,以及Window scale:窗口缩放。
Maximum segment size (MSS):最大分段大小:用于指定TCP连接中可以传输的最大数据段大小(以字节为单位),这有助于避免在IP层进行数据包分片,从而提高传输效率。
IP层进行数据包分片的主要原因在于,链路层具有最大传输单元Maximum Transmission Unit(MTU)这个特性,它限制了数据帧的最大长度,以太网的MTU通常为1500字节。如果IP层有数据包要传输,且数据包的长度超过了MTU,为了确保数据包能够成功传输,IP层就需要将其分割成多个小的IP数据包进行传输,使每一片的长度都小于或等于MTU。而在IP层的分片在丢包后是没有类似TCP那样的重传机制的,意味着IP层丢包后整个TCP数据段都需要重传,那不如通过MSS限定好TCP的数据段大小,原来一个TCP数据段就能传完的,现在分成几个TCP数据段,避免到了IP层再分片,丢包时只要重传某个小的TCP段就可以了,提高传输效率。
MSS的大小通常由发送方在TCP连接的建立阶段(即三次握手期间)通过SYN包中的MSS选项来通告给接收方。
Window scale:窗口缩放:
一、前言
说到TCP滑动窗口协议,相信大家都很熟悉,但是说道到Window Scaling参数或许知道的和用过的人却不多,这里我们来谈谈Window Scaling的由来。
通过TCP报头可以发现,窗口大小只有16位,但是序号和确认序号是32位的。也就是说假如接收缓冲区足够大,其实是可以发送一个32位,也就是2^32个字节大小的数据,而不是一个16位,2^16字节大小的数据,然而事实却并非如此。
在RFC 1323的2.3节中做出了解释:
(因为TCP通过检测数据段的序列号是否在窗口左边沿的2^31范围内来判断数据的新旧,并据此决定是否将数据视为“旧”并丢弃。为了确保新数据不会被错误地当作旧数据处理,发送端窗口的左边缘的数据序号需与接收端窗口的右边缘的数据序号保持不超过2^31的距离。同样接收窗口的左边缘的数据序号也需与发送端窗口的右边缘的数据序号保持不超过2^31的距离。
由于窗口的左右边缘之差即为窗口大小,且窗口可能错开的最大距离为一个窗口大小,这些限制意味着两倍的最大窗口大小必须小于2^31,即最大窗口大小应小于2^30。
因此规定窗口大小不能大于序号的一半,即2^31÷2=2^30,因此窗口大小wnd≤2^30,又因为报头中窗口大小是16位,最大窗口大小除报头窗口大小就能得到窗口放大的倍数,2^30÷2^16=2^14,因此最大的放大倍数是14。)
实际上有两个问题需要思考:
1.为什么TCP通过检测数据段的序列号是否在窗口左边沿的2^31范围内来判断数据的新旧,而不是通过检测数据段的序列号是否在窗口左边沿的2^32范围内来判断数据的新旧?
因为如果通过检测数据段的序列号是否在窗口左边沿的2^32范围内来判断数据的新旧,那其实就无所谓旧数据了,所有收到的数据序号都不会重复,同时也在窗口范围内,因此所有收到的序号都是新数据。
2.为什么窗口可能错开的最大距离为一个窗口大小,即2^31?
黄能富教授的《计算机网络概论》给出了一个样例。
RFC1323 TCP determines if a data segment is "old" or "new" by testing whether its sequence number is within 2**31 bytes of the left edge of the window, and if it is not, discarding the data as "old". To insure that new data is never mistakenly considered old and vice- versa, the left edge of the sender's window has to be at most 2**31 away from the right edge of the receiver's window. Similarly with the sender's right edge and receiver's left edge. Since the right and left edges of either the sender's or receiver's window differ by the window size, and since the sender and receiver windows can be out of phase by at most the window size, the above constraints imply that 2 * the max window size must be less than 2**31, or max window < 2**30 Since the max window is 2**S (where S is the scaling shift count) times at most 2**16 - 1 (the maximum unscaled window), the maximum window is guaranteed to be < 2*30 if S <= 14. Thus, the shift count must be limited to 14 (which allows windows of 2**30 = 1 Gbyte). If a Window Scale option is received with a shift.cnt value exceeding 14, the TCP should log the error but use 14 instead of the specified value.
二、TCP滑动窗口
众所周知,TCP是一种面向连接可靠消息传输协议;为了保证可靠,连接的两端保持对所有传输数据的严格跟踪,以便在需要时候进行重传或重新排序。另外为了跟踪已经发送了的数据在发送端有TCP发送缓存,在接受端有接受缓存,滑动窗口则是这个缓存的一部分,接收方接受数据后会把ack和当前滑动窗口可用空间告诉发送方,发送方则发送的数据不能超过接收方剩余窗口大小,如果接收方窗口内数据还没来得及由应用程序读取,窗口满了,则发送方会停止发送数据,直到接收方滑动窗口有空间。
假设我们有两个主机A和B,它们建立了一个TCP连接。在连接开始时,两个主机为传入数据分配32 KB的缓冲区空间,因此每个主机的初始窗口大小为32,768。
主机A需要向主机B发送数据,一开始则主机B告诉主机A可以在自己接受主机B确认之前传输最多32,768字节的数据(以最大段大小或MSS的间隔) 。假设MSS为1460字节,主机A可以在耗尽主机B的接收窗口之前发送22个段。
当确认收到主机A发送的数据时,主机B可以调整其窗口大小。例如,如果上层应用程序仅处理了一半缓冲区,则主机B会将其窗口大小降低到16 KB(这时候主机A在接受到B的确认前最多发送16KB数据到B)。如果缓冲区仍然完全填满,主机B会将其窗口大小设置为零,表明它还不能接受更多数据,这时候主机A则停止发送数据。
在具有高带宽和极低延迟的LAN上,滑动窗口很少会满。但是,在高带宽,高延迟网络上,会出现一个有趣的现象:在发送方接受到接收方发出确认之前,并不能最大化利用滑动窗口大小。
例如,假设在通过专用10 Mbps路径连接的两台主机之间建立TCP连接,单向延迟为80ms。两个主机都约定最大窗口大小为65,535字节(16位无符号整数的最大值)。我们可以计算在一个时间点在一个方向上传输的潜在数据量,带宽*延迟:10,000,000 bps除以每字节8位,乘以0.08秒等于100,000字节。
换句话说,如果主机A开始连续发送给主机B,它将在主机B接收到发送的第一个字节之前发送100,000个字节。但是由于约定的最大接收窗口只有65,535字节,所以主机A必须在发送65,535字节后停止发送,并等待来自主机B的确认。(为简单起见,我们的示例计算不考虑TCP和低层报头。)这种延迟浪费了潜在的吞吐量,不必要地增加了通过网络可靠传输数据所需的时间。创建TCP窗口缩放以解决此问题。
三、窗口缩放因子
窗口缩放在RFC 1072中引入并在RFC 1323中进行了改进。实际上,窗口缩放只是将16位窗口字段扩展为32位长度。解决方案是定义TCP选项以指定计数,通过该计数,TCP标头字段应按位移位以产生更大的值。
如上图 window size设置为5840字节,但是窗口缩放因子为7(window scale),也就是这时候最大实际窗口为 5840*128。window scale为1将字段的二进制值向左移位一位,使其加倍。计数为2将值向左移动两位,使其翻倍。计数为7(如上例所示)将该值乘以128.
窗口缩放选项(window scaleing)可以在tcp握手时候在SYN分组中的连接期间仅发送一次。可以通过修改TCP标头中的窗口字段的值来动态调整窗口大小,但是在TCP连接的持续时间内,标度乘数保持静态。仅当两端都包含选项时,缩放才有效;如果只有连接的一端支持窗口缩放,则不会在任一方向上启用它。最大有效比例值为14。
回顾我们之前的示例,我们可以观察窗口缩放如何使我们能够更有效地进行网络传输。为了计算我们的理想窗口,我们将端到端延迟加倍以找到往返时间,并将其乘以可用带宽:2 * 0.08秒* 10,000,000 bps / 8 = 200,000字节。为了支持这种大小的窗口,主机B可以将其窗口大小设置为3,125,其window scaleing因子为6(3,125左移6乘以200,000)。幸运的是,这些计算都是由现代TCP / IP堆栈实现自动处理的。
面向字节流
数据在传输过程中被视为一连串的字节序列,而不考虑这些数据所代表的具体含义或结构。读写双方通过自定义协议,确定数据和数据之间的边界,在组包和拆包时就可以根据协议封装或提取出不同的数据。
发送方可能对一个缓冲区中的数据写很多次,接收方也可能一次就全部收完,也可能分很多次接收,对一段数据,无论收发的次数是多少,都和对方无关。
TCP的缓冲区
TCP是面向连接的协议,它通过三次握手建立连接,并在连接建立后提供可靠的传输服务。为了实现这一点,TCP协议在内核中为每个TCP连接分配了发送缓冲区和接收缓冲区。这些缓冲区实际上就是内存中的一片空地,用于暂存发送和接收的数据。
TCP发送数据
当应用层调用send函数发送数据时,系统把应用层数据拷贝到sk_buff的发送缓冲区中,当这个sk_buff需要从传输层发送到网络层时,系统会在sk_buff的缓冲区中的有效载荷前加上TCP报头,同时调整data指针指向TCP报头,使每个sk_buff中都有一个完整的TCP报文段。
根据TCP协议栈中的拥塞控制、流量控制等算法,每个sk_buff缓冲区中可能只有一次send的数据,也可能有多次send的数据。
系统形成的sk_buff会被连接到TCP的发送队列中,发送队列中的sk_buff结构体按照准备发送的顺序排列,并且包含了指向实际数据的指针char* data和其他元数据。当网络条件允许时(比如有足够的发送窗口和可用带宽),TCP就会从发送队列中取出sk_buff,将其传递给IP层进行后续的封装和发送。这个过程是异步的,意味着应用层调用send函数后,数据并不一定会立即被发送到网络上。
TCP接收数据
某个sockek从网络层收到属于它的sk_buff后,与UDP类似,会调整sk_buff中data指针指向有效载荷(用户数据),然后使对应的sk_buff链接到该socket的接收队列sk_receive_queue中。
当应用层调用recv读取数据时,系统会根据应用层调用recv时读取多少个字节,决定从一个还是多个sk_buff的接收缓冲区中读取多少数据。
如果应用层调用recv读取的数据量小于第一个sk_buff缓冲区中的有效载荷,系统不会一次性提取整个sk_buff缓冲区中有效载荷的数据,它只会提取一部分数据,并将剩余的数据留在sk_buff的缓冲区中。同时把data指针指向下一个需要拿走的数据的位置,同时更新len的大小(len表示数据包的实际长度,以字节为单位,即从data指针指向的位置开始,到数据包末尾的长度)。下次调用recv时,系统会继续从该sk_buff的缓冲区中data指向的位置开始,提取剩余的数据。如果sk_buff的缓冲区中的数据已经被完全读取,即len小于等于recv读取字节数,且队列中有多个sk_buff,系统会从队列中的下一个sk_buff的缓冲区中提取数据。
TCP报头中的序号和窗口大小都是针对有效载荷而言的,TCP的有效载荷是不包含报头的,可以用过wireshark查看TCP报文得到验证。
例如:发送端应用层第一次要发送的数据是10个字节,发送端收到接收端的窗口大小为100个字节,此时在sk_buff①的缓冲区中的数据是TCP报头(20字节)+数据①(10字节)=30字节,此时报文不一定马上交给IP层;
当应用层第二次要发送的数据是20个字节,此时在sk_buff①的缓冲区中的数据是TCP报头(20字节)+数据①(10字节)+数据②(20字节)=50字节,此时报文不一定马上交给IP层;
当应用层第三次要发送的数据是60个字节,此时如果继续把数据拷贝到sk_buff①的缓冲区中,就会变成TCP报头(20字节)+数据①(10字节)+数据②(20字节)+数据③(60)=110字节,超过接收端的窗口大小。
此时通常系统就会把sk_buff①的数据TCP报头(20字节)和数据①(10字节)+ 数据②(20字节)= 50字节的TCP段先发送出去。
而应用层第三次要发送的60个字节数据,并不会因为接收端的窗口大小不够而被阻塞。它可能会再次分割成多个较小的TCP段,保存在发送缓冲区中的sk_buff队列中,等待下一次发送。
假如sk_buff中的数据比下一次收到的接收端的窗口大小要大时,比如sk_buff②的缓冲区中TCP报头(20字节)+数据③(60)=80字节,而接收端的窗口大小为50个字节。TCP协议栈会重新拆分这个sk_buff中的数据。具体来说,TCP协议栈会根据当前的接收窗口大小,将原始sk_buff中的数据拆分成多个较小的数据块,每个数据块的大小都不会超过接收窗口的大小。然后,每个数据块都会被封装成一个新的sk_buff,并加入到发送队列中等待发送。
这样,每个新的sk_buff都包含了原始sk_buff中的一部分数据,并且它们的总和等于原始sk_buff中的数据量。TCP协议栈会按照发送队列中的顺序,逐个发送这些新的sk_buff,直到所有数据都被成功发送并确认。
如果因为网络原因导致TCP的某一端收到的数据不完整,比如只收到了序号101到200和201到300的数据,但没收到1到100的数据,但应用层在调用recv来读取时,TCP不会把任何数据交给应用层,而是使recv进行阻塞等待,直到TCP层收到了序号1到100的数据并把它放到接收缓冲区后,才会让recv把数据取走。
连接管理机制
为什么要三次握手?
用一句话说明的话,就是以最小的成本验证全双工。
如果只进行一次握手就建立连接,那么接收方每收到一次发送方发过来的SYN就要建立连接,开辟接收缓冲区,容易被人恶意攻击,当接收方是服务器时,会导致服务器资源消耗殆尽。另外这种方式只能验证发送方到接收方之间的通信OK,但无法验证接收方到发送方之间的通信是否OK,而我们TCP必须是全双工且可靠的。
如果进行两次握手就建立连接,那么接收方在发出ACK后就建立连接,但是这个ACK可能根本就没被发送方接收到,在无法确保接收端到发送端的通信是可靠的时候,连接就被建立了。
为什么不把三次握手搞成像四次挥手那样,接收端先发ACK,再发SYN?
其实这么做是可以的,但没必要,接收方通过捎带应答把ACK和SYN一起发回给发送方,可以节省网络资源。
为什么要四次挥手?
当发送方调用close时,会同时关闭socket 发送方向和读取方向,也就是 socket 不再有发送和接收数据的能力,并对接收方发送FIN。
当客户端A发送FIN报文给服务器B并进入FIN_WAIT_1状态时,它正在等待服务器B的ACK确认。一旦B收到这个FIN并发送了ACK,A就会进入FIN_WAIT_2状态。在FIN_WAIT_2状态下,客户端A在等待服务器B的FIN报文,以表示服务器也准备关闭连接。A虽然不发报文了,但是B可能还有报文没发完。以下是A可能接收到的报文类型及其处理方式:
1.来自B的数据报文:
- 如果B在收到A的FIN后仍然有数据要发送,它可能会发送这些数据。A会将这些数据放入接收缓冲区,并继续等待FIN。
注意,在A收到B的FIN报文之前,A仍然需要处理接收到的任何数据。如果A的应用层没有调用recv来取走数据的话,这些数据将留在接收缓冲区中,直到TCP连接最终关闭时(无论是正常收到FIN关闭,还是由于某种错误导致的关闭),TCP协议栈会释放与该连接相关联的资源,包括接收缓冲区。
- 如果A的接收缓冲区已满,A可能会发送一个TCP零窗口报文给B,告诉B暂停发送数据,直到A有更多的缓冲区空间。
注意,当A的接收缓冲区已满,导致客户端回复给服务器的ACK中可用窗口大小为0,而应用层又不调用recv来取走数据时,如果服务器还有数据要发给客户端,它也不能直接发送FIN包来关闭连接。这会导致一个僵持(deadlock)的状态。怎么办?这个问题不能单方面只考虑操作系统,正常写套接字,不就是双方把通信都发完了,我们各自关各自的嘛,这是最常规的。如果有这种特殊情况,还要发大量数据把对方缓冲区打满,那么这种情况的存在的话,一定是有特殊需求的,所以程序员在应用层就应该控制它的读写。
如果这种问题真的存在的话,应用层的逻辑是程序员写的,所以我们在调接口的时候,就不应该在应用层调close,而应该调的是类似于shutdown这样的接口。比如说你不写了,你就只把你的写端关闭。当本地端调用shutdown(sockfd, SHUT_WR)时,它会发送一个FIN段给对端,并进入FIN_WAIT1状态。此时,本地端不再发送数据,但可以继续接收数据,后续可以继续使用读文件描述符的读方法来读取数据。
因此发送端可以先执行close来关闭写方向,然后继续使用recv来接收任何剩余数据,直到服务端也发送FIN并完成四次挥手过程,recv调用才会最终返回0或产生EOF(文件结束符),指示连接已经被对端关闭。
2.来自B的FIN报文:
- 这是A在FIN_WAIT_2状态下期望接收的报文。一旦A收到B的FIN,A会发送一个ACK给B以确认这个FIN,并进入TIME_WAIT状态。
- 客户端在 TIME_WAIT 状态持续一段时间后(通常是2倍的MSL,MSL是报文最大生存时间),也进入 CLOSED 状态,连接完全关闭。MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值;在Centos7上默认配置的值是60s,Ubantu是30s;
MSL是TCP报文的最大生存时间, 因此TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK)。
TIME_WAIT 状态存在的目的
假如服务器有数据发给客户端,这些数据因为某些原因在网络上被延迟了,如果有TIME_WAIT的话就可以在此时把在网络上游荡的数据接收并处理掉,否则如果客户端再次用同样的IP和端口与服务器建立连接,那么这些上一次连接时的旧数据就会被传到新的连接中,被错误处理。
3.来自其他源的报文:
- 如果A收到一个不是来自B的TCP报文(即,一个错误的或恶意的报文),A可能会根据TCP的错误处理机制来处理它,但通常这不会影响A与B之间的连接状态。
4.超时:
如果A在等待FIN时超时(即,A等待了很长时间但没有收到B的FIN),A可能会重传FIN或采取其他行动来尝试关闭连接。
把服务器的ACK和FIN合并在一起变成三次挥手行不行?
某些情况是可以的,比如说服务器收到客户端的FIN请求后,自身也没有数据要再发送给客户端,那么服务器可以把FIN和ACK合并发送,类似捎带应答。
当客户端想与服务器断开连接时,最好的方式是用close还是shutdown?如何更优雅或更精准的控制断开连接?
使用close:如果您希望简单地关闭连接,并且不关心是否立即停止接收数据,可以使用close。这种方式较为简便,但在某些情况下可能不够优雅,因为假如在服务端发送FIN前,还继续传输数据,数据到达了客户端接收端缓冲区,客户端应用层在不断从缓冲区读取数据。此时如果收到了服务端发送的FIN,客户端操作系统就直接关闭socket了,那么在接收缓冲区中尚未被应用层读取的数据就丢失了。
使用shutdown:如果您希望更加精细地控制连接关闭过程,比如先告知对方自己不再发送数据但仍想继续接收对方可能发送的数据直至对方也准备关闭连接,应使用shutdown函数,并指定SHUT_WR标志来关闭写方向。这种方式允许进行更平滑的关闭流程,特别是在需要执行一些清理操作(如确保所有数据都已接收完毕)的场景下更为合适。
解决TIME_WAIT状态引起的bind失败的方法
在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的。服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是每秒都有很大数量的客户端来请求)。这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务器端主动清理掉), 就会产生大量TIME_WAIT连接,每个连接都会占用一个通信五元组(源IP地址、源端口、目的IP地址、目的端口和传输层协议)。
如果新来的客户端连接使用的通信五元组和TIME_WAIT占用的连接重复了,就会出现问题。
使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
接收方接收缓冲区长时间为0时怎么办?
假设A主机向B主机发送数据,当B的窗口为0,则A不能再向B发送数据。只能等到B的应用进程将缓存中的数据清空,才能有新的窗口值,当接收端窗口从0恢复时,会发送一个窗口更新的报文给发送端。
但是这里有一个问题,如果接收方窗口长时间为0,或者窗口更新的报文丢包了,这样发送端不可能知道接收端的窗口情况。
TCP发送方遇到接收方窗口为0时,会启动窗口探测机制以确保连接不会因死锁而中断。以下是TCP窗口探测机制的基本步骤:
- 接收方窗口为0:当接收方的接收窗口大小为0时,发送方会停止发送数据,并启动一个持续计时器(persist timer)。
- 发送窗口探测报文:当持续计时器超时后,发送方会发送一个窗口探测报文(也称为窗口探查报文段或window probe)给接收方,以获取最新的窗口大小。这个报文段通常只包含一个字节的数据,用于触发接收方的响应。
- 接收方响应:接收方在收到窗口探测报文后,会根据其当前的接收窗口大小来响应。如果接收窗口仍然为0,接收方会发送一个窗口大小为0的确认报文(ACK)给发送方;如果接收窗口已经打开(即窗口大小大于0),接收方会发送一个包含新窗口大小的确认报文给发送方。
注意,当接收端缓冲区已满时,发送端发送一个窗口探测报文(通常包含一个字节的数据),而接收端由于缓冲区已满无法接收这个字节的数据。在这种情况下,接收端回复的ACK确认序号应该还是之前成功接收到的最后一个字节的序号加1,而不是窗口探测报文中的字节序号加1。
比如送端成功发送并打满接收端缓冲区的最后一个数据序号是10,那么接收端会回复一个ACK,确认序号为11,表示它期望接收下一个序号为11的字节。当发送端发送窗口探测报文(序号为11)时,由于接收端缓冲区已满,它无法接收这个字节的数据。因此,接收端回复的ACK确认序号仍然是11,而不是12。同时,这个ACK还可能包含接收端当前的窗口大小信息,以便发送端根据窗口大小来调整自己的发送速率。
超时重传
Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍
若重发一次之后,仍然得不到应答,下一次重传的等待时间就是2×500ms;
若仍然得不到应答,那么下一次重传的等待时间就是4×500ms。以此类推,以指数的形式递增;
当累计到一定的重传次数后,TCP就会认为是网络或对端主机出现了异常,进而强转关闭连接。
滑动窗口——流量控制
滑动窗口就是接收方允许发送方在不需要收到接收方应答的情况下,一次可以发送多少数据分包。
滑动窗口大小就是TCP报头的窗口大小,也就是接收端一次能接收的数据的大小。发送端的发送窗口,对应的是接收端的接收窗口,通常情况下发送窗口和接收窗口虽然都会动态调整,但是它们两个总是一起变大或者一起变小的。
在谈论滑动窗口前,我们要有一个意识,滑动窗口的大小是根据接收端接收缓冲区大小动态变化的,因此所有谈论滑动窗口大小时,其实是需要考虑接收缓冲区的情况的。
滑动窗口的应用
比如接收端的接收窗口是15,序号14-28的位置虚位以待,意味着发送端可以连续发送15段数据而不需要等待接收端的ACK。假如发送端缓冲区中的数据是14-33,已经发送了14-23的数据,那么它还能继续发24-28的数据,但是29-33的数据就不能再发送了。
当发送端数据14被接收端接收后,收到接收端发送的ACK,并且接收端的窗口向右滑动,仍然保持是15,此时发送端的滑动窗口也可以向右移动一格,可以发送数据29。
快重传
假如接收端没收到数据14,而是收到了3个数据14后的数据,那么接收端会回复三个ACK14,发送端此时就会马上重发数据14,而不用等待数据14超时重传。
这里有个问题,接收端除了会回复三个ACK14,这三个ACK中同时也会带有窗口大小。假如接收端的接收缓冲区只有20,其中数据9-13这5段数据是已经接收但应用层还没取走的。那么这三个ACK回复的窗口大小应该是多少呢?发送端的窗口大小又该怎么变化呢?滑动窗口的大小一定等于窗口的右边界减左边界吗?
接收端接收缓冲区的总大小是20,其中数据9-13这5段数据是已经接收但应用层还没取走,因此接收端告诉发送端窗口大小为15;
发送端把自己的发送窗口同步为15后,就把14到28号数据包发送出去;
接收端14号数据包没收到,收到15号数据包,回复ACK14,因为接收缓冲区是20,此时已经有9-13和15号数据,接收缓冲区的有效剩余空间是20-5-1=14,因此窗口大小需要调整为14,但自身接收窗口的左端和右端都不变;
发送端收到ACK14,下一个应该发的包是14,但窗口大小为14,发送窗口的左端不变,右端移动到27的位置;
接收端14号数据包没收到,收到16号数据包,回复ACK14,因为接收缓冲区是20,此时已经有9-13和15、16号数据,接收缓冲区的有效剩余空间是20-5-2=13,因此窗口大小需要调整为13,但自身接收窗口的左端和右端都不变;
发送端收到ACK14,下一个应该发的包是14,但窗口大小为13,发送窗口的左端不变,右端移动到26的位置;
.....
接收端14号数据包没收到,收到28号数据包,回复ACK14,因为接收缓冲区是20,此时已经有9-13和15-28号数据,接收缓冲区的有效剩余空间是20-5-14=1,因此窗口大小需要调整为1,但自身接收窗口的左端和右端都不变;
发送端收到ACK14,下一个应该发的包是14,但窗口大小为11,发送窗口的左端不变,右端移动到1的位置;
至此发送端的滑动窗口大小变成1,但已经发出去的数据是14到28,比当前的滑动窗口大。实际已经发送出去但尚未被确认的数据包(14到28号)的序列号范围超过了当前的发送窗口大小。这是因为发送窗口的大小是根据接收端报告的窗口大小和确认信息来动态调整的。
而接收端滑动窗口大小一直在减小,但窗口左端和窗口右端的位置却一直没变化。因此滑动窗口的大小不一定就等于窗口的右边界减左边界。
当接收缓冲区大小一定,如果应用层一直不取走数据,那么接收端回复的ACK中窗口大小就应该是越来越小的。
拥塞控制
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题。
因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的。
TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据;
网上很多人都会以这幅图作为TCP拥塞控制的图解:
但是我个人认为是有很多缺陷甚至错误的,比如:
1.从指数增长到线性增长时,这个阈值(threshol)是怎么确定的?为什么上来就是16?
2.怎么判断是否触发拥塞?
3.TCP Tahoe版本已废弃不用了吗?
4.这个拥塞控制是只探测两次吗?还是说会不断重复的进行探测?
对于很多拥塞控制的问题,其实看一下黄能富教授《计算机网络概论》中的一张图就能豁然开朗了:
拥塞窗口开始的时候是慢启动,刚开始发送的时候,定义拥塞窗口大小为1;
每次收到一个ACK应答,拥塞窗口加1,即扩大到上次的两倍,这样的拥塞窗口增长速度, 是指数级别的。"慢启动" 只是指初使时慢,但是增长速度非常快。
为了不增长的那么快,因此不能使拥塞窗口单纯指数增长,此处引入一个叫做慢启动的阈值,当拥塞窗口超过这个阈值(threshol)的时候, 不再按照指数方式增长, 而是按照线性方式增长。
但是要明确的是,刚开始发送的时候并不清楚这个阈值,发送端是按指数增长的方式一直增加拥塞窗口的大小,直到触发拥塞,至此这个阈值才第一次被确定下来,阈值=1/2的拥塞窗口。
①如果拥塞的原因是连续收到三个相同的ACK,即触发了快重传时,证明网络开始拥挤了,但还不算特别严重,此时拥塞窗口会减半,但发送端不会再从1开始按指数级增加的方式发送数据,而是在1/2拥塞窗口处,以线性增加的方式发送数据。
②如果拥塞的原因是触发了超时重传,证明网络拥挤比较严重,此时拥塞窗口直接减到1,重新开始按指数级增长的方式探测拥塞窗口。
因此实际上TCP Reno版本和TCP Tahoe版本都在使用,只是针对不同的场景。
需要留意的一个误区是,拥塞控制不是探测了一次或两次就结束了,拥塞控制是按上述情况①或情况②持续进行的。
不过实际发送的窗口大小不一定就是拥塞窗口,TCP协议需要对拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口。
参考资料:
黄能富教授《计算机网络概论》