极客时间--趣谈网络协议--第三模块 底层网络知识详解:最重要的传输层 总结

TCP 和 UDP 有哪些区别?

TCP 是面向连接的:

所谓的建立连接,是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性。TCP通过三次握手建立连接。

TCP 提供可靠交付。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。

TCP 是面向字节流的。之所以变成了流,这也是 TCP 自己的状态维护做的事情。

TCP 是可以有拥塞控制的。它意识到包丢弃了或者网络的环境不好了,就会根据情况调整自己的行为,看看是不是发快了,要不要发慢点。

TCP 其实是一个有状态服务,里面精确地记着发送了没有,接收到没有,发送到哪个了,应该接收哪个了,错一点儿都不行。

HTTP\HTTPS 基于TCP

UDP 是面向无连接的:

IP 包是没有任何可靠性保证的,UDP 继承了 IP 包的特性,不保证不丢失,不保证按顺序到达。

IP 包可不是一个流,而是一个个的 IP 包。UDP 继承了 IP 的特性,基于数据报的,一个一个地发,一个一个地收。

UDP 就不会,应用让我发,我就发,管它洪水滔天。

UDP 则是无状态服务。发出去就发出去了。

如果 MAC 层定义了本地局域网的传输行为,IP 层定义了整个网络端到端的传输行为,这两层基本定义了这样的基因:网络传输是以包为单位的,二层叫帧,网络层叫包,传输层叫段。我们笼统地称为包。包单独传输,自行选路,在不同的设备封装解封装,不保证到达。UDP 完全继承了这些特性。

VXLAN\DHCP\TFTP基于UDP

UDP 包头是什么样的?

接收端如何知道接到的是UDP还是TCP?

在 IP 头里面有个 8 位协议,这里会存放,数据里面到底是 TCP 还是 UDP。

一台机器上跑着这么多的应用程序,应该给谁呢?

无论应用程序写的使用 TCP 传数据,还是 UDP 传数据,都要监听一个端口。正是这个端口,用来区分应用程序,所以说端口不能冲突。无论是 TCP 还是 UDP 包头里面应该有端口号,根据端口号,将数据交给相应的应用程序。

UDP头内容:

UDP 的三大特点

第一,沟通简单,不需要大量的数据结构、处理逻辑、包头字段。它相信网络通路默认就是很容易送达的,不容易被丢弃的。

第二,轻信他人。它不会建立连接,虽然有端口号,但是监听在这个地方,谁都可以传给他数据,他也可以传给任何人数据,甚至可以同时传给多个人数据。

第三,愣头青,做事不懂权变。它不会根据网络的情况进行发包的拥塞控制,无论网络丢包丢成啥样了,它该怎么发还怎么发。

 UDP 的三大使用场景

第一,需要资源少,在网络情况比较好的内网,或者对于丢包不敏感的应用。

第二,不需要一对一沟通,建立连接,而是可以广播的应用。

第三,需要处理速度快,时延低,可以容忍少数丢包,但是要求即便网络拥塞,也毫不退缩,一往无前的时候。

UDP使用实例

一、网页或者 APP 的访问

QUIC(全称 Quick UDP Internet Connections,快速 UDP 互联网连接)是 Google 提出的一种基于 UDP 改进的通信协议,其目的是降低网络通信的延迟,提供更好的用户互动体验。QUIC 在应用层上,会自己实现快速连接建立、减少重传时延,自适应拥塞控制,是应用层“城会玩”的代表。

二、流媒体的协议

很多直播应用,都基于 UDP 实现了自己的视频传输协议。

三、实时游戏

游戏对实时要求较为严格的情况下,采用自定义的可靠 UDP 协议,自定义重传策略,能够把丢包产生的延迟降到最低,尽量减少网络问题对游戏性造成的影响。

四、IoT 物联网

