2.3 收发数据
将HTTP请求消息交给协议栈
当控制流程从connect回到应用程序之后,接下来就进入数据收发阶段了。
首先,协议栈并不关心应用程序传来的数据是什么内容。应用程序在调用write时会指定发送数据的长度,在协议栈看来,要发送的数据就是一定长度的二进制字节序列而已。
其次,协议栈并不是一收到数据就马上发送出去,而是会将数据存放在内部的发送缓冲区中,并等待应用程序的下一段数据。这样做是有道理的。应用程序交给协议栈发送的数据长度是由应用程序本身来决定的,不同的应用程序在实现上有所不同,有些程序会一次性传递所有的数据,有些程序则会逐字节或者逐行传递数据。总之,一次将多少数据交给协议栈是由应用程序自行决定的,协议栈并不能控制这一行为。
第一个判断要素是每个网络包能容纳的数据长度,协议栈会根据一个叫作MTU的参数来进行判断。
另一个判断要素是时间。
MTU:一个网络包的最大长度,以太网中一般为1500字节。
MSS:除去头部之后,一个网络包所能容纳的TCP数据的最大长度。
协议栈发送数据的判断要素:第一个判断要素是每个网络包能容纳的数据长度,协议栈会根据一个叫作MTU的参数来进行判断。另一个判断要素是时间。
在进行发送操作时需要综合考虑这两个要素以达到平衡。不过,TCP协议规格中并没有告诉我们怎样才能平衡,因此实际如何判断是由协议栈的开发者来决定的,也正是由于这个原因,不同种类和版本的操作系统在相关操作上也就存在差异。
协议栈也给应用程序保留了控制发送时机的余地。应用程序在发送数据时可以指定一些选项,比如如果指定“不等待填满缓冲区直接发送”,则协议栈就会按照要求直接发送数据。像浏览器这种会话型的应用程序在向服务器发送数据时,等待填满缓冲区导致延迟会产生很大影响,因此一般会使用直接发送的选项。
对较大的数据进行拆分
根据发送缓冲区中的数据拆分的情况,当判断需要发送这些数据时,就在每一块数据前面加上TCP头部,并根据套接字中记录的控制信息标记发送方和接收方的端口号,然后交给IP模块来执行发送数据的操作(图2.6)。
使用ACK号确认网络包已收到
首先,TCP模块在拆分数据时,会先算好每一块数据相当于从头开始的第几个字节,接下来在发送这一块数据时,将算好的字节数写在TCP头部中,“序号”字段就是派在这个用场上的。
然后,发送数据的长度也需要告知接收方,不过这个并不是放在TCP头部里面的,因为用整个网络包的长度减去头部的长度就可以得到数据的长度,所以接收方可以用这种方法来进行计算。
有了上面两个数值,我们就可以知道发送的数据是从第几个字节开始,长度是多少了。
通过这些信息,接收方还能够检查收到的网络包有没有遗漏。
在实际的通信中,序号并不是从1开始的,而是需要用随机数计算出一个初始值,这是因为如果序号都从1开始,通信过程就会非常容易预测,有人会利用这一点来发动攻击。但是如果初始值是随机的,那么对方就搞不清楚序号到底是从多少开始计算的,因此需要在开始收发数据之前将初始值告知通信对象。大家应该还记得在我们刚才讲过的连接过程中,有一个将SYN控制位设为1并发送给服务器的操作,就是在这一步将序号的初始值告知对方的。实际上,在将SYN设为1的同时,还需要同时设置序号字段的值,而这里的值就代表序号的初始值。
通过“序号”和“ACK号”可以确认接收方是否收到了网络包。
网卡、集线器、路由器都没有错误补偿机制,一旦检测到错误就直接丢弃相应的包。应用程序也是一样,因为采用TCP传输,即便发生一些错误对方最终也能够收到正确的数据,所以应用程序只管自顾自地发送这些数据就好了。
根据网络包平均往返时间调整ACK号等待时间
在公司里的局域网环境下,几毫秒就可以返回ACK号,但在互联网环境中,当遇到拥塞时需要几百毫秒才能返回ACK号也并不稀奇。
正因为波动如此之大,所以将等待时间设置为一个固定值并不是一个好办法。因此,TCP采用了动态调整等待时间的方法,这个等待时间是根据ACK号返回所需的时间来判断的。
TCP会在发送数据的过程中持续测量ACK号的返回时间,如果ACK号返回变慢,则相应延长等待时间;相对地,如果ACK号马上就能返回,则相应缩短等待时间。
使用窗口有效管理ACK号
滑动窗口,就是在发送一个包之后,不等待ACK号返回,而是直接发送后续的一系列包。这样一来,等待ACK号的这段时间就被有效利用起来了。
接收方需要告诉发送方自己最多能接收多少数据,然后发送方根据这个值对数据发送操作进行控制,这就是滑动窗口方式的基本思路。
当接收方的TCP收到包后,会先将数据存放到接收缓冲区中。然后,接收方需要计算ACK号,将数据块组装起来还原成原本的数据并传递给应用程序,如果这些操作还没完成下一个包就到了也不用担心,因为下一个包也会被暂存在接收缓冲区中。如果数据到达的速率比处理这些数据并传递给应用程序的速率还要快,那么接收缓冲区中的数据就会越堆越多,最后就会溢出。缓冲区溢出之后,后面的数据就进不来了,因此接收方就收不到后面的包了,这就和中途出错的结果是一样的,也就意味着超出了接收方处理能力。我们可以通过下面的方法来避免这种情况的发生。
接收方需要告诉发送方自己最多能接收多少数据,然后发送方根据这个值对数据发送操作进行控制,这就是滑动窗口方式的基本思路。
前面提到的能够接收的最大数据量称为窗口大小,它是TCP调优参数中非常有名的一个。
ACK与窗口的合并
更新窗口大小的时机应该是接收方从缓冲区中取出数据传递给应用程序的时候。这个操作是接收方应用程序发出请求时才会进行的,而发送方不知道什么时候会进行这样的操作,因此当接收方将数据传递给应用程序,导致接收缓冲区剩余容量增加时,就需要告知发送方,这就是更新窗口大小的时机。
那么ACK号又是什么情况呢?当接收方收到数据时,如果确认内容没有问题,就应该向发送方返回ACK号,因此我们可以认为收到数据之后马上就应该进行这一操作。
发送方的数据到达接收方,在接收操作完成之后就需要向发送方返回ACK号,而再经过一段时间,当数据传递给应用程序之后才需要更新窗口大小。但如果根据这样的设计来实现,每收到一个包,就需要向发送方分别发送ACK号和窗口更新这两个单独的包。这样一来,接收方发给发送方的包就太多了,导致网络效率下降。
在等待发送ACK号的时候正好需要更新窗口,这时就可以把ACK号和窗口更新放在一个包里发送,从而减少包的数量。当需要连续发送多个ACK号时,也可以减少包的数量,这是因为ACK号表示的是已收到的数据量,也就是说,它是告诉发送方目前已接收的数据的最后位置在哪里,因此当需要连续发送ACK号时,只要发送最后一个ACK号就可以了,中间的可以全部省略。当需要连续发送多个窗口更新时也可以减少包的数量,因为连续发生窗口更新说明应用程序连续请求了数据,接收缓冲区的剩余空间连续增加。这种情况和ACK号一样,可以省略中间过程,只要发送最终的结果就可以了。
接收HTTP响应消息
首先,浏览器在委托协议栈发送请求消息之后,会调用read程序(之前的图2.3④)来获取响应消息。然后,控制流程会通过read转移到协议栈,然后协议栈会执行接下来的操作。和发送数据一样,接收数据也需要将数据暂存到接收缓冲区中,这里的操作过程如下。首先,协议栈尝试从接收缓冲区中取出数据并传递给应用程序,但这个时候请求消息刚刚发送出去,响应消息可能还没返回。响应消息的返回还需要等待一段时间,因此这时接收缓冲区中并没有数据,那么接收数据的操作也就无法继续。这时,协议栈会将应用程序的委托,也就是从接收缓冲区中取出数据并传递给应用程序的工作暂时挂起,等服务器返回的响应消息到达之后再继续执行接收操作。
大家可以认为这时协议栈会进入暂停状态,但实际上并非如此。协议栈会负责处理来自很多应用程序的工作,因此挂起其中一项工作并不意味着协议栈就完全暂停了,协议栈会继续执行其他的工作。在执行其他工作的时候,挂起的工作并没有在执行,因此看上去和暂停是一样的。
首先,协议栈会检查收到的数据块和TCP头部的内容,判断是否有数据丢失,如果没有问题则返回ACK号。
然后,协议栈将数据块暂存到接收缓冲区中,并将数据块按顺序连接起来还原出原始的数据
最后将数据交给应用程序。
具体来说,协议栈会将接收到的数据复制到应用程序指定的内存地址中,然后将控制流程交回应用程序。将数据交给应用程序之后,协议栈还需要找到合适的时机向发送方发送窗口更新。