计算机网络协议 —— TCP&UDP

声明:以下内容为学习刘超老师的极客时间专栏——《趣谈网络协议》时的学习笔记,不代表刘超老师的任何个人观点,如描述有误,可在评论中指出。同时,通过这门课程也学到了很多知识,刘超老师讲得通俗易懂,如果有需要的话,可自行订阅学习,直达链接。

网络协议 —— TCP&UDP

  • TCP
    • 什么是 TCP?
    • TCP 三次握手
    • 为什么需要三次握手?
    • TCP 四次挥手
    • 为什么需要四次挥手?
    • TCP 状态机
    • TCP 是如何解决顺序问题、丢包问题、流量控制的?
      • 顺序问题与丢包问题
      • 流量控制问题
    • TCP 是如何解决拥塞控制的?
  • UDP
    • 什么是 UDP ?
    • UDP 三大使用场景
    • 基于 UDP 特点的五个实际应用场景
  • TCP 与 UDP 有哪些区别?
  • 套接字 Socket
    • 基于 TCP 协议的 Socket 程序函数调用过程
    • 基于 UDP 协议的 Socket 程序函数调用过程
    • 服务器如何连接更多的项目?
      • 方式一:多进程方式(将项目外包给其他公司)
      • 方式二:多线程方式(将项目转包给独立的项目组)
      • 方法三:IO 多路复用,一个线程维护多个 Socket(一个项目组支撑多个项目)
      • 方式四:IO 多路复用(一个项目组支撑多个项目,从“派人盯着”到“有事通知”)

TCP

什么是 TCP?

TCP 的全称是 Transmission Control Protocol,传输控制协议

通过三次握手来建立 TCP 连接,三次握手就是用来启动和确认 TCP 连接的过程。一旦连接建立后,就可以发送数据了,当数据传输完成后,会断开连接。

主要特点:

  1. 能够确保连接的建立和数据包的发送;
  2. 支持错误重传机制;
  3. 支持拥塞控制,能够在网络拥堵的情况下延迟发送;
  4. 能够提供错误校验和,甄别有害的数据包;
  5. 支持流量控制、按序传输;
  6. 能够维护连接。

TCP 包头格式
计算机网络协议 —— TCP&UDP_第1张图片

  1. 源端口号、目标端口号和 UDP 一样必不可少,有了这两个端口号,数据才知道应该发个哪个应用。
  2. 序号。即包的序号,通过给包编号来解决乱序问题,达到按序传输的目的。
  3. 确认序号。发出去的包应该有确认,如果没有收到就应该重新发送,直到送达。这个可以解决不丢包的问题。
  4. 状态位。如 SYN 是发起一个连接、ACK 是回复、RST 是重新连接、FIN 是结束连接等。TCP 面向连接,双方需要维护连接的状态,发送这些带状态位的包时,会引起双发的状态变更。
  5. 窗口大小。TCP 要做流量控制,通信双方各声明一个窗口,标识自己当前能够处理的能力。

TCP 三次握手

三次握手相关名词释义

消息类型 描述
SYN 这个消息是用来初始化和建立连接的
ACK 帮助对方确认收到的SYN消息
SYN-ACK 本地的SYN消息和较早的ACK数据包
FIN 用来断开连接
  • SYN:它的全称是 Synchronize Sequence Numbers,同步序列编号。是 TCP/IP 建立连接时使用的握手信号。在客户机和服务器之间建立 TCP 连接时,首先会发送的一个信号。客户端在接受到 SYN 消息时,就会在自己的段内生成一个随机值 X。
  • SYN-ACK:服务器收到 SYN 后,打开客户端连接,发送一个 SYN-ACK 作为答复。确认号设置为比接收到的序列号多一个,即 X+1,服务器为数据包选择的序列号是另一个随机数 Y。
  • ACK:Acknowledge character,确认字符,表示发来的数据已确认接收无误。最后,客户端将ACK发送给服务器。序列号被设置为所接收的确认值即 Y+1.

TCP 建立连接时需要经过三次握手,其状态时序图:如下:
计算机网络协议 —— TCP&UDP_第2张图片

  1. 一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态。
  2. 客户端主动发起连接 SYN,之后处于 SYN-SENT 状态。服务端收到发起的连接,返回 SYN,并且 ACK 客户端的 SYN,之后处于 SYN-RCVD 状态。
  3. 客户端收到服务端发送的 SYN 和 ACK 之后,发送 ACK 的 ACK,之后处于 ESTABLISHED 状态,因为它一发一收成功了。
  4. 服务端收到 ACK 的 ACK 之后,处于 ESTABLISHED 状态,因为它也一发一收了。

