四、计算机网络——传输层
- 1 运输层概述
- 2 TCP 和 UDP 前置知识
- 3 UDP
- 4 TCP
- 5 后记
今天我们主要来聊一下计算机网络传输层的相关知识。
传输层位于应用层和网络层之间,是 OSI 分层体系中的第四层,同时也是网络体系结构的重要部分。运输层主要负责网络上的端到端通信。
运输层为运行在不同主机上的应用程序之间的通信起着至关重要的作用。下面我们就来一起探讨一下关于运输层的协议部分
1 运输层概述
计算机网络的运输层非常类似于高速公路,高速公路负责把人或者物品从一个地方运到另一个地方,而计算机网络的运输层则负责把报文从一端运输到另一端,这个端指的就是端系统。在计算机网络中,任意一个可以交换信息的介质都可以称为端系统,比如手机、网络媒体、电脑、运营商等。
在运输层运输报文的过程中,会遵守一定的协议规范,比如一次传输的数据限制、选择什么样的运输协议等。运输层实现了让两个互不相关的端系统进行逻辑通信的功能,看起来像是让两个人面对面对话一样!
运输层协议是在端系统中实现的,而不是在路由器中实现的,因为路由器只是做识别转发功能。这也就是说,只有端系统自己知道要把数据包送到哪里!这就比如快递员送快递一样,当然是要由寄快递的人知道要给谁寄快递,快递员才不会管你这个快递是寄给谁的,人家只是负责运输!
现在我们可以认为数据包已经发送到了某台计算机中,但是计算机中有很多程序,你这个数据包是发给哪个程序的呢?
TCP 如何判断是哪个端口的呢?
还记得数据包的结构吗,这里来回顾一下
数据包经过每层后,该层协议都会在数据包附上包首部,一个完整的包首部图如上所示。
在数据传输到运输层后,会为其附上 TCP 首部,首部包含着源端口号和目的端口号。
在发送端,运输层将从发送应用程序进程接收到的报文转化成运输层分组
,分组在计算机网络中也称为 报文段(segment)。运输层一般会将报文段进行分割,分割成为较小的块,为每一块加上运输层首部并将其向目的地发送。
在发送过程中,可选的运输层协议(也就是交通工具) 主要有 TCP
和 UDP
,所以,关于这两种运输协议的选择及其特性也是我们着重探讨的重点。
2 TCP 和 UDP 前置知识
在 TCP/IP 协议中能够实现传输层功能的,最具代表性的就是 TCP 和 UDP。提起 TCP 和 UDP ,就得先从这两个协议的定义说起。
TCP 叫做传输控制协议(TCP,Transmission Control Protocol),通过名称可以大致知道 TCP 协议有控制传输的功能,主要体现在其可控,可控就表示着可靠,确实是这样的,TCP 为应用层提供了一种可靠的、面向连接的服务,它能够将分组可靠的传输到服务端。
UDP 叫做 用户数据报协议(UDP,User Datagram Protocol),通过名称可以知道 UDP 把重点放在了数据报上,它为应用层提供了一种无需建立连接就可以直接发送数据报的方法。
怎么计算机网络中的术语对一个数据的描述这么多啊?
在计算机网络中,在不同层之间会有不同的描述。我们上面提到会将运输层的分组称为报文段,除此之外,还会将 TCP 中的分组也称为报文段,然而将 UDP 的分组称为数据报,同时也将网络层的分组称为数据报。在数据链路层的数据被称为帧,在物理层都叫比特。
但是为了统一,一般在计算机网络中我们统一称 TCP 和 UDP 的报文为 报文段
,这个就相当于是约定,到底如何称呼不用过多纠结啦。
2.1 套接字
在 TCP 或者 UDP 发送具体的报文信息前,需要先经过一扇门,这个门就是套接字(socket),套接字向上连接着应用层,向下连接着网络层。在操作系统中,操作系统分别为应用和硬件提供了接口(Application Programming Interface)。而在计算机网络中,套接字同样是一种接口,它也是有接口 API 的。
使用 TCP 或 UDP 通信时,会广泛用到套接字的 API,使用这套 API 设置 IP 地址、端口号,实现数据的发送和接收。
现在我们知道了, Socket 和 TCP/IP 没有必然联系,Socket 的出现只是方便了 TCP/IP 的使用,如何方便使用呢?你可以直接使用下面 Socket API 的这些方法。
方法 | 描述 |
---|---|
create() | 创建一个 socket |
bind() | 套接字标识,一般用于绑定端口号 |
listen() | 准备接收连接 |
connect() | 准备充当发送者 |
accept() | 准备作为接收者 |
write() | 发送数据 |
read() | 接收数据 |
close() | 关闭连接 |
2.1.1 套接字类型
套接字的主要类型有三种,下面我们分别介绍一下:
数据报套接字(Datagram sockets)
:数据报套接字提供一种无连接的服务,而且并不能保证数据传输的可靠性。数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP( User DatagramProtocol)协议
进行数据的传输。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。流套接字(Stream sockets)
:流套接字用于提供面向连接、可靠的数据传输服务。能够保证数据的可靠性、顺序性。流套接字之所以能够实现可靠的数据服务,原因在于其使用了传输控制协议,即 TCP 协议。原始套接字(Raw sockets)
: 原始套接字允许直接发送和接收 IP 数据包,而无需任何特定于协议的传输层格式,原始套接字可以读写内核没有处理过的 IP 数据包。
2.1.2 套接字处理过程
在计算机网络中,要想实现通信,必须至少需要两个端系统,至少需要一对两个套接字才行。下面是套接字的通信过程。
- socket 中的 API 用于创建通信链路中的端点,创建完成后,会返回描述该套接字的套接字描述符。
就像使用文件描述符来访问文件一样,套接字描述符用来访问套接字。
- 当应用程序具有套接字描述符后,它可以将唯一的名称绑定在套接字上,服务器必须绑定一个名称才能在网络中访问
- 在为服务端分配了 socket 并且将名称使用 bind 绑定到套接字上后,将会调用 listen api。
listen
表示客户端愿意等待连接的意愿,listen 必须在 accept api 之前调用。 - 客户端应用程序在流套接字(基于 TCP)上调用
connect
发起与服务器的连接请求。 - 服务器应用程序使用
accept
API 接受客户端连接请求,服务器必须先成功调用 bind 和 listen 后,再调用 accept api。 - 在流套接字之间建立连接后,客户端和服务器就可以发起 read/write api 调用了。
- 当服务器或客户端要停止操作时,就会调用
close
API 释放套接字获取的所有系统资源。
虽然套接字 API 位于应用程序层和传输层之间的通信模型中,但是套接字 API 不属于通信模型。套接字 API 允许应用程序与传输层和网络层进行交互。
在往下继续聊之前,我们先播放一个小插曲,简单聊一聊 IP。
2.2 聊聊 IP
IP
是Internet Protocol(网际互连协议)
的缩写,是 TCP/IP 体系中的网络层
协议。设计 IP 的初衷主要想解决两类问题:
- 提高网络扩展性:实现大规模网络互联。
- 对应用层和链路层进行解藕,让二者独立发展。
IP 是整个 TCP/IP 协议族的核心,也是构成互联网的基础。为了实现大规模网络的互通互联,IP 更加注重适应性、简洁性和可操作性,并在可靠性做了一定的牺牲。IP 不保证分组的交付时限和可靠性,所传送分组有可能出现丢失、重复、延迟或乱序等问题。
我们知道,TCP 协议的下一层就是 IP 协议层,既然 IP 不可靠,那么如何保证数据能够准确无误地到达呢?
这就涉及到 TCP 传输机制的问题了,我们后面聊到 TCP 的时候再说。
2.3 端口号
在聊端口号前,先来聊一聊文件描述以及 socket 和端口号的关系
为了方便资源的使用,提高机器的性能、利用率和稳定性等等原因,我们的计算机都有一层软件叫做操作系统,它用于帮我们管理计算机可以使用的资源,当我们的程序要使用某个资源的时候,可以向操作系统申请,再由操作系统为我们的程序分配和管理资源。通常当我们要访问一个内核设备或文件时,程序可以调用系统函数,系统就会为我们打开设备或文件,然后返回一个文件描述符 fd(或称为 ID,是一个整数),我们要访问该设备或文件,只能通过该文件描述符。可以认为该编号对应着打开的文件或设备。
而当我们的程序要使用网络时,要使用到对应的操作系统内核的操作和网卡设备,所以我们可以向操作系统申请,然后系统会为我们创建一个套接字 Socket,并返回这个 Socket 的ID,以后我们的程序要使用网络资源,只要向这个 Socket 的编号 ID 操作即可。而我们的每一个网络通信的进程至少对应着一个 Socket。向 Socket 的 ID 中写数据,相当于向网络发送数据,向 Socket 中读数据,相当于接收数据。而且这些套接字都有唯一标识符——文件描述符 fd。
端口号是 16 位的非负整数,它的范围是 0 - 65535 ,这个范围会分为三种不同的端口号段,由 Internet 号码分配机构 IANA 进行分配
- 周知/标准端口号,它的范围是 0 - 1023
- 注册端口号,范围是 1024 - 49151
- 私有端口号,范围是 49152 - 65535
一台计算机上运行着多个应用程序,当一个报文段到达主机后,应该传输给哪个应用程序呢?你怎么知道这个报文段就是传递给 HTTP 服务器而不是 SSH 服务器的呢?
是凭借端口号吗?当报文到达服务器时,是端口号来区分不同应用程序的,所以应该借助端口号来区分。
举个例子反驳一下 cxuan,假如到达服务器的两条数据都是由 80 端口发出的你该如何区分呢?或者说到达服务器的两条数据端口一样,协议不同,该如何区分呢?
所以仅凭端口号来确定某一条报文显然是不够的。
互联网上一般使用四元组:源 IP 地址、目标 IP 地址、源端口号、目的端口号 来进行区分。如果其中的某一项不同,就被认为是不同的报文段。这些也是多路分解和多路复用的基础。
2.3.1 确定端口号
在实际通信之前,需要先确定一下端口号,确定端口号的方法分为两种:
- 标准既定的端口号
标准既定的端口号是静态分配的,每个程序都会有自己的端口号,每个端口号都有不同的用途。0 - 1023 范围内的端口号都是动态分配的既定端口号,例如 HTTP 使用 80 端口来标识,FTP 使用 21 端口来标识,SSH 使用 22 来标识。这类端口号有一个特殊的名字,叫做周知端口号(Well-Known Port Number)。
- 操作系统分配的端口号
第二种分配端口号的方式是一种动态分配法,在这种方法下,客户端应用程序可以完全不用自己设置端口号,凭借操作系统进行分配,操作系统可以为每个应用程序分配互不冲突的端口号。这种动态分配端口号的机制即使是同一个客户端发起的 TCP 连接,也能识别不同的连接。
2.4 多路复用和多路分解
我们上面聊到了在主机上的每个套接字都会分配一个端口号,当报文段到达主机时,运输层会检查报文段中的目的端口号,并将其定向到相应的套接字,然后报文段中的数据通过套接字进入其所连接的进程。下面我们来聊一下什么是多路复用和多路分解的概念。
多路复用和多路分解分为两种,即无连接
的多路复用/多路分解和面向连接
的多路复用/多路分解
2.4.1 无连接的多路复用和多路分解
开发人员会编写代码确定端口号是周知端口号还是时序分配的端口号。假如主机 A 中的一个 10637 端口要向主机 B 中的 45438 端口发送数据,运输层采用的是 UDP 协议,数据在应用层产生后,会在运输层中加工处理,然后在网络层将数据封装得到 IP 数据报,IP 数据包通过链路层交付给主机 B,主机 B 会检查报文段中的端口号判断是哪个套接字的,这一系列的过程如下所示
UDP 套接字就是一个二元组,二元组包含目的 IP 地址和目的端口号。
所以,如果两个 UDP 报文段有不同的源 IP 地址和/或相同的源端口号,但是具有相同的目的 IP 地址和目的端口号,那么这两个报文会通过套接字定位到相同的目的进程。
这里思考一个问题,主机 A 给主机 B 发送一个消息,为什么还需要知道源端口号呢?比如我给妹子表达出我对你有点意思的信息,妹子还需要知道这个信息是从我的哪个器官发出的吗?知道是我这个人对你有点意思不就完了?实际上是需要的,因为妹子如果要表达出她对你也有点意思,她是不是可能会亲你一口,那她得知道往哪亲吧?
这就是,在 A 到 B 的报文段中,源端口号会作为返回地址的一部分,即当 B 需要回发一个报文段给 A 时,B 需要从 A 到 B 中的源端口号取值,如下图所示
2.4.2 面向连接的多路复用与多路分解
如果说无连接的多路复用和多路分解指的是 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 来说,也能够进行分解。
3 UDP
UDP 的全称是用户数据报协议(UDP,User Datagram Protocol),UDP 为应用程序提供了一种无需建立连接就可以封装并发送 IP 数据包的方法。如果应用程序开发人员选择的是 UDP 而不是 TCP 的话,那么该应用程序相当于就是和 IP 直接打交道的。
从应用程序传递过来的数据,会附加上多路复用/多路分解的源和目的端口号字段,以及其他字段,然后将形成的报文传递给网络层,网络层将运输层报文段封装到 IP 数据报中,再交付给目标主机。
3.1 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 协议最大的特点就是速度快。
3.2 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 会提供差错检测的功能?
这其实是一种端到端的设计原则,这个原则说的是要让传输中各种错误发生的概率降低到一个可以接受的水平。UDP 不可靠的原因是它虽然提供差错检测的功能,但是对于差错没有恢复能力更不会有重传机制。
4 TCP
UDP 是一种没有复杂的控制,提供无连接通信服务的一种协议,换句话说,它将部分控制部分交给应用程序去处理,自己只提供作为传输层协议最基本的功能。
而与 UDP 不同的是,同样作为传输层协议,TCP 协议要比 UDP 的功能多很多。
TCP
的全称是 Transmission Control Protocol
,它被称为是一种面向连接的协议,这是因为一个应用程序开始向另一个应用程序发送数据之前,这两个进程必须先进行握手,握手是一个逻辑连接,并不是两个主机之间进行真实的握手。
这个连接是指各种设备、线路或者网络中进行通信的两个应用程序为了相互传递消息而专有的、虚拟的通信链路,也叫做虚拟电路。
一旦主机 A 和主机 B 建立了连接,那么进行通信的应用程序只使用这个虚拟的通信线路发送和接收数据就可以保证数据的传输,TCP 协议负责控制连接的建立、断开、保持等工作。
TCP 连接是全双工服务(full-duplex service) 的,全双工是什么意思?全双工指的是主机 A 与另外一个主机 B 存在一条 TCP 连接,那么应用程数据就可以从主机 B 流向主机 A 的同时,也从主机 A 流向主机 B。
一条 TCP 连接只能是点对点(point-to-point)的,那么所谓的多播,即一个主机对多个接收方发送消息的情况是不存在的,TCP 连接只能连接两个一对主机。
一旦 TCP 连接建立后,主机之间就可以相互发送数据了,客户进程通过套接字发送数据。数据一旦通过套接字后,它就由客户中运行的 TCP 协议所控制。
TCP 会将数据临时存储到连接的发送缓存(send buffer)中,这个 send buffer 是三次握手之间设置的缓存之一,然后 TCP 在合适的时间将发送缓存中的数据发送到目标主机的接收缓存中,实际上,每一端都会有发送缓存和接收缓存,如下图所示
主机之间的发送是以报文段(segment)进行的,那么什么是 Segement 呢?
TCP 会将要传输的数据流分为多个块,然后向每个块中添加 TCP 标头,这样就形成了一个 TCP 段也就是报文段。每一个报文段可以传输的长度是有限的,不能超过最大数据长度(Maximum Segment Size)MSS。在报文段向下传输的过程中,会经过链路层,链路层有一个 Maximum Transmission Unit,最大传输单元 MTU, 即数据链路层上所能通过最大数据包的大小,最大传输单元通常与通信接口有关。
因为计算机网络是分层考虑的,这个很重要,不同层的称呼不一样,对于传输层来说,称为报文段而对网络层来说就叫做 IP 数据包,所以,MTU 可以认为是网络层能够传输的最大 IP 数据包,而 MSS(Maximum segment size)可以认为是传输层的概念,也就是 TCP 数据包每次能够传输的最大量。
4.1 TCP 报文段结构
在简单聊了聊 TCP 连接后,下面我们就来聊一下 TCP 的报文段结构,如下图所示
TCP 报文段结构相比 UDP 报文结构多了很多内容。但是前两个 32 比特的字段是一样的。它们是源端口号和目标端口号,我们知道,这两个字段是用于多路复用和多路分解的。另外,和 UDP 一样,TCP 也包含校验和,除此之外,TCP 报文段首部还有下面这些
- 32 比特的序号字段(sequence number field) 和 32 比特的确认号字段(acknowledgment number field)。这些字段被 TCP 发送方和接收方用来实现可靠的数据传输。
-
4 比特的首部字段长度字段(header length field),这个字段指示了以 32 比特的字为单位的 TCP 首部长度。TCP 首部的长度是可变的,但是通常情况下,选项字段为空,所以 TCP 首部字段的长度是 20 字节。
- 16 比特的接受窗口字段(receive window field),这个字段用于流量控制。它用于指示接收方能够/愿意接受的字节数量
- 可变的选项字段(options field),这个字段用于发送方和接收方协商最大报文长度,也就是 MSS 时使用
- 6 比特的标志字段(flag field),
ACK
标志用于指示确认字段中的值是有效的,这个报文段包括一个对已被成功接收报文段的确认;RST
、SYN
、FIN
标志用于连接的建立和关闭;CWR
和ECE
用于拥塞控制;PSH
标志用于表示立刻将数据交给上层处理;URG
标志用来表示数据中存在需要被上层处理的 紧急 数据。紧急数据最后一个字节由 16 比特的紧急数据指针字段(urgeent data pointer field)指出。一般情况下,PSH 和 URG 并没有使用。
TCP 的各种功能和特点都是通过 TCP 报文结构来体现的,在聊完 TCP 报文结构之后,我们下面就来聊一下 TCP 有哪些功能及其特点了。
4.2 序号、确认号实现传输可靠性
TCP 报文段首部中两个最重要的字段就是序号和确认号,这两个字段是 TCP 实现可靠性的基础,那么你肯定好奇如何实现可靠性呢?要了解这一点,首先我们得先知道这两个字段里面存了哪些内容吧?
一个报文段的序号就是数据流的字节编号 。因为 TCP 会把数据流分割成为一段一段的字节流,因为字节流本身是有序的,所以每一段的字节编号就是标示是哪一段的字节流。比如,主机 A 要给主机 B 发送一条数据。数据经过应用层产生后会有一串数据流,数据流会经过 TCP 分割,分割的依据就是 MSS,假设数据是 10000 字节,MSS 是 2000 字节,那么 TCP 就会把数据拆分成 0 - 1999 , 2000 - 3999 的段,依次类推。
所以,第一个数据 0 - 1999 的首字节编号就是 0 ,2000 - 3999 的首字节编号就是 2000 。。。。。。
然后,每个序号都会被填入 TCP 报文段首部的序号字段中。
至于确认号的话,会比序号要稍微麻烦一些。这里我们先拓展下几种通信模型。
- 单工通信:单工通信只支持数据在一个方向上传输;在同一时间只有一方能接受或发送信息,不能实现双向通信,比如广播、电视等。
- 双工通信:由两个或者多个发送方同时在两个方向上通信。双工通信模型有两种:全双工(FDX)和半双工(HDX)
- 半双工:在半双工系统中,连接双方可以进行通信,但不能同时通信,比如对讲机,只有把按钮按住的人才能够讲话,只有一个人讲完话后另外一个人才能讲话。
- 全双工:在全双工系统中,连接双方可以同时进行通信,一个最常见的例子就是电话通信。全双工通信是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接收和发送能力。
单工、半双工、全双工通信如下图所示
TCP 是一种全双工的通信协议,因此主机 A 在向主机 B 发送消息的过程中,也在接受来自主机 B 的数据。主机 A 填充进报文段的确认号是期望从主机 B 收到的下一字节的序号。稍微有点绕,我们来举个例子看一下。比如主机 A 收到了来自主机 B 发送的编号为 0 - 999 字节的报文段,这个报文段会写入序号中,随后主机 A 期望能够从主机 B 收到 1000 - 剩下的报文段,因此,主机 A 发送到主机 B 的报文段中,它的确认号就是 1000 。
4.2.1 累积确认
这里再举出一个例子,比如主机 A 在发送 0 - 999 报文段后,期望能够接受到 1000 之后的报文段,但是主机 B 却给主机 A 发送了一个 1500 之后的报文段,那么主机 A 是否还会继续进行等待呢?
答案是会
的,因为 TCP 只会确认流中至第一个丢失字节为止的字节,因为 1500 虽然属于 1000 之后的字节,但是主机 B 没有给主机 A 发送 1000 - 1499 之间的字节,所以主机 A 会继续等待。
在了解完序号和确认号之后,我们下面来聊一下 TCP 的发送过程。下面是一个正常的发送过程
主机 A 向主机 B 发送了两段报文,第一段报文是 0 - 999 ,主机 B 接收之后会发送确认应答报文,该报文中包含对主机 A 发送 0 - 999 报文段的确认号,应答报文到达主机 A 之后,经过一段时间主机 A 会发送 1000 - 1999 这段报文,主机 B 对其进行确认后再发送应答报文。
通过上图可以看到,每次主机 A 发送完报文段之后,主机 B 都会发送一个应答报文,主机 A 才会发送接下来的报文段,那么这个应答报文是啥呢?实际上,TCP 就是通过确认应答(ACK)
来实现可靠的数据传输,当主机 A将数据发出之后会等待主机 B 的响应。如果有确认应答(ACK),说明数据已经成功到达。反之,则数据很可能会丢失。
如下图所示,如果在一定时间内主机 A 没有等到确认应答,则认为主机 B 发送的报文段已经丢失,并进行重发。
主机 A 给主机 B 的响应可能由于网络抖动等原因无法到达,那么在经过特定的时间间隔后,主机 A 将重新发送报文段。
主机 A 没有收到主机 B 的应答报文,可能是因为报文段在主机 B 在发送给主机 A 的过程中丢失。
如上图所示,由主机 B 返回的确认应答,由于网络拥堵等原因在传送的过程中丢失,并没有到达主机 A。主机 A 会等待一段时间,如果在这段时间内主机 A 仍没有等到主机 B 的响应,那么主机 A 会重新发送报文段。
要辩证的看待问题,如果主机 A 没有收到应答报文,不一定是主机 A 发送的报文段丢失,还有可能是主机 B 发送的应答报文丢失,还可能是主机 B 没有发送应答报文,所以没有收到报文的情况有很多种。
那么现在就存在一个问题,如果主机 A 给主机 B 发送了一个报文段后,主机 B 接受到报文段发送响应,此刻由于网络原因,这个报文段并未到达,等到一段时间后主机 A 重新发送报文段,然后此时主机 B 发送的响应在主机 A 第二次发送后失序到达主机 A,那么主机 A 应该如何处理呢?
TCP RFC 并未为此做任何规定,也就是说,我们可以自己决定如何处理失序到达的报文段。一般处理方式有两种
- 接收方立刻丢弃失序的报文段。
- 接收方接受时许到达的报文段,并等待后续的报文段。
一般来说通常采取的做法是第二种。
4.3 传输控制
4.3.1 利用窗口控制提高速度
前面我们介绍了 TCP 是以数据段的形式进行发送,如果经过一段时间内主机 A 等不到主机 B 的响应,主机 A 就会重新发送报文段,接受到主机 B 的响应,再会继续发送后面的报文段,我们现在看到,这一问一答的方式还存在许多意外条件,比如响应未收到、等待响应等,那么对崇尚性能的互联网来说,这种方式的性能应该不会很高。
那么如何提升性能呢?
为了解决这个问题,TCP 引入了窗口这个概念,这个窗口大家可以把它理解为发送期,就是说在这个窗口(发送期)中,通信双方可以任意发送数据,也就是说引入窗口后,从之前单次发送变成了一段时间内的多次报文发送。所以,即使在往返时间较长、频次很多的情况下,它也能控制网络性能的下降,如下图所示
我们之前每次请求发送都是以报文段的形式进行的,引入窗口后,每次请求都可以发送多个报文段,也就是说一个窗口可以发送多个报文段。窗口大小就是指无需等待确认应答就可以继续发送报文段的最大值。
在这个窗口机制中,大量使用了缓冲区的实现方式,通过对多个段同时进行确认应答的功能。
如下图所示,发送报文段中高亮部分即是我们提到的窗口,在窗口内,即是没有收到确认应答也可以把请求发送出去。不过,在整个窗口的确认应答没有到达之前,如果部分报文段丢失,那么主机 A 将仍会重传。为此,主机 A 需要设置缓存来保留这些需要重传的报文段,直到收到他们的确认应答。
在滑动窗口以外的部分是尚未发送的报文段和已经接受到的报文段,如果报文段已经收到确认则不可进行重发,此时报文段就可以从缓冲区中清除。
在收到确认的情况下,会将窗口滑动到确认应答中确认号的位置,如上图所示,这样可以顺序的将多个段同时发送,用以提高通信性能,这种窗口也叫做 滑动窗口(Sliding window)
。
4.3.2 窗口控制和重发
报文段的发送和接收,必然伴随着报文段的丢失和重发,窗口也是同样如此,如果在窗口中报文段发送过程中出现丢失怎么办?
首先我们先考虑确认应答没有返回的情况。在这种情况下,主机 A 发送的报文段到达主机 B,是不需要再进行重发的。这和单个报文段的发送不一样,如果发送单个报文段,即使确认应答没有返回,也要进行重发。
窗口在一定程度上比较大时,即使有少部分确认应答的丢失,也不会重新发送报文段。
我们知道,如果在某个情况下由于发送的报文段丢失,导致接受主机未收到请求,或者主机返回的响应未到达客户端的话,会经过一段时间重传报文。那么在使用窗口的情况下,报文段丢失会怎么样呢?
如下图所示,报文段 0 - 999 丢失后,但是主机 A 并不会等待,主机 A 会继续发送余下的报文段,主机 B 发送的确认应答却一直是 1000,同一个确认号的应答报文会被持续不断的返回,如果发送端主机在连续 3 次收到同一个确认应答后,就会将其所对应的数据重发,这种机制要比之前提到的超时重发更加高效,这种机制也被称为高速重发控制。这种重发的确认应答也被称为冗余 ACK(响应)。
主机 B 在没有接收到自己期望序列号的报文段时,会对之前收到的数据进行确认应答。发送端则一旦收到某个确认应答后,又连续三次收到同样的确认应答,那么就会认为报文段已经丢失。需要进行重发。使用这种机制可以提供更为快速的重发服务。
4.4 流量控制
我们知道,在每个 TCP 连接的一侧主机都会有一个 socket 缓冲区,缓冲区会为每个连接设置接收缓存和发送缓存,当 TCP 建立连接后,从应用程序产生的数据就会到达接收方的接收缓冲区中,接收方的应用程序并不一定会马上读取缓冲区的数据,它需要等待操作系统分配时间片。如果此时发送方的应用程序产生数据过快,而接收方读取接受缓冲区的数据相对较慢的话,那么接收方中缓冲区的数据将会溢出,导致数据丢失。
但是还好,TCP 有流量控制服务(flow-control service)机制用于消除缓冲区溢出的情况。流量控制是一个速度匹配服务,即发送方的发送速率与接受方应用程序的读取速率相匹配。
TCP 通过使用一个接收窗口(receive window) 的变量来提供流量控制。接收窗口会给发送方一个指示到底还有多少可用的缓存空间。发送端会根据接收端的实际接受能力来控制发送的数据量。
接收端向发送端通知自己可以接收数据量的大小,发送端会发送不超过这个限度的数据,这个大小限度就是窗口大小,还记得 TCP 的首部么,有一个接收窗口,我们上面聊的时候说这个字段用于流量控制。它用于指示接收方能够接受的字节数量。
那么如何实时知道接收方能够接收的数据量大小呢?
发送端主机会定期发送一个窗口探测包,这个包用于探测接收端主机是否还能够接受数据,当接收端的缓冲区一旦面临数据溢出的风险时,窗口大小的值也随之被设置为一个更小的值通知发送端,从而控制数据发送量。
下面是一个流量控制示意图
发送端主机根据接收端主机的窗口大小进行流量控制。由此也可以防止发送端主机一次发送过大数据导致接收端主机无法处理。
如上图所示,当主机 B 收到报文段 2000 - 2999 之后缓冲区已满,不得不暂时停止接收数据。然后主机 A 发送窗口探测包,窗口探测包非常小仅仅一个字节。然后主机 B 更新缓冲区接收窗口大小并发送窗口更新通知给主机 A,然后主机 A 再继续发送报文段。
在上面的发送过程中,窗口更新通知可能会丢失,一旦丢失发送端就不会发送数据,所以窗口探测包会随机发送,以避免这种情况发生。
4.5 连接管理
在继续介绍下面有意思的特性之前,我们先来把关注点放在 TCP 的连接管理上,因为没有 TCP 连接,也就没有后续的一系列 TCP 特性什么事儿了。假设运行在一台主机上的进程想要和另一台主机上的进程建立 TCP 连接,会经过如下步骤:
我们假设此时有一台客户端主机和一台服务端主机进行通信。
-
首先,客户端首先向服务器发送一个特殊的 TCP 报文段。这个报文段首部不包含数据,但是在报文段的首部中有一个 SYN 标志位被置为 1,这个报文段也可以叫做 SYN 报文段。客户端主机随机选择一个
初始序列号(client_isn)
,并将此数字放入初始 TCP SYN 段的序列号字段中发送给服务器。 -
一旦此报文到达服务器后,服务器会从报文中提取 TCP SYN 段,将 TCP 缓冲区和变量进行分配,然后给客户端回送一个报文段,这个报文段也不包含任何数据,只做通知的作用。不过它却包含了三个非常重要的信息。
这些缓冲区和变量的分配使 TCP 容易受到称为 SYN 泛洪的拒绝服务攻击。
- 首先,SYN 比特被置为 1 。
- 然后,TCP 报文段的首部确认号被设置为
client_isn + 1
,也就是 ACK = SYN + 1。 - 最后,服务器选择自己的
初始序号(server_isn)
SYN ,并将其放置到 TCP 报文段首部的序号字段中。
如果用大白话解释下就是,我收到了你发起建立连接的 SYN 报文段,这个报文段具有首部字段 client_isn。我同意建立该连接,我自己的初始序号是 server_isn。这个允许连接的报文段被称为
SYNACK 报文段
。 -
第三步,在收到 SYNACK 报文段后,客户端也要为该连接分配缓冲区和变量。客户端向服务器发送另外一个报文段,最后一个报文段对服务器发送的响应报文做了确认,确认的标准是客户端发送的数据段中确认号为 server_isn + 1,因为连接已经建立,所以 SYN 比特被置为 0 。以上就是 TCP 建立连接的三次数据段发送过程,也被称为
三次握手
。
一旦完成这三个步骤,客户和服务器主机就可以相互发送报文段了,在以后的每一个报文段中,SYN 比特都被置为 0 ,整个过程描述如下图所示
在客户端主机和服务端主机建立连接后,参与一条 TCP 连接的两个进程中的任何一个都能终止 TCP 连接。连接结束后,主机中的缓存和变量将会被释放。假设客户端主机想要终止 TCP 连接,它会经历如下过程:
客户应用进程发出一个关闭命令,客户 TCP 向服务器进程发送一个特殊的 TCP 报文段,这个特殊的报文段的首部标志 FIN 被设置为 1 。当服务器收到这个报文段后,就会向发送方发送一个确认报文段。然后,服务器发送它自己的终止报文段,FIN 位被设置为 1 。客户端对这个终止报文段进行确认。此时,在两台主机上用于该连接的所有资源都被释放了,如下图所示
在一个 TCP 连接的生命周期内,运行在每台主机中的 TCP 协议都会在各种 TCP 状态(TCP State)
之间进行变化,TCP 的状态主要有 LISTEN、SYN-SEND、SYN-RECEIVED、ESTABLISHED、FIN-WAIT-1、FIN-WAIT-2、CLOSE-WAIT、CLOSING、LAST-ACK、TIME-WAIT 和 CLOSED 。这些状态的解释如下
LISTEN
: 表示等待任何来自远程 TCP 和端口的连接请求。SYN-SEND
: 表示发送连接请求后等待匹配的连接请求。SYN-RECEIVED
: 表示已接收并发送连接请求后等待连接确认,也就是 TCP 三次握手中第二步后服务端的状态ESTABLISHED
: 表示已经连接已经建立,可以将应用数据发送给其他主机
上面这四种状态是 TCP 三次握手所涉及的。
FIN-WAIT-1
: 表示等待来自远程 TCP 的连接终止请求,或者等待先前发送的连接终止请求的确认。FIN-WAIT-2
: 表示等待来自远程 TCP 的连接终止请求。CLOSE-WAIT
: 表示等待本地用户的连接终止请求。CLOSING
: 表示等待来自远程 TCP 的连接终止请求确认。-
LAST-ACK
: 表示等待先前发送给远程 TCP 的连接终止请求的确认(包括对它的连接终止请求的确认)。 TIME-WAIT
: 表示等待足够的时间以确保远程 TCP 收到其连接终止请求的确认。CLOSED
: 表示连接已经关闭,无连接状态。
上面 7 种状态是 TCP 四次挥手,也就是断开链接所设计的。
TCP 的连接状态会进行各种切换,这些 TCP 连接的切换是根据事件进行的,这些事件由用户调用:OPEN、SEND、RECEIVE、CLOSE、ABORT 和 STATUS。涉及到 TCP 报文段的标志有 SYN、ACK、RST 和 FIN ,当然,还有超时。
我们下面加上 TCP 连接状态后,再来看一下三次握手和四次挥手的过程。
4.5.1 三次握手建立连接
下图画出了 TCP 连接建立的过程。假设图中左端是客户端主机,右端是服务端主机,一开始,两端都处于CLOSED(关闭)
状态。
- 服务端进程准备好接收来自外部的 TCP 连接,一般情况下是调用 bind、listen、socket 三个函数完成。这种打开方式被认为是
被动打开(passive open)
。然后服务端进程处于LISTEN
状态,等待客户端连接请求。 - 客户端通过
connect
发起主动打开(active open)
,向服务器发出连接请求,请求中首部同步位 SYN = 1,同时选择一个初始序号 sequence ,简写 seq = x。SYN 报文段不允许携带数据,只消耗一个序号。此时,客户端进入SYN-SEND
状态。 - 服务器收到客户端连接后,,需要确认客户端的报文段。在确认报文段中,把 SYN 和 ACK 位都置为 1 。确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y。请注意,这个报文段也不能携带数据,但同样要消耗掉一个序号。此时,TCP 服务器进入
SYN-RECEIVED(同步收到)
状态。 - 客户端在收到服务器发出的响应后,还需要给出确认连接。确认连接中的 ACK 置为 1 ,序号为 seq = x + 1,确认号为 ack = y + 1。TCP 规定,这个报文段可以携带数据也可以不携带数据,如果不携带数据,那么下一个数据报文段的序号仍是 seq = x + 1。这时,客户端进入
ESTABLISHED (已连接)
状态 - 服务器收到客户的确认后,也进入
ESTABLISHED
状态。
TCP 建立一个连接需要三个报文段,释放一个连接却需要四个报文段。
4.5.2 四次挥手
数据传输结束后,通信的双方可以释放连接。数据传输结束后的客户端主机和服务端主机都处于 ESTABLISHED 状态,然后进入释放连接的过程。
TCP 断开连接需要历经的过程如下:
- 客户端应用程序发出释放连接的报文段,并停止发送数据,主动关闭 TCP 连接。客户端主机发送释放连接的报文段,报文段中首部 FIN 位置为 1 ,不包含数据,序列号位 seq = u,此时客户端主机进入
FIN-WAIT-1(终止等待 1)
阶段。 - 服务器主机接受到客户端发出的报文段后,即发出确认应答报文,确认应答报文中 ACK = 1,生成自己的序号位 seq = v,ack = u + 1,然后服务器主机就进入
CLOSE-WAIT(关闭等待)
状态,这个时候客户端主机 -> 服务器主机这条方向的连接就释放了,客户端主机没有数据需要发送,此时服务器主机是一种半连接的状态,但是服务器主机仍然可以发送数据。 - 客户端主机收到服务端主机的确认应答后,即进入
FIN-WAIT-2(终止等待2)
的状态。等待客户端发出连接释放的报文段。 - 当服务器主机没有数据发送后,应用进程就会通知 TCP 释放连接。这时服务端主机会发出断开连接的报文段,报文段中 ACK = 1,序列号 seq = w,因为在这之间可能已经发送了一些数据,所以 seq 不一定等于 v + 1。ack = u + 1,在发送完断开请求的报文后,服务端主机就进入了
LAST-ACK(最后确认)
的阶段。 - 客户端收到服务端的断开连接请求后,客户端需要作出响应,客户端发出断开连接的报文段,在报文段中,ACK = 1, 序列号 seq = u + 1,因为客户端从连接开始断开后就没有再发送数据,ack = w + 1,然后进入到
TIME-WAIT(时间等待)
状态,请注意,这个时候 TCP 连接还没有释放。必须经过时间等待的设置,也就是2MSL
后,客户端才会进入CLOSED
状态,时间 MSL 叫做最长报文段寿命(Maximum Segment Lifetime)。 - 服务端主要收到了客户端的断开连接确认后,就会进入 CLOSED 状态。因为服务端结束 TCP 连接时间要比客户端早,而整个连接断开过程需要发送四个报文段,因此释放连接的过程也被称为四次挥手。
TCP 连接的任意一方都可以发起关闭操作,只不过通常情况下发起关闭连接操作一般都是客户端。然而,一些服务器比如 Web 服务器在对请求作出相应后也会发起关闭连接的操作。TCP 协议规定通过发送一个 FIN 报文来发起关闭操作。
所以综上所述,建立一个 TCP 连接需要三个报文段,而关闭一个 TCP 连接需要四个报文段。TCP 协议还支持一种半开启(half-open)状态,虽然这种情况并不多见。
4.5.3 TCP 半开启
TCP 连接处于半开启的这种状态是因为连接的一方关闭或者终止了这个 TCP 连接却没有通知另一方,也就是说两个人正在微信聊天,cxuan 你下线了你不告诉我,我还在跟你侃八卦呢。此时就认为这条连接处于半开启状态。这种情况发生在通信中的一方处于主机崩溃的情况下,人家发送方还是有理由说的啊,你 xxx 的,我电脑死机了我咋告诉你?只要处于半连接状态的一方不传输数据的话,那么是无法检测出来对方主机已经下线的。
另外一种处于半开启状态的原因是通信的一方关闭了主机电源 而不是正常关机。这种情况下会导致服务器上有很多半开启的 TCP 连接。
4.5.4 TCP 半关闭
既然 TCP 支持半开启操作,那么我们可以设想 TCP 也支持半关闭操作。同样的,TCP 半关闭也并不常见。TCP 的半关闭操作是指仅仅关闭数据流的一个传输方向。两个半关闭操作合在一起就能够关闭整个连接。在一般情况下,通信双方会通过应用程序互相发送 FIN 报文段来结束连接,但是在 TCP 半关闭的情况下,应用程序会表明自己的想法:”我已经完成了数据的发送发送,并发送了一个 FIN 报文段给对方,但是我依然希望接收来自对方的数据直到它发送一个 FIN 报文段给我”。 下面是一个 TCP 半关闭的示意图。
解释一下这个过程:
首先客户端主机和服务器主机一直在进行数据传输,一段时间后,客户端发起了 FIN 报文,要求主动断开连接,服务器收到 FIN 后,回应 ACK ,由于此时发起半关闭的一方也就是客户端仍然希望服务器发送数据,所以服务器会继续发送数据,一段时间后服务器发送另外一条 FIN 报文,在客户端收到 FIN 报文回应 ACK 给服务器后,断开连接。
TCP 的半关闭操作中,连接的一个方向被关闭,而另一个方向仍在传输数据直到它被关闭为止。只不过很少有应用程序使用这一特性。
4.5.5 同时打开和同时关闭
还有一种比较非常规的操作,这就是两个应用程序同时主动打开连接。虽然这种情况看起来不太可能,但是在特定的安排下却是有可能发生的。我们主要讲述这个过程。
通信双方在接收到来自对方的 SYN 之前会首先发送一个 SYN,这个场景还要求通信双方都知道对方的 IP 地址 + 端口号。
下面是同时打开的例子
如上图所示,通信双方都在收到对方报文前主动发送了 SYN 报文,都在收到彼此的报文后回复了一个 ACK 报文。
一个同时打开过程需要交换四个报文段,比普通的三次握手增加了一个,由于同时打开没有客户端和服务器一说,所以这里我用了通信双方来称呼。
像同时打开一样,同时关闭也是通信双方同时提出主动关闭请求,发送 FIN 报文,下图显示了一个同时关闭的过程。
同时关闭过程中需要交换和正常关闭相同数量的报文段,只不过同时关闭不像四次挥手那样顺序进行,而是交叉进行的。
4.5.6 聊一聊初始序列号
也许是我上面图示或者文字描述的不专业,初始序列号它是有专业术语表示的,初始序列号的英文名称是Initial sequence numbers (ISN),所以我们上面表示的 seq = v,其实就表示的 ISN。
在发送 SYN 之前,通信双方会选择一个初始序列号。初始序列号是随机生成的,每一个 TCP 连接都会有一个不同的初始序列号。RFC 文档指出初始序列号是一个 32 位的计数器,每 4 us(微秒) + 1。因为每个 TCP 连接都是一个不同的实例,这么安排的目的就是为了防止出现序列号重叠的情况。
当一个 TCP 连接建立的过程中,只有正确的 TCP 四元组和正确的序列号才会被对方接收。这也反应了 TCP 报文段容易被伪造
的脆弱性,因为只要我伪造了一个相同的四元组和初始序列号就能够伪造 TCP 连接,从而打断 TCP 的正常连接,所以抵御这种攻击的一种方式就是使用初始序列号,另外一种方法就是加密序列号。
4.5.7 什么是 TIME-WAIT
我上面只是简单提到了一下 TIME-WAIT 状态和 2MSL 是啥,下面来聊一下这两个概念。
MSL
是 TCP 报文段可以存活或者驻留在网络中的最长时间。RFC 793 定义了 MSL 的时间是两分钟,但是具体的实现还要根据程序员来指定,一些实现采用了 30 秒的这个最大存活时间。
那么为什么要等待 2MSL
呢?
主要是因为两个理由
- 为了保证最后一个响应能够到达服务器,因为在计算机网络中,最后一个 ACK 报文段可能会丢失,从而致使客户端一直处于
LAST-ACK
状态等待客户端响应。这时候服务器会重传一次 FINACK 断开连接报文,客户端接收后再重新确认,重启定时器。如果客户端不是 2MSL ,在客户端发送 ACK 后直接关闭的话,如果报文丢失,那么双方主机会无法进入 CLOSED 状态。 - 还可以防止
已失效
的报文段。客户端在发送最后一个 ACK 之后,再经过经过 2MSL,就可以使本链接持续时间内所产生的所有报文段都从网络中消失。从保证在关闭连接后不会有还在网络中滞留的报文段去骚扰服务器。
这里注意一点:在服务器发送了 FIN-ACK 之后,会立即启动超时重传计时器。客户端在发送最后一个 ACK 之后会立即启动时间等待计时器。
4.5.8 说好的 RST 呢
说好的 RST
、SYN
、FIN
标志用于连接的建立和关闭,那么 SYN 和 FIN 都现身了,那 RST 呢?也是啊,我们上面探讨的都是一种理想的情况,就是客户端服务器双方都会接受传输报文段的情况,还有一种情况是当主机收到 TCP 报文段后,其 IP 和端口号不匹配的情况。假设客户端主机发送一个请求,而服务器主机经过 IP 和端口号的判断后发现不是给这个服务器的,那么服务器就会发出一个 RST
特殊报文段给客户端。
因此,当服务端发送一个 RST 特殊报文段给客户端的时候,它就会告诉客户端没有匹配的套接字连接,请不要再继续发送了。
上面探讨的是 TCP 的情况,那么 UDP 呢?
使用 UDP 作为传输协议后,如果套接字不匹配的话,UDP 主机就会发送一个特殊的 ICMP 数据报。
4.5.9 SYN 洪泛攻击
下面我们来讨论一下什么是 SYN 洪泛攻击。
我们在 TCP 的三次握手中已经看到,服务器为了响应一个收到的 SYN,分配并初始化变量连接和缓存,然后服务器发送一个 SYNACK 作为响应,然后等待来自于客户端的 ACK 报文。如果客户端不发送 ACK 来完成最后一步的话,那么这个连接就处在一个挂起的状态,也就是半连接状态。
攻击者通常在这种情况下发送大量的 TCP SYN 报文段,服务端继续响应,但是每个连接都完不成三次握手的步骤。随着 SYN 的不断增加,服务器会不断的为这些半开连接分配资源,导致服务器的连接最终被消耗殆尽。这种攻击也是属于 Dos
攻击的一种。
抵御这种攻击的方式是使用 SYN cookie
,下面是它的工作流程介绍
- 当服务器收到一个 SYN 报文段时,它并不知道这个报文段是来自哪里,是来自攻击者主机还是客户端主机(虽然攻击者也是客户端,不过这么说更便于区分) 。因此服务器不会为报文段生成一个半开连接。与此相反,服务器生成一个初始的 TCP 序列号,这个序列号是 SYN 报文段的源和目的 IP 地址与端口号这个四元组构造的一个复杂的散列函数,这个散列函数生成的 TCP 序列号就是
SYN Cookie
,用于缓存 SYN 请求。然后,服务器会发送带着 SYN Cookie 的 SYNACK 分组。有一点需要注意的是,服务器不会记忆这个 Cookie 或 SYN 的其他状态信息。 - 如果客户端不是攻击者的话,它就会返回一个 ACK 报文段。当服务器收到这个 ACK 后,需要验证这个 ACK 与 SYN 发送的是否相同,验证的标准就是确认字段中的确认号和序列号,源和目的 IP 地址与端口号以及和散列函数的是否一致,散列函数的结果 + 1 是否和 SYNACK 中的确认值相同。(大致是这样,说的不对还请读者纠正) 。如果有兴趣读者可以自行深入了解。如果是合法的,服务器就会生成一个具有套接字的全开连接。
- 如果客户端没有返回 ACK,即认为是攻击者,那么这样也没关系,服务器没有收到 ACK,不会分配变量和缓存资源,不会对服务器产生危害。
4.6 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 状态。
这两张图一起看会比较好一些。
这里需要解释下什么是 RST。
这里有一种情况是当主机收到 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 是服务端在关闭前的最后一个状态,它是一种被动打开的状态。
4.7 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 的值可以通过应用程序来设置,或者是修改 net.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\Parameters
HKLM\System\CurrentControlSet\Services\Tcpip6\Parameters
其中有一个非常重要的变量就是 TcpMaxDataRetransmissions
,这个 TcpMaxDataRetransmissions 对应 Linux 中的 tcp_retries2 变量,默认值是 5。这个值的意思表示的是 TCP 在现有连接上未确认数据段的次数。
4.7.1 快速重传
我们上面提到了快速重传,实际上快速重传机制是基于接收端的反馈信息来触发的,它并不受重传计时器的影响。所以与超时重传相比,快速重传能够有效的修复丢包
情况。当 TCP 连接的过程中接收端出现乱序的报文(比如 2 - 4 - 3)到达时,TCP 需要立刻
生成确认消息,这种确认消息也被称为重复 ACK。
当失序报文到达时,重复 ACK 要做到立刻返回,不允许延迟发送,此举的目的是要告诉发送方某段报文失序到达了,希望发送方指出失序报文段的序列号。
还有一种情况也会导致重复 ACK 发给发送方,那就是当前报文段的后续报文发送至接收端,由此可以判断当前发送方的报文段丢失或者延迟到达。因为这两种情况导致的后果都是接收方没有收到报文,但是我们却无法判断到底是报文段丢失还是报文段没有送达。因此 TCP 发送端会等待一定数目的重复 ACK 被接受来决定数据是否丢失并触发快速重传。一般这个判断的数量是 3,这段文字表述可能无法清晰理解,我们举个例子。
如上图所示,报文段 1 成功接收并被确认为 ACK 2,接收端的期待序号为 2,当报文段 2 丢失后,报文段 3。失序到达,但是与接收端的期望不匹配,所以接收端会重复发送冗余 ACK 2。
这样,在超时重传定时器到期之前,接收收到连续三个相同的 ACK 后,发送端就知道哪个报文段丢失了,于是发送方会重发这个丢失的报文段,这样就不用等待重传定时器的到期,大大提高了效率。
4.7.2 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 段中。
4.7.3 伪超时和重传
在某些情况下,即使没有出现报文段的丢失也可能会引发报文重传。这种重传行为被称为 伪重传(spurious retransmission) ,这种重传是没有必要的,造成这种情况的因素可能是由于伪超时(spurious timeout),伪超时的意思就是过早的判定超时发生。造成伪超时的因素有很多,比如报文段失序到达,报文段重复,ACK 丢失等情况。
检测和处理伪超时的方法有很多,这些方法统称为检测
算法和响应
算法。检测算法用于判断是否出现了超时现象或出现了计时器的重传现象。一旦出现了超时或者重传的情况,就会执行响应算法撤销或者减轻超时带来的影响,下面是几种算法,此篇文章暂不深入这些实现细节
- 重复 SACK 扩展- DSACK
- Eifel 检测算法
- 前移 RTO 恢复 - F-RTO
- Eifel 响应算法
4.7.4 包失序和包重复
上面我们讨论的都是 TCP 如何处理丢包的问题,我们下面来讨论一下包失序和包重复的问题。
4.7.5 包失序
数据包的失序到达是互联网中极其容易出现的一种情况,由于 IP 层并不能保证数据包的有序性,每个数据包的发送都可能会选择当前情况传输速度最快的链路,所以很有可能出现发送了 A - > B -> C 的三个数据包,到达接收端的数据包顺序是 C -> A -> B 或者 B -> C -> A 等等。这就是包失序的一种现象。
在包传输中,主要分为两种链路:正向链路(SYN)和反向链路(ACK)
如果失序发生在正向链路,TCP 是无法正确判断数据包是否丢失的,数据的丢失和失序都会导致接收端收到无序的数据包,造成数据之间的空缺。如果这种空缺不够大的话,这种情况影响不大;但是如果空缺比较大的话,可能会导致伪重传。
如果失序发生在反向链路,就会使 TCP 的窗口前移,然后收到重复而应该被丢弃的 ACK,导致发送端出现不必要的流量突发,影响可用网络带宽。
回到我们上面讨论的快速重传,由于快速重传是根据重复 ACK 推断出现丢包而启动的,它不用等到重传计时器超时。由于 TCP 接收端会对接收到的失序报文立刻返回 ACK,所以网络中任何一个失序到达的报文都可能会造成重复 ACK。假设一旦收到 ACK,就会启动快速重传机制,当 ACK 数量激增,就会导致大量不必要的重传发生,所以快速重传应该达到重复阈值(dupthresh) 再触发。但是在互联网中,严重的失序并不常见,因此 dupthresh 的值可以设置的尽量小,一般来说 3 就能处理绝大部分情况。
4.7.6 包重复
包重复也是互联网中出现很少的一种情况,它指的是在网络传输过程中,包可能会出现传输多次的情况,当重传生成时,TCP 可能会出现混淆。
包的重复可以使接收端生成一系列的重复 ACK,这种情况可以使用 SACK 协商来解决。
4.8 TCP 数据流和窗口管理
我们在上面的讲述中知道了可以使用滑动窗口来实现流量控制,也就是说,客户端和服务器可以相互提供数据流信息的交换,数据流的相关信息主要包括报文段序列号、ACK 号和窗口大小。
图中的两个箭头表示数据流方向,数据流方向也就是 TCP 报文段的传输方向。可以看到,每个 TCP 报文段中都包括了序列号、ACK 和窗口信息,可能还会有用户数据。TCP 报文段中的窗口大小表示接收端还能够接收的缓存空间的大小,以字节为单位。这个窗口大小是一种动态的,因为无时无刻都会有报文段的接收和消失,这种动态调整的窗口大小我们称之为滑动窗口
,下面我们就来具体认识一下滑动窗口。
4.8.1 滑动窗口
TCP 连接的每一端都可以发送数据,但是数据的发送不是没有限制的,实际上,TCP 连接的两端都各自维护了一个发送窗口结构 (send window structure) 和 接收窗口结构 (receive window structure),这两个窗口结构就是数据发送的限制。
4.8.2 发送方窗口
下图是一个发送方窗口的示例。
在这幅图中,涉及滑动窗口的四种概念:
- 已经发送并确认的报文段:发送给接收方后,接收方回回复 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 发送方会发起窗口探测
,等待合适的时机再发送数据。
4.8.3 接收方窗口
接收方也维护了一个窗口结构,这个窗口要比发送方的简单很多。这个窗口记录了已经接收并确认的数据,以及它能够接收的最大序列号。接收方的窗口结构不会存储重复的报文段和 ACK,同时接收方的窗口也不会记录不应该收到的报文段和 ACK。下面是 TCP 接收方的窗口结构。
与发送端的窗口一样,接收方窗口结构也维护了一个 Left edge 和 Right edge。位于 Left edge 左边的被称为已经接收并确认的报文段,位于 Right edge 右边的被称为不能接收的报文段。
对于接收端来说,到达序列号小于 Left efge 的被认为是已经重复的数据,需要丢弃。超过 Right edge 的被认为超出处理范围。只有当到达的报文段等于 Left edge 时,数据才不会被丢弃,窗口才能够向前滑动。
接收方窗口结构也会存在零窗口的情况,如果某个应用进程消耗数据很慢,而 TCP 发送方却发送了大量的数据给接收方,会造成 TCP 缓冲区溢出,通告发送方不要再发送数据了,但是应用进程却以非常慢的速度消耗缓冲区的数据(比如 1 字节),就会告诉接收端只能发送一个字节的数据,这个过程慢慢持续,造成网络开销大,效率很低。
我们上面提到了窗口存在 Left edge = Right edge 的情况,此时被称为零窗口,下面我们就来具体研究一下零窗口。
4.8.4 零窗口
TCP 是通过接收端的窗口通告信息来实现流量控制的。通告窗口告诉了 TCP ,接收端能够接收的数据量。当接收方的窗口变为 0 时,可以有效的阻止发送端继续发送数据。当接收端重新获得可用空间时,它会给发送端传输一个 窗口更新
告知自己能够接收数据了。窗口更新一般是纯 ACK ,即不带任何数据。但是纯 ACK 不能保证一定会到达发送端,于是需要有相关的措施能够处理这种丢包。
如果纯 ACK 丢失的话,通信双方就会一直处于等待状态,发送方心想拉垮的接收端怎么还让我发送数据!接收端心想天杀的发送方怎么还不发数据!为了防止这种情况,发送方会采用一个持续计时器来间歇性的查询接收方,看看其窗口是否已经增长。持续计时器会触发窗口探测
,强制要求接收方返回带有更新窗口的 ACK。
窗口探测包含一个字节的数据,采用的是 TCP 丢失重传的方式。当 TCP 持续计时器超时后,就会触发窗口探测的发送。一个字节的数据能否被接收端接收,还要取决于其缓冲区的大小。
4.9 拥塞控制
有了 TCP 的窗口控制后,使计算机网络中两个主机之间不再是以单个数据段的形式发送了,而是能够连续发送大量的数据包。然而,大量数据包同时也伴随着其他问题,比如网络负载、网络拥堵等问题。TCP 为了防止这类问题的出现,使用了 拥塞控制
机制,拥塞控制机制会在面临网络拥塞时遏制发送方的数据发送。
拥塞控制主要有两种方法
端到端的拥塞控制
: 因为网络层没有为运输层拥塞控制提供显示支持。所以即使网络中存在拥塞情况,端系统也要通过对网络行为的观察来推断。TCP 就是使用了端到端的拥塞控制方式。IP 层不会向端系统提供有关网络拥塞的反馈信息。那么 TCP 如何推断网络拥塞呢?如果超时或者三次冗余确认就被认为是网络拥塞,TCP 会减小窗口的大小,或者增加往返时延来避免。网络辅助的拥塞控制
: 在网络辅助的拥塞控制中,路由器会向发送方提供关于网络中拥塞状态的反馈。这种反馈信息就是一个比特信息,它指示链路中的拥塞情况。
4.9.1 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 拥塞控制算法主要包含三个部分:慢启动、拥塞避免、快速恢复,下面我们依次来看一下
4.9.2 慢启动
当一条 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 就会执行一种快速重传并进入恢复状态。
4.9.3 拥塞避免
当 TCP 进入拥塞控制状态后,cwnd 的值就等于拥塞时值的一半,也就是 ssthresh 的值。所以,无法每次报文段到达后都将 cwnd 的值再翻倍。而是采用了一种相对保守
的方式,每次传输完成后只将 cwnd 的值增加一个 MSS
,比如收到了 10 个报文段的确认,但是 cwnd 的值只增加一个 MSS。这是一种线性增长模式,它也会有增长逾值,它的增长逾值和慢启动一样,如果出现丢包,那么 cwnd 的值就是一个 MSS,ssthresh 的值就等于 cwnd 的一半;或者是收到 3 个冗余的 ACK 响应也能停止 MSS 增长。如果 TCP 将 cwnd 的值减半后,仍然会收到 3 个冗余 ACK,那么就会将 ssthresh 的值记录为 cwnd 值的一半,进入 快速恢复
状态。
4.9.4 快速恢复
在快速恢复中,对于使 TCP 进入快速恢复状态缺失的报文段,对于每个收到的冗余 ACK,cwnd 的值都会增加一个 MSS 。当对丢失报文段的一个 ACK 到达时,TCP 在降低 cwnd 后进入拥塞避免状态。如果在拥塞控制状态后出现超时,那么就会迁移到慢启动状态,cwnd 的值被设置为 1 个 MSS,ssthresh 的值设置为 cwnd 的一半。
5 后记
这篇文章很长,主要介绍了传输层的相关概念,传输层的作用是什么,以及传输层两个核心协议 UDP 和 TCP 的特点,它们之间的区别,场景选型,以及它们的基本原理。
本篇文章需要理解的概念和原理比较多,需要多读几遍。
如果你在阅读文章的过程中发现错误和问题,请及时与我联系!
如果文章对你有帮助,希望小伙伴们三连走起!