Loading

计算机网络——传输层(上)

《计算机网络——自顶向下方法》的笔记。

这里所述的传输层(也称运输层)是TCP/IP分层架构中的传输层,处于应用层与网络层中间。

概述

本篇是传输层笔记的(上)部,因为篇幅实在太长,里面介绍的东西是在太多。本篇主要介绍:

  1. 什么是传输层
  2. 传输层的任务是什么,如何完成
  3. UDP
  4. 可靠数据传输的原理

传输层向上与应用层打交道,也就是用户的应用程序进程,它接收用户传入的报文或者将从网络中接收到的分组传递给应用层。向下,它与网络层打交道,它将用户发起的报文段传递给网络层,或者从网络层接收网络层分组。

传输层与任何底层网络的实际通信并无关系,它只是代表一端的进程与另一端的进程的逻辑连接。而实际的网络通信(如如何选择路由、如何传输每一个比特)传输层并不关心,这种抽象使得传输层可以与具体的底层网络实现方式剥离开。

网络层IP协议

在TCP/IP中,通常使用的网络层协议是IP协议,它并不能提供可靠的数据传输,而是尽力而为交付数据。就是说它不能保证数据不丢失、数据不损坏、数据按序到达。但是尽管这样传输层依然能够在其之上通过自己的方式来在两个端系统之间建立可靠的数据传输,稍后我们就会看到。

套接字

TCP/IP中的传输层协议有TCP和UDP,它们将两个端系统之间IP的交付服务扩展成运行在两个端系统之间进程的交付服务。IP层只能提供在两个主机间传递数据,它只能确保数据传递到主机上,但是具体由运行在主机上的哪一个进程来处理它并不知道。而传输层通过套接字可以将数据交付到正确的进程。

一个进程需要有一个(也可以有多个)套接字,套接字绑定到端口号上,当应用向传输层传递报文时,传输层在其头部添加传输层报头,这个报头中有源端口号和目的端口号,源端口号即发送端的套接字绑定的端口号,目的端口号是它要发送到主机上的哪个端口。当接收端的运输层从网络层中取到这个数据时,它会检测目的端口号,然后将这个数据传递给这个端口号绑定的套接字,此时进程就正确的接到了数据。

传输层将网络层中拿到的数据分配给正确的进程,这叫做传输层的多路分解,将不同应用的数据打上运输层报头传递给网络层,这叫做传输层的多路复用

UDP和TCP多路分解不同之处

UDP是一种无连接的协议,它不会为每一个连接建立一个新的socket,如果两个UDP报文具有相同的目的IP地址和端口号,不论它们的源IP和端口号是否相同,它们都会在同一个套接字中被处理,而TCP是一种面向连接的协议,两个不同源IP或源端口号的数据会定向到不同的socket。

如果你尝试socket编程,那么很容易理解上面的话。

无连接传输UDP

UDP除了提供一些校验机制确定数据是否损坏之外,它什么都不做。

UDP协议为应用层数据加上能够实现复用分解和差错校验的足够字段后,就直接将其扔到网络层中,所以UDP很简单,几乎是直接使用IP层提供的服务。

UDP如此简单粗暴的手法并非没有应用场景,而是有很大应用场景。

  1. 应用层可以更精细的控制何时发送数据,TCP具有拥塞控制机制,它会在链路拥挤时遏制发送方的发送速率,并且TCP在一定设置下还会具有将多个应用层数据打包一起传输的行为,这种行为下应用层数据不会立即被传输,而是等待足够多的数据到来
  2. 无需建立连接,建立连接需要几次握手,在断开时还需要几次挥手,这会造成额外的时延
  3. UDP的发送方接收方无需缓存数据,想要保证高效且可靠数据传输,有时需要发送方和接收方缓存一部分数据,但缓存的大小总是有限的,所以无缓存的设计可以容纳更多活跃用户
  4. 分组首部开销小

UDP天生适合某些对实时性要求较高且丢失一些数据也无伤大雅的传输需求,比如实时通信。

可靠数据传输原理

现在,在开始介绍TCP/IP中的可靠传输服务TCP之前,先要介绍一下可靠数据传输的一些理论作为前置知识,因为传输层不了解底层信道,所以它并不知道底层信道提供什么样的服务,它最好是不要对底层信道的可靠性做任何假设,所以要想在运输层中实现可靠数据传输,需要做很多的努力。

可靠是指发送端传输的数据包会不丢失的、正确的、按序的传递给接收端。

前置

