传输层协议知识总结

知识脉络

感觉TCP内容很多都非常细节,我的知识不是很成体系,就不写了。王道上面对重要的知识点都有简练的总结,而我下面的总结,则从设计思想的角度探讨某个协议的历史与逻辑根源。

传输层提供的功能

进程之间的通信

通过IP层,数据可以从网络中另一台计算机交付到当前的计算机。这些数据必然需要某个特定的应用程序来读取,以便进行处理,显示给用户。传输层提供的就是这样进程对进程之间的通信。

这样的话,发送数据的一方就不只是需要指定接收方的IP地址,还需要指定对方主机是由哪个进程来接收这些数据。

一个想法是每一台计算机中每一个进程都被分配了一个进程号,可以通过这个进程号来指定通信的双方。但是,很明显这里存在一些问题——首先是不同计算机分配的进程号格式不一致,在报文的首部并不容易用固定的字长编码;另外,这些进程号显然是不断变化的,每次通信的进程号不同会给数据传输带来很大的不方便。

为了解决上面的问题,一个自然的想法是给不同计算机的所有进程一个统一的进程编码。但是进程往往是不确定,并且常常会产生新的进程。这样的话,很少会有主机会知道某台主机上出现了新的进程并与之通信。

真正的解决方案是采用协议端口号,这是基于缓存的思想:与当前主机通信的其他主机上的进程将数据传输到当前主机后,就将数据放在某个本地仓库中,之后由本地的进程前去读取。因此发送方只需要知道目的主机的某个应用进程的仓库号是多少,就可以把数据发送到目的主机进程的指定仓库,从而实现进程之间的通信。

存在一些常见的应用进程,他们是互联网中最重要的一些应用程序。因此给这类进程都制定了一个熟知端口号(或系统端口号),数值为0 ~ 1023。这些进程有:

  • 21: ftp控制信息端口
  • 22: ssh
  • 23: telnet
  • 25: SMTP
  • 80: HTTP
  • 53: DNS
  • 69: TFTP
  • 443: HTTPS

这些功能大多还会在后面的应用层中讨论。

此外还有登记端口号,数值为1024 ~ 49151,是给没有熟知端口号的应用进程使用的。

客户端端口号则是客户进程在请求服务器的服务时,系统自动给客户端分配的端口号。这些服务器进程就可以通过这个客户端端口号与客户进程进行数据与命令的交互。通信结束后,刚才使用过的客户端端口号就不复存在了,可以分配给其他客户进程使用。

复用和分用

  • 复用: 不同的应用进程可以复用同一个运输层协议来传输数据
  • 分用: 接收方的运输层在剥去报文的首部后可以将这些数据交付给不同的目的应用进程

实现可靠传输

前面说过,IP层的传输是不可靠传输。这是因为一开始它就把锅丢给了传输层(误)。IP层认为计算机的差错处理能力很强,所以它只负责简单灵活地交付数据,然后依靠传输层来保证数据的可靠传输。

除此以外,传输层还会实现一些更高级的功能,如流量控制,拥塞控制这样。

UDP

话虽然像上面那样说,但是 UDP 只是采用了一个怎么简单怎么来的尽最大努力交付。所以 UDP 的特点是非常简单。

  • 无连接的传输。传输前不需要先建立连接
  • 尽最大努力交付,不保证可靠交付
  • 面向报文的。上面的应用进程交给它多大的报文要它发送,它就一股脑一起打个包就发送了。尽管报文太大会使下面IP层分片,报文太小会使得传输效率低下。
  • 没有拥塞控制。即使网络已经很拥塞了,他还是继续发他的数据,这样就可能导致一些数据的丢失,因为缓存满了就被路由器丢掉了。但是这样居然还有一定的好处,就是尽管数据会有丢失,但不会有太大的时延,因此对于一些实时性应用非常有用。比如像 QQ 这样的就是主要用UDP。
  • 支持一对一,一对多,多对一和多对多的交互通信。毕竟无连接啊
  • 首部开销小。它都这么简单了,还需要什么首部开销?无非

    • 源端口:两字节
    • 目的端口:两字节
    • 长度:两字节,单位Byte,最小是8(仅有首部)
    • 检验和:两字节,同时检验了源IP,目的IP,端口号,数据