一方面,物联网领域终端资源少,很可能只是个内存非常小的嵌入式系统,而维护 TCP 协议代价太大;另一方面,物联网对实时性要求也很高,而 TCP 时延大。Google 旗下的 Nest 建立 Thread Group,推出了物联网通信协议 Thread,就是基于 UDP 协议的。

五、移动通信领域

在 4G 网络里,移动流量上网的数据面对的协议 GTP-U 是基于 UDP 的。因为移动网络协议比较复杂,而 GTP 协议本身就包含复杂的手机上线下线的通信协议。

TCP 包头格式

 

 

 要比UDP的复杂,源端口号和目标端口号和UDP相同,除此外还有

1、包的序号

序号是为了解决乱序问题。

2、确认序号

序号可以解决不丢包的问题。

3、状态位。

例如 SYN 是发起一个连接,ACK 是回复,RST 是重新连接,FIN 是结束连接等。TCP 是面向连接的,因而双方要维护连接的状态,这些带状态位的包的发送,会引起双方的状态变更。

4、窗口大小

TCP 要做流量控制,通信双方各声明一个窗口,标识自己当前能够的处理能力,别发送的太快,也别发的太慢。除了做流量控制以外,TCP 还会做拥塞控制

通过对 TCP 头的解析,我们知道要掌握 TCP 协议,重点应该关注以下几个问题:

  • 顺序问题 ,稳重不乱;
  • 丢包问题,承诺靠谱;
  • 连接维护,有始有终;
  • 流量控制,把握分寸;
  • 拥塞控制,知进知退。

TCP 的三次握手

A发送请求,B接到请求后发送应答,A收到应答后发送对于应答的应答,然后连接建立成功。第一次连接建立后,就需要靠发送数据包(开始传输数据)维持连接。除此外,我们在程序设计的时候,可以要求开启 keepalive 机制,即使没有真实的数据包,也有探活包发送给B。另外,作为服务端 B 的程序设计者,对于 A 这种长时间不发包的客户端,可以主动关闭,从而空出资源来给其他客户端使用。

三次握手除了双方建立连接外,主要还是为了沟通一件事情,就是 TCP 包的序号的问题。

A 要告诉 B,我这面发起的包的序号起始是从哪个号开始的,B 同样也要告诉 A,B 发起的包的序号起始是从哪个号开始的。为什么序号不能都从 1 开始呢?因为这样往往会出现冲突。

例如,A 连上 B 之后,发送了 1、2、3 三个包,但是发送 3 的时候,中间丢了,或者绕路了,于是重新发送,后来 A 掉线了,重新连上 B 后,序号又从 1 开始,然后发送 2,但是压根没想发送 3,但是上次绕路的那个 3 又回来了,发给了 B,B 自然认为,这就是下一个包,于是发生了错误。

因而,每个连接都要有不同的序号。这个序号的起始序号是随着时间变化的,可以看成一个 32 位的计数器,每 4 微秒加一,如果计算一下,如果到重复,需要 4 个多小时,那个绕路的包早就死翘翘了,因为我们都知道 IP 包头里面有个 TTL,也即生存时间。

好了,双方终于建立了信任,建立了连接。前面也说过,为了维护这个连接,双方都要维护一个状态机,在连接建立的过程中,双方的状态变化时序图就像这样。

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

ACK (Acknowledge character)即是确认字符,在数据通信中,接收站发给发送站的一种传输类控制字符。表示发来的数据已确认接收无误,即对收到请求的应答回复。

TCP 四次挥手

如何断开连接,TCP 协议专门设计了几个状态来处理这些问题。

断开连接的时候的状态时序图

 断开的时候,我们可以看到,当 A 发送断开连接请求,就进入 FIN_WAIT_1 的状态,B 收到断开连接请求,发送知道了,就进入 CLOSE_WAIT 的状态。

A 收到”B收到断开连接请求“的应答后,就进入 FIN_WAIT_2 的状态,如果这个时候 B 直接断开连接,则 A 将永远在这个状态。TCP 协议里面并没有对这个状态的处理,但是 Linux 有,可以调整 tcp_fin_timeout 这个参数,设置一个超时时间。

