本篇博客整理了 TCP/IP 分层模型中传输层的 UDP 协议和 TCP 协议,旨在让读者更加深入理解网络协议栈的设计和网络编程。
目录
一、传输层
数据的传输离不开网络协议栈,而网络协议栈是分层的。
在 TCP/IP 分层模型中主要包含了应用层、传输层、网络层、数据链路层。
客户端与对端服务端之间的请求和响应主要发生在应用层。应用层与对端应用层之间的请求和响应,可以简单认为是直接发送到网络中的。但实际上,应用层需要先将数据交给传输层,由传输层对数据做进一步处理后,再将数据继续向下进行交付,贯穿整个网络协议栈,最终才能将数据发送到网络中。
而这个贯穿网络协议栈的数据传输过程,离不开端口号。
1)端口号 Port
.1- 五元组标识一个通信
在 TCP/IP 分层模型中,通过由源IP地址、源端口号、目的IP地址、目的端口号、协议号组成的一个五元组来标识一个通信。
例如,存在多台客户端主机同时访问服务器,这些客户端主机上可能有一个客户端进程,也可能有多个客户端进程,但它们都在访问同一台服务器,而这台服务器就是通过一个五元组来标识一个通信的,标识的过程如下:
- 提取出数据当中的目的 IP 地址和目的端口号,确定该数据是发送给当前服务进程的;
- 提取出数据当中的协议号,为该数据提供对应类型的服务;
- 提取出数据当中的源 IP 地址和源端口号,将其作为响应数据的目的 IP 地址和目的端口号,
- 将响应结果发送给对应的客户端进程。
【补】 协议号 vs 端口号
协议号存在于网络层的 IP 协议报头中,长度为 8 位,指明了数据报所携带的数据使用的是何种协议,以便让目的主机的网络层知道应该将该数据交付给传输层的哪个协议进行处理,主要作用于传输层和网络层之间。
端口号存在于传输层的 UDP 协议或 TCP 协议报头中,长度为 16 位。可以唯一地标识一台主机上的某个进程,主要作用于应用层和传输层之间。
.2- 端口号的作用
端口号是属于传输层的概念(在传输层协议的报头中会包含与端口相关的字段),用于标识一个主机上进行网络通信的不同的应用程序。
当主机从网络中获取到数据后,需要自底向上地贯穿协议栈进行数据的交付,而这个数据最终应该交给上层的哪个应用处理程序,由该数据当中的目的端口号来决定。数据在传输层会被提取出相应的目的端口号,进而确定该数据应该继续交付给主机上的哪一个服务进程。
【ps】 一个端口号绝不能被多个进程绑定
端口号的作用就是唯一标识一个进程,如果绑定一个已经被绑定的端口号,就会出现绑定失败的情况。
【ps】一个进程可以绑定多个端口号
这与“ 一个端口号绝不能被多个进程绑定”并不冲突,从端口号到进程被限制了唯一性,但从进程到端口号并没有被限制唯一性,因此一个进程是可以绑定多个端口号的。
.3- 范围划分
端口号的长度是 16 位,因此其范围是 0 ~ 65535,其中:
- 0 ~ 1023:知名端口号,例如 HTTP、FTP、SSH 等被广泛使用的应用层协议,其端口号是固定的。
- 1024 ~ 65535:操作系统动态分配的端口号,例如客户端程序的端口号就是由操作系统从这个范围分配的。
【Tips】常见的知名端口号
- ssh 服务器:22 端口
- ftp 服务器:21 端口
- telnet 服务器:23 端口
- http 服务器:80 端口
- https 服务器:443 端口
【补】/etc/services 文件记录了网络服务名和它们对应使用的端口号及协议,查看该文件即可查看知名端口号。
2)指令 netstat、iostat、pidof
netstat 是一个用来查看网络状态的重要指令,其常见的选项如下:
- n:拒绝显示别名,能显示数字的全部转换成数字。
- l:仅列出处于LISTEN(监听)状态的服务。
- p:显示建立相关链接的程序名。
- t (tcp):仅显示tcp相关的选项。
- u (udp):仅显示udp相关的选项。
- a (all):显示所有的选项,默认不显示LISTEN相关。
【Tips】netstat 的应用
- 查看 TCP 相关的网络信息:netstat -nltp
- 查看 UDP相关的网络信息:netstat -nlup
- 显示 LISTEN 状态以外的状态连接信息:去掉 l 选项
iostat 主要用于输出磁盘 IO 和 CPU 的统计信息,其常见的选项如下:
- c:显示CPU的使用情况。
- d:显示磁盘的使用情况。
- N:显示磁盘列阵(LVM)信息。
- n:显示NFS使用情况。
- k:以KB为单位显示。
- m:以M为单位显示。
- t:报告每秒向终端读取和写入的字符数和CPU的信息。
- V:显示版本信息。
- x:显示详细信息。
- p:显示磁盘分区的情况。
例如,查看磁盘 IO 和 CPU 的详细信息:iostat -x
【ps】iostat 属于 sysstat 软件包,使用前需通过 yum install sysstat 安装。
【补】CPU 属性值说明
- %user:CPU 处在用户模式下的时间百分比。
- %nice:CPU 处在带 NICE 值的用户模式下的时间百分比。
- %system:CPU 处在系统模式下的时间百分比。
- %iowait:CPU 等待输入输出完成时间的百分比。
- %steal:管理程序维护另一个虚拟处理器时,虚拟 CPU 的无意识等待时间百分比。
- %idle:CPU 空闲时间百分比。
“pidof + 进程名”可以查看进程的 PID。
配合 kill -9 可以快速杀死一个进程。
二、UDP 协议
1)UDP 的位置
网络编程所用到的各种 socket 系列接口,是由系统提供的、位于应用层和传输层之间的一层系统调用接口,主要用于搭建上层应用。例如基于 TCP 的 HTTP,实际就是 HTTP 搭建在 TCP 套接字编程之上。
socket 系列接口之下的传输层由操作系统管理,因此 UDP 是属于内核的,是操作系统自身的协议栈自带的,其所有功能都由操作系统来完成,换句话说,网络其实也是操作系统的一部分。
【补】常见的、基于 UDP 的应用层协议
- NFS:网络文件系统。
- TFTP:简单文件传输协议。
- DHCP:动态主机配置协议。
- BOOTP:启动协议(用于无盘设备启动)。
- DNS:域名解析协议。
2)UDP 报头
在应用层看到的端口号大部分为 16 位,其根本原因就是传输层协议中的端口号为 16 位。
【Tips】UDP 协议格式
- 16 位源端口号:表示数据从哪里来。
- 16 位目的端口号:表示数据要到哪里去。
- 16 位 UDP 长度:表示整个数据报(UDP 首部+UDP 数据)的长度。
- 16 位 UDP 检验和:如果 UDP 报文的检验和出错,就会直接将报文丢弃。
【Tips】UDP 采用定长报头分离报头与有效载荷
UDP 的报头只包含四个字段,每个字段的长度都是 16 位,总共8字节。由此,其实 UDP 采用的是一种定长报头。对于待读取的报文,读取完前 8 个字节后,剩下的就都是有效载荷了。
【Tips】UDP 通过报头中的目的端口号将有效载荷交付给相应的上层协议
UDP 的上层也有很多应用层协议,因此 UDP 必须想办法将有效载荷交给相应的上层协议,更准确地说,是交给相应的应用层进程。
应用层中的每一个网络进程都会绑定一个端口号,其中,服务端进程必须显示绑定一个端口号,客户端进程则是由系统动态绑定的一个端口号。
UDP 就是通过报头中的目的端口号,来找到相应的应用层进程,以完成数据交付的。
【补】UDP 报头
操作系统是用 C 语言写的,UDP 协议是属于内核的,因此 UDP 也一定是用 C 语言写的。实际上,UDP 的协议报头就是一个位段类型。
要完成数据的交付,就需要贯穿网络协议栈,就会涉及数据的封装、解包和分用。
对于数据的封装,当应用层将数据交给传输层后,传输层就会创建一个 UDP 报头类型的变量,并填充其中的各个字段,由此得到一个 UDP 报头;然后,操作系统在内核中开辟一块空间,将 UDP 报头和有效载荷拷贝到一起,形成 UDP 报文。
而对于数据的分用,当传输层从下层获取到一个报文后,就会读取该报文的前 8 个字节,并提取出对应的目的端口号,然后通过目的端口号找到相应的上层(应用层)进程,并将剩下的有效载荷向上交付。
3)UDP 的特点
UDP传输的过程类似于寄信,其特点如下:
- 无连接:无需建立连接,知道对端的 IP 和端口号就可以直接进行数据传输。
- 不可靠:UDP 没有确认机制,野没有重传机制,如果因网络故障而无法将数据送达,UDP 并不会给应用层返回任何错误信息。
- 面向数据报:不能够灵活地控制读写数据的次数和数量。
【补】面向数据报
面向数据报具体是指,对于应用层交给 UDP 报文,UDP 会原样发送,无论报文多长,既不会拆分,也不会合并。
例如用 UDP 传输 100 个字节的数据,发送端调用一次 sendto() 以发送100字节,那么对端也必须调用一次 recvfrom() 以接收100个字节,而不能循环调用 10 次 recvfrom(),每次接收 10 个字节。如此一来,UDP 就不能够灵活地控制读写数据的次数和数量。
【补】UDP 是全双工通信的,具有接收缓冲区
UDP 的套接字既支持读又支持写,因此 UDP 是全双工通信的。
UDP 并没有真正的发送缓冲区,调用 sendto() 发送的数据会直接交给内核,再由内核将数据传给网络层协议进行后续的传输动作。
但 UDP 确实具有接收缓冲区,尽管这个接收缓冲区无法保证收到的 UDP 报文与发送的 UDP报文顺序一致。UDP 具有接收缓冲区,是因为,需要将接收到的报文暂时的保存起来,供上层读取。
如果UDP没有接收缓冲区,就要求上层必须及时读取 UDP 获取到的报文,否则 UDP 从底层获取上来的报文数据就会被迫丢弃。在一个报文从一台主机传输到另一台主机的过程中,会消耗主机资源和网络资源,仅仅因为上次收到的报文没有被上层读取,而被迫丢弃一个可能没有错误的报文,就是在浪费主机资源和网络资源。
因此,UDP 本身是会维护一个接收缓冲区的。新到来的 UDP 报文,会被放入接收缓冲区,供上层直接从接收缓冲区中读取。
如果 UDP 接收缓冲区中没有数据,上层在读取时就会被阻塞;如果接收缓冲区满了,再到达的 UDP 数据就会因无法进入缓冲区而被丢弃。
【ps】使用 UDP 的注意事项
UDP 协议报头中的 UDP 长度字段最大为 16 位,因此一个 UDP 报文数据的最大长度是 64K(包含UDP报头的大小)。如果发送端需要传输的数据超过 64K,就需要在应用层进行手动分包、多次发送,然后在接收端进行手动拼装。
三、TCP 协议
TCP 协议(Transmission Control Protocol,传输控制协议)是当今互联网中使用最为广泛的传输层协议且没有之一,其根本原因为 TCP 提供了详尽的可靠性保证。
基于 TCP 的上层应用非常之多,例如 HTTP、HTTPS、FTP、SSH 等,甚至连 MySQL 的底层使用的也是 TCP。
【补】常见的、基于 TCP 的应用层协议
- HTTP(超文本传输协议)
- HTTPS(安全数据传输协议)
- SSH(安全外壳协议)
- Telnet(远程终端协议)
- FTP(文件传输协议)
- SMTP(电子邮件传输协议)
【Tips】TCP 协议的特点
TCP协议如此复杂,就是因为 TCP 既要保证可靠性,同时又要尽可能地提升性能。
(1)可靠性相关
- 检验和
- 序列号
- 确认应答
- 超时重传
- 连接管理
- 流量控制
- 拥塞控制
(2)性能提升相关
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
以上的 TCP 机制,有的是通过 TCP 报头体现的,还有的是通过 TCP 代码的实现逻辑体现的。
1)数据传输的可靠性
现代计算机大部分基于冯诺依曼体系结构。
虽然输入设备、输出设备、内存、CPU 都在一台机器上,但这几个硬件设备是彼此独立的,它们之间通过连接彼此的“线”进行数据交互,其中,连接内存和外设之间的叫做 IO 总线,连接内存和 CPU 的叫做系统总线。
单独的一台计算机也可以看作是一个小型的网络。计算机上的各种硬件设备之间也要进行数据通信,且在通信时也必须遵守各自的通信协议,只不过这些通信协议更多是在描述一些数据的含义。且由于这些硬件设备都在一台机器上,因此传输数据的“线”很短,传输数据时不容易出错。
但如果要进行通信的各个设备相隔千里,那么连接各个设备的“线”就会变得非常长,传输数据时也就很容易出现错误。
总之,网络中存在不可靠的根本原因就是,长距离数据传输所用的“线”太长了,一切网络问题都与较长的物理传输距离有关。
要保证传输到对端的数据无误,就必须引入可靠性,而 TCP 就是在此背景下诞生的,其本身就是一种保证可靠性的协议。
【补】既然已经有 TCP 这一种可靠性协议了,那为什么还存在不可靠的 UDP ?
首先需明确,“不可靠”和“可靠”都是中性词,它们都是在描述协议的特点。
虽然TCP 协议是可靠的协议,但也就意味着 TCP 协议需要做更多的工作来保证传输数据的可靠,引起不可靠的因素越多,例如数据在传输过程中出现了丢包、乱序、检验和失败等,保证可靠的成本也就越高。因此,TCP 的使用一定比 UDP 更复杂,维护成本也更高。
尽管 UDP 是不可靠的协议,但 UDP 协议不需要考虑数据传输时可能出现的问题,因此UDP无论是使用还是维护都足够简单。
不过,TCP 比 UDP 更复杂,却不意味着 TCP 一定比 UDP 效率低。TCP 中不仅有保证可靠性的机制,还有保证传输效率的机制。总而言之,TCP 和 UDP 之间并不存在绝对的高下,只有谁更合适。在网络通信时,具体采用 TCP 还是 UDP ,完全取决于上层的应用场景。
一般来说,如果应用场景严格要求数据在传输过程中的可靠性,那就必须采用 TCP 协议;如果应用场景允许数据传输出现少量丢包,那么肯定优先选择足够简单的 UDP 协议。
2)TCP 报头
【Tips】TCP 协议格式
类似地,TCP 报头在内核中也是一个位段类型。给数据封装 TCP 报头时,会用该位段类型定义一个变量,并其中的各个属性字段,最后将其拷贝到数据的首部,以此完成 TCP 报头的封装。
TCP 报头中,各个字段的含义如下:
- 源/目的端口号:表示数据是从哪个进程来,到发送到对端主机上的哪个进程。
- 32 位序号/ 32 位确认序号:分别代表 TCP 报文当中每个字节数据的编号以及对对方的确认,是 TCP 保证可靠性的重要字段。
- 4 位 TCP 报头长度:表示该 TCP 报头的长度,以 4 字节为单位。
- 6 位保留字段:TCP报头中暂时未使用的 6 个比特位。
- 16 位窗口大小:保证 TCP 可靠性机制和效率提升机制的重要字段。
- 16 位检验和:由发送端填充,采用 CRC 校验。接收端校验不通过,则认为接收到的数据有问题。(检验和包含 TCP 首部 + TCP 数据部分)
- 16 位紧急指针:标识紧急数据在报文中的偏移量,需要配合标志字段当中的 URG 字段统一使用。
- 选项字段:TCP 报头当中允许携带额外的选项字段,最多 40 字节。
TCP报头中,6 个标志位的含义如下:
- URG:紧急指针是否有效。
- ACK:确认序号是否有效。
- PSH:提示接收端应用程序立刻将 TCP 接收缓冲区当中的数据读走。
- RST:表示要求对方重新建立连接。携带 RST 标识的报文被称为复位报文段。
- SYN:表示请求与对方建立连接。携带 SYN 标识的报文被称为同步报文段。
- FIN:通知对方,本端要关闭了。携带 FIN 标识的报文被称为结束报文段。
【Tips】TCP 根据基本报头分离报头与有效载荷
从底层获取到一个报文后,虽然 TCP 不能直接得知报头的具体长度,但可以根据报文的前 20 个字节为 TCP 的基本报头,来分离报头与有效载荷。
- 获取到一个报文后,首先读取报文的前 20 个字节,从中提取出首部长度(共 4 位),由此获得 TCP 报头的大小 size(最大为 60 字节,最小为 20 字节);
- 如果 size 的值大于 20 字节,就继续在报文中向后读取 size − 20 个字节,而这部分数据就是 TCP 报头中的选项字段(最大为 40 字节,最小为 0 字节);
- 读取完 TCP 的基本报头和选项字段后,剩下的就是有效载荷了。
【Tips】TCP 也通过报头中的目的端口号将有效载荷交付给相应的上层协议
应用层的每一个网络进程都必须绑定一个端口号,其中,服务端进程必须显示绑定一个端口号,客户端进程则由系统动态绑定一个端口号。
TCP 报头中涵盖了目的端口号,从报头中提取出目的端口号,通过由内核维护的端口号与进程 PID 之间的映射关系,就可以找到相应的应用层进程,进而将有效载荷进行向上交付。
.1- 序号和确认序号
数据在传输过程中可能会出现各种各样的错误,仅凭发送端主机无法保证数据成功送达,只有收到对端主机发来的响应消息后,发送端主机才能确定上一次发送的数据被对端主机可靠地收到了。
TCP 要保证的是双方通信的可靠性。
收到主机 B 的响应后,主机 A 就能够保证自己上一次发送的数据被可靠地收到了,但主机 B 也是需要保证自己发送的响应数据被主机 A 可靠地收到了。因此,主机 A 在收到主机 B 的响应后,也需要对其进行响应......可是,这样就陷入了一个逻辑死循环,双方通信的可靠性仍无法得到保证。
因此严格来说,网络通信中并不存在 100% 的可靠性,双方在通信时总有一条最新的消息无法得到响应。
但其实,保证所有消息的可靠性并不是必要的,只需保证发送的每一个核心数据都能被响应即可,而对于一些无关紧要的数据(响应数据等),则无须保证其可靠性。如果发送端没有收到来自对端的响应数据,就认为上一次发送的报文丢失了,然后将上一次发送的数据进行重传,以此确保数据的完整。
这种策略在 TCP 中叫做确认应答机制,它并非要保证双方全部消息的可靠性,只要一方在发送数据后能够收到另一方的应答消息,则说明上一次发送的数据已经被另一方可靠地收到了。
【ps】响应数据与其他数据一样,也是一个完整的 TCP 报文,尽管该报文可能不携带有效载荷,但至少拥有一个 TCP 报头。
进行网络通信时,如果只有收到了上一次发送数据的响应才能发下一个数据,那么双方的通信过程就是串行的,通信效率极低。
因此,为了提高通信效率,允许在通信过程中,一方可以向另一方连续发送多个报文数据,且应保证发送的每个报文都有对应的响应消息。
但在连续发送多个报文时,各个报文在进行网络传输时选择的路径可能不同,进而导致报文到达对端主机的先后顺序也可能不同,引发数据错乱。
为了保证报文的有序,TCP 报头中封装了一段 32 位序号。
TCP 将发送出去的每个字节数据都进行了编号,这个编号叫做序列号。例如,如果发送端要发送 3000 字节的数据,每次发送 1000 字节,那么就需要用三个 TCP 报文来发送,而这三个 TCP 报文中的 32 位序号,所填即为发送数据中首个字节的序列号——1、1001、2001。
接收端在收到这三个 TCP 报文后,会根据 TCP 报头中的 32 位序号,在传输层对这三个报文进行顺序重排(接收端可以根据当前报文的 32 位序号与其有效载荷的字节数,来确定下一个报文的序号),然后将其放入 TCP 的接收缓冲区,使接收端持有报文的顺序就和发送端发送报文的顺序一致。
此外,TCP报头中还封装了一段 32 位确认序号,以告诉发送端,接收端已经收到了哪些数据、发送端下一次应该从哪里开始发数据。
例如,当主机 B 收到主机 A 发来的、 32 位序号为 1 的报文时,主机 B 实际收到的是序列号为 1-1000 的字节数据,于是,主机 B 会将响应报文中的 32 位确认序号填成 1001,并发送给主机 A 。这一方面是告诉主机 A,自己已经收到了序列号在 1001 之前的字节数据,另一方面则是提醒主机 A,下次发送应从序列号为 1001 的字节数据开始。
之后,主机 B 响应主机 A 发来的其他报文时,也会经历类似的过程。
【Tips】 序号和确认序号
序号和确认序号是确认应答机制的数据化表示,确认应答机制就是由序号和确认序号来保证的。此外,通过序号和确认序号还可以判断某个报文是否丢失。
32 位序号的作用是,保证数据按序到达,同时作为对端响应时填充 32 位确认序号的依据。
32 位确认序号的作用是,告诉对端当前已经收到的字节数据,以及对端下一次发送数据时应从哪一字节序号开始。
【补】TCP 为什么采用两套序号的机制?
如果通信双方只是一端发送数据、另一端接收数据,那只用一套序号即可,例如,发送端在发送数据时,将该序号看作是 32 位序号,而接收端在响应时,将该序号看作是 32 位确认序号。
但 TCP 采用的是两套序号的机制,其根本原因为,TCP 是全双工通信的,支持双方同时互发消息。例如,在双方发出的报文中,不仅需要填充 32 位序号来表明自己当前发送数据的序号,还需要填充 32 位确认序号,对对方上一次发送的数据进行确认,告诉对方下一次应从哪一字节序号开始发送。
一套序号无法满足“双方都有确认应答机制”的需求,于是 TCP 报头中就出现了两套序号。
【补】报文丢失了一部分要怎么办?
仍以上文为例,主机 A 发送了三个报文给主机 B,其中每个报文的有效载荷都是 1000 字节,因此这三个报文的 32 位序号分别是1、1001、2001。
假设在网络传输过程中,这三个报文出现了丢包,最终只有序号为 1 和 2001 的报文送达。
主机 B 在对报文进行顺序重排时,其实能够发现只收到了 1-1000 和 2001-3000 的字节数据,缺少了 1001-2000 的字节数据,然后在对主机A进行响应时,将响应报文中的 32 位确认序号填为1001,告诉主机 A 下次发送从序列号为 1001 的字节数据开始,让主机 A 进行数据重传,以确保数据的完整。
总而言之,发送端可以根据接收端响应的确认序号,来判断报文是否丢包、是否需进行数据重传。
【补】TCP 如何将每个字节的数据进行编号?
TCP 是具有发送缓冲区和接收缓冲区的。由于 TCP 是面向字节流的,因此可以将 TCP 的发送缓冲区和接收缓冲区都抽象成两个字符数组。
发送端发送数据时,应用层会先将数据拷贝到传输层的 TCP 发送缓冲区中,再继续向下贯穿发送到网络中。
进入发送缓冲区后,每一个字节的数据就相当于被放进了一个字符数组,同时也占有了一个字符数组的下标(但这个下标不从 0 开始,而从 1 开始往后递增),这个被字节数据占有的下标其实就是序号,如此一来,放入发送缓冲区的字节数据就自然而然地被编号了。
接收端接收数据时,会先将网络中的数据向上贯穿协议栈,放入接收缓冲区中,然后再向上交付至应用层。
放入接收缓冲区的字节数据也会自然而然地被编号,而在接收端响应报文中的确认序号,其实就是接收缓冲区中,最后一个有效数据所占下标的下一个下标。
.2- TCP 的缓冲区
在传输层内部,TCP 实现了接收缓冲区和发送缓冲区,其中,接收缓冲区用来暂时保存接收到的数据,发送缓冲区则用来暂时保存还未发送的数据。
发送缓冲区中的数据由应用层进行写入。当应用层调用 write() 或 send() 时,数据会先从应用层拷贝到发送缓冲区,再从发送缓冲区中发送到网络中。当数据写入发送缓冲区后,write() 或 send() 即可返回,而发送缓冲区中的数据具体什么时候发、怎么发,都由 TCP 自主决定的。
同样的,接收缓冲区中也由应用层进行读取。当应用层调用 read() 或 recv() 时,数据会先从网络中拷贝到接收缓冲区,再从接收缓冲区中拷贝到应用层。
TCP 之所以被称为 TCP(传输层控制协议),就是因为 TCP 能够自主决定最终数据的发送和接收方式、自主处理传输数据时可能遇到的各种问题,用户只需如何将数据写入 TCP 发送缓冲区、如何从 TCP 接收缓冲区中读取数据即可。
【Tips】发送缓冲区 和 接收缓冲区 的作用
数据在网络中传输时可能会出现某些错误,此时就可能要求发送端进行数据重传,因此 TCP 必须提供一个发送缓冲区来暂时保存发送出去的数据,以免需要进行数据重传。只有当发出去的数据被对端可靠的收到后,发送缓冲区中的这部分数据才可以被覆盖掉。
接收端处理数据的速度是有限的,为了保证没来得及处理的数据不会被迫丢弃,TCP 必须提供一个接收缓冲区来暂时保存未被处理的数据,因为数据传输是需要耗费资源的,我们不能随意丢弃正确的报文。此外,TCP 的数据重排也是在接收缓冲区中进行的。【Tips】发送缓冲区 和 接收缓冲区 是经典的生产者消费者模型
对于发送缓冲区来说,上层应用不断往发送缓冲区当中放入数据,下层网络层不断从发送缓冲区当中拿出数据准备进一步封装。此时上层应用扮演的就是生产者的角色,下层网络层扮演的就是消费者的角色,而发送缓冲区对应的就是“交易场所”。
对于接收缓冲区来说,上层应用不断从接收缓冲区当中拿出数据进行处理,下层网络层不断往接收缓冲区当中放入数据。此时上层应用扮演的就是消费者的角色,下层网络层扮演的就是生产者的角色,而接收缓冲区对应的就是“交易场所”。
因此,引入发送缓冲区和接收缓冲区,就相当于引入了两个生产者消费者模型,该生产者消费者模型将上层应用与底层通信细节进行了解耦,同时也支持了并发和忙闲不均。
.3- 窗口大小
发送端将数据发送给接收端,本质是将发送端的发送缓冲区中的数据,发送到接收端的接收缓冲区中。
但缓冲区是有容量上限的,如果接收端处理数据的速度小于发送端发送数据的速度,那么总有一个时刻接收端的接收缓冲区会被填满,此时如果发送端再发送数据,接收端的接收缓冲区就无法接收,进而造成数据丢包,引起丢包重传等一系列的连锁反应。
因此,TCP 报头中就封装了 16 位的窗口大小,以指明主机的接收缓冲区的剩余空间大小。
- 窗口大小字段越大,说明接收端接收数据的能力越强,此时发送端可以提高发送数据的速度。
- 窗口大小字段越小,说明接收端接收数据的能力越弱,此时发送端可以减小发送数据的速度。
- 如果窗口大小的值为0,说明接收端接收缓冲区已经被打满了,此时发送端就不应该再发送数据了。
接收端在对发送端进行响应时,就可以通过 16 位窗口大小,告知发送端自己当前接收缓冲区的剩余空间大小,好让发送端调整自己发送数据的速度。
【补】网络编程中的数据阻塞现象
在生产者消费者模型中,如果生产者生产数据时被阻塞,或消费者消费数据时被阻塞,那么一定是因为某些条件尚未就绪。
在进行网络编程时,调用 read() 或 recv() 从套接字中读取数据,可能会因套接字中没有数据而被阻塞住,这是因为,TCP 接收缓冲区中没有数据了,实际是阻塞在接收缓冲区中;同理,调用 write() 或 send() 往套接字中写入数据,可能会因套接字已经写满而被阻塞住,这是因为,TCP 发送缓冲区已经被写满,实际是阻塞在发送缓冲区中。
-4. 六个标志位
TCP 报文的种类有很多,除了正常通信时发送的普通报文,还有建立连接时发送的请求连接报文、断开连接时发送的断开连接报文等。
各种报文有着各自的执行动作,例如,正常通信的报文需要放到接收缓冲区中等待上层应用进行读取,建立和断开连接的报文需要让操作系统在 TCP 层执行对应的握手和挥手动作。
由于不同种类的 TCP 报文对应了是不同的处理逻辑,因此区分 TCP 报文的种类是必要的。
在 TCP 报头中封装的六个标志字段就用于区分 TCP 报文的种类,这六个标志位都分别只占用一个比特位,且值的含义是类似的,为 0 表示假,为 1 表示真。
【Tips】TCP 报头中,六个标志的具体含义
(1)SYN
- SYN 被设置为 1,表明该报文是一个建立连接的请求报文。
- 只有在连接建立阶段,SYN 才会被设置,而正常通信时 SYN 不会被设置。
(2)ACK
- ACK 被设置为 1,表明该报文可以对收到的报文进行确认。
- 一般除了第一个请求报文没有设置 ACK 外,其余报文基本都会设置 ACK。这是因为,发送的数据本身就具有一定的确认能力,所以在双方通信时,可以顺便对对方上一次发送的数据进行响应。
(3)FIN
- FIN 被设置为 1,表明该报文是一个断开连接的请求报文。
- 只有在断开连接阶段,FIN 才被设置,而正常通信时 FIN 不会被设置。
(4)URG
- URG 被设置为 1,表明需要通过 TCP 报头中的 16 位紧急指针来找到紧急数据,否则一般无需关注报头中的 16 位紧急指针。
- 16 位紧急指针代表的就是紧急数据在报文中的偏移量。由于紧急指针只有一个,它也只能标识数据段中的一个位置,因此紧急数据只能发送一个字节。
由于TCP能够保证数据按序到达,即便数据被发送端分成了若干个 TCP 报文来发送,最终到达接收端时也都是有序的,接收端的应用层在从接收缓冲区读取数据时,也必须按顺序读取。
但有时,发送端会发送了一些紧急数据,需要接收端的应用层进行特殊的读取。此时就需要用到 URG 标志位,以及 TCP 报头中的 16 位紧急指针。
应用层读取紧急数据也需调用 recv() 从套接字中读取。recv() 的第四个参数 flags 有一个 MSG_OOB 选项可供设置。OOB 是带外数据(out-of-band)的简称,是一些比较重要的数据。在应用层调用 recv() 并设置 MSG_OOB 选项,即可读取紧急数据。
相应的,send() 的第四个参数 flags 也提供了一个 MSG_OOB 选项,在应用层调用 send() 并设置MSG_OOB选项,即可发送紧急数据。
(5)PSH
- PSH 被设置为 1,以告诉对端尽快将接收缓冲区中的数据向上交付。
其实,read() 或 recv() 从接收缓冲区中读取数据时,并非是缓冲区中没有数据时读取操作才会被阻塞,而是数据在缓冲区未达到一定量,读取操作就会被阻塞。
接收缓冲区和发送缓冲区其实都有一个水位线的概念。
假设 TCP 接收缓冲区的水位线是 100 字节,那么只有当接收缓冲区中有 100 字节及以上的数据时,才会让 read() 或 recv() 读取这 100 字节的数据并进行返回。
当 PSH 被设置为 1 时,就是在告知对方操作系统,尽快将接收缓冲区中的数据向上交付,哪怕接收缓冲区中的数据还没到达水位线。
这也是为什么,在调用 read() 或 recv() 读取数据时,期望读取的字节数和实际读取的字节数未必是吻合的。
(6)RST
- RST 被设置为 1,以要求对方重新建立连接。
- 在通信双方的连接未建立时,一方向另一方发数据,另一方响应的报文中 RST 标志位就会被置1,要求对方重新建立连接。此外,在双方建立好连接可以正常通信时,如果已建立好的连接发生了异常,也会要求重新建立连接。
3)数据的收发
.1- 面向字节流
在应用层创建一个 TCP 的套接字时,同时会在内核中创建一个发送缓冲区和一个接收缓冲区。
对于发送数据,在应用调用 write() 可以将数据写入发送缓冲区后, write() 自行返回了,而 TCP 发送缓冲区中的数据由 TCP 自行进行发送。如果发送的字节数太长,TCP 会将其拆分成多个数据包发出;如果发送的字节数太短,TCP可能会先将其留在发送缓冲区中,等到时机合适再发送。
对于接收数据,数据会从网卡驱动程序到达内核的 TCP 接收缓冲区,应用层可以调用 read() 来读取接收缓冲区中的数据。而调用 read() 时,可以按任意字节数进行读取。
由于缓冲区的存在,TCP 程序的读和写无须一一匹配。写 100 个字节数据时,既可以调用一次 write() 写 100 字节,也可以调用 100 次 write() ,每次写一个字节。而读 100 个字节数据时,既可以调用一次 read() 读100个字节,调用 100 次 read() ,每次读一个字节。
TCP 其实并不关心发送缓冲区中有什么样的数据,在 TCP 看来,它们只是一个个的字节数据,而它的任务就是将这些字节数据,准确无误地发送到对端的接收缓冲区中。至于如何解释这些数据,完全由应用层来决定。
TCP 这种处理数据的过程,就叫做面向字节流。
.2- 粘包问题
粘包问题中的“包”,是指的应用层的数据包。
在 TCP 报头中,并没有像 UDP 报头中一样的“报文长度”字段,站在传输层的角度,TCP 是一个一个报文过来的,按照序号排好序放在缓冲区中;但站在应用层的角度,能看到的只是一串连续的字节数据,面对这么一连串的字节数据,应用程序并不知道,从哪部分开始到哪部分结束才是一个完整的应用层数据包。而这就是粘包问题。
TCP 存在粘包问题,其根本原因就是,TCP 是面向字节流的,TCP 报文之间并没有明确的边界。而要解决 TCP 粘包问题,就必须明确 TCP 报文和报文之间的边界。
- 对于定长的包,只需保证每次都按固定大小读取即可。
- 对于变长的包,可以在报头的位置,约定一个包的总长度字段,以此得知包的结束位置,例如 HTTP 报头中包含了 Content-Length 属性,来表示正文的长度。此外,还可以在包和包之间使用明确的分隔符,由于应用层协议是程序员自己来定的,因此只要保证分隔符不与正文冲突即可。
与之相反的,UDP 是不存在粘包问题的,其根本原因就是,UDP 报头中的 16 位“UDP长度”字段记录了 UDP 报文的长度,UDP在底层就明确了报文和报文之间的边界。
并且,UDP是将数据一个一个上交给应用层的,站在应用层的角度,在传输层使用 UDP 协议时,自己要么收到的是完整的 UDP 报文,要么就一个也不收,绝不会出现收到“半个” UDP 报文的情况。
4)保证可靠性
.1- 确认应答机制
确认应答机制(ACK)是通过 TCP 报头中的 32 位序号和 32 位确认序号,来保证双方通信的可靠性的。但它并非要保证双方全部消息的可靠性,而是由接收端的应答消息,来确认发送端的消息被对端可靠地收到了。
-2. 超时重传机制
TCP 的超时重传机制是指,发送端发出数据后,在一个特定的时间间隔内,如果得不到对端的应答,就会重发数据。
TCP 保证通信可靠性的手段,一方面是通过 TCP 报头体现的,另一方面是通过 TCP 代码的实现逻辑体现的。例如超时重传机制就是后者,发送端在发送数据后,会开启一个定时器,来确认是否在一段时间内收到了对端的响应数据,否则会向对端重传数据。这个定时的过程就是由 TCP 的代码逻辑实现的。
【补】丢包的可能情况
丢包发生时,发送端会进行超时重传。
丢包的可能情况分为两种,一种是发送的数据报文丢失,使得发送端在一定时间内收不到对端的响应报文;另一种是对端的响应报文丢失,使得发送端在一定时间内收不到对端的响应报文。
丢包发生时,发送方其实无法辨别,究竟是是发送的数据报文丢失了,还是对端的响应报文丢失了,但发送端都会因在一定时间内收不到对端的响应报文,而进行超时重传。
发送端的发送缓冲区中的数据在发出后,操作系统不会立即将数据删除或覆盖,而会允许其暂时保留,以便进行超时重传。直到发送端收到响应报文后,先前发出的数据才会从发送缓冲区中删除或覆盖。
如果是响应报文丢失而导致的超时重传,接收端就会收到一个重复的报文数据,不过,收端可以根据报头中的 32 位序号来判断曾经是否收到过这个报文,从而对报文去重。【补】超时重传的等待时间
超时重传的时间既不能太长也不能太短。设置的太长,会导致丢包后对端长时间收不到数据,进而影响整体重传的效率;设置的太短,会导致对端收到大量的重复报文,影响数据处理的效率,也浪费了网络资源。
因此,超时重传的等待时间一定要是合理的。最理想的情况应该是,找到一个最小的固定时间,但实际中,等待时间是动态变化的,它的长短与网络环境有关。网好的时候,等待时间可以短一些,网卡的时候,重传的时间就需要长一些,换句话说,等待时间其实是上下浮动的,并不是一个固定值。
为了保证在任何环境下都有高性能的通信,TCP 会动态地计算等待时间。
Linux中以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍(BSD Unix 和 Windows 也是如此)。如果重发一次之后,仍然得不到应答,那么下一次重传的等待时间就调整为 2×500ms;如果继续得不到应答,就调整为 4×500ms......以此类推,当累计到一定的重传次数后,如果还得不到应答,TCP 就会认为是网络或对端主机出现了异常,然后强制关闭与对端的连接。
5)连接管理机制
TCP的各种可靠性机制并不在于主机与主机之间,而是基于连接的。在进行 TCP 通信之前需要先建立连接,就是因为TCP的各种可靠性保证都是基于连接的,所以也称 TCP 是面向连接的。
一台服务器启动后,可能有多个客户端前来访问,如果 TCP 不是基于连接的,也就意味着服务器端只有一个接收缓冲区,客户端们发来的数据都会拷贝到这一个接收缓冲区中,那么这些数据就可能会互相干扰。
面向连接也是 TCP 可靠性的一种体现,只有通信建立好连接,才能带来各种可靠性的保证。在一台机器上,可能存在大量的连接,因此操作系统就需要对这些连接进行管理。
操作系统的管理方式主要是“先描述,再组织”,更具体地说,操作系统中一定有一个描述连接的结构体,其中包含了连接的各种属性字段,而这些结构体最终会被组织成某种数据结构,以便操作系统将连接的管理转化成对数据结构的增删查改。
管理连接也是有成本的,既包括管理连接结构体的时间成本,又有存储连接结构体的空间成本。建立连接,其实就是在操作系统中定义一个连接结构体对象,并填充其中的各种属性字段,最后将其插入某种数据结构中;而断开连接,其实就是将一个连接结构体对象从某种数据结构中删除,并释放这个连接结构体对象曾经占用的各种资源。
.1- 建立连接,三次握手
三次握手,即在 TCP 通信之前建立连接的过程。
以客户端和服务器之间的通信为例,客户端需要先与服务器建立连接,具体方式是向服务器发送连接请求,然后双方的 TCP 会在底层自动进行三次握手:
- 在客户端发送的接请求报文中,SYN 标志位会被设置为 1,表示请求与服务器建立连接。
- 服务器收到连接请求报文后,会向客户端也发起连接请求,同时对收到的连接请求进行响应,其响应报文中的 ACK 位和 SYN 位均会被设置为 1 ,其中,ACK 是服务器对客户端请求的确认应答信号,SYN 是服务器向客户端发起的连接请求。
- 客户端收到响应报文后,根据 ACK 位知道了服务器收到了自己的连接请求,再根据 SYN 位知道了服务器向自己也发起了连接请求,于是将字节套接字的状态设置为 ESTABLISHED(确认建立连接)并正式建立连接,然后再反馈给服务器一个 ACK 应答信号。服务器根据客户端发来的 ACK 应答信号,知道客户端已经建立好连接了,于是也将自己套接字的状态设置为 ESTABLISHED ,以表示连接已建立。
【补】从套接字编程的角度简单理解三次握手
- 客户端调用 connect() 后,三次挥手就被发起了。
- 服务端调用 accept() 后,三次挥手的请求就被接收到了,然后开始后续动作。
- 三次挥手的过程完全由操作系统来维护。
- 最终服务端和客户端双方的套接字都是 ESTABLISHED 状态,双方正式连接连接。
【Tips】建立连接时采用三次握手的原因
- 三次握手是验证双方通信信道的最小次数,能够让能建立的连接尽快建立起来。
- 三次握手能够保证异常连接挂在客户端,既转移了风险,又节约了可用资源。
【补】建立连接的过程中,为什么“握手”发生三次 ?
这是因为,三次握手恰好是验证双方通信信道的最小次数。
其实,连接的建立并不保证能百分百成功。
在三次握手中,前两次由于下一次握手对其进行响应,因此能保证被对方收到,但第三次握手没有对应的响应报文,如果第三次握手时客户端发送的 ACK 报文丢包,那么连接的建立就会失败。
虽然客户端发起第三次握手后,三次握手就算完成了,但如果服务器没有收到客户端发来的第三次握手,就不会建立连接。换句话说,不论采用几次握手,最后一次握手的可靠性都是无法保证的。
既然连接的建立不保证能百分百成功,因此建立连接时,具体采用几次握手,取决于几次握手时的优点更多。
TCP 是全双工通信的,因此,连接建立就需要验证双方的通信信道是否是连通的。而仅通过三次握手,双方就都能知道,自己与对方是否都能够正常发送和接收数据。
在客户端看来,当它收到服务器发来的第二次握手时,说明它发出的第一次握手已经被对方可靠地收到了,足以证明客户端能发、服务器能收;同时,既然服务器能发来的第二次握手时,也足以证明客户端能收、服务器能发。总得来说,客户端在收到服务器发来的第二次握手后,就能够认为自己和服务器都是能发能收的,双方通信信道是连通的了。
而在服务器看来,当它收到客户端发来第一次握手时,足以证明客户端能发、服务器能收,而当它收到客户端发来的第三次握手时,就说明自己发出的第二次握手被对方可靠的收到了,也足以证明客户端能发、服务器能收。总得来说,服务器在收到客户端发来第一次握手和第三次握手后,就能够认为自己和客户端都是能发能收的,双方通信信道是连通的了。因此,三次握手恰好是验证双方通信信道的最小次数。
既然三次握手已经能够验证双方通信信道是否正常了,那么三次以上的握手当然也是可以验证的,也就没有必要进行更多次的握手了。
【补】三次握手能够保证连接建立时的异常连接挂在客户端
对于客户端来说,客户端收到服务器发来的第二次握手时,客户端就可以认为双方通信信道是连通的了,于是在客户端发出第三次握手后,这个连接就已经在客户端建立了。
但对于服务器来说,只有收到客户端发来的第三次握手后,服务器才可以认为双方通信信道是连通的,然后进一步建立连接。
也就是说,双方在三次握手时,建立连接的时间点是不一样的。如果客户端最后发出的第三次握手丢包了,服务器就不会建立连接,而客户端就相当于在短暂地维护一个异常的连接。
维护连接既需要时间成本,也需要空间成本,而三次握手能够保证连接建立异常时,这个异常连接挂在客户端,并不会影响到服务器。
客户端可能维护的异常连接也不会特别多,不会像服务器一样,一旦多个客户端发起的连接都建立失败,就需要耗费大量资源来维护这些异常连接。而且异常连接也不会一直维护下去。如果服务器长时间收不到客户端发来的第三次握手,就会将第二次握手进行超时重传,使客户端有机会重新发出第三次握手。另外,如果客户端认为连接已经建立好,并向服务器发送数据,服务器就能够发现没有和该客户端建立连接,然后会要求客户端重新建立连接。
【补】三次握手时,客户端和服务器具体的状态变化
- 最初,客户端和服务器都处于 CLOSED 状态。
- 为了能够接收客户端发来的连接请求,服务器需要从 CLOSED 变为 LISTEN 。
- 客户端发起第一次握手后,状态会变为 SYN_SENT 状态。
- 处于 LISTEN 的服务器收到客户端的第一次握手后,会将客户端的连接请求放入内核的等待队列中,然后向客户端发起第二次握手,同时,状态从 LISTEN 变为 SYN_RCVD。
- 客户端收到服务器发来的第二次握手后,就会向服务器发送第三次握手,并将状态从 SYN_SENT 变为 ESTABLISHED,以表面连接已建立。
- 服务器收到客户端发来的第三次握手后,将状态从 SYN_RCVD 变为 ESTABLISHED,以表面连接已建立。
【补】套接字与三次握手之间的具体关系
- 在客户端发起连接建立请求之前,服务器需要调用 listen() 先进入 LISTEN 状态.
- 然后,客户端调用 connect() ,向服务器发起三次握手以建立连接。connect() 并不参与底层的三次握手,只是发起三次握手,connect() 返回时,在底层,要么成功完成了三次握手,要么是三次握手失败。
- 完成三次握手后,服务器就会建立一个连接,并将其放入内核的等待队列,此后,服务器需要调用 accept() 从等待队列中获取这个建立好的连接。
- 服务器获取了这个建立好的连接之后,双方就可以调用 read() / recv()和 write() / send() 进行数据交互了。
(附:详见套接字编程,请移步至——【Linux网络】套接字编程)
.2- 断开连接,四次挥手
四次挥手,即在 TCP 通信结束之后断开连接的过程。
仍以客户端和服务器之间的通信为例,通信结束后,客户端需要与服务器断开连接,双方的 TCP 会在底层自动进行四次挥手:
- 客户端向服务器发送报文,其中的 FIN 位被设置为 1,表示请求与服务器断开连接。
- 服务器收到来自客户端的断开连接请求后,将自己的套接字状态设为 CLOSE_WAIT,然后给客户端发送确认应答信号。
- 再也没有数据要发送给客户端时,服务器就会向客户端发送报文,其中的 FIN 位也被设置为 1,表示请求与客户端断开连接。
- 客户端收到来自服务器的断开连接请求后,将自己的套接字状态设为 TIME_WAIT,然后给服务器发送确认应答信号。
【补】断开连接的过程中,为什么“挥手”发生四次 ?
这是因为,双方的通信信道是有两个方向的,每两次挥手对应关闭一个方向的通信信道,关闭两个方向就需要进行四次挥手。
TCP 是全双工通信的,建立连接时需要建立双方的连接,同理,断开连接时也需要断开双方的连接,在断开连接时,不仅要断开从客户端到服务器方向的通信信道,也要断开从服务器到客户端的通信信道。
其中,每两次挥手就对应关闭了一个方向的通信信道,因此,断开连接时需要进行四次挥手。
此外,四次挥手中,虽然第二次和第三次都是由服务器发送给客户端的,但绝不能合并在一起。第二次挥手是服务器发给客户端的应答信号,第三次挥手是则服务器发给客户端的断开连接请求。服务器对来自客户端的断开连接请求进行响应后,不一定会马上发起第三次挥手,因为服务器可能还有某些数据要发送给客户端,只有当服务器端将这些数据发送完后才会向客户端发起第三次挥手。
【补】四次挥手时,客户端和服务器具体的状态变化
- 在挥手前,客户端和服务器都处于连接建立后的 ESTABLISHED 状态。
- 客户端向服务器发起第一次挥手,以请求断开连接,其状态会从 ESTABLISHED 变为 FIN_WAIT_1。
- 服务器收到来自客户端的连接断开请求后,向客户端发起第二次挥手,以确认应答,其状态会从 ESTABLISHED 变为 CLOSE_WAIT。
- 已经没有数据需要发送给客户端时,服务器会向客户端发起第三次挥手,以请求断开连接,并等待最后一个确认应答信号的到来,其状态会从 CLOSE_WAIT 变为LASE_ACK。
- 客户端收到服务器发来的第三次挥手后,会向服务器发起第四次挥手,以确认应答,其状态会从 CLOSE_WAIT 进入 TIME_WAIT。
- 服务器收到客户端发来的第四次挥手后,会彻底关闭连接,最终变为 CLOSED 状态。
- 服务器关闭连接后,客户端会等待一个 2MSL(Maximum Segment Lifetime,报文最大生存时间)然后进入 CLOSED 状态。
【补】套接字与四次挥手之间的具体关系
- 客户端调用 close() 关闭自己的套接字,向服务器发起断开连接请求。
- 服务器调用 close() 关闭由 accept() 返回的套接字,向客户端发起断开连接请求。
- 每调用一次 close() 就对应两次挥手,双方都要调用 close(),因此就是四次挥手。
(附:详见套接字编程,请移步至——【Linux网络】套接字编程)
【补】CLOSE_WAIT 状态
CLOSE_WAIT 状态算是一种关于内存资源的风险提示。
进行四次挥手时,如果只有客户端调用了 close() 关闭自己的套接字,而服务器不调用close() 关闭由 accept() 返回的套接字,客户端就会进入到 FIN_WAIT_2 状态,服务器则会进入 CLOSE_WAIT 状态。
但只有完成完整的四次挥手,连接才算真正断开,双方的连接资源才会被释放。
如果服务器没有主动关闭不需要的套接字,服务器中就会存在大量处于 CLOSE_WAIT 状态的连接,每个连接都会占用服务器的资源,进而导致服务器的可用资源越来越少。因此,如果服务器不及时关闭不需要的套接字,除了会造成文件描述符的泄漏,还可能导致连接资源未完全释放,进而造成严重的内存泄漏。
因此在进行套接字编程时,一旦发现服务器中存在大量处于 CLOSE_WAIT 状态的连接,就应当尽快检查服务器是否及时调用 close() 关闭了不需要的套接字。
【补】TIME_WAIT 状态
TIME_WAIT 状态是为了避免第四次挥手发生丢包、服务器资源被闲置连接无效占用的情况而存在的,让主动发起四次挥手的客户端去担负维护闲置连接的成本。
假设第四次挥手发生了丢包,客户端在发出第四次挥手后就立即进入 CLOSED 状态将连接关闭,此后哪怕服务器能够进行超时重传,也得不到客户端的任何响应了。虽然在经过若干次超时重发却仍得不到响应,服务器最终也会将对应的连接关闭,但在不断进行超时重传时,服务器还需要一直维护这条闲置的连接,这样就对服务器很不友好。
为了避免这种情况,客户端在发起四次挥手后就不会立即进入 CLOSED 状态,而是进入 TIME_WAIT 状态进行等待,如果第四次挥手发生了丢包,客户端也能收到服务器的超时重传并进行响应。
客户端在进行四次挥手后进入 TIME_WAIT 状态,如果第四次挥手发生了丢包,客户端在一段时间内仍能接收服务器重发的 FIN 报文并对其进行响应,就能较大概率地保证最后一个 ACK 被服务器收到,以顺利完成四次挥手。此外,客户端在四次挥手后进入TIME_WAIT状态,还可以保证双方通信信道上的数据在网络中尽可能地消散掉。
类似的,TIME_WAIT 的等待时长既不能太长也不能太短。太长可能会让等待方花费过多成本来维护闲置连接,造成资源的浪费;太短可能无法保证 ACK 被服务器较大概率地收到,也难以保证数据在网络中消散。
因此 TCP 规定,主动关闭连接的一方在四次挥手后要处于TIME_WAIT状态,并等待两个 MSL(Maximum Segment Lifetime,报文最大生存时间)才能进入 CLOSED 状态。
MSL 能保证在两个传输方向上,尚未被接收或迟到的报文段最终都消失,同时它也是在理论上保证最后一个报文可靠到达的时间。在 RFC1122 中,MSL 被规定为两分钟,但在各个操作系统中的实现不同(通过指令
cat /proc/sys/net/ipv4/tcp_fin_timeout
可查看 MSL 在 Linux Centos 7.6 上的值默认为 60s)。
【Tips】连接异常断开的可能情况
(1)进程终止
客户端进程终止时会释放相应的文件描述符,但仍然可以发送 FIN 以断开连接,和一般的进程退出没有区别。总之,连接会正常断开,没啥事。
一个进程在退出时,会自动关闭自己曾打开的所有文件描述符。
同样的,客户端进程在退出时,也会自动关闭相应的文件描述符,然后客户端和服务端会正常完成四次挥手,释放连接资源。
(2)机器重启与进程终止的情况类似,连接也会正常断开。
客户端正常访问服务器时,如果将客户端主机重启,客户端主机上的操作系统会先杀掉所有进程,然后再进行关机重启,但客户端进程在退出时,也会自动关闭相应的文件描述符,然后客户端和服务端会正常完成四次挥手,释放连接资源。
(3)机器掉电/网线断开
正常访问服务器时,客户端却突然掉线了,服务端在短时间内也无法知道客户端掉线了,但服务端会根据 TCP 的保活策略,暂时维持与客户端建立的连接。
TCP 实现了一种基于保活定时器的心跳机制,以支持服务器定期询问客户端的存在状态。
服务端会定期向客户端发送询问报文,检查对方是否在线,如果连续多次都没有收到来自客户端的 ACK 应答,服务端就会关闭与客户端建立的连接。此外,客户端也可能会定期向服务端“报平安”,而如果服务端长时间没有收到客户端的“平安”消息,也会关闭与客户端建立的连接。
【补】TCP 中的各种定时器
- 重传定时器:为了控制丢失的报文段或丢弃的报文段,也就是对报文段确认的等待时间。
- 坚持定时器:专门为对方零窗口通知而设立的,也就是向对方发送窗口探测的时间间隔。
- 保活定时器:为了检查空闲连接的存在状态,也就是向对方发送探查报文的时间间隔。
- TIME_WAIT定时器:双方在四次挥手后,主动断开连接的一方需要等待的时长。
6)管控收发的数据量
.1- 滑动窗口
双方在进行TCP通信时,可以一次向对方发送多条数据,这样可以将多个等待响应的时间重叠起来,以提高通信的效率。
但一次性发送的多个报文中,会有相当一部分是暂时得不到应答的。
由数据的发送和响应,发送缓冲区其实可以分为三个部分:
- 第一部分:由已经发送且已经收到 ACK 的数据组成。
- 第二部分:由已经发送但尚未收到 ACK 的数据组成。
- 第三部分:由尚未发送的数据组成。
而其中的第二部分就叫做滑动窗口,用于描述发送端不用等待 ACK、一次性所能发送的数据最大量。
【Tips】滑动窗口的作用
滑动窗口主要用于限定不用等待 ACK、一次性所能发送的数据量,既可以提高发送端发送数据的效率,又可以支持超时重传机制。
滑动窗口的值 = min( 自身的拥塞窗口大小,对端的窗口大小),这是因为,发送数据时不仅要考虑对端的接收能力,还要考虑当前网络的状况。滑动窗口越大,网络的吞吐率就越高,同时也说明对端的接收能力很强。
假设对端的窗口大小为固定的 4000,且忽略自身的拥塞窗口大小。那么,发送端不用等待 ACK 一次所能发送的数据即为 4000字节,也就是说,此时的滑动窗口大小为 4000 字节。若当前连续发送 1001-2000、2001-3000、3001-4000、4001-5000 这四个段,就无需等待任何 ACK,可以直接进行发送。
当收到对端响应的确认序号为 2001 时,说明 1001-2000 这个数据段已经被可靠地收到了,此时 1001-2000 这个数据段,应该被纳入发送缓冲区中的第一部分,然后滑动窗口可以移动,继续发送 5001-6000 的数据段......以此类推。发送的数据陆续收到对应的 ACK 后,就可以陆续将其归置到发送缓冲区的第一部分,即滑动窗口的左侧,然后根据当前滑动窗口的大小,来决定是否需要将发送缓冲区的第三部分,即滑动窗口右侧的数据,归置到滑动窗口中。
另外,TCP 的超时重传机制要求暂时保存已经发送但尚未收到 ACK 的数据,这部分数据其实就位于滑动窗口中。而滑动窗口的左侧,才是已经发送且已经收到 ACK 的数据,才可以被覆盖或删除。所以滑动窗口除了限定不收到ACK而可以直接发送的数据之外,滑动窗口也可以支持TCP的重传机制。
【ps】滑动窗口未必始终是整体右移的
这是因为,滑动窗口会因对端窗口大小的变化,而变宽或变窄。
仍假设,对端的窗口大小为固定的 4000,且忽略发送端自身的拥塞窗口大小,此时的滑动窗口大小就为 4000 字节。若当前连续发送 1001-2000、2001-3000、3001-4000、4001-5000 这四个段,就无需等待任何 ACK,可以直接进行发送。
如果对端已经收到了 1001-2000 的数据段并对其进行了响应,但对端应用层一直不从接收缓冲区中读取数据,对端的窗口大小就会因接收缓冲区的实时容量有限,而从 4000 变为 3000,发送端的滑动窗口也会从 4000 变为 3000。
当发送端收到对端的响应序号 2001 时,就会将 1001-2000 的数据段归置到滑动窗口的左侧,但由于对端的窗口大小当前为 3000,而1001-2000的数据段归置到滑动窗口的左侧后,剩余的滑动窗口大小刚好为 3000,于是滑动窗口的右侧不会继续向右进行扩展。
【补】滑动窗口的实现
TCP 接收缓冲区和发送缓冲区都可以看作是一个字符数组,而滑动窗口可以看作是由两个指针限定的一个区域范围。
假设滑动窗口左端的指针为 start,右端的指针为 end。将滑动窗口内的数据归置于左侧,其实就是 start 右移来实现的;而将右侧的数据归置于滑动窗口中,其实就是 end 右移来实现的;如此,通过 start 和 end 的右移,就可以完成对滑动窗口的整体右移。
如果发送端收到的对端响应报文,其中的确认序号为 x,窗口大小为 w,就可以将 start更新为 x,而将 end 更新为 star t+ w,以此就完成了对滑动窗口的整体右移。
【补】丢包问题
发送端一次性向对端发送多个报文数据时,也可能存在两种丢包的情况。
(1)数据包已经抵达,ACK丢包
其实部分的 ACK 丢包并不要紧,仍可以通过后续的 ACK 进行确认。
假设要传6000字节的数据,2001-3000 和 4001-5000 的数据包以送达但 ACK 丢失了,只要发送端后续能够收到 5001-6000 的 ACK,也能知道 2001-3000 和 4001-5000 的数据包被可靠地接收了。
这是因为,如果接收端没有收到 2001-3000 和 4001-5000 的数据包,就不会设置确认序号为 6001。设置确认序号为 6001 ,既表明了序号为 1-6000 的字节数据我都收到了,又提醒发送端,下一次应该从序号为 6001 的字节数据开始发送。
(2)数据包丢了
数据包丢失时,TCP 会启用高速重发控制,或称快重传。
仍假设要传6000字节的数据,但 1001-2000 的数据包丢失了。
1001-2000的数据包丢失后,发送端会一直收到来自对端的、确认序号为 1001 的响应报文,这就是对端在提醒发送端,下一次应该从序号为 1001 的字节数据开始发送。
如果发送端连续三次收到确认序号为 1001 的响应报文,就会将 1001-2000 的数据包重新进行发送。而接收端收到 1001-2000 的数据包后,就会直接发送确认序号为 6001 的响应报文,因为 2001-6000 的数据早已收到了。但要注意的是,快重传其实会在大量的数据重传,和个别的数据重传之间做平衡。
实际在上面的假设中,发送端并不知道是 1001-2000 的数据包丢了。当重复收到确认序号为 1001 的响应报文后,理论上发送端会将 1001-7000 的数据全部进行重传,但这样会导致大量数据被重复传送,因此,发送端会尝试先把 1001-2000 的数据包进行重发,然后根据因重发而得到的确认序号来决定是否需要重发其它数据包
【补】快速重传与超时重传
快速重传不像超时重传一样,需要通过设置重传定时器,在固定的时间后才会进行重传,而是只要发送端连续收到三次相同的应答,就会触发快重传。
虽然快速重传能够很快判定数据包丢失,但并不能完全取待超时重传,因为有时数据包丢失后可能并没有收到对方三次重复的应答,就触发不了快重传机制,而只能进行超时重传。
总得来说,快速重传是一个效率上的提升方式,超时重传是所有重传机制的保底策略,两者都是不可或缺的。
.2- 流量控制
流量控制,即 TCP 可以根据接收端接收数据的能力,来决定发送端发送数据的速度。
由于接收端处理数据的速度是有限的,一旦发送端将数据发得太快,导致接收端的接收缓冲区被填满,后续发送的数据就会丢包,进而引起重传等一系列连锁反应,因此,TCP 支持接收端可以将自己接收数据的能力告知发送端,让发送端控制自己发送数据的速度。
【Tips】流量控制的过程
- 接收端将其接收缓冲区大小,填入 TCP 报头中的“窗口大小”字段,然后通过 ACK 位通知发送端。
- 窗口大小字段越大,网络的吞吐量就越高。接收端一旦发现自己的接收缓冲区快满了,就会实时地将窗口大小设置成一个更小的值并通知给发送端。发送端得知后,就会减慢自己发送的速度。
- 如果接收端缓冲区满了,接收端就会将窗口大小设置为 0,提醒发送端暂时不要发送数据,然后定期发送一个窗口探测数据段,使发送端实时地知道接收端的窗口大小,以便后续及时发送数据;同时,发送端每隔一段时间还会向接收端发送不携带有效数据的报文,询问接收端的窗口大小,以便后续及时。发送数据。
【补】窗口大小的实际值,由窗口字段的值左移 M 位得到
TCP报头中的窗口大小字段是16 位的,16 位的最大整数为 65535,但 TCP 窗口的最大值并非 65535。
TCP报头中还有一个 40 字节的选项字段,其中包含了一个窗口扩大因子 M,而窗口大小的值,其实是由窗口字段的值左移 M 位得到的。
【补】建立连接时,就能得知对端的窗口大小
双方在进行 TCP 通信之前,会先进行三次握手以建立连接。
三次握手时,除了验证双方通信信道是否通畅,还进行了其他信息的交互,其中就包括告知对方自己的接收能力。
因此,在双方还没有正式开始通信之前,其实就已经知道了对方接收数据的能力,于是在发送数据时,双方都不会出现缓冲区溢出的问题。
.3- 拥塞控制
在通信过程中,TCP 不仅能考虑通信双端主机的问题,同时也能考虑网络的问题,这主要是通过流量控制、滑动窗口、拥塞窗口三种机制体现的。
- 流量控制:针对于对端接收缓冲区的接收能力,可以控制发送方发送数据的速度,避免对端接收缓冲区溢出。
- 滑动窗口:针对于发送端不用等待 ACK、一次性所能发送的最大数据量,可以提高发送端发送数据的效率。
- 拥塞窗口:针对于双方通信时网络的问题,避免发送的数据引起网络拥塞。
两台主台在通信过程中,出现个别数据包的丢包是正常的,此时可以通过快重传或超时重发对数据包进行补发来解决。但出现大量丢包,就不是正常现象了,TCP 就不再推测是双方接收和发送数据的问题,而判断是双方通信信道网络出现了拥塞问题。
如果网络中的主机在同一时间节点都大量向网络中发送数据,那么位于网络中某些关键节点的路由器中,就可能排了很长的报文,最终导致报文无法在超时时间内到达对端主机,进而导致大量丢包。
当网络出现拥塞问题时,通信双方虽然不能提出特别有效的解决方案,但双方主机可以做到不加重网络的负担。例如出现大量丢包时,双方不立即将这些报文进行重传,而少发数据甚至不发数据,等待网络状况恢复后,再慢慢恢复数据的传输速率。
网络出现拥塞问题,一定是网络中大部分主机共同作用的结果。网络拥塞时,影响的不只是一台主机,而几乎是网络中的所有主机,此时,所有使用 TCP 协议的主机都会执行拥塞避免算法。也就是说,拥塞控制是所有主机在网络崩溃后都会遵守的策略,一旦出现网络拥塞,所有主机都要执行拥塞避免,这样才能有效缓解网络拥塞问题。
【Tips】拥塞窗口
拥塞避免算法的关键在于拥塞窗口的值。
网络上始终有很多的计算机,可能使得当前的网络已经比较拥塞了,因此在通信刚开始时,不清楚当前网络状态,就贸然发送大量的数据,可能会引起网络拥塞问题。
TCP 引入了慢启动机制,在通信刚开始时,先发少量的数据试探网络状态,再决定以什么样的速度传输数据。这个慢启动机制就是拥塞窗口。
TCP 除了有窗口大小和滑动窗口以外,还有一个拥塞窗口。拥塞窗口是可能引起网络拥塞的阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
在通信刚开始时,在发送端发送的报文中,拥塞窗口的大小会被定义为 1,发送端每收到一个来自接收端的 ACK 后,拥塞窗口的值就加 1 。每次发送数据包时,发送端会将拥塞窗口和接收端主机反馈的窗口大小进行比较,取较小值作为实际发送数据的窗口大小,即滑动窗口的大小。由滑动窗口的值 = min( 自身的拥塞窗口大小,对端的窗口大小),若忽略接收端的窗口大小(即接收端接收数据的能力),那么滑动窗口的值就只取决于发送端当前拥塞窗口的大小,此时拥塞窗口和滑动窗口就是以指数级别进行增长的。
由此,慢启动只是在通信开始时启动比较慢,但随着通信时间的推移,拥塞窗口的值理论上会增长得越来越快,但如果一直以指数级别增长下去,就可能在短时间内再次导致网络拥塞问题。因此,不能让拥塞窗口以指数级别一直增长下去。
具体的方法是,引入慢启动的阈值,并将拥塞窗口的增长方式分为两段,当拥塞窗口的值超过慢启动的阈值时,就不再以指数级别增长,而以线性级别增长。
实际上,在在通信刚开始时,慢启动的阈值就设置为对端窗口大小的最大值。而在每次发生超时重发时,慢启动阈值会变成当前拥塞窗口的一半,且拥塞窗口的值被重新置为 1,如此循环下去。
也就是说,一台主机在进行网络通信时,会不断进行指数增长、加法增大和乘法减小。不过,也不是所有的主机都是同时在进行指数增长、加法增大和乘法减小的。
每台主机的拥塞窗口的值未必相同,即便是同区域的两台主机在同一时刻也如此。因此在同一时刻,网络中的一部分主机可能正在进行正常通信,而另一部分主机可能已经发生网络拥塞了。
7)提升数据传输效率
.1- 延迟应答
延迟应答并不是为了保证可靠性而存在的,而是留出一点时间,好让接收端接收缓冲区中的数据尽可能被应用层消费掉,使接收端在进行 ACK 响应时,向发送端报告的窗口大小可以更大些,从而增大网络吞吐量,提高数据的传输效率和资源的利用率。
如果接收端在收到数据后立即进行 ACK 应答,此时返回的窗口就可能较小。
假设接收端的接收缓冲区剩余空间大小为 1M,一次性收到500K的数据后,立即进行 ACK 应答,返回的窗口就是 500K。但接收端也是不停在处理接收缓冲区中的数据的,且处理速度很快,一般 10ms 之内就能将 500K 的数据消费掉。在这种情况下,接收端处理还远没有达到自己的极限,即使窗口的值再放大一些,也能处理过来。
如果接收端稍微等一会儿再进行 ACK 应答,例如 200ms ,那么返回的窗口大小就是1M了,这样既可以保证效率,也可以更好地利用资源。
不过,延迟应答的频率也是有限制的,并非所有的数据包都可以被延迟应答。
- 数量限制:每 N 个包就延迟应答一次。
- 时间限制:超过最大延迟时间就应答一次(不会导致误超时重传)。
延迟应答的具体数量一般N取2,最大延迟时间一般取 200ms,不过,在不同的操作系统中,这些值又有所不同。
.2- 捎带应答
捎带应答其实是 TCP 通信中最常规的一种方式,指接收端既给发送端发了数据,又对来自发送端的数据进行了响应。
例如主机 A 给主机 B 发送了一条消息,主机 B 收到消息后要对其进行 ACK 应答,而如果主机 B 恰好也要给主机 A 发消息,那么主机 B 本该应答的 ACK 就可以搭个顺风车,和本就要发给主机 A 的消息一起发给主机 A 。
捎带应答使双方通信时可以不用发送单纯的确认报文,能直观地提升发送数据的效率。
此外,由于捎带应答的报文携带了有效数据,因此发送端收到该报文后也会对其进行响应。收到这个响应报文,不仅意味着接收端发送的数据被发送端可靠地收到了,同时也意味着接收端的 ACK 应答被发送端端可靠的收到了。
8)TCP 连接队列
listen() 是套接字编程中常用的一个系统调用接口,功能是将服务端的 TCP 套接字状态设置为 LISREN,让服务端可以监听客户端发来的连接请求。
#include<sys/type.h>
#include<sys/socket.h>
int listen(int sockfd, int backlog);
功能:用于在 bind() 之后,服务端监听客户端的连接请求。
参数:1.sockfd: 需要设置为监听状态的套接字(文件描述符)。
2.backlog:全连接队列的最大长度。
如果有多个客户端同时发来连接请求,未被服务端处理的请求会放入连接队列,
该参数代表的就是全连接队列的最大长度,一般设置为5或10即可。
返回值:监听成功返回 0;失败返回-1,并设置合适的错误码。
其中,listen() 的第二个参数 backlog,与全连接队列有关。
TCP在进行连接管理时,会用到两个连接队列:
- 全连接队列:用于保存处于 ESTABLISHED 状态、尚未被上层调用 accept() 取走的连接。
- 半连接队列:用于保存处于 SYN_SENT 和 SYN_RCVD 状态、尚未完成三次握手的连接。
一般 TCP 全连接队列的长度,就等于 listen 第二个参数的值 + 1 。
假设在进行服务端的套接字编程时,将参数 backlog 的值设置为 2,服务端全连接队列的长度就为 3,此时服务端就至多允许三个处于 ESTABLISHED 状态的连接存在,如果有更多客户端的连接请求到来,服务端就不会对其进行响应,甚至直接将其拒绝。
全连接队列的长度为 3,且服务端中已经存在了 3 个处于 ESTABLISHED 状态的连接,若还有更多客户端的连接请求到来,那么第 4 个连接请求就会被放入半连接队列,使服务端中新增一个处于 SYN_RCVD 状态的连接。而在第 4 个连接请求之后,服务端不会再新增任何状态的连接,也就是说,剩余的连接请求就都石沉大海了。
【Tips】全连接队列的长度
TCP 全连接队列的长度由两个值决定:
- 应用层调用 listen() 时所传入的第二个参数 backlog(一般设置为 5)。
- 系统变量 net.core.somaxconn,其默认值为 128 。
- 全连接队列的长度 = min( listen 所传参数 backlog , net.core.somaxconn ) + 1 。
【ps】连接队列不能太长
虽然维护连接队列能让服务器处于几乎满载工作的状态,但连接队列也不能设置得太长。
如果队列太长,靠近队列尾部的连接需要等待较长时间才能得到服务,客户端的连接请求也迟迟得不到响应。此外,服务器维护连接也是需要成本的,连接队列设置的越长,系统就要花费越多的成本去维护这个队列。
与其维护一个长连接队列,导致客户端等待过久,占用许多暂时用不到的资源,还不如将部分资源节省出来给服务器使用,让服务器更快地为客户端提供服务。
补、用 UDP 协议实现可靠传输
用 UDP 协议实现可靠传输,是一道经典面试题。
面对此问题,一定要能先想到 TCP 协议,因为 TCP 协议就是一种支持可靠传输的协议。而用 UDP 协议实现可靠传输,无非是在应用层来实现可靠性,那么就可以参考 TCP 协议保证可靠性的各种机制。例如:
- 引入序列号,保证数据按序到达。
- 引入确认应答,确保对端接收到了数据。
- 引入超时重传,如果隔一段时间没有应答,就进行数据重发。
- …
但 TCP 保证可靠性的机制一点也不少,不妨对症下药,与面试官进行沟通,问问“用 UDP 实现可靠传输”的应用场景是什么,再根据场景的特性,针对性地引入 TCP 的机制。