TCP

概述

TCP就比较强了,提供的是可靠的传输。除此以外,还提供了额外的功能,像是流量控制,拥塞控制这样。所以很明显,他非常复杂…

  • 面向连接的传输。数据传输之前必须先建立连接。传输完数据后必须释放已经建立的连接。
  • 只能一对一。因为面向连接啊,你只能传输给连接的另一方。
  • 提供可靠交付的服务。通过TCP传输的数据,无差错,不丢失,不重复,并且按序到达
  • 提供全双工通信。双方都有发送缓存和接受缓存,可以同时收发
  • 面向字节流。TCP并不理解应用进程交给它的报文,而只是看做无结构的字节流。所以他可以自己决定什么时候发送多大的字节流,这样发送的时候就顺便加上流量控制和拥塞控制。只要接收方收到同样的字节流就行。

关于TCP的连接的话,前面也说过了,需要同时知道IP地址和端口号。所以TCP使用套接字作为连接的端点。其中,套接字socket = (IP地址: 端口号)。比如说:127.0.0.1:80就表示本机的80端口

可靠传输的原理

关于这个问题可以再思考思考<两支蓝军与白军作战的例子>。之前就是思考这个想出了自动重传请求(ARQ, Automatic Repeat reQuest)

自动重传请求

  • 前提: A是发送方,B是接收方
  • A向B发送数据
  • 如果B收到数据,则向A发送一个确认信息
  • 若A向B发送数据失败,或是数据在传输过程中失真,则B不会向A发送确认信息,那么A由于没有受到B的确认信息,A会向B重新发送信息
  • 若B向A发送的确认信息在传输过程中丢失,那么A也不会受到这个确认信息,A仍然会向B重新发送
  • 若A第一次发送的数据时延较大,A向B第二次发送数据并成功接收。B过了一段时间后又收到了A第一次发送的数据,B收到以后就丢弃这个数据

可以看到,实现自动重传请求有两种方法

  • 停止等待协议:A每发送一个数据就停止发送,直到收到B发来的确认信息。停止等待协议固然很简单,不需要维护太多信息。但是很明显信道的利用率太低。因此一般是使用滑动窗口协议。
  • 滑动窗口协议:基于流水线的原理,A不断地发送数据,并且在发送过程中不断接收来自B的确认信息。

要实现滑动窗口协议,需要满足

  • 发送方需要发送当前所发报文的是第几个报文,以便让接收方将没有按序到达的报文组装起来。由于TCP是基于字节流的传输,所以一般是发送当前报文的第一个字节序号
  • 接收方要对发送方发送的字节流进行确认。如果每个字节逐个确认,那效率也太低了吧,需要发送大量的信息。所以一般是使用累积确认的方式,即只对按序收到的数据中的最高字节进行确认,表示到这个字节以前接收方都已经收到了。发送方即可丢掉之前的数据。

所以基于上面的讨论,TCP报文的首部应该包含当前发送的字节序号,以及确认号

TCP可靠传输的实现

再谈滑动窗口协议

发送方A需要管理一个发送窗口,里面包含当前已经发送但未收到确认的数据,以及当前可以发送的数据。同理,接收方B需要管理一个接收窗口。很明显的是,发送方的发送窗口不能大于接收方的接收窗口。

要实现上面那个条件,就需要接收方告诉发送方自己当前的接收窗口有多大,使得发送方可以调整自己的发送窗口。这样,我们的TCP报文的首部就又增加了一个字段:窗口大小

超时重传时间