会使用一些预定义的函数来说明,这里给出它们的定义

  • rdt_send:该函数向应用层提供可靠的数据传输服务
  • udt_send:该函数是传输层用来将数据传递给网络层的,它应该被视作不可靠的
  • rdt_rcv:该函数是请求以可靠方式接收数据的
  • deliver_data:该函数用于传输层向应用层交付数据

构造可靠数据传输协议

rdt1.0

首先,为了简单,我们假设底层信道是完全可靠的,这样我们的传输层协议就可以在rdt_send事件到来时直接调用udt_send

我们使用有限状态机(FSM)来描述这个协议的状态转换,它只有一个状态。要说明的是虚线箭头是初始状态,实线箭头是状态发生变更,而箭头旁边的代码横线上方是引起状态变更的事件,下方是状态变更要执行的操作。

所以上图,rdt1.0的发送端在rdt_send事件到来时通过make_pkt制作传输层包并且调用udt_send直接交付到网络层,除此之外它没做什么。这个也有点像UDP的状态转换。rdt1.0的接收端在rdt_rcv事件到来时,调用extract进行传输层包的解包,然后直接通过deliver_data传递给上层。

因为底层信道完全可靠,所以这一切显得很简单,我们的协议并没有做什么额外的操作。

rdt2.0

现在假设我们的底层信道有可能出现比特差错,即它有可能把一个0变成1(或者相反)。此时我们需要添加校验数据包是否正确的函数。

  1. corrupt函数在数据包已经被损坏时返回真
  2. notcorrupt在数据包没有被损坏时返回真

注意,此时我们的make_pkt函数中就需要在打传输层包时添加相关的校验字段,用于在接收端校验数据是否损坏。

想象你在打电话时,可能由于信号波动,对方说的话中间的一个字被“吞”了,导致你不知道对方在表达什么,这时,你作为接收方,你必须向发送方说:“嘿!我没有听到!请你重复一遍!”!运输层也采取这种办法,一旦它发现数据已经损坏,它就会发送一个否定应答(NAK),一旦它正确接收数据,它就发送一个肯定应答(ACK)

时刻注意现在我们假设的底层信道只是会出现比特差错,并不会丢失数据包和数据失序

下面是rdt2.0的发送端FSM,它有两个状态,它每发一个数据包就要等待接收方对该数据包的肯定或否定应答。

首先它等待来自上层的调用,当事件rdt_send从上层到来时,它打包并发送数据包,这里的make_pkt中添加了一个校验和用来接收端检测数据包是否损坏。发送数据之后,它转入另一个状态,等待接收端的ACK或NAK,当它接收到NAK时,重传该数据,当它接收到ACK时什么也不做,转到另一个状态,继续等待上层调用。

此时的发送方在等待ACK或NAK时无法继续响应上层的调用,它必须停下来,等待ACK到达,这种协议叫做“停等协议”。实际上它的带宽利用率极低。

下面是接收端的FSM:

接收端也很简单,接收到消息并且消息损坏,那么发送NAK,否则解包,传递到上层并发送ACK。

rdt2.0有一个致命缺陷,就是在一个可能发生比特差错的信道上,如何保证ACK和NAK的传输不会出错呢?如果它们出错,发送端可能无法识别接收端的应答,甚至它有可能把ACK误认为是NAK。

有几种可以参考的解决办法

  1. 如果发送方不理解接收方的应答,那么发送方可以向接收方发送类似于NAK的其它消息,那么这就会在协议里添加新的部分,并且,这一条数据也有损坏的可能,显然这种办法没法让我们逃出这个问题
  2. 给ACK或NAK也引入校验,如果接到含糊不清的ACK或NAK,发送端直接重传数据。此时接收方中可能存在冗余分组,因为可能存在接收方正确接收到数据然后返回ACK,但是这条确认消息的发送过程中产生了比特差错,接收方无法认出它,所以接收方进行了一次重传。所以接收方需要一种机制来判断发送方此次发送的分组是一个重传分组还是按序的下一个分组。

rdt2.1/2.2

此时,可以在发送端打包时加入一个序号,然后接收端通过这个序号就能确认该分组是一个重传分组还是它想要的下一个分组。

对于上面那种停等协议,这个序号只需要是0或1即可。当正常发送数据时,该序号发生变化,而发送重传分组时,该序号不变,此时接收方就可以根据序号是否与上一个分组的序号相同来判断此数据包是重传数据包还是下一个分组了。

这样,我们定义了rdt2.1的FSM

