术→技巧, 研发

通讯协议中TCP和UDP的区别

钱魏Way · · 2 次浏览

TCP(Transmission Control Protocol,传输控制协议)

TCP 是一种面向连接的单播协议,在 TCP 中,并不存在多播、广播的这种行为,因为 TCP 报文段中能明确发送方和接受方的 IP 地址。在发送数据前,相互通信的双方(即发送方和接受方)需要建立一条连接,在发送数据后,通信双方需要断开连接,这就是 TCP 连接的建立和终止。

TCP 连接的建立和终止

如果你看过我之前写的关于网络层的一篇文章,你应该知道 TCP 的基本元素有四个:即发送方的 IP 地址、发送方的端口号、接收方的 IP 地址、接收方的端口号。而每一方的 IP + 端口号都可以看作是一个套接字,套接字能够被唯一标示。套接字就相当于是门,出了这个门,就要进行数据传输了。

TCP 的连接建立 -> 终止总共分为三个阶段

以下是非常典型的 TCP 连接的建立和关闭过程,其中不包括数据传输的部分。

TCP 建立连接三次握手

从最开始双方都处于CLOSED状态。然后服务端开始监听某个端口,进入了LISTEN状态。然后客户端主动发起连接,发送 SYN , 自己变成了SYN-SENT状态。服务端接收到,返回SYN和ACK(对应客户端发来的SYN),自己变成了SYN-REVD。之后客户端再发送ACK给服务端,自己变成了ESTABLISHED状态;服务端收到ACK之后,也变成了ESTABLISHED状态。

第一次握手:客户端向服务端发送连接请求报文段。该报文段的头部中SYN=1,ACK=0,seq=x。请求发送后,客户端便进入SYN-SENT状态。

  • PS1:SYN=1,ACK=0表示该报文段为连接请求报文。
  • PS2:x为本次TCP通信的字节流的初始序号。

TCP规定:SYN=1的报文段不能有数据部分,但要消耗掉一个序号。

第二次握手:服务端收到连接请求报文段后,如果同意连接,则会发送一个应答:SYN=1,ACK=1,seq=y,ack=x+1。

该应答发送完成后便进入SYN-RCVD状态。

  • PS1:SYN=1,ACK=1表示该报文段为连接同意的应答报文。
  • PS2:seq=y表示服务端作为发送者时,发送字节流的初始序号。
  • PS3:ack=x+1表示服务端希望下一个数据报发送序号从x+1开始的字节。

第三次握手:当客户端收到连接同意的应答后,还要向服务端发送一个确认报文段,表示:服务端发来的连接同意应答已经成功收到。

该报文段的头部为:ACK=1,seq=x+1,ack=y+1。

客户端发完这个报文段后便进入ESTABLISHED状态,服务端收到这个应答后也进入ESTABLISHED状态,此时连接的建立完成!

为什么不可以两次握手?

为了阻止历史重复连接的初始化。客户端有可能会连续发送多次请求建立连接的 SYN 报文,在网络拥堵等情况下:

一个「旧 SYN 报文」比「最新的 SYN 」 报文早到达了服务端;

那么此时服务端就会回一个 SYN + ACK 报文给客户端;

在三次握手的情况下,客户端收到后这个报文之后可以判断这是一个历史连接(序列号过期或超时),那么客户端就会发送 RST 报文给服务端,表示中止这一次连接。(如果是两次握手,客户端就不能判断当前连接是历史连接了)

对于上面这种情况,如果不进行第三次握手,服务器发出 SYN + ACK 报文后就认为新的连接已经建立了,并一直等待客户端发来数据,这样服务器的许多资源就白白浪费了。

为什么不需要四次握手?

四次握手其实也能够可靠地同步双方的初始化序号,只是建立 TCP 连接的时候不需要像结束 TCP 连接的时候那样 ACK 和 FIN 报文必须分开发送,建立 TCP 连接的时候,ACK 报文和 SYN 报文是一起发送的。所以,第二步和第三步可以优化成一步,所以就成了「三次握手」。

三次握手:

  • 第一次和第二次握手是为了保证服务端能接收到客户端的消息并能做出正确应答
  • 第二次和第三次握手是为了保证客户端能收到服务端的消息并能做出正确应答

TCP 断开连接 – 四次挥手

数据传输结束后,通信的双方可以释放连接。数据传输结束后的客户端主机和服务端主机都处于 ESTABLISHED 状态,然后进入释放连接的过程。

TCP 断开连接需要历经的过程如下:

  • 客户端应用程序发出释放连接的报文段,并停止发送数据,主动关闭 TCP 连接。客户端主机发送释放连接的报文段,报文段中首部 FIN 位置为 1 ,不包含数据,序列号位 seq = u,此时客户端主机进入 FIN-WAIT-1(终止等待 1) 阶段。
  • 服务器主机接受到客户端发出的报文段后,即发出确认应答报文,确认应答报文中 ACK = 1,生成自己的序号位 seq = v,ack = u + 1,然后服务器主机就进入 CLOSE-WAIT(关闭等待) 状态。
  • 客户端主机收到服务端主机的确认应答后,即进入 FIN-WAIT-2(终止等待2) 的状态。等待客户端发出连接释放的报文段。
  • 这时服务端主机会发出断开连接的报文段,报文段中 ACK = 1,序列号 seq = v,ack = u + 1,在发送完断开请求的报文后,服务端主机就进入了 LAST-ACK(最后确认)的阶段。
  • 客户端收到服务端的断开连接请求后,客户端需要作出响应,客户端发出断开连接的报文段,在报文段中,ACK = 1, 序列号 seq = u + 1,因为客户端从连接开始断开后就没有再发送数据,ack = v + 1,然后进入到 TIME-WAIT(时间等待) 状态,请注意,这个时候 TCP 连接还没有释放。必须经过时间等待的设置,也就是 2MSL 后,客户端才会进入 CLOSED 状态,时间 MSL 叫做最长报文段寿命(Maximum Segment Lifetime)。
  • 服务端主要收到了客户端的断开连接确认后,就会进入 CLOSED 状态。因为服务端结束 TCP 连接时间要比客户端早,而整个连接断开过程需要发送四个报文段,因此释放连接的过程也被称为四次挥手。

TCP 连接的任意一方都可以发起关闭操作,只不过通常情况下发起关闭连接操作一般都是客户端。然而,一些服务器比如 Web 服务器在对请求作出相应后也会发起关闭连接的操作。TCP 协议规定通过发送一个 FIN 报文来发起关闭操作。

所以综上所述,建立一个 TCP 连接需要三个报文段,而关闭一个 TCP 连接需要四个报文段。TCP 协议还支持一种半开启(half-open) 状态,虽然这种情况并不多见。

TCP 半开启

TCP 连接处于半开启的这种状态是因为连接的一方关闭或者终止了这个 TCP 连接却没有通知另一方,也就是说两个人正在微信聊天,cxuan 你下线了你不告诉我,我还在跟你侃八卦呢。此时就认为这条连接处于半开启状态。这种情况发生在通信中的一方处于主机崩溃的情况下,你 xxx 的,我电脑死机了我咋告诉你?只要处于半连接状态的一方不传输数据的话,那么是无法检测出来对方主机已经下线的。

另外一种处于半开启状态的原因是通信的一方关闭了主机电源 而不是正常关机。这种情况下会导致服务器上有很多半开启的 TCP 连接。

TCP 半关闭

既然 TCP 支持半开启操作,那么我们可以设想 TCP 也支持半关闭操作。同样的,TCP 半关闭也并不常见。TCP 的半关闭操作是指仅仅关闭数据流的一个传输方向。两个半关闭操作合在一起就能够关闭整个连接。在一般情况下,通信双方会通过应用程序互相发送 FIN 报文段来结束连接,但是在 TCP 半关闭的情况下,应用程序会表明自己的想法:”我已经完成了数据的发送发送,并发送了一个 FIN 报文段给对方,但是我依然希望接收来自对方的数据直到它发送一个 FIN 报文段给我”。 下面是一个 TCP 半关闭的示意图。

