TCP 协议
- 一、传输层
- 1. 再谈端口号
- 2. 端口号范围划分
- 3. 进程和端口号
- 4. netstat
- 5. pidof
- 二、UDP 协议
- 1. UDP 协议端格式(报文)
- 2. UDP 的特点
- 3. 面向数据报
- 4. UDP 的缓冲区
- 三、TCP 协议
- 1. 认识 TCP
- 2. TCP 协议段格式
- (1)4 位首部长度
- (2)16 位窗口大小
- (3)32 位序号和 32 位确认序号
- (4)6 个标记位
- a. ACK
- b. SYN
- c. FIN
- d. PSH
- e. RST
- f. URG && 16位紧急指针
- 3. 确认应答(ACK)机制
- 4. 超时重传机制
- 5. 连接管理机制
- (1)三次握手
- (2)四次挥手
- (3)为什么需要三次握手?
- (4)为什么需要四次挥手?
- (5)三次握手的状态
- (6)四次挥手的状态
- 6. 流量控制
- 7. 滑动窗口
- (1)丢包问题
- (2)滑动窗口移动问题
- 8. 延迟应答
- 9. 捎带应答
- 10. 拥塞控制
- 11. TCP 小结
- 四、面向字节流
- 五、粘包问题
- 六、TCP 异常情况
一、传输层
1. 再谈端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序。在 TCP/IP 协议中, 用 “源IP”, “源端口号”, “目的IP”, “目的端口号”, “协议号” 这样一个五元组来标识一个通信(可以通过netstat -n查看)。
2. 端口号范围划分
0 - 1023: 知名端口号,HTTP, FTP, SSH 等这些广为使用的应用层协议,它们的端口号都是固定的。
- ssh服务器, 使用22端口
- ftp服务器, 使用21端口
- telnet服务器, 使用23端口
- http服务器, 使用80端口
- https服务器, 使用443
在 Linux 中,可以查看 /etc/services
下的文件,查看知名端口号。
1024 - 65535: 操作系统动态分配的端口号。客户端程序的端口号,就是由操作系统从这个范围分配的。
3. 进程和端口号
一个进程是否可以 bind 多个端口号?可以,因为一个进程当中对于端口的绑定是和 socket 强相关,理论上该进程如果创建多个 socket,可以给每一个 socket 都进行绑定一个端口。
但是一个端口号不能被多个进程 bind.
4. netstat
netstat 是一个用来查看网络状态的重要工具。
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服务状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
5. pidof
在查看服务器的进程 id 时非常方便。
语法:pidof [进程名]
功能:通过进程名, 查看进程 id
假设我们需要获得所有名为 httpd 的进程 id,可以使用 ps axj | head -1 && ps axj | grep httpd | awk '{print $2}'
,然后如果想把这些 pid 全部 kill 掉,可以使用 ps axj | grep httpd | awk '{print $2}' | xargs kill -9
,其中 xargs
表示将管道的多行信息按行为单位以命令行参数的形式交到 kill -9
的后面。
以上查看 httpd 的进程 id 的方式非常麻烦,所以我们可以使用 pidof + 进程名直接获取!
二、UDP 协议
1. UDP 协议端格式(报文)
UDP 报文的格式就上图,很简单,使用定长报头将报头和有效载荷进行分离。那么通过目的端口号可以知道将报文的有效载荷交付给上层的哪个协议。
- 16位UDP长度,表示整个数据报(UDP首部+UDP数据)的最大长度;
- 如果校验和出错,就会直接丢弃;
我们注意到,UDP 协议首部中有一个16位的最大长度,也就是说一个 UDP 能传输的数据最大长度是64K(包含UDP首部)。那么如果发送的数据大于 64K,那么就要在应用层中将该数据拆分成许多个 64K 进行传输了。
2. UDP 的特点
- 无连接:知道对端的IP和端口号就直接进行传输,不需要建立连接;
- 不可靠:没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP 协议层也不会给应用层返回任何错误信息;
- 面向数据报:不能够灵活的控制读写数据的次数和数量;
3. 面向数据报
应用层交给 UDP 多长的报文,UDP 原样发送,既不会拆分,也不会合并。
假设用 UDP 传输100个字节的数据,如果发送端调用一次 sendto,发送100个字节,那么接收端也必须调用对应的一次 recvfrom,接收100个字节;而不能循环调用10次 recvfrom,每次接收10个字节。
4. UDP 的缓冲区
UDP 没有真正意义上的发送缓冲区。调用 sendto 会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作;
UDP 具有接收缓冲区,但是这个接收缓冲区不能保证收到的 UDP 报的顺序和发送 UDP 报的顺序一致,如果数据发生了乱序,也照样向上交付;另外如果缓冲区满了, 再到达的 UDP 数据就会被丢弃。
UDP 的 socket 既能读,也能写,这个概念叫做全双工。
三、TCP 协议
1. 认识 TCP
TCP 全称为 “传输控制协议(Transmission Control Protocol”)。人如其名,要对数据的传输进行一个详细的控制。
TCP 协议在操作系统内部是存在自己的发送缓冲区和接收缓冲区,每建立一个连接,就会在操作系统内部建立对应的发送缓冲区和接收缓冲区。而我们自己定义的缓冲区称为用户级缓冲区,例如我们自己定义的 char buffer[]
,我们调用 write、read
等接口就需要传入我们自己的缓冲区,要么就是写入,要么就是读取。那么当我们调用这些系统接口的时候,write
本质不是把数据发送到网络中,本质是将我们缓冲区的数据拷贝到 TCP 的发送缓冲区中!此时我们的工作就已经做完了,数据什么时候发送、发送多少,出错怎么办都是由 TCP 协议自主决定!
这一套和我们之前学的文件系统一模一样!以前我们学的文件也有文件缓冲区,操作系统也会定期将文件缓冲区中的内容刷新到磁盘中,只不过现在是将磁盘换成了网卡!
假设我们是客户端,向服务端发送数据,那么服务端的底层也是 TCP 的时候,也会有对应的发送缓冲区和接收缓冲区,那么我们发送数据的时候,本质就是将数据从我们的发送缓冲区通过网络拷贝到服务端的接收缓冲区!
2. TCP 协议段格式
我们知道,在传输层我们将数据包称为数据段。所以我们先看一下 TCP 协议段格式:
首先我们先思考第一个问题,报头和有效载荷如何分离呢?如果交付给上层呢?首先将 TCP 分为三个部分,我们称 TCP 报头中前 20 个字节为标准报头;选项部分我们可以忽略;最后一部分为 TCP 的有效载荷。至于如何交付给上层的问题,我们在 UDP 协议中提过,通过源端口号和目的端口号即可确认。
(1)4 位首部长度
那么报头和有效载荷如何分离呢?我们认识一下 TCP 报头中的 4位首部长度,也就是除了有效载荷之外的长度。那么这个 4位首部长度,它的取值范围是从 0000 ~ 1111,最多也就表示 15,而标准报头的长度已经是 20 个字节了,怎么能表示超过 15 个字节以上的长度呢?其实这个 4位首部长度 在计算的时候,是有基本的大小单位的,就是 4字节,所以上面计算的结果需要乘上这个基本单位,也就是首部长度的取值范围是 0 ~ 60 字节。而标准报头的长度位 20 个字节,所以选项的长度为 40 个字节。如果我们忽略选项,那么 4位首部长度 应该填多少呢?那么我们假设 4位首部长度 为 x,x 乘以基本单位 4 字节,假设我们的标准长度为 20 字节,因为忽略了选项,所以就是 x * 4 = 20,那么 x = 5,用二进制表示就是 0101,所以忽略选项的时候,4位首部长度 应该填的是 0101. 所以这个 4位首部长度 就可以准确地帮我们把报头从整个报文里去掉,也就是做到了报头和有效载荷分离!我们称为这个 4位首部长度 称为自描述字段,所以 TCP 是通过固定长度 + 自描述字段做到的!
(2)16 位窗口大小
在客户端和服务端进行通信的时候,在应用层下来的报文,在传输层需要封装对应的报头,然后再传给下层继续封装对应层的报头,最后再进行通信,所以,客户端和服务端基于 TCP 协议进行通信的时候,发送的可是完整的 TCP 报头,一定要携带完整的 TCP 报头!
如果客户端一直在给服务端在发数据的时候,数据就一直拷贝到服务端的接收缓冲区,此时如果服务端的应用层读取的非常慢,就会导致服务端的接收缓冲区很快被打满,此时发送方的数据就会出现丢包的情况;这时候服务端就需要想办法让客户端发慢一点或者不发!这种由发送方向服务端发数据,通过控制发送数据的速度来让对方能来得及接收,从而规避大面积丢包的情况,称为流量控制!由于 TCP 是传输控制,所以它注定要解决这个问题,所以就需要控制客户端发送的数据量和服务端读取的数据量。
下面我们再进一步,我们知道 TCP 是具有可靠性的,那么它靠什么保证可靠性呢?最基本的一个特点就是:确认应答机制。也就是只要是客户端给服务端发消息,即便是服务端没有数据发给客户端,服务端也要对该客户端发的信息进行确认应答!
所以当客户端在给服务端发送一条消息的时候,服务端会立即给客户端一个确认应答,无论是消息还是确认应答,都会携带完整的 TCP 报头!TCP 要对通信过程进行流量控制,即要客户端发送慢一点,依据是什么呢?依据就是由对方的接收缓冲区中的剩余缓冲区大小决定的!那么客户端怎么知道对方接收缓冲区的剩余大小呢?TCP 是基于确认应答机制的!当客户端给服务端发送信息的时候,服务端是要给客户端确认应答的!而且需要携带完整的 TCP 报头,所以此时在服务端的确认应答中的 TCP 报头中,有一个字段叫做 16位窗口大小,填充的就是自己接收缓冲区剩余空间的大小!所以客户端收到了该确认应答后就知道了对方的缓冲区剩余大小,就可以在发送的时候知道最多应该发多少数据了!反过来也一样,这样就可以通过 16位窗口大小 互相知道对方的接收缓冲区大小,就可以基于此进行流量控制!
(3)32 位序号和 32 位确认序号
从上面知道 TCP 协议通信是基于确认应答机制的,那么当客户端给服务端发送信息时,或者反过来,只要有一端收到了对方的应答,就表明自己最近发送的一条信息对方收到了,也就是保证了一方的可靠性。而在消息发送应答的过程中,最新的一条消息是没有应答的!因为如果每一条消息都有应答,那么这个过程将会无穷无尽,所以,最新的一条消息是没有应答的,也就是我们无法保证可靠性,所以我们无法保证发出去的消息是 100% 可靠的!
如上图,当一方给对方发消息的时候,只要自己收到了对方的应答,就表明自己从对方的数据是可靠的,但是站在另一方,就不需要知道自己的应答对方是否收到了,也就是无法保证自己的应答的可靠性。所以当两方互相发送信息并且收到了对方的应答,这样才能保证两方的可靠性!
如果客户端发送消息时没有收到应答呢?这时候一段时间后,如果客户端没有收到应答,就会认为数据丢失了,就会重发。反之采用类似的策略就可以保证两个朝向上的可靠性。
但是实际上,像上面这种一个数据一个应答的方式效率太低了,所以 TCP 一般在做应答的同时会稍带数据,这种称为捎带应答。
但是客户端在发送消息的时候也不可能一条数据一条数据地串行地发,而是多条消息并行地发,这样可以提高效率,那么客户端需要保证刚刚发的一批消息都要有响应,如下图:
那么客户端按顺序把一批数据发出,服务端收到的数据一定是客户端历史上发的顺序吗?不一定!这种情况称为数据包乱序问题!这也是不可靠的一种,所以 TCP 此时就需要有相应解决方案!所以 TCP 协议报头中有一个叫做 32位序号 的字段,有了这个字段,对方就可以根据序号进行排序保证数据的按序到达!
那么这个 32位序号 是什么呢?我们在发送数据的时候,在应用层拷贝到 TCP 发送缓冲区的数据是按字节为单位按顺序拷贝的!也就是可以看成一个 char 类型的数组,所以它的每一个字节天然都会有一个编号,本质就是数组下标!那么当我们向服务端发送数据的时候,假设我们是按数据块为单位发送,那么在发送一个数据块的时候,在 TCP 协议报头中的 32位序号,填充的就是发送数据块的最后一个字符的下标!当服务端收到数据之后,就可以根据报头中的 32位序号 对数据按字节为单位进行排序重组,就能得到正确的顺序!
那么我们客户端在发送数据的时候可是不止一个数据的啊,我们发送的是一批数据,那么客户端在接受服务端的应答的时候,怎么知道哪个应答对应的是哪个请求呢?所以在 TCP 协议报头中有一个字段叫做 32位确认序号!这个字段填充的是收到的报头字段中的 32位序号 + 1. 比如客户端的第一个数据中的报头字段的 32位序号 为 1000,那么对于这个请求进行应答的报头中的 32位确认序号 填充的就是 1001. 为什么要这么规定呢?确认序号的意义是:表示确认序号之前的内容,我已经全部收到了!下一次发送请从确认序号的位置开始发送! 这只是一个客观的理由,后面我们还会再解释这个原因。
那么为什么要有 32位序号 和 32位确认序号 两个字段呢?在应答的时候我们复用 32位序号 不就好了吗?原因在于我们上面所提到的捎带应答!对方在做应答的同时也有可能发送数据给我们,所以此时对方就需要用到 32位序号 和 32位确认序号!
(4)6 个标记位
我们先认识一下 TCP 在建立连接的时候,是要进行 TCP 的三次握手的,而在断开连接的时候,是要进行四次挥手的。而且服务器:客户端的比例是 1 : n 的。那么无论是在建立连接还是正常的数据通信还是断开连接,都是要携带 TCP 完整的报文的!所以有些 TCP 请求是用来建立连接的,有些是用来正常的数据通信的,有些是用来断开连接的!所以这就注定了 TCP 报文是有各种 “类型” 的!不同的类型,决定了对方要做不同的动作!那么接收方如何得知报头的类型各自是什么呢?所以要在 TCP 中需要存在 6个标记位!所以 6个标记位 存在的意义是区分 TCP 报文的类型的!
a. ACK
- ACK:确认号是否有效
在通信的过程中,只要建立连接的时候三次握手成功了,大部分情况下,所有报文的 ACK 标记位都是默认置1的,它表示确认号是否有效,也就是客户端给服务端发送消息,服务端给我们的应答中,ACK 必须是置1的,也就是它是一个确认报文,所以只要一个报文具有应答属性,那么它的 ACK 标记位就需要置1,至于它是不是一个携带数据的确认报文,就看它有没有数据。
b. SYN
- SYN:请求建立连接;我们把携带 SYN 标记位的称为同步报文段
如果发送方的 TCP 报文中的 SYN 标记位被置1,就说明它是一个具有请求建立连接属性的报文,也就是和对方进行三次握手建立连接。
c. FIN
FIN:通知对方,本端要关闭了,我们称携带 FIN 标识的为结束报文段
如果报文中的 FIN 标记位被置1,说明就要和对方进行四次挥手断开连接了。
那么上面的 SYN 和 FIN 并不是以传送数据为目的,而是想让接收方得知我们想进行建立连接或者断开连接这样的控制请求。
d. PSH
PSH:提示接收端应用程序立刻从 TCP 缓冲区把数据读走
当客户端向服务端发送数据的时候,本质是将客户端 TCP 中的发送缓冲区的内容拷贝到服务端的 TCP 接受缓冲区,其实在服务器方是 OS 将接受到的数据放到接受缓冲区中,然后用户再进行读取,那么这种就是系统级别的生产者消费者模型!如果此时服务端的用户层没有将数据读走,就会导致它的接受缓冲区很快被打满,客户端的发送缓冲区也就阻塞了,此时客户端只能等待了,该等到什么时候呢?当服务端开始读取了,那么又怎么知道对方的缓冲区有空间了呢?所以此时客户端必须不停地给服务端发送 TCP 报文,并且是携带 PSH 标记位置1的 TCP 报文,表示的是提示接收端应用程序立刻从 TCP 缓冲区把数据读走!
e. RST
RST:对方要求重新建立连接,我们把携带 RST 标识的称为复位报文段
虽然 TCP 是保证可靠性的,但是 TCP 允许连接建立失败。例如客户端向服务端发起建立连接请求,需要进行三次握手,TCP 不能保证每一次握手都能成功,TCP 的可靠性体现在握手失败后采取的重新建立连接的策略,而不是保证我们三次握手都能成功。
我们知道服务端:客户端的比例关系是 1: n,所以在服务端必定有许多已经建立好的连接,所以服务端需要对这些已经建立好的连接进行管理,先描述再组织!所以服务端需要对这些已经建立好的连接创建一个结构体,存放有关该连接的属性信息,例如客户端的 IP 地址、确认序号等等。
那么当客户端对服务端发起建立连接三次握手请求时,有可能会发生异常情况,例如在进行三次握手时,客户端是主动发送的一方,首先给服务端发送 SYN,对于客户端来说这是第一次握手,此时服务端接收到该请求报文,对于服务端来说这是第一次握手;然后服务端给客户端应答,这是对于服务端来说是第二次握手,客户端接受到服务端的应答后是对于客户端的第二次握手;最后客户端再次给服务端进行应答,这是对于客户端来说是第三次握手,只要客户端把该应答发送出去了,客户端就认为建立已经成功了,那么该应答对于服务端来说有可能会丢失,如果没有丢失,服务端正常接收到,这对于服务端来说就是第三次握手,如下图:
但是如果该应答丢失了,服务端就认为与该客户端的建立还没有建立成功,但是对于客户端来说已经与服务端建立成功了!如下图:
所以此时客户端认为已经建立成功后,就正常给服务端发送信息,而服务端认为还没有建立连接,就收到了该客户端的信息,所以此时服务端就会向该客户端发送标记位 RST 置1的报文,要求与客户端进行重新建立连接!
f. URG && 16位紧急指针
URG:紧急指针是否有效
在进行 TCP 通信时需要保证数据的按序到达,即便是没有按照顺序到达的数据,也要进行对数据按字节为单位排序,但是有些情况需要数据进行插队处理,也就是紧急数据需要给用户层优先读取,优先处理,这种情况就需要设置 URG 标记位。
如果在一个报文中 URG 标记位设置为1,表示这个报文中含有紧急数据,那么报文中的字段 16位紧急指针 表示有效,这个 16位紧急指针 表示的是该报文的数据中包含紧急数据的偏移量!在 TCP 中紧急数据的大小为 1 字节,所以只要知道了紧急数据相对于数据的偏移量,就能得到紧急数据。
我们可以通过 send
接口的最后一个参数设置发送紧急数据,我们称这种紧急数据为带外数据,设置为 MSG_OOB
即可,如下:
同理读取的时候也需要将 recv
对应的参数设置为 MSG_OOB
.
3. 确认应答(ACK)机制
确认应答机制是保证 TCP 协议通信可靠性的一种。具体地我们在 16位窗口大小 中已经说过了,在这里就不再重复了。
4. 超时重传机制
我们先看下面这种情况,主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B;如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发,这种机制叫做超时重传机制,如下图:
第二种情况就是主机B收到了主机A的数据,但是主机A未收到B发来的确认应答,也可能是因为ACK丢失了。这时候也在一段时间后主机A没有收到主机B的应答,也会进行超时重传,如下图:
如果是第二种情况,在主机B已经收到了主机A的报文之后,由于是在应答的过程中发生丢包导致主机A没有收到应答,此时主机A也会重发数据,这就会导致主机B会出现重复报文的情况。为了防止这种情况就需要进行去重,那么主机B应该如何去重呢?我们知道,报文是有序号的,序号除了能保证按序到达之外,除了允许少量的应答丢失之外,它还可以对重复报文进行去重!
那么这个超时时间如何设置和规定呢?最理想的情况下,找到一个最小的时间,保证 “确认应答一定能在这个时间内返回”。但是这个时间的长短,随着网络环境的不同,是有差异的。如果超时时间设的太长,会影响整体的重传效率;如果超时时间设的太短,有可能会频繁发送重复的包。
TCP 为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。Linux中(BSD Unix和Windows也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。如果仍然得不到应答, 等待 4*500ms 进行重传。依次类推,以指数形式递增。累计到一定的重传次数,TCP 认为网络或者对端主机出现异常,强制关闭连接。
5. 连接管理机制
TCP 的连接管理机制也就是三次握手和四次挥手的过程,如下图:
(1)三次握手
三次握手也就是客户端向服务器发起建立连接的请求,过程如下:
- 首先是向服务端发起一次 SYN,对于客户端来说这是第一次握手;
- 此时服务端接收到该请求报文,对于服务端来说这是第一次握手;
- 然后服务端给客户端应答,这是对于服务端来说是第二次握手;
- 客户端接受到服务端的应答后是对于客户端的第二次握手;
- 最后客户端再次给服务端进行应答,这是对于客户端来说是第三次握手,只要客户端把该应答发送出去了,客户端就认为建立已经成功了;
- 那么该应答对于服务端来说有可能会丢失,如果没有丢失,服务端正常接收到,这对于服务端来说就是第三次握手
我们在用户层调用 connect()
接口本质就是要求客户端 TCP 层构建一个 SYN 报文推送给服务端的 TCP 层。那么 connect 也有返回值,在 connect 返回之前,会阻塞等待三次握手成功。而在服务端应用层中的 accept()
接口,它本身不参与三次握手,而是把已经建立好的连接拿走,如果没有建立好的连接,那么 accept 也会阻塞住。
在双方进行三次握手时,双方的连接状态会发生变化,当客户端发起三次握手,客户端会处于 SYN_SENT
状态,这个状态称为同步发送。如果服务端收到了一个 SYN,它的状态称为 SYN_RCVD
,表示同步收到。紧接着服务端给客户端响应 SYN+ACK,一旦客户端收到了这个报文,就会发送最后一个 ACK 报文,这个报文一旦发出,客户端的状态就变为 ESTABLISHED
,表示客户端本地连接已经建立。而对于服务端只有收到了客户端发的最后一个应答才会更新它的状态为 ESTABLISHED
,表示服务端也建立连接成功。
(2)四次挥手
当客户端在应用层调用 close()
系统接口的时候,就表示客户端发起了四次挥手断开连接的请求,本质就是客户端 TCP 层向服务端 TCP 层发送 FIN 标记位置1的报文,注意,这里发送的都是没有数据的 TCP 报文,只是将相应的标记位置1了。 此时服务端收到了客户端的请求后,为了保证 TCP 的可靠性,服务端要对客户端进行 ACK 应答,此时就完成了两次挥手过程。
此时如果当服务端也没有数据需要发送给客户端了,那么服务端就给客户端也发送 FIN 请求,此时客户端也给服务端 ACK 应答,就完成了四次挥手的过程。如果服务端还有数据需要发送给客户端,那么就可以继续发送,此时因为四次挥手还没有完成,所以连接没有完全断开,客户端还是可以接受服务端的数据的,当服务端完全没有数据发送给客户端的时候,再给客户端发送 FIN 即可。
这也就是为什么在四次挥手中 FIN 和 ACK 需要分开的原因了,而我们可以看到在三次握手中 SYN 和 ACK 是压缩成一个报文的,因为客户端需要建立连接的时候服务端需要马上建立连接并进行应答,所以这两个压缩是必然的;但是四次挥手中当客户端想要和服务端断开连接时,表示客户端没有数据需要发送给服务端了,但是服务端可能还有数据需要发给客户端,所以此时服务端就不能马上断开连接,因此就需要将 FIN 和 ACK 分离。
(3)为什么需要三次握手?
首先第一个理由,三次握手是为了建立连接,而建立连接就需要保证通信的通道通畅,所以前两次握手成功就能验证客户端到服务端的通路是否通畅!因为前两次握手保证客户端至少进行了一次收和发的过程是成功的!而最后客户端给服务端应答的时候,只要服务端也收到了该报文,就说明服务端到客户端的通路也是通畅的!即验证了全双工通路的通畅!
另外第二个理由,如果只是进行一次握手,客户端给服务端发起建立连接的请求,只要服务端收到了该请求,就认为连接建立成功。那么服务器:客户端是 1:n 的,所以每当客户端发起一个建立连接请求,服务端也不用对该请求进行应答,所以此时服务端就需要为该连接创建管理结构体,但是客户端可不止一个请求,当客户端不断地发起建立连接的请求的时候,服务端就必须建立,直到服务端崩溃。所以一次握手很容易导致服务器连接资源被打满的情况,这种称为 SYN 洪水。如下图:
如果是两次握手,因为是服务端先收到了客户端的建立连接请求,然后给客户端应答,只要服务端发出了该应答,就表示服务端建立连接成功,所以如果是两次握手,就代表服务端先成功建立连接。如下图:
但是如果服务端对客户端的应答在中途丢失或者异常了,导致客户端对服务器的连接没有建立成功,但是在服务端该连接已经建立成功了,所以服务端就需要长时间维持这个异常连接的资源!如果当这个客户端客户量非常大,有一部分的客户端在建立连接时出异常了,这就说明服务端需要长时间挂着这一部分异常的连接!这就导致了建立连接异常的成本嫁接到了服务端!所以两次握手也是不建议的。如下图:
那么三次握手中,在最后一次握手中,是客户端给服务端发送应答,那么如果当这个应答丢失了,只有客户端认为该连接已经建立成功,服务端认为没有建立成功,所以服务端没有付出太大的成本,维护连接的工作就落在了客户端上,所以这种奇数次的握手就能保证建立连接不一致的问题嫁接到客户端上,所以即便有多个客户端访问服务端,如果有一部分连接建立失败了,都是由客户端自己维护这个已经建立好的资源,对服务器的影响不大,就能保证服务器的稳定性!
同时,三次握手也是验证全双工的最小次数,所以当验证完全双工的通畅性后,就不需要过多地验证了,所以三次握手是最合适的。
(4)为什么需要四次挥手?
断开连接的本质就是没有数据给对方发送了,而发送数据是双方都有可能发的,所以必须需要双方都要进行断开连接,也就是必须需要断开两次,也就是四次挥手。
(5)三次握手的状态
根据上图我们可以得出结论,建立连接成功和上层是否有 accept()
没有关系,三次握手是双方操作系统自动完成的。当客户端和服务端都处于 ESTABLISHED
时,表明双方连接建立完成,accept()
只是在双方建立好连接后将连接拿上来。
我们在前面用过 listen()
接口,但是却没有介绍它的第二个参数,下面我们介绍一下它的第二个参数。我们以前在写代码的时候,是将它的第二个参数 backlog
设置为 10,为什么呢?
如果我们把 backlog
设置为1,当多个客户端向服务端发起连接请求时,当连接建立好后,服务器就在底层为该连接建立好对应的结构体并维护该连接。假设 accept()
没有把建立好的连接拿上去,那么操作系统就必须对这些已经建立好的连接进行临时维护的效果,所以操作系统为了更好地管理这些已经建立好的连接,它采用的是队列的形式管理,如下图:
所以三次握手的本质就是将已经建立好的连接放入这个已经建立好的队列中,而 listen() 的第二个参数 backlog + 1 表示的就是这个已经建立好的连接队列的最大长度!我们把这个队列叫做全连接队列!所以我们上面将 backlog 设置为1,那么这个全连接的长度就是2,如果我们的服务端没有调用 accept(),那么这个队列中最大的已经建立好连接的长度只能是2,往后的连接在客户端看来是已经建立好了,但是对于服务端来说,全连接队列已经满了,所以对于服务端来说从第三个连接开始就会建立失败!
那么怎么保证建立失败呢?首先,从第三个连接开始,客户端是正常进行三次握手,客户端把最后一个 ACK 发给服务端,就说明客户端已经建立好连接了,但是此时服务端发现全连接队列是满的,所以服务端会直接丢弃这个 ACK,所以此时对于服务端,该连接的状态还是 SYN_RECV,无法变为 ESTABLISHED,如下图:
对于服务端来说,不会长时间维护 SYN_RECV,服务端是被建立连接的一方,处于 SYN_RECV 的连接,称为半连接,其实半连接就是客户端和服务端连接建立不一致问题!半连接也是有对应的队列存储的,称为半连接队列!但是对于半连接,操作系统不会长时间维护,所以半连接过一会就会消失!
其实在建立连接时,该连接首先是要进入半连接队列中,如果条件满足,也就是全连接队列中还没满,那么服务端就不会丢弃客户端的 ACK,才会被放入全连接队列中,如下图:
那么我们已经知道 listen() 的第二个参数 backlog 的含义了,但是 backlog 的长度不能太长,为什么呢?因为如果 backlog 太长了,可能会导致服务器上,有些连接来不及给上层处理,但依旧要在系统内长时间维持!也就是说,如果 backlog 太长,可能就会把全连接队列打得非常满,而此时上层也非常忙,没有时间把连接从底层全连接队列中取走!这就会导致在操作系统非常忙,也就是内存资源不足的情况下,底层连接还在不断建立,而维护这些连接会占用操作系统的资源,而维持这些资源也不会创造任何价值!所以 backlog 的长度不能设置太长,全连接队列就不会维持太多的已经建立好的连接,就能腾出一些资源供上层使用,从而可以尽快让上层从全连接队列中取走连接!
那么 backlog 的长度也不能没有,为什么呢?当服务端正在给其他客户端连接服务的时候,如果此时服务端忙不过来,新来的客户端连接也就不能被 accept(),那么如果此时没有 backlog,也就是没有全连接队列,那么新来的连接全部都会建立失败,都会在半连接队列中等待,而半连接队列中的连接很快就会被释放,所以此时如果服务端想 accept() 的时候,发现没有连接可以获取,就导致操作系统的资源没有得到充分的利用!如果维持一个短的全连接队列,就可以使操作系统想要获取连接的时候,随时从全连接队列中获取,因为全连接队列维持连接的时间非常长!
(6)四次挥手的状态
如果一个客户端正在连接服务端,客户端是主动断开连接的一方,客户端此时直接 ctrl + c 退出,此时客户端给服务端发送 FIN 标记位置1的报头,服务端的状态变为 CLOSE_WAIT,然后给客户端应答,此时由于服务端没有断开连接,所以该连接的状态就会长时间保持在 CLOSE_WAIT.
如果主动断开连接的一方是服务端,在四次挥手完成之后,要进入 TIME_WAIT 状态,等待若干时长之后才会自动释放,这个时间在30s到60s不等。例如上图中左边是服务端,右边是客户端,当服务端 accept() 了客户端的连接之后,服务端主动 close() 断开连接,此时客户端状态为 CLOSE_WAIT,应答之后,服务端的状态变为 FIN_WAIT2,然后客户端再直接 ctrl + c,连接就会主动断开,此时服务端的状态变为 TIME_WAIT,再给客户端应答,此时客户端四次挥手完成,连接断开。但是对于服务端来说,四次挥手也完成了,因为服务端最后的应答也发出去了,但是此时服务端的连接还没有彻底断开,需要等待若干时长才能被释放。也就是说,此时服务端的 ip + port 正在被使用!此时如果我们想重启服务器,根据端口号不能同时被两个不同的进程绑定,此时就会出现绑定错误!这个问题我们以前也遇到过,这也就是为什么当我们关闭服务器后想要马上重启服务器,会出现绑定失败的原因!那么客户端为什么不会存在这个问题呢?因为客户端使用的是随机端口!
所以我们可以通过接口设置让服务器关闭后可以立马重启,也就是地址复用,该接口为 setsockopt()
如下图:
其中参数,第一个为文件描述符;第二个为层级,一般都是设置到 SOL_SOCKET 这一层,表示在套接字层我们要对它进行设置;其他参数可以参考如下代码。下面我们在服务器启动的时候使用该接口,代码如下:
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt);
TCP 协议规定,主动关闭连接的一方要处于 TIME_ WAIT 状态,等待两个 MSL(maximum segment lifetime) 的时间后才能回到CLOSED 状态。什么是 MSL 时间呢?一个报文在网络中是会有存活时间的,也就是从客户端发出去,到服务端收到,这个时间窗口内,数据包都在网络里面,那么这个数据报文在网络里面存在的最大时长,就称为 MSL;MSL在 RFC1122 中规定为两分钟,但是各操作系统的实现不同,在 Centos7 上默认配置的值是60s.
那么为什么要处于 TIME_WAIT 状态呢?首先是为了让通信双方历史的数据得以消散;其次可以让我们断开连接,四次挥手具有较好的容错性。
6. 流量控制
我们在上面介绍 16位窗口大小 的时候已经认识流量控制了。在这里就再简单说明一下。接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。因此 TCP 支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)。
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,通过 ACK 端通知发送端;
- 窗口大小字段越大,说明网络的吞吐量越高;
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端;
- 发送端接受到这个窗口之后,就会减慢自己的发送速度;
- 如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端;
那么在第一次发的时候,怎么保证数据量是合理的呢?我们在建立连接的时候通过了三次握手,在三次握手的时候,双方也交换了报文,所以在三次握手期间,双方已经协商了对方的接受能力!
接收端如何把窗口大小告诉发送端呢?回忆我们的TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息。那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节吗?实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移 M 位。
7. 滑动窗口
我们已经知道了 TCP 的超时重传机制,那么为了保证超时重传,发送方必须在收到对方应答之前,将该报文保存起来,以便该报文丢失后可以进行重传。但是发送方可能也会一次性发多个报文,所以在收到这些报文的应答之前,这种已经发出去但是暂时没有收到应答的报文,可能会存在多个,那么这些报文会保存在哪里呢?其实没有必要对报文进行保存,我们知道,发送方有自己的发送缓冲区,在发送报文的时候,只需要将自己发送缓冲区的数据拷贝到底层,让底层通过网络发送给对方接受缓冲区即可。那么将数据发出去之后,发送缓冲区中依旧还存在该数据的,所以根本不需要对这些报文进行保存!所以要做的,无非就是对缓冲区做一个简单的区域划分就可以了!
所以我们可以对缓冲区的内容进行简单的划分,划分为已发送已确认、已发送未确认、待发送,如下图:
那么也就是对于已发送已确认的区域,是可以被覆盖的,因为已经收到了应答!而已发送未确认的区域的数据是不可被覆盖的,因为尚未收到应答!如果未来该区域中的部分数据收到了应答,那么就可以将该部分的数据纳入到已发送已确认的区域,也就是红色框整体向右移动了!所以我们把这部分已发送未确认的区域称为滑动窗口!因为有滑动窗口区域,我们才可以一次向对方发送大量的报文!那么这个滑动窗口的大小是多少呢?其实就是对方接收缓冲区窗口的大小,所以滑动窗口的大小不能超过对方接收缓冲区的大小,也就是应答报文的窗口大小!那么怎么维护这个窗口呢?其实就是使用算法中的双指针下标来维护!
(1)丢包问题
假设滑动窗口内已发送的数据报文的序号如下,当主机A将序号为1000到5000度报文都发送给主机B后,主机B对主机A进行应答,但是序号为3000应答丢了,其他应答主机A都收到了,那么是不是窗口还要维持序号为2000到5000的报文呢?我们前面说过,对于序号的定义是,确认序号假设是x,那么就代表x之前的报文已经全部收到了,也就是允许少量的ACK丢失!所以当3000报文的应答丢了,但是收到了5000的应答,代表5000之前的报文主机B已经全部收到了!所以滑动窗口就不需要维护1000到5000的报文了,因为主机A已经全部收到了!
我们上面所说的是应答丢了,但是如果是数据在传输的过程中丢了呢?假设序号为1001~2000的数据丢了,那么当其他三个报文主机B收到了并给主机A应答,但是此时报头的应答序号中只能填1001,因为1001~2000的数据报文丢了!所以滑动窗口的位置不变!所以此时,其他的应答中的报头序号也只能填1001,这时候主机B就会给主机A发送三次重复的确认应答!主机A也收到了主机B的三个同样的确认应答!那么此时主机A是否应该对1001~2000的数据进行超时重传呢?这样做的效率太低了!当主机A收到了主机B三次重复的序号应答后,马上会对该序号的数据进行重新发送,这种策略称为快速重传!
那么已经有了快速重传,为什么还要有超时重传呢?因为快速重传是有条件的,当收到三次重复的确认应答时才会触发!所以快速重传是保证效率的!
(2)滑动窗口移动问题
首先滑动窗口是不能向左移动的,因为左边是已经发送并且已经确认的数据。其次滑动窗口的大小是会根据对方接收缓冲区的大小动态变化的,所以变化可以分为变小、不变、变大。
当滑动窗口向右移动时,又可以分为三种情况。第一种,右不变,左移动,此时说明滑动窗口的大小一直在变小,也就是对方的接收缓冲区的大小也在变小,就说明对方的上层一直都没有读取数据!第二种,左右都移动,范围变大,此时说明滑动窗口的大小变得更大了,也就是对方的接收缓冲区也变大了,说明对方的上层一次性把数据读的很多,导致接收缓冲区变大了!但是也可以是左右移动,范围缩小,此时说明对方上层读取数据的速度太慢。第三种就是左右移动,范围不变,也就是滑动窗口的大小不变,此时就是当对方接收缓冲区满了之后,假设对方的上层读的就是我们滑动窗口的大小,此时滑动窗口继续更新同样的大小,此时窗口的大小不变。
其实滑动窗口的两个指针中,start 假设为窗口的起始位置,end 为结束位置,start 就是根据确认序号,设置确认序号以确认自己的位置。而 end 是根据确认序号+对方接收缓冲区剩余大小更新位置的。所以流量控制的本质就是通过滑动窗口实现的!
那么滑动窗口在向右移动的时候,会在发送缓冲区中越界吗?不会!TCP 采用了类似的环状算法!就像用数组模拟的环形队列一样!所以不会越界。
8. 延迟应答
当客户端和服务端在进行通信的时候,发送方一次发送更多的数据,就说明发送的效率越高,想要发送的效率高,就得让对方告诉发送方自己能接收更多的数据;所以如果接收方可以给发送方更新一个更大的窗口大小,就可以提高发送的效率!那么如何让接收方给发送方通告一个更大的窗口呢?那就是当接收方收到一个报文,不立马进行应答,先等一个规定的时间,给上层一些时间将数据取走,此时接收缓冲区就变大了!所以这种策略叫做延迟应答!
那么又了延迟应答就一定可以提高效率吗?不一定,因为还需要取决于上层读取数据。所以对于我们来说,每次都尽快通过 read(), recv() 接口把数据从缓冲区中读取上来是最好的,因为这样 TCP 就可以给对方更新一个更大的窗口!
9. 捎带应答
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 “一发一收” 的,意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine, thank you”;那么这个时候ACK就可以顺便和数据一起发送,和服务器回应的 “Fine, thank you” 一起回给客户端。这也就提高了双方通信的效率。
10. 拥塞控制
我们上面所讲的 TCP 的所有策略,都是需要双方的机器共同维护实现的。但是在双方通信时,是基于网络信道通信的,通信双方是对网络情况无能为力的,所以 TCP 还考虑了双方网络的情况!
所以,如果发送数据出现了问题,不仅仅是对方主机出现了问题,还有可能是网络出现了问题。如果在通信的时候,出现了少量的丢包,属于正常的情况;但是出现了大量的丢包,TCP 判断就是网络出现了问题,也就是网络拥塞了,而网络出现问题可以分为硬件设备出问题引起阻塞,和数据量太大引起的阻塞。此时我们不能立即对报文进行超时重发,因为如果是硬件问题引起的阻塞,重发也无济于事;如果是数据量太大而引起的阻塞,我们还继续重发的话,只会加重网络拥塞的情况!由于网络资源是共享的,我们每一个人都减少发送的数据量的话就可以缓解网络拥塞的情况了!
所以 TCP 引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据,如下图:
- 此处引入一个概念程为拥塞窗口;
- 发送开始的时候,定义拥塞窗口大小为1;
- 每次收到一个ACK应答,拥塞窗口加1;
- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口;
也就是滑动窗口大小 = min(接收端缓冲区剩余窗口大小,拥塞窗口);
怎么理解呢?接收端缓冲区剩余窗口大小由对方主机接受能力决定,但是如果对方接受能力非常强,而网络非常拥塞,此时如果按照对方窗口大小发送大的数据量,会加剧网络的拥塞!而拥塞窗口考虑的是动态的网络的接受能力,所以在它们之间取较小值可以既考虑到对方的接受能力,又能考虑到网络的情况。所以拥塞窗口主要是主机用来判断网络健康程度的指标,超过拥塞窗口,会引发网络拥塞,否则不会。
像上面这样的拥塞窗口增长速度,是指数级别的。“慢启动” 只是指初使时慢,但是增长速度非常快。为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。此处引入一个叫做慢启动的阈值,当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
“慢启动” 指的是前期慢,增长幅度高。也就是当出现网络拥塞的时候,发送少量报文试一试网络的情况,如果没有问题,说明网络已经趋于正常了,应该尽快恢复正常通信。
当 TCP 开始启动的时候,慢启动阈值(ssthresh)等于窗口最大值;在每次超时重发的时候,也就是发生网络拥塞时,慢启动阈值会变成原来的一半,同时拥塞窗口置回1.
11. TCP 小结
为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能。其中可靠性和提高性能分别是靠下面的保证:
可靠性:
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
四、面向字节流
创建一个 TCP 的 socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区;
- 调用 write 时,数据会先写入发送缓冲区中;
- 无论用户层发送的是什么数据,由于 TCP 是面向字节流的,所以最终这些数据都是被分成若干字节放在发送缓冲区中
- 如果发送的字节数太长,会被拆分成多个 TCP 的数据包发出;
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去;
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用 read 从接收缓冲区拿数据;
- 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据,这个概念叫做全双工
由于缓冲区的存在,TCP 程序的读和写不需要一一匹配,例如:
- 写100个字节数据时,可以调用一次 write 写100个字节,也可以调用100次 write,每次写一个字节;
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read 100个字节,也可以一次 read 一个字节,重复100次;
由于 TCP 下的缓冲区中,只有字节的概念,发送数据实质就是将发送缓冲区的字节拷贝到对方接收缓冲区中,所以在双方的缓冲区就有了字节被流动起来的概念,所以称之为面向字节流!
五、粘包问题
首先要明确,粘包问题中的 “包”,是指的应用层的数据包。在 TCP 的协议头中,没有如同 UDP 一样的 “报文长度” 这样的字段,但是有一个序号这样的字段。站在传输层的角度,TCP 是一个一个报文按照字节序号过来的,按照序号排好序放在缓冲区中。但是站在应用层的角度,看到的只是一串连续的字节数据。那么应用层看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的报文。此时用户就需要将这些字节流变回一个一个完整的请求报文,所以就导致用户层读上来数据后,有可能读到的是半个报文、一个半报文……所以如果用户层不对报文进行一个一个地分离,在进行报文处理时,可能会多处理或少处理对应的请求,这种情况我们称之为粘包问题!
那么如何避免粘包问题呢? 由于粘包问题是相对于用户层来说的,所以是需要应用层来解决。归根结底就是一句话,明确两个包之间的边界! 有如下方法:
- 定长报文:对于定长的包,保证每次都按固定大小读取即可;例如 Request 结构,是固定大小的,那么就从缓冲区从头开始按 sizeof(Request) 依次读取即可;
- 使用特殊字符:以 \3、\n 等特殊字符对报文和报文之间进行分隔;
- 使用自描述字段+定长报头:假设报头是8个字节,而前4个字节用来描述的是有效载荷的长度,就像 UDP 一样;
- 使用自描述字段+特殊字符:就像 http 报头一样,它的协议中就有 Content-Length 字段,它表明的是有效载荷的长度,用户以空行为结尾读完 http 报头,再根据 Content-Length 的长度再从缓冲区中读取对应的长度即可;
那么对于 UDP 协议来说,是否也存在 “粘包问题” 呢?
- 对于 UDP,如果还没有上层交付数据,UDP 的报文长度仍然在。同时,UDP 是一个一个把数据交付给应用层。就有很明确的数据边界;
- 站在应用层的角度,使用 UDP 的时候,要么收到完整的 UDP 报文,要么不收,不会出现"半个"的情况;
六、TCP 异常情况
- 进程终止:因为每创建一个套接字,本质上在操作系统当中就是新增一个文件描述符,所以连接本身是和文件相关的,而文件的生命周期是随进程的,所以无论是当进程正常终止还是异常终止,都会释放文件描述符,仍然可以发送 FIN,正常进行四次挥手,连接正常自动断开,和正常关闭没有什么区别。
- 机器重启:本质上机器重启或者关机是先要杀掉所有进程,所以和第一种情况一样。
- 机器掉电/网线断开:首先当客户端发生机器掉电或者网线断开后,是没有机会向服务器进行四次挥手的,因为网线断开后,网络通道直接断开了,所以此时服务器并不知道客户端网线已经断开了,所以服务器正常维护与该客户端的连接。当客户端把网线连上之后,以前的连接早已不存在,如果此时客户端再向服务端发消息,服务器就会认定为连接认知不一致,服务器就会向客户端发送 reset,让客户端重新建立连接,而服务器直接关闭与该客户端之前的连接。如果客户端连上网之后一直不给服务端发消息,由于 TCP 内部是有一些保活机制的,也就是保活定时器,如果服务端发现与该客户端的连接长时间没有消息,就会给该客户端发送保活消息,如果发送几次保活信息都没有应答,服务端就会认定连接已经断开,服务器就会关闭该连接。