用现实生活来举例的话就是(小明-客户端,小红-服务端)
5. 小明给小红打电话,接通了后,小明说喂,能听到吗,这就相当于是连接建立
6. 小红给小明回应,能听到,你能听到我说的话吗,这就相当于是请求响应
7. 小明听到小红的回应后,说,可以的,这相当于是连接确认

在这之后小明和小红就可以通话/交换信息了。

为什么需要三次握手?

三次握手的目的是建立可靠的通信信道,说到通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。

  1. 第一次握手:Client 什么都不能确认;Server 确认了对方发送正常;
  2. 第二次握手:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:自己接收正常,对方发送正常;
  3. 第三次握手:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:自己发送、接收正常,对方发送接收正常。

所以三次握手就能确认双发收发功能都正常,缺一不可。

TCP 四次挥手

TCP 断开连接时需要经过四次挥手,其状态时序图:如下:
计算机网络协议 —— TCP&UDP_第3张图片

  1. 首先,客户端应用程序决定要终止连接(这里服务端也可以选择断开连接)。这会使客户端将 FIN 消息发送到服务器,并进入 FIN_WAIT_1 状态。当客户端处于 FIN_WAIT_1 状态时,它会等待来自服务器的 ACK 响应。
  2. 然后第二步,当服务器收到 FIN 消息时,服务器会立刻向客户端发送 ACK 确认消息
  3. 当客户端收到服务器发送的 ACK 响应后,客户端就进入 FIN_WAIT_2 状态,然后等待来自服务器的 FIN 消息。
  4. 服务器发送 ACK 确认消息后,一段时间(可以进行关闭后)会发送 FIN 消息给客户端,告知客户端可与进行关闭。
  5. 当客户端收到从服务器发送的 FIN 消息时,客户端就会由 FIN_WAIT_2 状态变为 TIME_WAIT 状态。处于 TIME_WAIT 状态的客户端允许重新发送 ACK 到服务器为了防止信息丢失。客户端在 TIME_WAIT 状态下花费的时间取决于它的实现,在等待一段时间后,连接关闭,客户端上所有的资源(包括端口号和缓冲区数据)都被释放。

补充说明:等待的时间设为 2MSL,MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 域,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟等。

为什么要设置等待时间即 TIME_WAIT 状态?
(这里 A 代表上图中左边的一端,B 代表上图中右边的一端)

  1. 预留足够时间以重新发包,确认连接断开。A 收到 B 的 FIN 时,会从 FIN_WAIT_2 状态结束,然后再发一个 ACK 给 B。TCP 协议要求 A 最后等待一段时间 TIME_WAIT,是为了防止 B 没有收到 ACK时,有足够的时间可以重新发送 FIN,A 再次收到 FIN 并重新发送 ACK 有足够时间到达 B。
  2. 防止混乱。A 从 FIN_WAIT_2 状态结束时,A 的端口就直接空出来了,但 B 原来发过的很多包很可能还在路上,如果 A 的端口被一个新的应用占用了,这个新的应用会收到上个连接中 B 发过来的包,虽然序列号是重新生成的,但是这里要上一个双保险,防止产生混乱,因而也需要等足够长的时间,等到原来 B 发送的所有的包都完成了,再空出端口来。
  3. B 超过了 2MSL 的时间依然没有收到它发的 FIN 的 ACK,按照 TCP 的原理,B 会重发 FIN,A 再收到这个包就直接发送 RST,B 就知道 A 早就断开连接了。

还是可以用上面那个通过的例子来进行描述

  1. 小明对小红说,我所有的东西都说完了,我要挂电话了。(发送 FIN 消息到服务器,进入 FIN_WAIT_1 状态,等待来自服务器的 ACK 响应即等待来自小红的确认响应)
  2. 小红说,收到,我这边还有一些东西没说。(服务器收到 FIN 消息,向客户端发送 ACK 确认消息,客户端进入 FIN_WAIT_2 状态。)
  3. 经过若干秒之后,小红也说完了,小红说,我说完了,现在可以挂断了。(发送 FIN 消息给客户端,告知客户端可以关闭)
  4. 小明收到消息后,又等了若干时间后,挂断了电话(收到服务器发送的 FIN 消息,由 FIN_WAIT_2 状态变为 TIME_WAIT 状态,一段时间后,连接关闭)

为什么需要四次挥手?

任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了TCP连接。

举个例子:A 和 B 打电话,通话即将结束后,A 说“我没啥要说的了”,B 回答“我知道了”,但是 B 可能还会有要说的话,A 不能要求 B 跟着自己的节奏结束通话,于是 B 可能又巴拉巴拉说了一通,最后 B 说“我说完了”,A 回答“知道了”,这样通话才算结束。

TCP 状态机