解释一下这个过程:首先客户端主机和服务器主机一直在进行数据传输,一段时间后,客户端发起了 FIN 报文,要求主动断开连接,服务器收到 FIN 后,回应 ACK ,由于此时发起半关闭的一方也就是客户端仍然希望服务器发送数据,所以服务器会继续发送数据,一段时间后服务器发送另外一条 FIN 报文,在客户端收到 FIN 报文回应 ACK 给服务器后,断开连接。

TCP 的半关闭操作中,连接的一个方向被关闭,而另一个方向仍在传输数据直到它被关闭为止。只不过很少有应用程序使用这一特性。

同时打开和同时关闭

还有一种比较非常规的操作,这就是两个应用程序同时主动打开连接。虽然这种情况看起来不太可能,但是在特定的安排下却是有可能发生的。我们主要讲述这个过程。

通信双方在接收到来自对方的 SYN 之前会首先发送一个 SYN,这个场景还要求通信双方都知道对方的 IP 地址 + 端口号。

下面是同时打开的例子:

如上图所示,通信双方都在收到对方报文前主动发送了 SYN 报文,都在收到彼此的报文后回复了一个 ACK 报文。

一个同时打开过程需要交换四个报文段,比普通的三次握手增加了一个,由于同时打开没有客户端和服务器一说,所以这里我用了通信双方来称呼。

像同时打开一样,同时关闭也是通信双方同时提出主动关闭请求,发送 FIN 报文,下图显示了一个同时关闭的过程。

同时关闭过程中需要交换和正常关闭相同数量的报文段,只不过同时关闭不像四次挥手那样顺序进行,而是交叉进行的。

初始序列号

初始序列号的英文名称是 Initial sequence numbers (ISN),所以我们上面表示的 seq = v,其实就表示的 ISN。

在发送 SYN 之前,通信双方会选择一个初始序列号。初始序列号是随机生成的,每一个 TCP 连接都会有一个不同的初始序列号。RFC 文档指出初始序列号是一个 32 位的计数器,每 4 us(微秒) + 1。因为每个 TCP 连接都是一个不同的实例,这么安排的目的就是为了防止出现序列号重叠的情况。

当一个 TCP 连接建立的过程中,只有正确的 TCP 四元组和正确的序列号才会被对方接收。这也反应了 TCP 报文段容易被伪造 的脆弱性,因为只要我伪造了一个相同的四元组和初始序列号就能够伪造 TCP 连接,从而打断 TCP 的正常连接,所以抵御这种攻击的一种方式就是使用初始序列号,另外一种方法就是加密序列号。

TCP 状态转换

我们上面聊到了三次握手和四次挥手,提到了一些关于 TCP 连接之间的状态转换,那么下面我就从头开始和你好好梳理一下这些状态之间的转换。

首先第一步,刚开始时服务器和客户端都处于 CLOSED 状态,这时需要判断是主动打开还是被动打开,如果是主动打开,那么客户端向服务器发送 SYN 报文,此时客户端处于 SYN-SEND 状态,SYN-SEND 表示发送连接请求后等待匹配的连接请求,服务器被动打开会处于 LISTEN 状态,用于监听 SYN 报文。如果客户端调用了 close 方法或者经过一段时间没有操作,就会重新变为 CLOSED 状态,这一步转换图如下:

这里有个疑问,为什么处于 LISTEN 状态下的客户端还会发送 SYN 变为 SYN_SENT 状态呢?

这种情况可能出现在 FTP 中,LISTEN -> SYN_SENT 是因为这个连接可能是由于服务器端的应用有数据发送给客户端所触发的,客户端被动接受连接,连接建立后,开始传输文件。也就是说,处于 LISTEN 状态的服务器也是有可能发送 SYN 报文的,只不过这种情况非常少见。

处于 SYN_SEND 状态的服务器会接收 SYN 并发送 SYN 和 ACK 转换成为 SYN_RCVD 状态,同样的,处于 LISTEN 状态的客户端也会接收 SYN 并发送 SYN 和 ACK 转换为 SYN_RCVD 状态。如果处于 SYN_RCVD 状态的客户端收到 RST 就会变为 LISTEN 状态。

这里有一种情况是当主机收到 TCP 报文段后,其 IP 和端口号不匹配的情况。假设客户端主机发送一个请求,而服务器主机经过 IP 和端口号的判断后发现不是给这个服务器的,那么服务器就会发出一个 RST 特殊报文段给客户端。

因此,当服务端发送一个 RST 特殊报文段给客户端的时候,它就会告诉客户端没有匹配的套接字连接,请不要再继续发送了。

RST:(Reset the connection)用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。如果接收到 RST 位时候,通常发生了某些错误。

上面没有识别正确的 IP 端口是一种导致 RST 出现的情况,除此之外,RST 还可能由于请求超时、取消一个已存在的连接等出现。

位于 SYN_RCVD 的服务器会接收 ACK 报文,SYN_SEND 的客户端会接收 SYN 和 ACK 报文,并发送 ACK 报文,由此,客户端和服务器之间的连接就建立了。

这里还要注意一点,在同时打开的情况下,它的状态变化是这样的。

为什么会是这样呢?在同时打开的情况下,两端主机都发起 SYN 报文,而主动发起 SYN 的主机会处于 SYN-SEND 状态,发送完成后,会等待接收 SYN 和 ACK , 在双方主机都发送了 SYN + ACK 后,双方都处于 SYN-RECEIVED(SYN-RCVD) 状态,然后等待 SYN + ACK 的报文到达后,双方就会处于 ESTABLISHED 状态,开始传输数据。

好了,到现在为止,我给你叙述了一下 TCP 连接建立过程中的状态转换,现在你可以泡一壶茶喝点水,等着数据传输了。

好了,现在水喝够了,这时候数据也传输完成了,数据传输完成后,这条 TCP 连接就可以断开了。

现在我们把时钟往前拨一下,调整到服务端处于 SYN_RCVD 状态的时刻,因为刚收到了 SYN 包并发送了 SYN + ACK 包,此时服务端很开心,但是这时,服务端应用进程关闭了,然后应用进程发了一个 FIN 包,就会让服务器从 SYN_RCVD -> FIN_WAIT_1 状态。

然后把时钟调到现在,客户端和服务器现在已经传输完数据了 ,此时客户端发送了一条 FIN 报文希望断开连接,此时客户端也会变为 FIN_WAIT_1 状态,对于服务器来说,它接收到了 FIN 报文段并回复了 ACK 报文,就会从 ESTABLISHED -> CLOSE_WAIT 状态。

位于 CLOSE_WAIT 状态的服务端会发送 FIN 报文,然后把自己置于 LAST_ACK 状态。处于 FIN_WAIT_1 的客户端接收 ACK 消息就会变为 FIN_WAIT_2 状态。

这里需要先解释一下 CLOSING 这个状态,FIN_WAIT_1 -> CLOSING 的转换比较特殊

CLOSING 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送 FIN 报文后,按理来说是应该先收到(或同时收到)对方的 ACK 报文,再收到对方的 FIN 报文。但是 CLOSING 状态表示你发送 FIN 报文后,并没有收到对方的 ACK 报文,反而却也收到了对方的 FIN 报文。

什么情况下会出现此种情况呢?其实细想一下,也不难得出结论:那就是如果双方在同时关闭一个链接的话,那么就出现了同时发送 FIN 报文的情况,也即会出现 CLOSING 状态,表示双方都正在关闭连接。

FIN_WAIT_2 状态的客户端接收服务端主机发送的 FIN + ACK 消息,并发送 ACK 响应后,会变为 TIME_WAIT 状态。处于 CLOSE_WAIT 的客户端发送 FIN 会处于 LAST_ACK 状态。