它有四个状态,但是上面两个和下面两个做的都是一样的工作,只是它们使用的序号不同,所以还是很简单的。当上层调用时,初始rdt2.1会使用序号0进行发送,然后它转入到等待接收端应答的状态,当接收端应答到来时,它不能再完全相信接收端的的应答了,它要检测这个应答是否产生了差错,如果产生差错或者是NAK的话,它就重传刚刚那个消息(那个消息的序号没有变化,还是0),当没产生差错并且应答是ACK时,数据包正确到达了接收端,此时转入下一个状态。这次采用序号1进行发送。后面的内容都差不多。

rdt2.1的接收方看起来很复杂,像两只共用肚腩的小兔子哈哈哈哈

rdt2.1首先等待接收发送端过来的序号为0的分组,如果该分组受损,发送NAK,如果未受损但序号不是0,这时可能是向上面所说某个之前已经正确接收的分组的ACK应答没有被发送端正确收到,导致发送端重传了该分组,这时直接给发送端发一个ACK即可。剩下的我想也不用我解释了。

rdt2.2则是一种无NAK协议,如果不发送NAK,而是对正确接收的上一个分组再发送一个ACK,发送端接收到同一个分组的两次ACK,它就知道该分组后面的一个分组没有被正确传递。无NAK协议的思路和接收端冗余分组差不多,它是在发送端引入冗余ACK。这时需要在ACK上加一个序号了。这时如果发送端发送的分组序号为0,那么它接到的ACK 0就是代表该分组正确接收,如果是ACK 1就代表该分组没被正确接收,接收端正确接收的是该分组之前的一个分组。

因为没有了NAK,所以rdt2.2的接收端FSM特别简单

rdt3.0

rdt3.0需要解决最后一个问题,即如果底层信道会发生丢包,咋整。

这时你就得想,怎样才能判定一个包丢了。emmm...因为传输层并不卷入底层的细节,所以我们永远没法判断一个包是不是真的丢了,因为它永远可能在下一瞬间到达。所以我们只能基于经验来判断,当一个包发出去后,我们在一定时间内(比如基于往次传输的实验计算)没有接收到接收端的应答,那么我们就认为它丢了,这时就应该重传它,尽管稍后它可能会正确的到达接收端并返回ACK。

下图是rdt3.0的发送方FSM

关于初始状态的那个rdt_rcv先不看,一会解释。当rdt_send事件到来时,它依然按照rdt2.2一样构造分组,调用udt_send,只不过最后它启动了一个定时器,然后转向下一个状态。这个定时器的目的即是当指定时间后还没有接收到ACK应答消息,就认为产生了丢包。然后下面它开始等待ACK 0,如果此时接收到ACK 1或者应答消息损坏,就什么都不做,这是它和rdt2.2不同的地方,它选择的是直接丢弃错误的应答消息,而rdt2.2选择重传消息(不过rdt3.0也会在定时器超时后重传)。然后过一会,当定时器超时,如果正确的应答消息还没到,就重新发送,并重置定时器。只有当它收到了正确的应答消息时,才认为接收端已经接到了消息,这时停止计时器转为下一个状态。此时,信道中可能有冗余的消息,即之前发的包没有丢,但由于延时导致定时器timeout然后重发消息,此时可能还会接收到延时的应答消息,所以,状态等待来自上层的调用1需要在接收到应答消息时直接丢弃,初始状态中的那个rdt_rcv也一样。

接收方的FSM没有提供。

这是使用rdt3.0可能会出现的几种状况

流水线可靠数据传输协议

解决了可靠数据传输中的全部问题后,我们来考虑效率。上面的rdt协议都是停等协议,它导致无论信道有多大的带宽,但始终只能利用它传递一个数据包

解决办法是,使用流水线技术

提高效率的同时也带来了实现上的复杂度:

  1. 必须增加序号范围,0和1交替的方式已经无法满足需求
  2. 发送方和接收方必须缓存分组,比如发送方需要缓存已发送但未被确认的分组
  3. 使用新的丢失、差错和延时过大导致的重传的重传策略,常见的有GBN(回退N步)和SR(选择重传)

GBN

这里有一份官方的GBN交互动画,有助于理解GBN。

GBN允许从停等协议的最大允许信道中有1个分组变成N个,它维护一个大小为N的滑动窗口,并且定义了两个变量:

  1. base:最早未确认的分组序号
  2. nextseqnum:下一个要传送的分组序号

现在,我们可以把发送方看到的序号范围分为4个部分:

[0, base - 1]为已经被确认的分组,[base, nextseqnum - 1]为已经发送但尚未被确认的分组,[nextseqnum, base + N - 1]为目前还可以用来向信道中发送分组的序号,[base + N, +无穷]为不可用的序号。

这个窗口会随着分组被不断地确认而向前滑动,同时,在计算机中一切都要有一个上界的限定,比如你用2个字节来保存分组序号,那么不可能让它一直递增,此时你必须使用求余来将序号限定在[0, 2^8-1],让这个所有可用的序号范围空间连成一个环路。