发送方在没有收到来自接收方的信息时,会对之前的数据进行重传。可是这个重传时间应该怎么选择呢?如果重传时间太短,那么会占有网络的带宽,使得网络的负荷增大。重传时间太长又会使得数据传输的效率非常低。此外,网络的情况是在不断变化的,有时比较畅通,有时又会比较拥堵,因此常数的重传时间显然是行不通的。重传时间的选择需要根据网络的情况实现自适应。

简单的想法是可以管理平均的往返时间(RTT, Rount Trip Time),取超时重传时间为RTT_S + 4RTT_D, 其中RTT_S是平均的RTT值,而RTT_D往返时间的标准差。按照正态分布考虑的话,这个应该可以涵盖所有的正常情况。

上面的算法存在一个问题,即RTT值不好计算。这是因为如果一个报文段发送了两次,如何判断收到的确认信息是对第一个报文的确认,还是对第二次发送报文的确认?

一个改进方法是,既然不能算,那我就不算了(?)。只要报文重传了,就不计算其RTT。但是这样的话,如果某一时刻,时延突然增大了很多,使得报文发生重传。这以后RTT时间就无法进行更新,因此报文就不断重传。

所以最终方案是,如果报文重传了,就不计算它的RTT,而是直接将之前的超时重传时间RTO加倍。

TCP流量控制

首先明确一个问题:为什么TCP要进行流量控制?

前面提到过,TCP的发送方和接收方分别会管理一个发送缓存和接收缓存。设想如果接收方的接收缓存小于发送方的发送缓存,那么发送方发送的部分数据就会被接收方所丢弃,从而不可避免地需要重传,提高了网络的负荷。所以前面说,发送缓存一定要小于接收缓存。

所以流量控制就是这样一个概念:让发送方的发送速率不要太快,以使得接收方来得及接收。所以很明显,这里可以直接利用前面提到过的窗口字段来进行流量控制—接收方向发送方发送自己当前的接收窗口的大小,从而使发送方可以调整自己的发送窗口值。

从上面讨论可以看到,流量控制是点对点通信量的控制,是端到端的问题。这有别于后面会提到的拥塞控制。

上面的利用窗口大小来进行流量控制的方法存在一个问题—设想某一时刻接收方将自己的接收窗口设置为零,并通知了发送方。于是发送方被禁止发送数据。这时由于某些原因,接收方又增大了自己的窗口值,但是这个新的窗口值报文在传输过程中丢失了。这以后发送方就一直在等待接收方非零窗口的通知,而接收方一直在等待发送方传来的数据。这种死锁局面将一直持续下去。

为了解决这个问题,TCP为每个连接都设置了一个持续计时器。只要连接的一方收到另一方的零窗口通知,就启动计时器。计时器的时间到期,就发送一个零窗口检测报文段,对方在确认这个报文段时给出当前窗口值。如果窗口仍是零,则重置持续计时器。若窗口不是零了,死锁的僵局就被打破了。

TCP报文段的发送时机

之前是提到过的,是说TCP是面向字节流的传输,他可以自己决定将多少个字节流打包发送出去。那么,究竟应该如何决定一个TCP报文的大小呢,这是TCP报文发送时机的问题。

存在一些比较想当然的机制:

  • 维持一个变量,等于最大报文段长度(MSS, Maximum Segment Size)。只要缓存中的数据达到了MSS,就组装成一个TCP报文段发送出去。
  • 基于时间的发送。发送方的一个计时器时限到了,就把当前缓存中的数据装入报文段(但长度不大于MSS),发送出去。
  • 发送方的应用进程指明要求发送报文段。

上面的三种方法都有一个共同的问题:不够灵活。比如要是用户只发送很少的数据(如一个字节),使用第二三种方法传输效率会非常低,使用第一种方法会使数据迟迟得不到发送。

解决方案是把几种方法融合起来(就跟多级反馈队列调度算法似的),是叫Nagel算法。即:

  • 应用进程把要发送的数据逐个字节给发送缓存
  • 发送方把第一个数据字节发送出去,后面到达的数据都缓存起来
  • 发送方收到对第一个字节的确认后,再把缓存中的所有数据组成一个报文段都发送出去。同时继续缓存后面到达的数据
  • 只有收到对前一个报文段的确认后,才能发送后一个报文段(类似停止等待协议,但这是基于时间的发送)
  • 当到达的数据达到发送窗口的一半或已经达到最大报文段长度(MSS)时,立即发送(基于报文大小的发送)