如果 B 没有跑路,B发送的”断开连接请求“到达 A 时,A 发送"B断开连接的请求”的 ACK 后,从 FIN_WAIT_2 状态结束,按说 A 可以跑路了,但是最后的这个 ACK 万一 B 收不到呢?所以 TCP 协议要求 A 最后等待一段时间 TIME_WAIT,这个时间要足够长,长到如果 B 没收到 ACK 的话,B的”断开连接请求“会重发的,A 会重新发一个 ACK 并且足够时间到达 B。并且如果没有这个等待时间,A直接关闭就会把端口空出来,并且可能被其他应用程序使用,那么B还在路上的包会发送到别的请求上,所以这个等待时间要长,等待的时间设为 2MSLMSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。

因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 域,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟不等。

还有一个异常情况就是,B 超过了 2MSL 的时间,依然没有收到它发的 FIN 的 ACK,怎么办呢?按照 TCP 的原理,B 当然还会重发 FIN,这个时候 A 再收到这个包之后,A 就表示,我已经在这里等了这么长时间了,已经仁至义尽了,之后的我就都不认了,于是就直接发送 RST,B 就知道 A 早就跑了。

TCP 状态机

将连接建立和连接断开的两个时序状态图综合起来,就是这个著名的 TCP 的状态机。学习的时候比较建议将这个状态机和时序状态机对照着看,不然容易晕。

 在这个图中,加黑加粗的部分,是上面说到的主要流程,其中阿拉伯数字的序号,是连接过程中的顺序,而大写中文数字的序号,是连接断开过程中的顺序。加粗的实线是客户端 A 的状态变迁,加粗的虚线是服务端 B 的状态变迁。

如何实现一个靠谱的协议?

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

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

第一部分:发送了并且已经确认的。

第二部分:发送了并且尚未确认的。

第三部分:没有发送,但是已经等待发送的。

第四部分:没有发送,并且暂时还不会发送的。

第三第四部分就是流量控制。 如何控制这个流量呢?在 TCP 里,接收端会给发送端报一个窗口的大小,叫 Advertised window。这个窗口的大小应该等于上面的第二部分加上第三部分,就是已经交代了没做完的加上马上要交代的。超过这个窗口的,接收端做不过来,就不能发送了。

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

 

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

对于接收端来讲,它的缓存里记录的内容要简单一些。

第一部分:接受并且确认过的。也就是我领导交代给我,并且我做完的。

第二部分:还没接收,但是马上就能接收的。也即是我自己的能够接受的最大工作量。

第三部分:还没接收,也没法接收的。也即超过工作量的部分,实在做不完。

 

 对应的数据结构就像这样:

 

 

  •  MaxRcvBuffer:最大缓存的量;
  • LastByteRead 之后是已经接收了,但是还没被应用层读取的;
  • NextByteExpected 是第一部分和第二部分的分界线。

顺序问题与丢包问题

确认与重发的机制:

第一种方法:超时重

通过自适应重传算法(Adaptive Retransmission Algorithm),计算超时时间,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 丢了。

流量控制问题

在对于包的确认中,同时会携带一个窗口的大小。

拥塞控制问题

也是通过窗口的大小来控制的,前面的滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满。

拥塞窗口和滑动窗口共同控制发送的速度。TCP 的拥塞控制主要来避免两种现象,包丢失和超时重传。

基于 TCP 和 UDP 协议的 Socket 编程

Socket 这个名字很有意思,可以作插口或者插槽讲。虽然我们是写软件程序,但是你可以想象为弄一根网线,一头插在客户端,一头插在服务端,然后进行通信。所以在通信之前,双方都要建立一个 Socket。

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

在网络层,Socket 函数需要指定到底是 IPv4 还是 IPv6,分别对应设置为 AF_INET 和 AF_INET6。