这里不少图和博客虽然在图上画的是 FIN + ACK 报文后才会处于 LAST_ACK 状态,但是描述的时候,一般通常只对于 FIN 进行描述。也就是说 CLOSE_WAIT 发送 FIN 才会处于 LAST_ACK 状态。

所以这里 FIN_WAIT_1 -> TIME_WAIT 的状态也就是接收 FIN 和 ACK 并发送 ACK 之后,客户端处于的状态。

然后位于 CLOSINIG 状态的客户端这时候还有 ACK 接收的话,会继续处于 TIME_WAIT 状态,可以看到,TIME_WAIT 状态相当于是客户端在关闭前的最后一个状态,它是一种主动关闭的状态;而 LAST_ACK 是服务端在关闭前的最后一个状态,它是一种被动打开的状态。

TIME_WAIT 状态

通信双方建立 TCP 连接后,主动关闭连接的一方就会进入 TIME_WAIT 状态。TIME_WAIT 状态也称为 2MSL 的等待状态。在这个状态下,TCP 将会等待最大段生存期(Maximum Segment Lifetime, MSL) 时间的两倍。

这里需要解释下 MSL

MSL 是 TCP 段期望的最大生存时间,也就是在网络中存在的最长时间。这个时间是有限制的,因为我们知道 TCP 是依靠 IP 数据段来进行传输的,IP 数据报中有 TTL 和跳数的字段,这两个字段决定了 IP 的生存时间,一般情况下,TCP 的最大生存时间是 2 分钟,不过这个数值是可以修改的,根据不同操作系统可以修改此值。

基于此,我们来探讨 TIME_WAIT 的状态。

当 TCP 执行一个主动关闭并发送最终的 ACK 时,TIME_WAIT 应该以 2 * 最大生存时间存在,这样就能够让 TCP 重新发送最终的 ACK 以避免出现丢失的情况。重新发送最终的 ACK 并不是因为 TCP 重传了 ACK,而是因为通信另一方重传了 FIN,客户端经常回发送 FIN,因为它需要 ACK 的响应才能够关闭连接,如果生存时间超过了 2MSL 的话,客户端就会发送 RST,使服务端出错。

TCP 超时和重传

没有永远不出错误的通信,这句话表明着不管外部条件多么完备,永远都会有出错的可能。所以,在 TCP 的正常通信过程中,也会出现错误,这种错误可能是由于数据包丢失引起的,也可能是由于数据包重复引起的,甚至可能是由于数据包失序 引起的。

TCP 的通信过程中,会由 TCP 的接收端返回一系列的确认信息来判断是否出现错误,一旦出现丢包等情况,TCP 就会启动重传操作,重传尚未确认的数据。TCP 的重传有两种方式,一种是基于时间,一种是基于确认信息,一般通过确认信息要比通过时间更加高效。所以从这点就可以看出,TCP 的确认和重传,都是基于数据包是否被确认为前提的。

TCP 在发送数据时会设置一个定时器,如果在定时器指定的时间内未收到确认信息,那么就会触发相应的超时或者基于计时器的重传操作,计时器超时通常被称为重传超时(RTO)。

但是有另外一种不会引起延迟的方式,这就是快速重传。TCP 在每次重传一次报文后,其重传时间都会加倍,这种”间隔时间加倍”被称为二进制指数补偿(binary exponential backoff) 。等到间隔时间加倍到 15.5 min 后,客户端会显示 Connection closed by foreign host.

TCP 拥有两个阈值来决定如何重传一个报文段,这两个阈值被定义在 RFC[RCF1122] 中,第一个阈值是 R1,它表示愿意尝试重传的次数,阈值 R2 表示 TCP 应该放弃连接的时间。R1 和 R2 应至少设为三次重传和 100 秒放弃 TCP 连接。

这里需要注意下,对连接建立报文 SYN 来说,它的 R2 至少应该设置为 3 分钟,但是在不同的系统中,R1 和 R2 值的设置方式也不同。

  • 在 Linux 系统中,R1 和 R2 的值可以通过应用程序来设置,或者是修改ipv4.tcp_retries1 和 net.ipv4.tcp_retries2 的值来设置。变量值就是重传次数。tcp_retries2 的默认值是 15,这个充实次数的耗时大约是 13 – 30 分钟,这只是一个大概值,最终耗时时间还要取决于 RTO ,也就是重传超时时间。tcp_retries1 的默认值是 3 。对于 SYN 段来说,net.ipv4.tcp_syn_retries 和 net.ipv4.tcp_synack_retries 这两个值限制了 SYN 的重传次数,默认是 5,大约是 180 秒。
  • Windows 操作系统下也有 R1 和 R2 变量,它们的值被定义在下方的注册表(HKLM\System\CurrentControlSet\Services\Tcpip\ParametersHKLM\System\CurrentControlSet\Services\Tcpip6\Parameter)中。其中有一个非常重要的变量就是 TcpMaxDataRetransmissions,这个 TcpMaxDataRetransmissions 对应 Linux 中的 tcp_retries2 变量,默认值是 5。这个值的意思表示的是 TCP 在现有连接上未确认数据段的次数。

快速重传

我们上面提到了快速重传,实际上快速重传机制是基于接收端的反馈信息来触发的,它并不受重传计时器的影响。所以与超时重传相比,快速重传能够有效的修复丢包情况。当 TCP 连接的过程中接收端出现乱序的报文(比如 2 – 4 – 3)到达时,TCP 需要立刻生成确认消息,这种确认消息也被称为重复 ACK。

当失序报文到达时,重复 ACK 要做到立刻返回,不允许延迟发送,此举的目的是要告诉发送方某段报文失序到达了,希望发送方指出失序报文段的序列号。

还有一种情况也会导致重复 ACK 发给发送方,那就是当前报文段的后续报文发送至接收端,由此可以判断当前发送方的报文段丢失或者延迟到达。因为这两种情况导致的后果都是接收方没有收到报文,但是我们却无法判断到底是报文段丢失还是报文段没有送达。因此 TCP 发送端会等待一定数目的重复 ACK 被接受来决定数据是否丢失并触发快速重传。一般这个判断的数量是 3,这段文字表述可能无法清晰理解,我们举个例子。

如上图所示,报文段 1 成功接收并被确认为 ACK 2,接收端的期待序号为 2,当报文段 2 丢失后,报文段 3。失序到达,但是与接收端的期望不匹配,所以接收端会重复发送冗余 ACK 2。

这样,在超时重传定时器到期之前,接收收到连续三个相同的 ACK 后,发送端就知道哪个报文段丢失了,于是发送方会重发这个丢失的报文段,这样就不用等待重传定时器的到期,大大提高了效率。

SACK

在标准的 TCP 确认机制中,如果发送方发送了 0 – 10000 序号之间的数据,但是接收方只接收到了 0 -1000, 3000 – 10000 之间的数据,而 1000 – 3000 之间的数据没有到达接收端,此时发送方会重传 1000 – 10000 之间的数据,实际上这是没有必要的,因为 3000 后面的数据已经被接收了。但是发送方无法感知这种情况的存在。

如何避免或者说解决这种问题呢?

为了优化这种情况,我们有必要让客户端知道更多的消息,在 TCP 报文段中,有一个 SACK 选项字段,这个字段是一种**选择性确认(selective acknowledgment)**机制,这个机制能告诉 TCP 客户端,用我们的俗语来解释就是:“我这里最多允许接收 1000 之后的报文段,但是我却收到了 3000 – 10000 的报文段,请给我 1000 – 3000 之间的报文段”。

但是,这个选择性确认机制的是否开启还受一个字段的影响,这个字段就是 SACK 允许选项字段,通信双方在 SYN 段或者 SYN + ACK 段中添加 SACK 允许选项字段来通知对端主机是否支持 SACK,如果双方都支持的话,后续在 SYN 段中就可以使用 SACK 选项了。

这里需要注意下:SACK 选项字段只能出现在 SYN 段中。