将连接建立和连接断开的两个时序状态图综合起来就是著名的 TCP 的状态机了。
计算机网络协议 —— TCP&UDP_第4张图片

TCP 是如何解决顺序问题、丢包问题、流量控制的?

顺序问题、丢包问题、流量控制都是通过滑动窗口来解决的

为了保证顺序性,TCP 发送每一个包时都有一个 ID。在建立连接的时候,双方会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但这个应答也不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式成为累计确认或者累计应答(cumulative acknowledgement)。

为记录所以发送的包和接收的包,TCP 需要发送到和接收端都有缓存来保存这些记录。发送端的缓存里按照包的 ID 一个个排列,根据处理的情况分为四个部分:

  1. 发送了且已经确认的;
  2. 发送了且尚未确认的;
  3. 没有发送但已经等待发送的;
  4. 没有发送,且暂时还不会发送的。

为什么还要区分第三和第四部分,而不分为同一部分?

因为需要考虑到接收方的处理能力,这也是 TCP 做流量控制的策略之一。在 TCP 里,接收端会给发送端报一个窗口的大小,叫 Advertised window。这个窗口的大小应该等于上面的第二部分加上第三部分,超过这个窗口的,接收端做不过来,就不能发送了。

于是,发送端需要保持下面的数据结构:

计算机网络协议 —— TCP&UDP_第5张图片

  • LastByteAcked:第一部分和第二部分的分界线;
  • LastByteSent:第二部分和第三部分的分界线;
  • LastByteAcked + AdvertisedWindow:第三部分和第四部分的分界线。

接收端缓存记录的内容如下:

  1. 第一部分:接收并且确认的;
  2. 第二部分:还没接收,但马上就能接收的;
  3. 第三部分:还没接收,也没法接收的。
    对应的数据结构如下:

计算机网络协议 —— TCP&UDP_第6张图片

  • MaxRcvBuffer:最大缓存的量;(上图中 MaxRcvBuffer = 14,即 1~14)
  • LastByteRead:之后是已经接收了,但还没被应用层读取的;(上图中 LastByteRead = 0)
  • NextByteExpected:第一部分和第二部分的分界线。(上图中 NextByteExpected = 6,就是目前接收到五,下一个期望的是六)

第二部分的窗口有多大?

NextByteExpected 和 LastByteRead 的差就是还没有应用层读取的部分占用占用掉的 MaxRcvBuffer 的量,我们定义为 A,即 A = NextByteExpected - LastByteRead,对应图中的 1~6。