还要指定到底是 TCP 还是 UDP。还记得咱们前面讲过的,TCP 协议是基于数据流的,所以设置为 SOCK_STREAM,而 UDP 是基于数据报的,因而设置为 SOCK_DGRAM。

两端创建了 Socket 之后,接下来的过程中,TCP 和 UDP 稍有不同

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

TCP的服务需要监听端口,首先调用bind函数给Socket赋予一个ip地址和端口号,然后调用listen函数进行监听,在 TCP 的状态图里面,有一个 listen 状态,当调用这个函数之后,服务端就进入了这个状态,这个时候客户端就可以发起连接了。

在内核中,为每个 Socket 维护两个队列。一个是已经建立了连接的队列,这时候连接三次握手已经完毕,处于 established 状态;一个是还没有完全建立连接的队列,这个时候三次握手还没完成,处于 syn_rcvd 的状态。

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

在服务端等待的时候,客户端可以通过 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。这个缓存里面能够看到完整的包的结构

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

对于 UDP 来讲,过程有些不一样。UDP 是没有连接的,所以不需要三次握手,也就不需要调用 listen 和 connect,但是,UDP 的交互仍然需要 IP 和端口号,因而也需要 bind。UDP 是没有维护连接状态的,因而不需要每对连接建立一组 Socket,而是只要有一个 Socket,就能够和多个客户端通信。也正是因为没有连接状态,每次通信的时候,都调用 sendto 和 recvfrom,都可以传入 IP 地址和端口。

 

 服务器如何建立更多的连接?

方式一:多进程方式

在 Linux 下,创建子进程使用 fork 函数。通过名字可以看出,这是在父进程的基础上完全拷贝一个子进程。在 Linux 内核中,会复制文件描述符的列表,也会复制内存空间,还会复制一条记录当前执行到了哪一行程序的进程。

显然,复制的时候在调用 fork,复制完毕之后,父进程和子进程都会记录当前刚刚执行完 fork。这两个进程刚复制完的时候,几乎一模一样,只是根据 fork 的返回值来区分到底是父进程,还是子进程。如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。
因为复制了文件描述符列表,而文件描述符都是指向整个内核统一的打开文件列表的,因而父进程刚才因为 accept 创建的已连接 Socket 也是一个文件描述符,同样也会被子进程获得。

接下来,子进程就可以通过这个已连接 Socket 和客户端进行互通了,当通信完毕之后,就可以退出进程,那父进程如何知道子进程干完了项目,要退出呢?还记得 fork 返回的时候,如果是整数就是父进程吗?这个整数就是子进程的 ID,父进程可以通过这个 ID 查看子进程是否完成项目,是否需要退出。

方式二:多线程方式

在 Linux 下,通过 pthread_create 创建一个线程,也是调用 do_fork。不同的是,虽然新的线程在 task 列表会新创建一项,但是很多资源,例如文件描述符列表、进程空间,还是共享的,只不过多了一个引用而已。新的线程也可以通过已连接 Socket 处理请求,从而达到并发处理的目的。

方式三:IO 多路复用,一个线程维护多个 Socket

由于 Socket 是文件描述符,因而某个线程盯的所有的 Socket,都放在一个文件描述符集合 fd_set 中,这就是项目进度墙,然后调用 select 函数来监听文件描述符集合是否有变化。一旦有变化,就会依次查看每个文件描述符。那些发生变化的文件描述符在 fd_set 对应的位都设为 1,表示 Socket 可读或者可写,从而可以进行读写操作,然后再调用 select,接着盯着下一轮的变化。

方式四:IO 多路复用,从“派人盯着”到“有事通知”

能完成这件事情的函数叫 epoll,它在内核中的实现不是通过轮询的方式,而是通过注册 callback 函数的方式,当某个文件描述符发送变化的时候,就会主动通知。

posted @ 2022-03-08 11:18  皮肤黝黑的小白  阅读(215)  评论(0编辑  收藏  举报