伪超时和重传

在某些情况下,即使没有出现报文段的丢失也可能会引发报文重传。这种重传行为被称为 伪重传(spurious retransmission) ,这种重传是没有必要的,造成这种情况的因素可能是由于伪超时(spurious timeout),伪超时的意思就是过早的判定超时发生。造成伪超时的因素有很多,比如报文段失序到达,报文段重复,ACK 丢失等情况。

检测和处理伪超时的方法有很多,这些方法统称为检测算法和响应算法。检测算法用于判断是否出现了超时现象或出现了计时器的重传现象。一旦出现了超时或者重传的情况,就会执行响应算法撤销或者减轻超时带来的影响,下面是几种算法,此篇文章暂不深入这些实现细节

  • 重复 SACK 扩展- DSACK
  • Eifel 检测算法
  • 前移 RTO 恢复 – F-RTO
  • Eifel 响应算法

包失序和包重复

上面我们讨论的都是 TCP 如何处理丢包的问题,我们下面来讨论一下包失序和包重复的问题。

包失序

数据包的失序到达是互联网中极其容易出现的一种情况,由于 IP 层并不能保证数据包的有序性,每个数据包的发送都可能会选择当前情况传输速度最快的链路,所以很有可能出现发送了 A – > B -> C 的三个数据包,到达接收端的数据包顺序是 C -> A -> B 或者 B -> C -> A 等等。这就是包失序的一种现象。

在包传输中,主要分为两种链路:正向链路(SYN)和反向链路(ACK)

  • 如果失序发生在正向链路,TCP 是无法正确判断数据包是否丢失的,数据的丢失和失序都会导致接收端收到无序的数据包,造成数据之间的空缺。如果这种空缺不够大的话,这种情况影响不大;但是如果空缺比较大的话,可能会导致伪重传。
  • 如果失序发生在反向链路,就会使 TCP 的窗口前移,然后收到重复而应该被丢弃的 ACK,导致发送端出现不必要的流量突发,影响可用网络带宽。

回到我们上面讨论的快速重传,由于快速重传是根据重复 ACK 推断出现丢包而启动的,它不用等到重传计时器超时。由于 TCP 接收端会对接收到的失序报文立刻返回 ACK,所以网络中任何一个失序到达的报文都可能会造成重复 ACK。假设一旦收到 ACK,就会启动快速重传机制,当 ACK 数量激增,就会导致大量不必要的重传发生,所以快速重传应该达到重复阈值(dupthresh) 再触发。但是在互联网中,严重的失序并不常见,因此 dupthresh 的值可以设置的尽量小,一般来说 3 就能处理绝大部分情况。

包重复

包重复也是互联网中出现很少的一种情况,它指的是在网络传输过程中,包可能会出现传输多次的情况,当重传生成时,TCP 可能会出现混淆。包的重复可以使接收端生成一系列的重复 ACK,这种情况可以使用 SACK 协商来解决。

TCP 数据流和窗口管理

客户端和服务器可以相互提供数据流信息的交换,数据流的相关信息主要包括报文段序列号、ACK 号和窗口大小。

图中的两个箭头表示数据流方向,数据流方向也就是 TCP 报文段的传输方向。可以看到,每个 TCP 报文段中都包括了序列号、ACK 和窗口信息,可能还会有用户数据。TCP 报文段中的窗口大小表示接收端还能够接收的缓存空间的大小,以字节为单位。这个窗口大小是一种动态的,因为无时无刻都会有报文段的接收和消失,这种动态调整的窗口大小我们称之为滑动窗口,下面我们就来具体认识一下滑动窗口。

滑动窗口

TCP 连接的每一端都可以发送数据,但是数据的发送不是没有限制的,实际上,TCP 连接的两端都各自维护了一个发送窗口结构 (send window structure) 和 接收窗口结构 (receive window structure),这两个窗口结构就是数据发送的限制。

发送方窗口

下图是一个发送方窗口的示例。

在这幅图中,涉及滑动窗口的四种概念:

  • 已经发送并确认的报文段:发送给接收方后,接收方回回复 ACK 来对报文段进行响应,图中标注绿色的报文段就是已经经过接收方确认的报文段。
  • 已经发送但是还没确认的报文段:图中绿色区域是经过接收方确认的报文段,而浅蓝色这段区域指的是已经发送但是还未经过接收方确认的报文段。
  • 等待发送的报文段:图中深蓝色区域是等待发送的报文段,它属于发送窗口结构的一部分,也就是说,发送窗口结构其实是由已发送未确认 + 等待发送的报文段构成。
  • 窗口滑动时才能发送的报文段:如果图中的 [4,9] 这个集合内的报文段发送完毕后,整个滑动窗口会向右移动,图中橙色区域就是窗口右移时才能发送的报文段。

滑动窗口也是有边界的,这个边界是 Left edge 和 Right edge,Left edge 是窗口的左边界,Right edge 是窗口的右边界。

当 Left edge 向右移动而 Right edge 不变时,这个窗口可能处于 close 关闭状态。随着已发送的数据逐渐被确认从而导致窗口变小时,就会发生这种情况。

当 Right edge 向右移动时,窗口会处于 open 打开状态,允许发送更多的数据。当接收端进程读取缓冲区数据,从而使缓冲区接收更多数据时,就会处于这种状态。

还可能会发生 Right edge 向左移动的情况,会导致发送并确认的报文段变小,这种情况被称为糊涂窗口综合症,这种情况是我们不愿意看到的。出现糊涂窗口综合症时,通信双方用于交换的数据段大小会变小,而网络固定的开销却没有变化,每个报文段中有用数据相对于头部信息的比例较小,导致传输效率非常低。

这就相当于之前你明明有能力花一天时间写完一个复杂的页面,现在你花了一天的时间却改了一个标题的 bug,大材小用。

每个 TCP 报文段都包含 ACK 号和窗口通告信息,所以每当收到响应时,TCP 接收方都会根据这两个参数调整窗口结构。

TCP 滑动窗口的 Left edge 永远不可能向左移动,因为发送并确认的报文段永远不可能被取消,就像这世界上没有后悔药一样。这条边缘是由另一段发送的 ACK 号控制的。当 ACK 标号使窗口向右移动但是窗口大小没有改变时,则称该窗口向前滑动。

如果 ACK 的编号增加但是窗口通告信息随着其他 ACK 的到达却变小了,此时 Left edge 会接近 Right edge。当 Left edge 和 Right edge 重合时,此时发送方不会再传输任何数据,这种情况被称为零窗口。此时 TCP 发送方会发起窗口探测,等待合适的时机再发送数据。

接收方窗口

接收方也维护了一个窗口结构,这个窗口要比发送方的简单很多。这个窗口记录了已经接收并确认的数据,以及它能够接收的最大序列号。接收方的窗口结构不会存储重复的报文段和 ACK,同时接收方的窗口也不会记录不应该收到的报文段和 ACK。下面是 TCP 接收方的窗口结构。

与发送端的窗口一样,接收方窗口结构也维护了一个 Left edge 和 Right edge。位于 Left edge 左边的被称为已经接收并确认的报文段,位于 Right edge 右边的被称为不能接收的报文段。

对于接收端来说,到达序列号小于 Left efge 的被认为是已经重复的数据,需要丢弃。超过 Right edge 的被认为超出处理范围。只有当到达的报文段等于 Left edge 时,数据才不会被丢弃,窗口才能够向前滑动。

接收方窗口结构也会存在零窗口的情况,如果某个应用进程消耗数据很慢,而 TCP 发送方却发送了大量的数据给接收方,会造成 TCP 缓冲区溢出,通告发送方不要再发送数据了,但是应用进程却以非常慢的速度消耗缓冲区的数据(比如 1 字节),就会告诉接收端只能发送一个字节的数据,这个过程慢慢持续,造成网络开销大,效率很低。