GBN发送方FSM,由于不需要手动编写两个状态之间的转换,所以很简洁:

初始状态下base=1, nextseqnum=1,当rdt_send事件发生,先判断窗口是否还有可用序号,如果有就将它放到sndpkt数组中(这可以看作是缓存),并发送,发送后要判断如果当前发送的序号是窗口中的第一个序号,就开启定时器。

rdt3.0的处理办法一致,接到不正确的应答消息直接忽略,等待超时来处理。当超时事件发生时,重发所有已发送但尚未被确认的分组。当收到正确的ACK时,从该应答消息中获取序号,并更新base,如果更新后已经没有尚未确认的消息了,就停止计时器,否则重新启动计时器。

上面的发送端基于一个假设,就是接收端的ACK携带一个序号,并且这个ACK代表接收端已经接受了该序号之前全部的分组。这中方式叫累计确认

下面看接收端:

接收端维护两个变量,一个是expectedseqnum,代表它期待的下一个来自发送端的序列号,sndpkt是给发送端返回的ACK消息,该sndpkt会被以后的事件更新。

然后在接收端正确接收到数据的情况下,即数据没有损坏并且是它期待的数据,它就解包发给上层,更新sndpktexpectedseqnum,发送ACK给客户端。否则任何情况,它都发送没有更新之前的sndpkt,表示它接收到了之前的所有分组。

expectedseqnum保证了GIB的接收端中接到的分组是按序的,并且一旦出现乱序分组就直接丢弃,比如第n+1个分组比第n个先到,那么第n+1个将由于第n个还没到而被丢弃。这种方法导致接收端不需要缓存任何失序分组,但是有点浪费网络带宽。

总结一下:GBN协议的正确性是由发送方和接收方共同维护的,我单独看发送方的FSM时,我会想,为什么[base, nextseqnumber - 1]之间都是尚未被确认的分组,不会在中间存在一个已经被确认的分组吗?但是实际上,该段的正确性是因为接收方的有序确认提供的。接收方不会乱序的发送确认,所以该段的正确性就得以保持了。

SR

GIB的问题是,当窗口很大时,也许一个错误的分组就会造成大量的重传。

SR(选择重传)则是只重传那些有可能出错的分组。

SR发送方事件

  1. 上层事件:当发送方从上层接到数据时,检查下一个可用序号,如果位于窗口内则使用该序号发送,否则拒绝或缓存
  2. 超时事件:选择重传要求每个分组必须拥有自己的逻辑定时器,此时只需要重传超时的分组即可(可以用单个硬件定时器模拟多个逻辑定时器)
  3. ACK事件:收到ACK,如果序号在窗口内,则将那个序号置为已经确认,如果序号等于send_base,那么send_base将向右移动到最小的已发送未确认序号。如果窗口移动了并且有序号落在窗口内的未发送分组,发送它们

SR接收方事件:

  1. 窗口内的分组被正确接收:返回一个ACK,如果该分组以前没收到过就缓存,如果序号为rcv_base那么它以及它后面所有连续的已接收被缓存的分组都递交给上层,接收窗口移动。
  2. 在窗口之前[rcv_base - N, rcv_base - 1]的分组被正确收到:返回一个ACK,尽管它们是接收方已经确认的分组
  3. 其它情况:忽略该分组

接收方的第二步很重要,第二步获得的分组都是以前确认过但由于一些原因(丢包或者延迟过大)它的ACK并未传回给发送方,这时必须返回一个ACK,否则发送方的窗口将无法向前移动。

SR若想正常工作,要对指定序号空间下的窗口大小有要求,比如:

下图中所有的序号为0,1,2,窗口大小为3,首先发送方要发送序号为012的三个分组,假设这三个分组都接收到了,但是它们的ACK都丢失了,这时发送端等待超时后就会重传之前序号为0的分组,注意,此时是一次重传!

而下图,还是012都被接收,然后分组0的ACK被发送端接收,它使用序号3发送下一个分组,然后序号为1的ACK被发送端接收,它使用序号0发送下一个分组。注意这时的序号0是一个新数据。

但是对于接收端来说,它没有任何办法知道这个序号0是新分组还是重传分组,所以此时SR协议无法正常工作。

对于SR协议而言,窗口长度必须小于或等于序号空间大小的一半

关于为何小于等于一半,这篇文章写的挺好:返回N协议与选择重传协议的发送窗口大小问题

posted @ 2022-04-10 12:23  yudoge  阅读(267)  评论(0编辑  收藏  举报