首先,为什么需要运输层?我们都知道网络层中根据 IP 地址可以确定整个网络中的一台主机。但是,真正进行通信的是两台主机中的进程。网络层只能唯一标识主机。如果没有了运输层,网络层可以通过IP地址把数据交付给接收方,但是对于接收方主机而言,并不知道这个数据要交给哪一个进程。所以在整个计算机通信过程中引入运输层,站在运输层的角度来看,互相通信的是两台主机中的两个进程。
那么运输层是如何确定互相通信的是哪个进程?在运输层中引入一个端口的概念。主机上的进程需要和网络中某一台主机进行通信就必须监听某一个端口,当数据到达主机时,运输层可以从数据报文中得知数据应该投递到哪个端口。这样子监听该进程的端口就可以读取到数据。
TCP 之所以复杂,很大一部分原因是在于它在不可靠的网络层上建立了一个可靠的通信。假设 A 主机给 B 主机发送数据。从网络层的角度来看,它只负责把数据发送给主机B,至于数据能否完整到达主机 B,网络层并不关心。所以,我们称网络层不提供可靠通信。但是在这样子不可靠的网络层之上 TCP 提供了一个可靠的通信连接。即发送方发送出去的数据,必须被接收方接收到。所以 TCP 需要做大量的工作来保证通信的可靠性。这一点在学习 TCP 协议的过程中一定要牢记,这对于理解 TCP 协议很有帮助。那么 TCP 是如何做到在不可靠的网络层基础上提供一个可靠的通信。
停止等待协议
最简单的保证可靠可以使用应答回复的方式:主机 A 发送数据给 B,B 收到数据之后给 A 发送一个回复:"我已经收到刚刚发送的数据"。这种是在理想的情况下的通信过程,但是也要考虑到很多意外情况
- 数据报没有到达接收方,可能丢失在网络中
- 数据报在传输过程中出现差错,B收到差错报文
- B收到正常报文之后发送给A的确认报文丢失
- B收到正常报文之后发送给A的确认报文很长时间才到达A
为了保证在意外情况下主机 A 发送的数据仍然可以被主机 B 接收到,TCP 需要进行一些处理。
在第一种情况下,B 主机没有收到数据报所以不会对 A 主机发出应答响应。A 在超过一段时间仍然没有收到主机 B 的确认,就认为刚刚发送的数据报丢失了。于是重新发送刚才的数据报。这种机制称之为超时重传。
第二种情况,B 主机收到的报文在网络传输过程中出现差错,与 A 发送的数据报不完全一样。那么 B 就会丢失掉这份数据报,同时也不会对 A 发送任何通知。这样子回到了第一种的情况,A 会认为刚刚的数据报丢失,会对刚刚的数据报进行超时重传。
从上面的两种情况我们可以得知 A 应该维护一个超时定时器。在超时定时器到期之后还没有收到接收方的确认,就认为数据报丢失,重传数据报。
第三种情况,由于 B 的确认丢失,A 没有收到确认在超时之后就会重新发送数据报。B 在收到数据报之后应该采取两个动作:
- 丢弃这个重复的数据报
- 向 A 发送这个数据报的确认。
第四种情况,B 的确认号在超时定时器到期之后才到达 A。此时 A 已经重新发送以为丢失的数据报,这种情况下 A 和 B 的处理方式分别为:
- A 丢弃该确认号不做任何处理
- B 会再次收到 A 重新发送的数据,然后再次向 A 发送确认号
要实现上面的处理方式,发送发和接收方应该维护有如下的信息:
- 发送方在发送数据报之后应该保留这个数据报,防止可能的重传
- 每个数据报应该有一个唯一的标识,这样子 B 才能对收到的数据报进行确认响应。
- 超时定时器的时间应该比数据报传输的平均时间长。如果过短接收方要重传大部分不必要的数据报,过长则大部分时间都浪费在等待中。
以上的这种通信方式称之为自动重传请求 (ARQ) 既重传的请求是自动进行的,接收方不需要告知发送方要重新发送某个数据报。正是基于以上的确认和重传机制,可以在不可靠的网络层上建立可靠的 TCP 协议。
这种通信方式简单,但是也存在一个严重的问题:对信道的利用率太低。发送方每次都得等待接收方的确认之后才能发送下一个数据报。
为了提高信道的利用率,发送方一般不会使用停止等待协议,而是采用类似流水线的方式发送数据报,类似下图的方式。
从上图我们可以很明显看出来,采用流水线的方式发送可以极大的提高信道的利用率。但是效率提高了,TCP 仍然需要保证通信的可靠性,所以当采用流水线的方式发送数据报之后,采用什么协议来保证通信的可靠性?
连续 ARQ 协议 && 滑动窗口协议
如上图,发送方对数据分组之后不是一个一个的发送给接收方并且等待接收方的应答,而是维护了一个发送窗口,并且规定
- 发送窗口从左向右移动
- 在这个发送窗口之前的数据必然是已经发送而且等到接收方的确认收到
- 落在发送窗口之内的数据是接收方可以发送的数据
- 在发送窗口之后的数据是不能发送的数据。
从上图的例子中 : [1,2] 数据包为已经发送并且得到确认的数据,[3,7] 是发送方可以发送的数据,[8,~] 的数据是不能发送的数据。这种维护一个发送窗口的方式发送数据报文的行为,称之为滑动窗口协议
那么,在在滑动窗口协议下,接收方如何给发送方发送确认报文 (ACK)。这里主要有两种方式
- 回退 N
- 选择重传
这里,我们先讨论回退 N 的方式 : 这种方式很简单,接收方使用累计确认的方式发送确认报文。接收方不会对每个报文都发送确认,而是收到几个分组之后,对按序到达的最后一个分组发送确认。这就意味着在此编号之前的所有分组都已经收到了。上图,假设发送方发送了编号为 [3,7] 的报文,5 号报文丢失在网络中,而且其他的报文接收方都接收到了,这个时候接收方发送当前最高编号 4 给发送方。那么,发送方就认为 [5,7] 的报文都丢失了,会重新发送这些报文,即使 [6,7] 号报文接收方也收到了。
根据上面的描述,我们发现在网络环境质量不好的时候,回退N的方式会使得数据通信变得更加拥塞。
同样,为了避免接收方的确认报文在网络中丢失造成死锁,接收方应该维护一个超时时间,当在一定的时间没有收到确认报文,则重新发送数据报。超时时间的选择也是有一定算法的:一个报文从发出去的时间到收到相应报文确认的时间称之为 RTT。那么超时时间应该收集多个 RRT 时间之后进行一系列的计算得出一个时间。如果超时时间过短就会导致重发大量不必要的数据报,太长就会增大延迟。
缓存
要明确一点,从应用层传递下来的数据在运输层并不一定会马上发送出去。同样,接收方接收到数据之后也不一定会马上交付给应用层。在 TCP 中规定,通信双方维护有接收缓存和发送缓存两个缓冲区用于接收数据。当应用层的数据传递给运输层之后,数据并不是马上发送出去而是进去发送缓冲中等待到适当的时机再发送。同样接收到数据之后也是马上交付给应用层,也是放入接收缓存中等待时机再提交给应用层。
下面讨论在什么"时机"下才会把数据交付/发送出去。
对于接收缓存而言比较简单,只缓存两种数据:
- 按序到达,但是还没有被应用程序读取。
- 未按序到达的数据
对于第一种情况,可能是应用程序暂时没空从接收缓存中读取数据,它可以再随时读取接收缓存的数据。第二种情况,说明数据没有按序到达,TCP 不会直接交付给应用层,当数据按序到达之后 TCP 才会交付给应用层。至于应用层会不会读取数据就不一定了。
对于发送缓存而言,要考虑的问题就比较多了。要根据应用层的程序特性来决定如何发送数据。
考虑一个场景,使用 ssh 连接到远程主机的时候。这种交互式的通信方式意味着每次输入的命令可能只有一个字节。但是在网络协议中,经过 TCP 层和 IP 层的封装,发送到网络中的字节长达 41 个字节(20TCP 首部 + 20IP 首部 +1 个字节数据)。倘若客户端每次都是发送类似的小字节数据,那么对于通信信道的浪费将会非常严重。
为了解决这个问题,引入了一个称为 Nagle 算法。这个算法的逻辑如下: 把程序要发送的数据放入 TCP 发送缓存,发送方只把第一个字节的数据发送出去,当收到对第一个字节数据的确认之后,才把其他的数据组装成一个报文发送出去。这样子就可以避免网络中充斥着许多小包数据。这个算法在计算机网络中得到了广泛的应用,在 nginx 的配置项中默认已经开启了这个选项 (tcp_nopush)
但是对于一些实时竞技类的游戏则不能使用 nagle 算法,因为游戏对于网络的延迟有很高的要求,nagle 会把数据延迟发送,这样子会造成游戏的卡顿。很多时候游戏都不会常用 TCP 协议,而是直接使用 UDP 这种无连接简单的方式来保证游戏的实时性。至于魔兽采用了 TCP 的方式是有一定的特殊性和优化,但是也要注意魔兽即使使用了 TCP 也仍然是关闭了 nagle 算法。
另一个问题,当接收方处理接收缓存的数据较慢的时候会引发:糊涂窗口综合症。假设 TCP 接收方的缓存已经满了,而交互式的程序只从接收缓存中读取 1 个字节,这样接收缓存只腾出了 1 个字节。然后向发送发发送确认,注意确认报文长达 41 个字节。由于发送方总是很迫切的希望发送数据,所以当收到确认之后发送方又只发送 1 个字节的有效数据,但是报文长度还是 41 个字节。这样往复下去会使得网络效率低下。
上述的问题,有两个方法可以减缓
- 接收方接收到数据同样马上发送确认,但是同时对发送方宣布窗口大小为 0。这样接收方就暂时不会发送数据
- 报文到达时不马上发送确认,直到缓存有足够的空间。这样就可以避免发送方滑动窗口。但是这也存在一个问题,接收方延迟发送确认的时间不应该超过超时时间,如果过长会导致发送方误以为数据丢失重新发送数据。
回顾上面,主要针对两点进行了讨论
- 如何在不可靠的 IP 层基础上实现可靠通信
- 提高网络效率
下一章将针对TCP报文,三次握手,流量控制,拥塞控制等内容进行说明。
本文首发于 https://jaychen.cc/
作者:jaychen