我们上面提到了窗口存在 Left edge = Right edge 的情况,此时被称为零窗口,下面我们就来具体研究一下零窗口。

零窗口

TCP 是通过接收端的窗口通告信息来实现流量控制的。通告窗口告诉了 TCP ,接收端能够接收的数据量。当接收方的窗口变为 0 时,可以有效的阻止发送端继续发送数据。当接收端重新获得可用空间时,它会给发送端传输一个 窗口更新 告知自己能够接收数据了。窗口更新一般是纯 ACK ,即不带任何数据。但是纯 ACK 不能保证一定会到达发送端,于是需要有相关的措施能够处理这种丢包。

如果纯 ACK 丢失的话,通信双方就会一直处于等待状态,发送方心想拉垮的接收端怎么还让我发送数据!接收端心想天杀的发送方怎么还不发数据!为了防止这种情况,发送方会采用一个持续计时器来间歇性的查询接收方,看看其窗口是否已经增长。持续计时器会触发窗口探测,强制要求接收方返回带有更新窗口的 ACK。

窗口探测包含一个字节的数据,采用的是 TCP 丢失重传的方式。当 TCP 持续计时器超时后,就会触发窗口探测的发送。一个字节的数据能否被接收端接收,还要取决于其缓冲区的大小。

拥塞控制

有了 TCP 的窗口控制后,使计算机网络中两个主机之间不再是以单个数据段的形式发送了,而是能够连续发送大量的数据包。然而,大量数据包同时也伴随着其他问题,比如网络负载、网络拥堵等问题。TCP 为了防止这类问题的出现,使用了 拥塞控制 机制,拥塞控制机制会在面临网络拥塞时遏制发送方的数据发送。

拥塞控制主要有两种方法

  • 端到端的拥塞控制: 因为网络层没有为运输层拥塞控制提供显示支持。所以即使网络中存在拥塞情况,端系统也要通过对网络行为的观察来推断。TCP 就是使用了端到端的拥塞控制方式。IP 层不会向端系统提供有关网络拥塞的反馈信息。那么 TCP 如何推断网络拥塞呢?如果超时或者三次冗余确认就被认为是网络拥塞,TCP 会减小窗口的大小,或者增加往返时延来避免。
  • 网络辅助的拥塞控制: 在网络辅助的拥塞控制中,路由器会向发送方提供关于网络中拥塞状态的反馈。这种反馈信息就是一个比特信息,它指示链路中的拥塞情况。

下图描述了这两种拥塞控制方式

TCP 拥塞控制

如果你看到这里,那我就暂定认为你了解了 TCP 实现可靠性的基础了,那就是使用序号和确认号。除此之外,另外一个实现 TCP 可靠性基础的就是 TCP 的拥塞控制。如果说

TCP 所采用的方法是让每一个发送方根据所感知到的网络的拥塞程度来限制发出报文段的速率,如果 TCP 发送方感知到没有什么拥塞,则 TCP 发送方会增加发送速率;如果发送方感知沿着路径有阻塞,那么发送方就会降低发送速率。

但是这种方法有三个问题

  • TCP 发送方如何限制它向其他连接发送报文段的速率呢?
  • 一个 TCP 发送方是如何感知到网络拥塞的呢?
  • 当发送方感知到端到端的拥塞时,采用何种算法来改变其发送速率呢?

我们先来探讨一下第一个问题,TCP 发送方如何限制它向其他连接发送报文段的速率呢?

我们知道 TCP 是由接收缓存、发送缓存和变量(LastByteRead, rwnd,等)组成。发送方的 TCP 拥塞控制机制会跟踪一个变量,即 拥塞窗口(congestion window) 的变量,拥塞窗口表示为 cwnd,用于限制 TCP 在接收到 ACK 之前可以发送到网络的数据量。而接收窗口(rwnd) 是一个用于告诉接收方能够接受的数据量。

一般来说,发送方未确认的数据量不得超过 cwnd 和 rwnd 的最小值,也就是

LastByteSent – LastByteAcked <= min(cwnd,rwnd)

由于每个数据包的往返时间是 RTT,我们假设接收端有足够的缓存空间用于接收数据,我们就不用考虑 rwnd 了,只专注于 cwnd,那么,该发送方的发送速率大概是 cwnd/RTT 字节/秒 。通过调节 cwnd,发送方因此能调整它向连接发送数据的速率。

一个 TCP 发送方是如何感知到网络拥塞的呢?

这个我们上面讨论过,是 TCP 根据超时或者 3 个冗余 ACK 来感知的。

当发送方感知到端到端的拥塞时,采用何种算法来改变其发送速率呢 ?

这个问题比较复杂,且容我娓娓道来,一般来说,TCP 会遵循下面这几种指导性原则

  • 如果在报文段发送过程中丢失,那就意味着网络拥堵,此时需要适当降低 TCP 发送方的速率。
  • 一个确认报文段指示发送方正在向接收方传递报文段,因此,当对先前未确认报文段的确认到达时,能够增加发送方的速率。为啥呢?因为未确认的报文段到达接收方也就表示着网络不拥堵,能够顺利到达,因此发送方拥塞窗口长度会变大,所以发送速率会变快
  • 带宽探测,带宽探测说的是 TCP 可以通过调节传输速率来增加/减小 ACK 到达的次数,如果出现丢包事件,就会减小传输速率。因此,为了探测拥塞开始出现的频率, TCP 发送方应该增加它的传输速率。然后慢慢使传输速率降低,进而再次开始探测,看看拥塞开始速率是否发生了变化。

在了解完 TCP 拥塞控制后,下面我们就该聊一下 TCP 的 拥塞控制算法(TCP congestion control algorithm) 了。TCP 拥塞控制算法主要包含三个部分:慢启动、拥塞避免、快速恢复,下面我们依次来看一下

慢启动

当一条 TCP 开始建立连接时,cwnd 的值就会初始化为一个 MSS 的较小值。这就使得初始发送速率大概是 MSS/RTT 字节/秒 ,比如要传输 1000 字节的数据,RTT 为 200 ms ,那么得到的初始发送速率大概是 40 kb/s 。实际情况下可用带宽要比这个 MSS/RTT 大得多,因此 TCP 想要找到最佳的发送速率,可以通过 慢启动(slow-start) 的方式,在慢启动的方式中,cwnd 的值会初始化为 1 个 MSS,并且每次传输报文确认后就会增加一个 MSS,cwnd 的值会变为 2 个 MSS,这两个报文段都传输成功后每个报文段 + 1,会变为 4 个 MSS,依此类推,每成功一次 cwnd 的值就会翻倍。如下图所示:

发送速率不可能会一直增长,增长总有结束的时候,那么何时结束呢?慢启动通常会使用下面这几种方式结束发送速率的增长。

  • 如果在慢启动的发送过程出现丢包的情况,那么 TCP 会将发送方的 cwnd 设置为 1 并重新开始慢启动的过程,此时会引入一个 ssthresh(慢启动阈值) 的概念,它的初始值就是产生丢包的 cwnd 的值 / 2,即当检测到拥塞时,ssthresh 的值就是窗口值的一半。
  • 第二种方式是直接和 ssthresh 的值相关联,因为当检测到拥塞时,ssthresh 的值就是窗口值的一半,那么当 cwnd > ssthresh 时,每次翻番都可能会出现丢包,所以最好的方式就是 cwnd 的值 = ssthresh ,这样 TCP 就会转为拥塞控制模式,结束慢启动。
  • 慢启动结束的最后一种方式就是如果检测到 3 个冗余 ACK,TCP 就会执行一种快速重传并进入恢复状态。

拥塞避免