上述基于滑动窗口的流量控制还有一个叫糊涂窗口综合征的问题。

TCP的拥塞控制

前面也说了,拥塞控制和流量控制是不一样的。流量控制是点对点的控制,而拥塞控制则是一个整体性的问题。简单说来,拥塞发生的条件为

对网络资源的需求 > 可用资源

这样的话,拥塞控制并不只是关乎某一个连接的流量控制,而是一个全局性的过程。需要协调网络中所有主机,所有路由器,以及降低网络传输性能有关的所有因素。

TCP的拥塞控制方法

TCP进行拥塞控制的算法有四种:慢开始,拥塞避免,快重传,快恢复。为了实现这四种算法,发送方会维持一个叫拥塞窗口的变量。发送方让自己的发送窗口等于拥塞窗口。而判断网络拥塞的依据就是出现了超时。

  • 慢开始
    当主机开始发送数据时,并不清楚当前网络的状态是怎么样的。较好的方法是探测一下,即由小到大逐渐增大发送窗口。

    • 一开始,发送方将自己的拥塞窗口设置为1,发送一个报文段
    • 发送方收到对这个报文段的确认后,将拥塞窗口由1增大到2,发送两个报文段
    • 发送方每收到对一个报文段的确认将将拥塞窗口增大1。因此,每经过一个传输轮次,拥塞窗口就加倍。
  • 拥塞避免

    • 可以看到,采用慢开始算法后,拥塞窗口的大小呈现指数增长。为了防止慢开始算法使拥塞窗口增长太快,还需要设置一个慢开始门限。当拥塞窗口大于慢开始门限时,使用拥塞避免算法。

    • 简单说来,拥塞避免算法就是减小拥塞窗口增大的幅度,使之缓慢增大。即每经过一个传输轮次,拥塞窗口增大1。这样,拥塞窗口就以线性规律缓慢增大。

    • 使用慢开始与拥塞避免算法的流程为

      • 一开始将拥塞窗口设置为1,使用慢开始算法
      • 拥塞窗口以指数规律增大,达到了慢开始门限
      • 使用拥塞避免算法,使拥塞窗口缓慢增大
      • 出现了超时重传,调整门限值为当前拥塞窗口的一半,并设置拥塞窗口为1,回到了第一步
  • 快重传
    设想一种情况,在TCP传输过程中,某个报文段出现了超时重传,TCP因此重新开始慢开始算法。但是实际上这个报文只是在传输过程中丢失了,并不是因为网络出现了拥塞。此时采取慢开始算法反而会降低数据的传输效率。因此,需要有办法可以检测出个别报文丢失的情况。这就是快重传算法。

    • 接收方在收到数据时要立即发送确认,不要等待自己要发送数据才进行捎带确认。
    • 在收到失序的报文段时要发送对已收到报文的重复确认。即如果接收方已经接受了M1, M2,并且发送了确认。这时没有收到M3,却收到了M4。此时,接收方应该发送对M2的重复确认。
    • 发送方在收到对某个报文的重复确认后,就知道某个报文段在传输过程中丢失了,立即进行重传。这样就不会出现超时,发送方也不会误以为出现了网络拥塞。
  • 快恢复

    • 在收到对某个报文段的重复确认后,发送方知道只是丢失了个别报文段,并不是网络出现了拥塞。因此不启动慢开始,而是执行快恢复算法
    • 调整门限值为拥塞窗口的一半,同时设置拥塞窗口等于门限值
    • 执行拥塞避免算法

主动队列管理

拥塞控制是一个全局的控制,需要协调网络中各个部分。所以这里考虑一下路由器的问题。

