今天我们来总结学习一下 TCP 发送报文的相关知识,主要包括发送报文的步骤,MSS,滑动窗口和 Nagle 算法。
发送报文
该节主要根据陶辉大神的系列文章总结而来。如下图所示,我们一起来看一下 TCP 发送报文时操作系统内核都做了那些事情。其中有些概念在接下来的小节中会介绍。
首先,用户程序在用户态调用 send 方法来发送一段较长的数据。然后 send 函数调用内核态的 tcp_sendmsg 方法进行处理。
主要注意的是,send 方法返回成功,内核也不一定真正将 IP 报文都发送到网络中,也就是说内核发送报文和 send 方法是不同步的。所以,内核需要将用户态内存中的发送数据,拷贝到内核态内存中,不依赖于用户态内存,使得进程可以快速释放发送数据占用的用户态内存。
在拷贝过程中,内核将待发送的数据,按照 MSS 来划分成多个尽量接近 MSS 大小的分片,放到这个 TCP 连接对应的 tcp_write_queue 发送队列中。
内核中为每个 TCP 连接分配的内核缓存,也就是 tcp_write_queue 队列的大小是有限的。当没有多余的空间来复制用户态的待发送数据时,就需要调用 sk_stream_wait_memory 方法来等待空间,等到滑动窗口移动,释放出一些缓存出来(收到发送报文相对应的 ACK 后,不需要再缓存该已发送出的报文,因为既然已经确认对方收到,就不需要重发,可以释放缓存)。
当这个套接字是阻塞套接字时,等待的超时时间就是 SO_SNDTIMEO 选项指定的发送超时时间。如果这个套接字是非阻塞套接字,则超时时间就是 0。也就是说,sk_stream_wait_memory 对于非阻塞套接字会直接返回,并将 errno 错误码置为 EAGAIN。
我们假定使用了阻塞套接字,且等待了足够久的时间,收到了对方的 ACK,滑动窗口释放出了缓存。所以,可以将剩下的用户态数据都组成 MSS 报文拷贝到内核态的缓存队列中。
最后,调用 tcp_push 等方法,它最终会调用 IP 层的方法来发送 tcp_write_queue 队列中的报文。注意的是,IP 层方法返回时,也不意味着报文发送了出去。
在发送函数处理过程中,Nagle 算法、滑动窗口、拥塞窗口都会影响发送操作。
MTU 和 MSS
我们都知道 TCP/IP 架构有五层协议,低层协议的规则会影响到上层协议,比如说数据链路层的最大传输单元 MTU 和传输层 TCP 协议的最大报文段长度 MSS。
数据链路层协议会对网络分组的长度进行限制,也就是不能超过其规定的 MTU,例如以太网限制为 1500 字节,802.3 限制为 1492 字节。但是,需要注意的时,现在有些网卡具备自动分包功能,所以也可以传输远大于 MTU 的帧。
网络层的 IP 协议试图发送报文时,若报文的长度大于 MTU 限制,就会被分成若干个小于 MTU 的报文,每个报文都会有独立的 IP 头部。IP 协议能自动获取所在局域网的 MTU 值,然后按照这个 MTU 来分片。IP 协议的分片机制对于传输层是透明的,接收方的 IP 协议会根据收到的多个 IP 包头部,将发送方 IP 层分片出的 IP 包重组为一个消息。
这种 IP 层的分片效率是很差的,因为首先做了额外的分片操作,然后所有分片都到达后,接收方才能重组成一个包,其中任何一个分片丢失了,都必须重发所有分片。
所以,TCP 层为了避免 IP 层执行数据报分片定义了最大报文段长度 MSS。在 TCP 建立连接时会通知各自期望接收到的 MSS 的大小。
需要注意的是 MSS 的值是预估值。两台主机只是根据其所在局域网的计算 MSS,但是 TCP 连接上可能会穿过许多中间网络,这些网络分别具有不同的数据链路层,导致问题。比如说,若中间途径的 MTU 小于两台主机所在的网络 MTU 时,选定的 MSS 仍然太大了,会导致中间路由器出现 IP 层的分片或者直接返回错误(设置 IP 头部的 DF 标志位)。
比如阿里中间件的这篇文章 (链接不见的话,请看文末) 所说,当上述情况发生时,可能会导致卡死状态,比如 scp 的时候进度卡着不懂,或者其他更复杂操作的进度卡死。
滑动窗口
IP 层协议属于不可靠的协议,IP 层并不关心数据是否发送到了接收方,TCP 通过确认机制来保证数据传输的可靠性。
除了保证数据必定发送到对端,TCP 还要解决包乱序(reordering)和流控的问题。包乱序和流控会涉及滑动窗口和接收报文的 out_of_order 队列,另外拥塞控制算法也会处理流控,详情请看 TCP 拥塞控制算法简介。
TCP 头里有一个字段叫 Window,又叫 Advertised-Window,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,否则会导致接收端处理不过来。
我们可以将发送的数据分为以下四类,将它们放在时间轴上统一观察。
Sent and Acknowledged: 表示已经发送成功并已经被确认的数据,比如图中的前 31 个字节的数据
Send But Not Yet Acknowledged:表示发送但没有被确认的数据,数据被发送出去,没有收到接收端的 ACK,认为并没有完成发送,这个属于窗口内的数据。
Not Sent,Recipient Ready to Receive:表示需要尽快发送的数据,这部分数据已经被加载到缓存等待发送,也就是发送窗口中。接收方 ACK 表示有足够空间来接受这些包,所以发送方需要尽快发送这些包。
Not Sent,Recipient Not Ready to Receive:表示属于未发送,同时接收端也不允许发送的,因为这些数据已经超出了发送端所接收的范围
除了四种不同范畴的数据外,我们可以看到上边的示意图中还有三种窗口。
Window Already Sent:已经发送了,但是没有收到 ACK,和 Send But Not Yet Acknowledged 部分重合。
Usable Window : 可用窗口,和 Not Sent,Recipient Ready to Receive 部分重合
Send Window: 真正的窗口大小。建立连接时接收方会告知发送方自己能够处理的发送窗口大小,同时在接收过程中也不断的通告能处理窗口的大小,来实时调节。
下面,我们来看一下滑动窗口的滑动。下图是滑动窗口滑动的示意图。
当发送方收到发送数据的确认消息时,会移动发送窗口。比如上图中,接收到 36 字节的确认,将其之前的 5 个字节都移除发送窗口,然后 46-51 的字节发出,最后将 52 到 56 的字节加入到可用窗口。
下面我们来看一下整体的示意图。
图片来源为 tcpipguide.
client 端窗口中不同颜色的矩形块代表的含义和上边滑动窗口示意图中相同。我们只简单看一下第二三四步。接收端发送的 TCP 报文 window 为 260,表示发送窗口减少 100,可以发现黑色矩形缩短了,也就是发送窗口减少了 100。并且 ack 为 141,所以发送端将 140 个字节的数据从发送窗口中移除,这些数据从 Send But Not Yet Acknowledged 变为 Sent and Acknowledged,也就是从蓝色变成紫色。然后发送端发送 180 字节的数据,就有 180 字节的数据从 Not Sent,Recipient Ready to Receive 变为 Send But Not Yet Acknowledged,也就是从绿色变为蓝色。
Nagle 算法
上述滑动窗口会出现一种 Silly Window Syndrome 的问题,当接收端来不及取走 Receive Windows 里的数据,会导致发送端的发送窗口越来越小。到最后,如果接收端腾出几个字节并告诉发送端现在有几个字节的 window,而我们的发送端会义无反顾地发送这几个字节。
只为了发送几个字节,要加上 TCP 和 IP 头的 40 多个字节。这样,效率太低,就像你搬运物品,明明一次可以全部搬完,但是却偏偏一次只搬一个物品,来回搬多次。
为此,TCP 引入了 Nagle 算法。应用进程调用发送方法时,可能每次只发送小块数据,造成这台机器发送了许多小的 TCP 报文。对于整个网络的执行效率来说,小的 TCP 报文会增加网络拥塞的可能。因此,如果有可能,应该将相临的 TCP 报文合并成一个较大的 TCP 报文(当然还是小于 MSS 的)发送。
Nagle 算法的规则如下所示(可参考 tcp_output.c 文件里 tcp_nagle_check 函数注释):
如果包长度达到 MSS,则允许发送;
如果该包含有 FIN,则允许发送;
设置了 TCP_NODELAY 选项,则允许发送;
未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于 MSS)均被确认,则允许发送;
上述条件都未满足,但发生了超时(一般为 200ms),则立即发送。
当对请求的时延非常在意且网络环境非常好的时候(例如同一个机房内),Nagle 算法可以关闭。使用 TCP_NODELAY 套接字选项就可以关闭 Nagle 算法
订阅最新文章,欢迎关注我的微信公众号
个人博客: Remcarpediem
个人微信公众号:
参考
阿里中间件 http://jm.taobao.org/2017/07/…