当 TCP 进入拥塞控制状态后,cwnd 的值就等于拥塞时值的一半,也就是 ssthresh 的值。所以,无法每次报文段到达后都将 cwnd 的值再翻倍。而是采用了一种相对保守的方式,每次传输完成后只将 cwnd 的值增加一个 MSS,比如收到了 10 个报文段的确认,但是 cwnd 的值只增加一个 MSS。这是一种线性增长模式,它也会有增长逾值,它的增长逾值和慢启动一样,如果出现丢包,那么 cwnd 的值就是一个 MSS,ssthresh 的值就等于 cwnd 的一半;或者是收到 3 个冗余的 ACK 响应也能停止 MSS 增长。如果 TCP 将 cwnd 的值减半后,仍然会收到 3 个冗余 ACK,那么就会将 ssthresh 的值记录为 cwnd 值的一半,进入 快速恢复 状态。

快速恢复

在快速恢复中,对于使 TCP 进入快速恢复状态缺失的报文段,对于每个收到的冗余 ACK,cwnd 的值都会增加一个 MSS 。当对丢失报文段的一个 ACK 到达时,TCP 在降低 cwnd 后进入拥塞避免状态。如果在拥塞控制状态后出现超时,那么就会迁移到慢启动状态,cwnd 的值被设置为 1 个 MSS,ssthresh 的值设置为 cwnd 的一半。

TCP报文头部字段

源端口、目标端口

如何标识唯一标识一个连接?答案是 TCP 连接的四元组——源 IP、源端口、目标 IP 和目标端口。

那 TCP 报文怎么没有源 IP 和目标 IP 呢?这是因为在 IP 层就已经处理了 IP 。TCP 只需要记录两者的端口即可。

序列号

即Sequence number, 指的是本报文段第一个字节的序列号。

从图中可以看出,序列号是一个长为 4 个字节,也就是 32 位的无符号整数,表示范围为 0 ~ 2^32 – 1。如果到达最大值了后就循环到0。

序列号在 TCP 通信的过程中有两个作用:

  • 在 SYN 报文中交换彼此的初始序列号。
  • 保证数据包按正确的顺序组装。

ISN

即Initial Sequence Number(初始序列号),在三次握手的过程当中,双方会用过SYN报文来交换彼此的 ISN。

ISN 并不是一个固定的值,而是每 4 ms 加一,溢出则回到 0,这个算法使得猜测 ISN 变得很困难。那为什么要这么做?

如果 ISN 被攻击者预测到,要知道源 IP 和源端口号都是很容易伪造的,当攻击者猜测 ISN 之后,直接伪造一个 RST 后,就可以强制连接关闭的,这是非常危险的。

而动态增长的 ISN 大大提高了猜测 ISN 的难度。

确认号

即ACK(Acknowledgment number)。用来告知对方下一个期望接收的序列号,小于ACK的所有字节已经全部收到。这个序号表示数据接收端期望接收的下一个字节的编号是多少,同时也表示上一个序号的数据已经收到。

标记位

常见的标记位有SYN,ACK,FIN,RST,PSH。

SYN 和 ACK 已经在上文说过,后三个解释如下: FIN: 即 Finish,表示发送方准备断开连接。

  • URG=1:该字段为一表示本数据报的数据部分包含紧急信息,是一个高优先级数据报文,此时紧急指针有效。紧急数据一定位于当前数据包数据部分的最前面,紧急指针标明了紧急数据的尾部。
  • ACK=1:该字段为一表示确认号字段有效。此外,TCP 还规定在连接建立后传送的所有报文段都必须把 ACK 置为一。
  • PSH=1:该字段为一表示接收端应该立即将数据 push 给应用层,而不是等到缓冲区满后再提交。
  • RST=1:该字段为一表示当前 TCP 连接出现严重问题,可能需要重新建立 TCP 连接,也可以用于拒绝非法的报文段和拒绝连接请求。
  • SYN=1:当SYN=1,ACK=0时,表示当前报文段是一个连接请求报文。当SYN=1,ACK=1时,表示当前报文段是一个同意建立连接的应答报文。
  • FIN=1:该字段为一表示此报文段是一个释放连接的请求报文。

窗口大小

占用两个字节,也就是 16 位,但实际上是不够用的。因此 TCP 引入了窗口缩放的选项,作为窗口缩放的比例因子,这个比例因子的范围在 0 ~ 14,比例因子可以将窗口的值扩大为原来的 2 ^ n 次方。

校验和

占用两个字节,防止传输过程中数据包有损坏,如果遇到校验和有差错的报文,TCP 直接丢弃之,等待重传。

可选项

常用的可选项有以下几个:

  • TimeStamp: TCP 时间戳
  • MSS: 指的是 TCP 允许的从对方接收的最大报文段。
  • SACK: 选择确认选项。
  • Window Scale: 窗口缩放选项。

UDP(User Data Protocol,用户数据报协议)

套接字

在 TCP 或者 UDP 发送具体的报文信息前,需要先经过一扇 门,这个门就是套接字(socket),套接字向上连接着应用层,向下连接着网络层。在操作系统中,操作系统分别为应用和硬件提供了接口(Application Programming Interface)。而在计算机网络中,套接字同样是一种接口,它也是有接口 API 的。

使用 TCP 或 UDP 通信时,会广泛用到套接字的 API,使用这套 API 设置 IP 地址、端口号,实现数据的发送和接收。

现在我们知道了, Socket 和 TCP/IP 没有必然联系,Socket 的出现只是方便了 TCP/IP 的使用,如何方便使用呢?你可以直接使用下面 Socket API 的这些方法。

方法描述 create()创建一个 socketbind()套接字标识,一般用于绑定端口号 listen()准备接收连接 connect()准备充当发送者 accept()准备作为接收者 write()发送数据 read()接收数据 close()关闭连接

套接字类型

套接字的主要类型有三种,下面我们分别介绍一下

  • 数据报套接字(Datagram sockets):数据报套接字提供一种无连接的服务,而且并不能保证数据传输的可靠性。数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP( User DatagramProtocol)协议进行数据的传输。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。
  • 流套接字(Stream sockets):流套接字用于提供面向连接、可靠的数据传输服务。能够保证数据的可靠性、顺序性。流套接字之所以能够实现可靠的数据服务,原因在于其使用了传输控制协议,即TCP(The Transmission Control Protocol)协议
  • 原始套接字(Raw sockets): 原始套接字允许直接发送和接收 IP 数据包,而无需任何特定于协议的传输层格式,原始套接字可以读写内核没有处理过的 IP 数据包。

套接字处理过程

在计算机网络中,要想实现通信,必须至少需要两个端系统,至少需要一对两个套接字才行。下面是套接字的通信过程。

  • socket 中的 API 用于创建通信链路中的端点,创建完成后,会返回描述该套接字的套接字描述符。就像使用文件描述符来访问文件一样,套接字描述符用来访问套接字。
  • 当应用程序具有套接字描述符后,它可以将唯一的名称绑定在套接字上,服务器必须绑定一个名称才能在网络中访问
  • 在为服务端分配了 socket 并且将名称使用 bind 绑定到套接字上后,将会调用 listen api。listen 表示客户端愿意等待连接的意愿,listen 必须在 accept api 之前调用。
  • 客户端应用程序在流套接字(基于 TCP)上调用connect 发起与服务器的连接请求。
  • 服务器应用程序使用acceptAPI 接受客户端连接请求,服务器必须先成功调用 bind 和 listen 后,再调用 accept api。
  • 在流套接字之间建立连接后,客户端和服务器就可以发起 read/write api 调用了。
  • 当服务器或客户端要停止操作时,就会调用close API 释放套接字获取的所有系统资源。

虽然套接字 API 位于应用程序层和传输层之间的通信模型中,但是套接字 API 不属于通信模型。套接字 API 允许应用程序与传输层和网络层进行交互。

在往下继续聊之前,我们先播放一个小插曲,简单聊一聊 IP。

IP

IP 是Internet Protocol(网际互连协议)的缩写,是 TCP/IP 体系中的网络层协议。设计 IP 的初衷主要想解决两类问题

  • 提高网络扩展性:实现大规模网络互联
  • 对应用层和链路层进行解藕,让二者独立发展。