当网络中出现拥塞时,路由器的队列长度不足以容纳要转发的所有分组,因此会将尾部的分组丢弃,即尾部丢弃策略。这样的尾部丢弃会导致一连串分组的丢失,使发送方出现超时重传,这些发送方都进入了慢开始状态。这样一来,全网的通信量会突然下降很多,而网络恢复正常后,通信量又会突然增大很多。即TCP的全局同步

为了避免全局同步,可以采取主动队列管理。主动是指路由器会主动的丢弃某一些分组,而不是被动等到队列满了以后才尾部丢弃。实现这个需要路由器维持两个参数:队列长度最小门限和最大门限

  • 若平均队列长度小于最小门限,则把新到达的分组放入队列
  • 若平均队列长度大于最大门限,则把新到达的分组丢弃
  • 若介于两者之间,则按照某一个概率随机丢掉到达的分组

这样,以一定概率随机丢掉新到达的分组,使得拥塞控制只在个别的TCP上进行,因而避免了全局同步的情况。

TCP的连接

连接的建立:三报文握手

之前谈过可靠传输的原理,依照自动重传请求,只需要两个报文就可以可靠地发送连接信息了,为什么需要第三个报文。

考虑一种情况:

  • 客户端向服务器发送了两次请求报文段(ACK = 0, SYN = 1),第一次发送的请求报文段在某个网络结点长时间滞留了,因此发了第二次。
  • 服务器收到了第二次的请求报文段,因此向客户端回送了确认连接(ACK = 1, SYN = 1)。若只需要两报文握手的话,此时连接已经建立
  • 连接释放以后,客户向服务器发送的第一个请求报文段终于发到了服务器。服务器误以为客户端再次请求连接,因此再次回送确认连接报文。
  • 由于客户端实际上并没有发送第二次连接请求,客户端会忽略服务器的确认报文。
  • 但是服务器误以为连接已经建立,因此白白消耗了服务器资源。

因此,为了避免上面这种情况,需要客户端在收到从服务器发来的确认连接报文段后,再次向服务器发送确认。这样,在上面那种情况下,由于服务器收不到确认,就知道客户实际上并没有要求连接。

连接的释放: 四报文握手

  • 在某一时刻,A和B处于TCP连接状态。若A已经没有要向B发送的数据了,因此A首先发出连接释放请求。
  • B收到请求后即发出确认。

若此时就完成连接的释放,会存在一些问题—若此时B还有需要向A发送的数据,则这些数据得不到发送。因此,B发出确认后TCP的连接并没有释放,而只是释放A->B这个方向的连接。B->A方向仍然可以发送数据。此时TCP处于半关闭状态。故之后还有以下步骤:

  • B也没有向A发送的数据后,向A发送连接释放请求。
  • A在收到B发来的连接释放请求报文段后,对此发出确认。B收到确认后进入closed状态。

但是,此时TCP还没有释放掉。必须还要经过两倍的最长报文段寿命(MSL, Maximum Segment Lifetime)后,A才进入closed状态。这是由于考虑到A最后发送的这个确认报文段仍有可能丢失,如果等待两倍MSL,A就可以在收到B超时重传的连接请求报文段后及时向B重传确认。若A发送了确认报文后就进入closed状态,就无法收到B重传的连接释放请求,因而也无法重传确认报文。

此外,等待两倍MSL的时间,可以使本连接持续时间内所产生的所有报文段都从网络中消失,这样下一次新的连接中就不会出现这种旧的连接请求报文段。

TCP的连接释放还有一种问题,考虑客户和服务器建立连接后客户端因为某种原因而崩溃了,此时客户显然无法向服务器发送连接释放报文,这样服务器的资源就一直被白白占用。因此TCP还会设置一个保活计时器,服务器每收到一次客户端的数据就重新设置保活计时器。若一定时间内还没有收到来自客户的数据,服务器就发送一个探测报文段。若一连发送十个探测报文段还没有来自客户的响应,服务器就以为这个客户出现了故障,因而就关闭这个连接。