AdvertisedWindow 就是 MaxRcvBuffer - A,即:AdvertisedWindow = MaxRcvBuffer - ((NextByteExpected - 1) - LastByteRead,对应图中就是:AdvertisedWindow = 14 - ((6 - 1) - 0 = 9,即 AdvertisedWindow 为图中的 6~14。

(NextByteExpected - 1)+ AdvertiseWindow 就是第二部分和第三部分的分界,也就是 LastByteRead + MaxRcvBuffer,即 0 + 14 = 14。

第二部分由于收到的包可能不是顺序的,会出现空档,只有和第一部分连续的,可以马上进行回复,中间空着的部分需要等待,哪怕后面的已经来了。

顺序问题与丢包问题

接下来结合一个例子来看:

在发送端,1 ~ 3 已经发送并确认,4 ~ 9 是发送了还没确认,10 ~ 12 是还没发出的,13 ~ 15 是接收方没有空间,不准备发的,如下图:
计算机网络协议 —— TCP&UDP_第7张图片
在接收端,1 ~ 5 是已经完成 ACK 但没有读取的,6 ~ 7 是等待接收的,8 ~ 9 是已经接收但没有 ACK 的,如下图:
计算机网络协议 —— TCP&UDP_第8张图片
发送端和接收端当前的状态如下:

  • 1 ~ 3 没有问题,双发达成一致;
  • 4 ~ 5 接收端说 ACK 了,但发送端还没收到,可能在路上,也可能丢了;
  • 6 ~ 9 都发了,但 8 ~ 9 已经到了,6 ~ 7 还没到接收端,出现了乱序,缓存着但没办法 ACK。

根据这个例子可以看出,顺序问题和丢包问题都有可能发生,那 TCP 是如何解决的?

确认与重发机制。

假设 4 的 ACK 到了,5 的 ACK 丢了,6、7 的数据包丢了,TCP 会如何处理?

超时重试:对每一个发送但没有 ACK 的包,都设置一个定时器,超过了一定的时间,就重新尝试。这个时间不宜过短也不宜过长,时间必须大于往返时间 RTT,否则会引起不必要的重传,过长会导致超时时间变长,访问就会变慢了。

如何确定这个时间?

自适应重传算法(Adaptive Retransmission Algorithm):估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还会不断变化,因为网络状况不断变化,除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。

例如:如果过一段时间,5、6、7 都超时了,就会重新发送。接收端发现 5 原来接收过,于是丢弃 5;6 收到了,发送 ACK,要求下一个是 7,7 又丢了。
当 7 再次超时需要重传时,TCP 的策略是超时间隔加倍:每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

超时触发重传存在的问题:超时周期可能相对较长,有没有更快的方式?

快速重传机制:当接收端收到一个序号大于下一个所期望的报文段时,就会检测到数据流中的一个间隔,于是接收端就会发送冗余的 ACK,仍然 ACK 的是期望接收的报文段(其实就是告诉发送端想接收哪个包)。而当接收端收到三个冗余的 ACK 后,就会在定时器过期之前,重传丢失的报文段。

例如,接收端发现 6、8 收到了,但 7 还没到,那肯定是丢了,于是发送 6 额 ACK,并要求下一个是 7。接下来,收到后续的包,仍然发送 6 的 ACK,要求下一个是 7。如果收到 3 个重复 ACK,就会认为 7 丢了,不等超时,马上重发。

除了超时重试、快速重传机制,有没有其他机制?

Selective Acknowledgment(SACK)
在 TCP 头里加一个叫 SACK 的东西,可以将缓存的地图发送给对方。例如可以发送 ACK6、SACK8、SACK9,有了地图,发送端一下子就能看出来是 7 丢了,就可以进行重发。

流量控制问题

TCP 在对于包的确认中,同时会携带一个窗口的大小,以此来实现流量控制。

假设窗口不变,窗口始终为 9。4 的 ACK 到达时,会右移一个,这时第 13 包也可以发送了。

计算机网络协议 —— TCP&UDP_第9张图片
这个时候,假设发送端发送过猛,将第三部分的 10、11、12、13 全部发送完毕,之后就停止发送了,未发送可发送部分为 0。
计算机网络协议 —— TCP&UDP_第10张图片
当 5 的 ACK 到达时,窗口再滑动了一个,这时,第 14 个包可以发送了。
计算机网络协议 —— TCP&UDP_第11张图片
如果接收端处理得太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至设置为 0,则发送端将暂时停止发送。

假设一个极端情况,接收端的应用一直不读取缓存中的数据,当数据包 6 确认后,窗口大小就不能再是 9了,就要变为 8。
计算机网络协议 —— TCP&UDP_第12张图片
新的窗口 8 通过 6 的 ACK 到达发送端时,发送端的窗口并没有平行右移,而是左面的边左移了,窗口的大小从 9 改为 8.
计算机网络协议 —— TCP&UDP_第13张图片
如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口越来越小,直到为 0。
计算机网络协议 —— TCP&UDP_第14张图片
当这个窗口通过包 14 的 ACK 到达发送端的时候,发送端的窗口也调整为 0,停止发送。
计算机网络协议 —— TCP&UDP_第15张图片
如果这样的话,发送端会定时发送窗口探测数据包,看是否有机会调整窗口大小。当接收端比较慢时,要防止低能窗口综合征,就是别空出一个字节来就赶快告诉发送端,然后马上又填满了。
当窗口达到一定大小或者缓冲区为一半为空,才更新窗口,窗口太小的时候不更新。

TCP 是如何解决拥塞控制的?

拥塞控制是通过拥塞窗口来解决的。TCP 发送包相当于往管里面倒水,快了容易溢出,慢了浪费带宽, TCP 的拥塞控制就是在不堵塞、不丢包的情况下,尽量发挥带宽,找到最优值。(有点要摸着石头过河的意思)

对于发送方来讲,判断网络快慢是比较难的,对于 TCP 来说,它根本不知道整个网络路径会经历什么,对它来讲就是一个黑盒。

水管有粗细,网络有带宽,即每秒钟能否发送多少数据;
水管有长度,网络有时延,在理想状态下,水管里水的量 = 水管粗细 x 水管长度,对应网络就是:通道的容量 = 带宽 x 往返延迟

什么是带宽、往返延迟?
如果设置发送窗口,使得发送但未确认的包为通道的容量,就能够撑满整个管道。

计算机网络协议 —— TCP&UDP_第16张图片

如上图,假设往返时间 8s,去 4s,回 4s,每秒发送一个包,每个包 1024 bytes。已经过了 8s,则 8 个包都发出去了,其中 4 个包已到达接收端,但 ACK 还没有返回,不能算发送成功。
5-8 后四个包还在路上,还没被接收。这个时候,整个管道正好满了,在发送端,已发送未确认的 8 个包,正好等于带宽,也即每秒发送 1 个包,乘以返回时间 8s。

如果在此基础上再调大窗口,使得单位时间可以发送更多的包,可能会出现包丢失超时重传的现象。

包丢失:原来发送一个包,从一端到达另一端,假设一共经过四个设备,每个设备处理一个包时间耗费 1s,所以到达另一端需要耗费 4s,如果发送的更加快速,则单位时间内,会有更多的包到达这些中间设备,这些设备还是只能每秒处理一个包的话,多出来的包就会被丢弃。

超时重传:例如这个四个设备本来每秒处理一个包,但在这些设备上加缓存,处理不过来的在队列里面排着,这样包就不会丢失,但是缺点是会增加时延,这个缓存的包,4s 肯定到达不了接收端了,如果时延达到一定程度,就会超时重传。

TCP 的拥塞控制可以避免上述两种现象,一旦出现这种情况,就说明,发送速度太快了,得慢点。

那如何知道速度有多快?如何调整窗口大小?

慢启动:举个例子,如果我们通过漏斗往瓶子里灌水,不可能一桶水一下子倒进去,肯定会溅出来,要一开始慢慢倒,就可以越倒越快。

对应到网络中(指数性增长):

  1. 一条 TCP 连接开始,cwnd(拥塞窗口)设置一个报文段,一次只能发送一个;
  2. 当收到这一个确认的时候,cwnd 加一,于是一次能发送两个;
  3. 当这两个的确认到达时,每个确认 cwnd 加一,于是两个 cwnd 就加二,此时一次就能发送四个;
  4. 当这四个的确认到达时,每个确认 cwnd 加一,于是四个 cwnd 就加四,此时一次就能发送八个;

涨到什么时候是个头?

有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,可能水管快满了,要慢下来。

那如何慢下来?

每收到一个确认后,cwnd 增加 1/cwnd 而不是增加 1 了,如上例子,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。

但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。
拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 cwnd/2,将 cwnd 设为 1,重新开始慢启动。

但这种方式一旦超时重传,就会马上回到解放前,太激进了,将一个高速的传输速度一下子停了下来,会造成网络卡顿

那有没有更好的办法解决?
快速重传算法:当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。

计算机网络协议 —— TCP&UDP_第17张图片

正是这种知进退,使得时延很重要的情况下,反而降低了速度。

但 TCP 的拥塞控制主要来避免的两个现象都是有问题的:

  1. 丢包并不代表着通道满了,也可能是管子本来就漏水。例如公网上带宽不满也会丢包,这个时候就认为拥塞了,退缩了,其实是不对的。
  2. TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这时候已经晚了。其实 TCP 只要填满管道就可以了,不应该接着填,直到连缓存也填满。

如何优化上述两个问题?

TCP BBR 拥塞算法:它企图找到一个平衡点,就是通过不断地加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。

计算机网络协议 —— TCP&UDP_第18张图片

UDP

什么是 UDP ?

UDP 的全称是 User Datagram Protocol,用户数据报协议

它不需要所谓的握手操作,从而加快了通信速度,允许网络上的其他主机在接收方同意通信之前进行数据传输。数据报是与分组网络关联的传输单元。

主要特点:

  1. 能够支持容忍数据包丢失的带宽密集型应用程序;
  2. 具有低延迟的特点;
  3. 能够发送大量的数据包;
  4. 能够允许 DNS 查找,DNS 是建立在 UDP 之上的应用层协议;
  5. 简单。包头简单,无需各种复杂的数据结构、处理逻辑、包头字段;
  6. 不安全。不会建立连接,虽然有端口号,可以接收任何人的数据,也可以传给任何人数据;
  7. 不支持拥塞控制。无法根据实际网络情况调整发送速率,无法控制丢包。

UDP 包头格式
计算机网络协议 —— TCP&UDP_第19张图片

UDP 三大使用场景

  1. 需要资源少,在网络情况比较好的内网,或者对于丢包不敏感的应用。如 DHCP 协议就是基于 UDP 的,获取 IP 地址获取不到时,可以再获取一次。
  2. 不需要一对一建立连接,而是可以广播的应用。如 DHCP 协议就是基于 UDP 协议的,而 DHCP 就是一种广播的形式。
  3. 需要处理速度快,时延低,可以容忍少数丢包,但是要求即便网络拥塞,也毫不退缩,一往无前的时候。

基于 UDP 特点的五个实际应用场景

  1. 网页或者 APP 的访问。QUIC(全称 Quick UDP Internet Connections,快速 UDP 互联网连接)是 Google 提出的一种基于 UDP 改进的通信协议,其目的是降低网络通信的延迟,提供更好的用户互动体验。
    QUIC 在应用层上,会自己实现快速连接建立、减少重传时延,自适应拥塞控制。
  2. 流媒体的协议。直播协议多使用 RTMP,直播实时性比较重要,宁可丢包,也不要卡顿,很多直播应用都基于 UDP 实现了自己的视频传输协议。
  3. 实时游戏。游戏对实时要求较为严格的情况下,采用自定义的可靠 UDP 协议,自定义重传策略,能够把丢包产生的延迟降到最低,尽量减少网络问题对游戏性造成的影响。
  4. IoT 物联网。维护 TCP 协议代价太大,而物联网对实时性要求也很高。
  5. 移动通信领域。在 4G 网络里,移动流量上网的数据面对的协议 GTP-U 是基于 UDP 的。因为移动网络协议比较复杂,而 GTP 协议本身就包含复杂的手机上线下线的通信协议,如果基于 TCP,TCP 的机制就显得非常多余。

TCP 与 UDP 有哪些区别?

TCP UDP
面向连接的协议 无连接的协议
在发送数据前先需要建立连接,然后再发送数据 无需建立连接就可以直接发送大量数据
按照特定顺序重新排列数据包 数据包没有固定顺序,所有数据包都相互独立
传输的速度比较慢 传输会更快
头部字节有20字节 头部字节只有8字节
重量级,在发送任何用户数据之前,TCP需要三次握手建立连接 轻量级,没有跟踪连接,消息排序等
会进行错误校验,并能够进行错误恢复 也会错误检查,但会丢弃错误的数据包
有发送确认 没有发送确认
会使用握手协议,例如SYN,SYN-ACK,ACK 无握手协议
提供可靠交付,可以确保将数据传送到路由器 无可靠交付,不能保证将数据传送到目标
  1. TCP 面向连接,UDP 面向无连接。面向连接的协议在互通之前会先建立连接,TCP 会三次握手,而 UDP 不会。所谓的连接建立,是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性。
  2. TCP 提供可靠交付,UDP 提供不可靠交付。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。UDP 则继承了 IP 包的特性,不保证不丢失,不保证按序到达。
  3. TCP 支持拥塞控制,UDP 不支持。当网络不好或者经常丢包时,会根据情况调整自己的发送速率。因而 TCP 是一个有状态服务,而 UDP 则是无状态服务。

综上所述,可以这样比喻:
如果 MAC 层定义了本地局域网的传输行为,IP 层定义了整个网络端到端的传输行为,这两层基本定义了这样的基因:网络传输是以包为单位的,二层叫帧,网络层叫包,传输层叫段。笼统地称为包。包单独传输,自行选路,在不同的设备封装解封装,不保证到达。基于这个基因,生下来的孩子 UDP 完全继承了这些特性,几乎没有自己的思想。

套接字 Socket

Socket 可以理解为插口或者插槽,可以想象为弄一根网线,一头插在客户端,一头插在服务端,然后进行通信,所以在通信之前,双方都要建立一个 Socket。

在建立 Socket 时,Socket 编程进行的是端到端的通信,往往意识不到中间经过多少局域网、路由器等,因而能够设置的参数,也只能是端到端协议之上网络层和传输层。

在网络层,Socket 函数需要设置 AF_INET 和 AF_INET6 分别对应 IPv4 和 IPv6。

TCP 协议是基于数据流的,所以设置为 SOCK_STREAM 指定为 TCP 传输;UDP 是基于数据报的,因而设置为 SOCK_DGRAM 指定为 UDP 传输。

基于 TCP 协议的 Socket 程序函数调用过程

如下图为基于 TCP 协议的 Socket 程序函数调用过程:
计算机网络协议 —— TCP&UDP_第20张图片
过程解析:

TCP 的服务端要先监听一个端口,一般是先调用 bind 函数,给这个 Socket 赋予一个 IP 地址和端口

为什么需要端口?
我们写的是应用程序,当一个网络包来的时候,内核要通过 TCP 头里面的这个端口,来找到这个应用程序。

为什么需要 IP 地址?
有时一台机器会有多个网卡,也就有多个 IP 地址,我们可以选择监听一个或者所有的网卡,你监听了哪个网卡,发往这个网卡的包,才会发给你。

当服务端有了 IP 和端口号,就可以调用 listen 函数进行监听。在 TCP 的状态图中,有一个 listen 状态,当调用这个函数之后,服务端就进入了这个状态,这时客户端就可以发起请求了。

在内核中,为每个 Socket 维护两个队列:

  1. 已经建立了连接的队列,这时连接三次握手已完毕,处于 established 状态
  2. 还没有完全建立连接的队列,这时连接三次握手还没完成,处于 syn_rcvd 的状态

接下来,服务端调用 accpet 函数,拿出一个已经完成的连接进行处理。如果还没有完成,就要等着。

在服务端等待时,客户端可以通过 connect 函数发起连接。先在参数中指明要连接的 IP 地址和端口号,然后开始发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功,服务的 accept 就会返回一个 Socket。

注意:监听的 Socket 和真正用来传数据的 Socket 是两个:监听 Socket、已连接 Socket

连接建立成功后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

TCP 的 Socket 就是一个文件流,因为 Socket 在 Linux 中就是以文件的形式存在的。除此之外,还存在文件描述符。写入和读出,也是通过文件描述符。

在内核中,Socket 是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构 task_struct,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标。

这个数组的内容是一个指针,指向内核中所有打开的文件的列表。既然是一个文件,就会有一个 inode,只不过 Socket 对应的 inode 不像真正的文件系统一样,保存在硬盘上的,而是在内存中的。在这个 inode 中,指向了 Socket 在内核中的 Socket 结构。

在这个结构里面,主要是两个队列:发送队列、接收队列。在这两个队列里面保存的是一个缓存 sk_buff。这个缓存里面能够看到完整的包的结构。
计算机网络协议 —— TCP&UDP_第21张图片

基于 UDP 协议的 Socket 程序函数调用过程

UDP 是没有连接的,所以不需要三次握手,也就不需要调用 listen 和 connect,但 UDP 的交互仍然需要 IP 和端口号,因而也需要 bind。

UDP 是没有维护年假状态的,因而不需要每对连接建立一组 Socket,而是只要有一个 Socket,就能够和多个客户端通信。也正因为没有连接状态,每次通信时,都调用 sendto 和 recvfrom,都可以传入 IP 地址和端口。

下图为基于 UDP 协议的 Socket 程序函数调用过程:
计算机网络协议 —— TCP&UDP_第22张图片

服务器如何连接更多的项目?

基于上述的几个基本的 Socket 函数,可以写出一个简单的网络交互程序,但基本上只能一对一沟通,如果是一个服务器,只能同时服务一个客户;但在企业级项目中,一个服务器是会有成千上万条连接的,服务于多个客户。就好比一个老板成立一家公司,只有自己一个人,只能自己上来服务客户,只能干完了一家再干下一家,这样赚不了多少钱,如果能连接更多的项目,就能赚更多的钱。

理论值:最大连接数,系统会用一个四元组来标识一个 TCP 连接。

{本机 IP,本机端口,对端 IP,对端端口}

服务器通常固定在某个本地端口上监听,等待客户端的连接请求。

因此,服务端 TCP 连接四元组中只有对端 IP,也就是客户端的 IP 和对端的端口,也即客户端的端口是可变的,因此,最大 TCP 连接数 = 客户端 IP 数 x 客户端端口数

对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。

服务端最大并发 TCP 连接数远不能达到理论上限,原因有两个:

  1. 文件描述符限制。 Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目。
  2. 内存限制。每个 TCP 连接都要占用一定内存,而操作系统的内存是有限的。

所以,在资源有限的情况下,服务端想要有更多的连接数,就需要降低每个连接的资源消耗。(相当于:作为老板,在自由有限的情况下,想接更多的项目赚跟多的钱,就要降低每个项目消耗的资源数目。)

方式一:多进程方式(将项目外包给其他公司)

使用此方式时,服务端相当于一个代理,在那里监听客户端发来的请求。

一旦建立了连接,就会有一个已连接 Socket,这时就可以创建一个子进程,然后将基于已连接 Socket 的交互交给这个新的子进程来做。(就好比新来了一个项目,你作为老板,项目不一定是自己做,可以再注册一家子公司,找点人,然后把项目转包给这家子公司做,以后对接就交给这家子公司了,你作为老板又可以去接新的项目了,赚更多的钱。)

那如何创建子公司,如何将项目移交给子公司?

在 Linux 下,创建子进程使用 fork 函数,通过在父进程的基础上完全拷贝一个子进程。

在 Linux 内核中,拷贝子进程会复制文件描述符的列表,复制内存空间,还会复制一条记录当前执行到了哪一行程序的进程。

复制的时候在调用 fork,复制完毕之后,父进程和子进程都会记录当前刚刚执行完 fork。

这两个进程刚复制完的时候,几乎一模一样,只是根据 fork 的返回值来区分到底是父进程,还是子进程。如果返回值是 0,则是子进程;如果返回值为其他整数,则为父进程。

进程复制过程如下:
计算机网络协议 —— TCP&UDP_第23张图片
因为复制了文件描述符列表,而文件描述符都是指向整个内核统一的打开文件列表的,因而父进程刚才因为 accept 创建的已连接 Socket 也是一个文件描述符,同样也会被子进程获得。

接下来,子进程就可以通过这个已连接 Socket 和客户端进行互通了,当通信完毕之后,就可以退出进程。

父进程如何知道子进程要退出?
在 fork 子进程时,父进程会获得子进程的 ID,父进程可以通过子进程 ID 查看子进程是否需要退出。

多进程处理的缺点
进程频繁创建与销毁会销毁很多资源。进程创建会消耗很多资源,所有的东西都要复制,容易造成资源的浪费。
举个例子,如果每次接一个项目,都申请一个新公司,然后干完了,就注销掉这个公司,实在是太麻烦了。毕竟一个新公司要有新公司的资产,有新的办公家具,每次都买了再买,不划算。
对应到服务器处理连接的过程就是,服务器每处理一条新的连接,就创建一个新的进程去处理,连接处理完成后,就销毁进程。但进程的创建与销毁时比较浪费资源的,创建进程会有 CPU、内存等资源的消耗。

方式二:多线程方式(将项目转包给独立的项目组)

线程相比进程会比较轻量级。如果创建进程相当于成立新公司,购买新办公家具,创建线程就相当于在同一个公司成立项目组。一个项目组做完了就可以被解散,然后组成另外的项目组,办公家具可以共用。(即线程可以共用同一个进程中的资源。)

在 Linux 下,通过 pthread_create 创建一个线程,也是调用 do_fork。不同的是,虽然新的线程在 task 列表会新创建一项,但是很多资源,例如文件描述符列表、进程空间,还是共享的,只不过多了一个引用而已。

新的线程也可以通过已连接 Socket 处理请求,从而达到并发处理的目的。

计算机网络协议 —— TCP&UDP_第24张图片
多线程处理的缺点
每新到来一个 TCP 连接,就需要分配一个进程或者线程。一台机器无法创建很多线程或者进程。
有个 C10K,它的意思是一台机器要维护 1 万个连接,就要创建 1 万个进程或者线程,那么操作系统是无法承受的。如果维持 1 亿用户在线需要 10 万台服务器,成本就太高了。

C10K 的问题就是,接的项目太多了,如果每个项目都成立单独的项目组,就要招聘 10 万人,那这人力成本非常巨大!

方法三:IO 多路复用,一个线程维护多个 Socket(一个项目组支撑多个项目)

一个项目组可以看多个项目。这时,每个项目组有应该有个项目进度墙,将自己组看的项目列在那里,然后每天通过项目墙看每个项目的进度,一旦某个项目有了进展,就派人去盯一下。

由于 Socket 都是文件描述符,因而每个线程盯的所有的 Socket,都放在一个文件描述符集合 fd_set 中,这就是项目进度墙,然后调用 select 函数来监听文件描述符集合是否有变化。一旦有变化,就会依次查看每个文件描述符。
发生变化的文件描述符在 fd_set 对应的位都设为 1,表示 Socket 可读或者可写,从而可以进行读写操作,然后再调用 select,接着盯着下一轮的变化。
select 函数的缺点
当每次 Socket 所在的文件描述符集合中有 Socket 发生变化的时候,都需要通过轮询的方式,即将全部项目都过一遍来查看进度,这大大影响了一个项目组能够支撑的最大的项目数量。因而使用 select,能够同时盯的项目数量由 FD_SETSIZE 限制。

方式四:IO 多路复用(一个项目组支撑多个项目,从“派人盯着”到“有事通知”)

上述 select 函数的缺点可以使用 epoll 函数来解决。使用 epoll 函数就相当于改成事件通知的方式,项目组不需要轮询每个项目,而是当项目进度发送变化时,主动通知项目组,然后项目组再根据项目进展情况做相应的操作。

什么是 epoll 函数?
epoll 函数在内核中的实现不是通过轮询的方式,而是通过注册 callback 函数的方式,当某个文件描述符发送变化时,就会主动通知。

如上图,假设进程打开了 Socket m,n,x 等多个文件描述符,现在需要通过 epoll 来监听这些 Socket 是否都有事件发生。
其中 epoll_create 创建一个 epoll 对象,也是一个文件,也对应一个文件描述符,同样也对应着打开文件列表中的一项。在这项里面有一个红黑树,在红黑树里,保存着这个 epoll 要监听的所有 Socket。
当 epoll_ctl 添加一个 Socket 时,其实是加入这个红黑树,同时红黑树里面的节点指向一个结构,将这个结构挂在被监听的 Socket 的事件列表中。
当一个 Socket 来了一个事件的时候,可以从这个列表中得到 epoll 对象,并调用 callback 通知它。
这种通知方式使得监听的 Socket 数据增加的时候,效率不会大幅度降低,能够同时监听的 Socket 的数据也非常的多了。上限就为系统定义的、进程打开的最大文件描述符个数。
因而,epoll 被称为解决 C10K 问题的利器。

你可能感兴趣的:(网络协议,网络协议,tcp,udp)