IP 是整个 TCP/IP 协议族的核心,也是构成互联网的基础。为了实现大规模网络的互通互联,IP 更加注重适应性、简洁性和可操作性,并在可靠性做了一定的牺牲。IP 不保证分组的交付时限和可靠性,所传送分组有可能出现丢失、重复、延迟或乱序等问题。

我们知道,TCP 协议的下一层就是 IP 协议层,既然 IP 不可靠,那么如何保证数据能够准确无误地到达呢?

这就涉及到 TCP 传输机制的问题了,我们后面聊到 TCP 的时候再说。

端口号

在聊端口号前,先来聊一聊文件描述以及 socket 和端口号的关系

为了方便资源的使用,提高机器的性能、利用率和稳定性等等原因,我们的计算机都有一层软件叫做操作系统,它用于帮我们管理计算机可以使用的资源,当我们的程序要使用一个资源的时候,可以向操作系统申请,再由操作系统为我们的程序分配和管理资源。通常当我们要访问一个内核设备或文件时,程序可以调用系统函数,系统就会为我们打开设备或文件,然后返回一个文件描述符 fd(或称为 ID,是一个整数),我们要访问该设备或文件,只能通过该文件描述符。可以认为该编号对应着打开的文件或设备。

而当我们的程序要使用网络时,要使用到对应的操作系统内核的操作和网卡设备,所以我们可以向操作系统申请,然后系统会为我们创建一个套接字 Socket,并返回这个 Socket 的 ID,以后我们的程序要使用网络资源,只要向这个 Socket 的编号 ID 操作即可。而我们的每一个网络通信的进程至少对应着一个 Socket。向 Socket 的 ID 中写数据,相当于向网络发送数据,向 Socket 中读数据,相当于接收数据。而且这些套接字都有唯一标识符——文件描述符 fd。

端口号是 16 位的非负整数,它的范围是 0 – 65535 之间,这个范围会分为三种不同的端口号段,由 Internet 号码分配机构 IANA 进行分配

  • 周知/标准端口号,它的范围是 0 – 1023
  • 注册端口号,范围是 1024 – 49151
  • 私有端口号,范围是 49152 – 6553

一台计算机上可以运行多个应用程序,当一个报文段到达主机后,应该传输给哪个应用程序呢?你怎么知道这个报文段就是传递给 HTTP 服务器而不是 SSH 服务器的呢?

是凭借端口号吗?当报文到达服务器时,是端口号来区分不同应用程序的,所以应该借助端口号来区分。

举个例子反驳一下 cxuan,假如到达服务器的两条数据都是由 80 端口发出的你该如何区分呢?或者说到达服务器的两条数据端口一样,协议不同,该如何区分呢?

所以仅凭端口号来确定某一条报文显然是不够的。

互联网上一般使用 源 IP 地址、目标 IP 地址、源端口号、目标端口号 来进行区分。如果其中的某一项不同,就被认为是不同的报文段。这些也是多路分解和多路复用 的基础。

确定端口号

在实际通信之前,需要先确定一下端口号,确定端口号的方法分为两种:

  • 标准既定的端口号:标准既定的端口号是静态分配的,每个程序都会有自己的端口号,每个端口号都有不同的用途。端口号是一个 16 比特的数,其大小在 0 – 65535 之间,0 – 1023 范围内的端口号都是动态分配的既定端口号,例如 HTTP 使用 80 端口来标识,FTP 使用 21 端口来标识,SSH 使用 22 来标识。这类端口号有一个特殊的名字,叫做周知端口号(Well-Known Port Number)。
  • 时序分配的端口号:第二种分配端口号的方式是一种动态分配法,在这种方法下,客户端应用程序可以完全不用自己设置端口号,凭借操作系统进行分配,操作系统可以为每个应用程序分配互不冲突的端口号。这种动态分配端口号的机制即使是同一个客户端发起的 TCP 连接,也能识别不同的连接。

多路复用和多路分解

我们上面聊到了在主机上的每个套接字都会分配一个端口号,当报文段到达主机时,运输层会检查报文段中的目的端口号,并将其定向到相应的套接字,然后报文段中的数据通过套接字进入其所连接的进程。下面我们来聊一下什么是多路复用和多路分解的概念。

多路复用和多路分解分为两种,即无连接的多路复用(多路分解)和面向连接的多路复用(多路分解)

无连接的多路复用和多路分解

开发人员会编写代码确定端口号是周知端口号还是时序分配的端口号。假如主机 A 中的一个 10637 端口要向主机 B 中的 45438 端口发送数据,运输层采用的是 UDP 协议,数据在应用层产生后,会在运输层中加工处理,然后在网络层将数据封装得到 IP 数据报,IP 数据包通过链路层尽力而为的交付给主机 B,然后主机 B 会检查报文段中的端口号判断是哪个套接字的。

UDP 套接字就是一个二元组,二元组包含目的 IP 地址和目的端口号。

所以,如果两个 UDP 报文段有不同的源 IP 地址和/或相同的源端口号,但是具有相同的目的 IP 地址和目的端口号,那么这两个报文会通过套接字定位到相同的目的进程。

这里思考一个问题,主机 A 给主机 B 发送一个消息,为什么还需要知道源端口号呢?比如我给妹子表达出我对你有点意思的信息,妹子还需要知道这个信息是从我的哪个器官发出的吗?知道是我这个人对你有点意思不就完了?实际上是需要的,因为妹子如果要表达出她对你也有点意思,她是不是可能会亲你一口,那她得知道往哪亲吧?

这就是,在 A 到 B 的报文段中,源端口号会作为 返回地址 的一部分,即当 B 需要回发一个报文段给 A 时,B 需要从 A 到 B 中的源端口号取值,如下图所示:

面向连接的多路复用与多路分解

如果说无连接的多路复用和多路分解指的是 UDP 的话,那么面向连接的多路复用与多路分解指的是 TCP 了,TCP 和 UDP 在报文结构上的差别是,UDP 是一个二元组而 TCP 是一个四元组,即源 IP 地址、目标 IP 地址、源端口号、目标端口号 ,这个我们上面也提到了。当一个 TCP 报文段从网络到达一台主机时,这个主机会根据这四个值拆解到对应的套接字上。

上图显示了面向连接的多路复用和多路分解的过程,图中主机 C 向主机 B 发起了两个 HTTP 请求,主机 A 向主机 C 发起了一个 HTTP 请求,主机 A、B、C 都有自己唯一的 IP 地址,当主机 C 发出 HTTP 请求后,主机 B 能够分解这两个 HTTP 连接,因为主机 C 发出请求的两个源端口号不同,所以对于主机 B 来说,这是两条请求,主机 B 能够进行分解。对于主机 A 和主机 C 来说,这两个主机有不同的 IP 地址,所以对于主机 B 来说,也能够进行分解。

UDP 的全称是 用户数据报协议(UDP,User Datagram Protocol),UDP 为应用程序提供了一种无需建立连接就可以发送封装的 IP 数据包的方法。如果应用程序开发人员选择的是 UDP 而不是 TCP 的话,那么该应用程序相当于就是和 IP 直接打交道的。

从应用程序传递过来的数据,会附加上多路复用/多路分解的源和目的端口号字段,以及其他字段,然后将形成的报文传递给网络层,网络层将运输层报文段封装到 IP 数据报中,然后尽力而为的交付给目标主机。最关键的一点就是,使用 UDP 协议在将数据报传递给目标主机时,发送方和接收方的运输层实体间是没有握手的。正因为如此,UDP 被称为是无连接的协议。

UDP 特点

UDP 协议一般作为流媒体应用、语音交流、视频会议所使用的传输层协议,我们大家都知道的 DNS 协议底层也使用了 UDP 协议,这些应用或协议之所以选择 UDP 主要是因为以下这几点

  • 速度快,采用 UDP 协议时,只要应用进程将数据传给 UDP,UDP 就会将此数据打包进 UDP 报文段并立刻传递给网络层,然后 TCP 有拥塞控制的功能,它会在发送前判断互联网的拥堵情况,如果互联网极度阻塞,那么就会抑制 TCP 的发送方。使用 UDP 的目的就是希望实时性。
  • 无须建立连接,TCP 在数据传输之前需要经过三次握手的操作,而 UDP 则无须任何准备即可进行数据传输。因此 UDP 没有建立连接的时延。如果使用 TCP 和 UDP 来比喻开发人员:TCP 就是那种凡事都要设计好,没设计不会进行开发的工程师,需要把一切因素考虑在内后再开干!所以非常靠谱;而 UDP 就是那种上来直接干干干,接到项目需求马上就开干,也不管设计,也不管技术选型,就是干,这种开发人员非常不靠谱,但是适合快速迭代开发,因为可以马上上手!
  • 无连接状态,TCP 需要在端系统中维护连接状态,连接状态包括接收和发送缓存、拥塞控制参数以及序号和确认号的参数,在 UDP 中没有这些参数,也没有发送缓存和接受缓存。因此,某些专门用于某种特定应用的服务器当应用程序运行在 UDP 上,一般能支持更多的活跃用户
  • 分组首部开销小,每个 TCP 报文段都有 20 字节的首部开销,而 UDP 仅仅只有 8 字节的开销。

这里需要注意一点,并不是所有使用 UDP 协议的应用层都是不可靠的,应用程序可以自己实现可靠的数据传输,通过增加确认和重传机制。所以使用 UDP 协议最大的特点就是速度快。

UDP 报文结构

下面来一起看一下 UDP 的报文结构,每个 UDP 报文分为 UDP 报头和 UDP 数据区两部分。报头由 4 个 16 位长(2 字节)字段组成,分别说明该报文的源端口、目的端口、报文长度和校验值。

  • 源端口号(Source Port) :这个字段占据 UDP 报文头的前 16 位,通常包含发送数据报的应用程序所使用的 UDP 端口。接收端的应用程序利用这个字段的值作为发送响应的目的地址。这个字段是可选项,有时不会设置源端口号。没有源端口号就默认为 0 ,通常用于不需要返回消息的通信中。
  • 目标端口号(Destination Port): 表示接收端端口,字段长为 16 位
  • 长度(Length): 该字段占据 16 位,表示 UDP 数据报长度,包含 UDP 报文头和 UDP 数据长度。因为 UDP 报文头长度是 8 个字节,所以这个值最小为 8,最大长度为 65535 字节。
  • 校验和(Checksum):UDP 使用校验和来保证数据安全性,UDP 的校验和也提供了差错检测功能,差错检测用于校验报文段从源到目标主机的过程中,数据的完整性是否发生了改变。发送方的 UDP 对报文段中的 16 比特字的和进行反码运算,求和时遇到的位溢出都会被忽略,比如下面这个例子,三个 16 比特的数字进行相加:

这些 16 比特的前两个和是:

然后再将上面的结果和第三个 16 比特的数进行相加:

最后一次相加的位会进行溢出,溢出位 1 要被舍弃,然后进行反码运算,反码运算就是将所有的 1 变为 0 ,0 变为 1。因此 1000 0100 1001 0101 的反码就是 0111 1011 0110 1010,这就是校验和,如果在接收方,数据没有出现差错,那么全部的 4 个 16 比特的数值进行运算,同时也包括校验和,如果最后结果的值不是 1111 1111 1111 1111 的话,那么就表示传输过程中的数据出现了差错。

下面来想一个问题,为什么 UDP 会提供差错检测的功能?

这其实是一种 端到端  的设计原则,这个原则说的是要让传输中各种错误发生的概率降低到一个可以接受的水平。

文件从主机 A 传到主机 B,也就是说 AB 主机要通信,需要经过三个环节:首先是主机 A 从磁盘上读取文件并将数据分组成一个个数据包 packet,,然后数据包通过连接主机 A 和主机 B 的网络传输到主机 B,最后是主机 B 收到数据包并将数据包写入磁盘。在这个看似简单其实很复杂的过程中可能会由于某些原因而影响正常通信。比如:磁盘上文件读写错误、缓冲溢出、内存出错、网络拥挤等等这些因素都有可能导致数据包的出错或者丢失,由此可见用于通信的网络是不可靠的。

由于实现通信只要经过上述三个环节,那么我们就想是否在其中某个环节上增加一个检错纠错机制来用于对信息进行把关呢?

网络层肯定不能做这件事,因为网络层的最主要目的是增大数据传输的速率,网络层不需要考虑数据的完整性,数据的完整性和正确性交给端系统去检测就行了,因此在数据传输中,对于网络层只能要求其提供尽可能好的数据传输服务,而不可能寄希望于网络层提供数据完整性的服务。

UDP 不可靠的原因是它虽然提供差错检测的功能,但是对于差错没有恢复能力更不会有重传机制。

TCP vs UDP

TCP 和 UDP 有许多区别和相似之处。它们都是通过 Internet 发送数据包的最常用的协议。并且它们都在 TCP/IP 协议栈的传输层上工作。

假设有两座房子,House1 和 House2,并且必须从 H1 发送一封信到 H2。但是这两座房子之间有一条河。现在我们如何寄信?

  • 解决方案 1:在河上架桥,然后将其交付。
  • 解决方案 2:通过鸽子运送。

第一种解决方案视为 TCP,必须进行连接(桥)才能传递数据(信)。这样得到的数据是可靠的,因为它可以直接到达另一端而不会丢失或者出错。

第二种解决方案类似 UDP,无需连接即可发送数据。与需要建立连接(桥)的 TCP 相比,该过程更快。但是数据并不可靠:因为我们并不知道这只鸽子是否会朝正确的方向前进,或者会在途中掉信或遇到一些其他问题。

简单总结下 TCP 和 UDP 的区别:

  • 连接和无连接:TCP 是面向连接的协议,而 UDP 是无连接协议。TCP 可以在发送数据之前在发送方和接收方之间建立连接。而 UDP 在发送数据之前不会先建立连接。
  • 可靠性:TCP 是可靠的。使用 TCP 协议发送的数据可以保证传递到接收。如果数据在传输过程中丢失,它会恢复数据并重新发送。TCP 还将检查数据包中的错误并跟踪数据包,以保证数据不会丢失或损坏。
  • 而 UDP 是不可靠的,它不能提供有保证质量的传递,并且数据报包可能会在传输中损坏或丢失。
  • 流量控制:TCP 使用流控制机制来确保发送者不会一次发送太多数据包而压倒接收者。TCP 将数据存储在发送缓冲区中,并在接收缓冲区中接收数据。当应用程序准备就绪时,它将从接收缓冲区读取数据。如果接收缓冲区已满,则接收器将无法处理更多数据并将其丢弃。为了保持可以发送给接收方的数据量,接收方会告诉发送方接收缓冲区中有多少剩余空间(接收窗口)。每次接收到数据包时,都会使用当前接收窗口的值向发送方发送一条消息。UDP 不提供流控制。使用 UDP,数据包以连续流的形式到达或被丢弃。
  • 速度:TCP 比 UDP 慢,因为它“顾虑”比较多:TCP 必须建立连接,进行错误检查,并确保按照发送顺序接收文件。而 UDP 则更简单,更高效。
  • 使用场景:
    • TCP 最适合用于对时序不太关心的,且要求高可靠性的应用程序。
      • 万维网(HTTP,HTTPS)
      • 安全外壳(SSH)
      • 文件传输协议(FTP)
      • 电子邮件(SMTP,IMAP / POP)
    • UDP 最适合需要速度和效率的应用程序。
      • 串流影片
      • 线上游戏
      • 现场直播
      • 域名系统(DNS)
      • 互联网协议语音(VoIP)
      • 普通文件传输协议(TFTP)

参考链接:

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注