七、连接管理机制
TCP的各种可靠性机制是基于连接的,与连接是强相关的。比如一台服务器启动后可能有多个客户端前来访问,如果TCP不是基于连接的,也就意味着服务器端只有一个接收缓冲区,此时各个客户端发来的数据都会拷贝到这个接收缓冲区当中,此时这些数据就可能会互相干扰,所以我们在进行TCP通信之前需要先建立连接。
1、操作系统对连接的管理
一台机器上可能会存在大量的TCP连接,此时操作系统就必须对这些连接进行管理。
在操作系统中有一个描述连接的结构体,该结构体当中包含了连接的各种属性字段,所有定义出来的连接结构体最终都会以某种数据结构组织起来,此时操作系统对连接的管理就变成了对该数据结构的增删查改。
- 建立连接,实际就是在操作系统中用该结构体定义一个结构体变量,然后填充连接的各种属性字段,最后将其插入到管理连接的数据结构当中即可。
- 断开连接,实际就是将某个连接从管理连接的数据结构当中删除,释放该连接曾经占用的各种资源。
因此连接的管理也是有成本的,这个成本就是管理连接结构体的时间成本,以及存储连接结构体的空间成本。
2、三次握手
一开始,客户端和服务端都处于 CLOSE
状态。
- 先是服务端主动监听某个端口,处于
LISTEN
状态,然后调用accept()
等待客户端连接。 - 然后客户端调用
socket()
函数,分配一个文件描述符,然后调用connect()
发起连接请求,正式开始三次握手。
三次握手的过程,实际是由双方的操作系统中的TCP层自主完成的!
connect
函数,触发连接,等待完成。accept
,等待建立完成,获取连接。
在编码角度看:
在原理角度看:
- 第一次握手:客户端会随机初始化序号(
client_isn
),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于SYN_SENT
状态。
- 第二次握手:服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(
server_isn
),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入client_isn + 1
, 接着把 SYN 和 ACK 标志位置为 1(服务端向客户端发起连接建立请求并对客户端发来的连接请求进行响应)。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于SYN_RCVD
状态。
- 第三次握手:客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入
server_isn + 1
,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于ESTABLISHED
状态。
- 服务端收到客户端的应答报文后,也进入
ESTABLISHED
状态。
一旦完成三次握手,双方都处于 ESTABLISHED
状态,此时连接就已建立完成,客户端和服务端就可以相互发送数据了。
需要注意的是:
- 客户端向服务器发起的建立连接请求,是请求建立从客户端到服务器方向的通信连接,而TCP是全双工通信,因此服务器在收到客户端发来的建立连接请求后,服务器也需要向客户端发起建立连接请求,请求建立从服务器到客户端方向的通信连接。
- 从上面的过程可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的
- 连接的建立不是百分之百能成功的,通信双方在进行三次握手时,其中前两次握手能够保证被对方收到,因为前两次握手都有对应的响应,但第三次握手是没有对应的响应报文的,如果第三次握手时客户端发送的ACK报文丢失了,那么连接建立就会失败。
如果第三次握手的ACK报文丢失导致,服务端也就不会进入
ESTABLISHED
状态,但是客户端已经进入了ESTABLISHED
状态,然后客户端给服务器发送数据时,服务端就会检测到双方认知状态不一致,于是向客户端发送一个RST
被置为1
的TCP报文,让客户端重新建立连接。
3、为什么是三次握手
我们来进行依次考虑:
一次握手
TCP是全双工通信,需要两个方向上的通信信道都没有问题,一次握手在成功的情况下只能保证一个方向的通信信道没有问题,没有办法实现双向通信。
两次握手
如果只有两次握手,当客户端发生的 SYN 报文在网络中阻塞,客户端没有接收到服务端的 ACK 报文,就会重新发送 SYN ,由于没有第三次握手,服务端不清楚客户端是否收到了自己回复的 ACK 报文,所以服务端每收到一个 SYN 就只能先主动建立一个连接。
这会造成什么情况呢?
如果客户端发送的 SYN 报文在网络中阻塞了,重复发送多次 SYN 报文,那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
而且在这种情况下,建立连接时的异常连接是挂在服务端的,不管客户端有没有成功收到服务端发送的 SYN + ACK
,服务端自己是先要维护好连接的(哪怕这是一个异常的连接),然后客户端收到服务端发送的 SYN + ACK
以后才会建立连接,如果没有收到,客户端就不必建立连接进行维护。
于是利用这个特点就可以对TCP进行「SYN洪水攻击」:
攻击者用大量的假IP地址发送初始连接请求(SYN)数据包,让服务端建立连接,然后切换IP继续发,于是导致服务端上面挂满了异常连接,而攻击者的机器上是不需要维护任何连接的,这样被攻击的服务器的CPU和内存资源会迅速耗尽,就无法响应任何新的请求了。
所以两次握手:可能会造成双方资源的浪费,同时也是不安全的。
三次握手
- 没有明显的设计漏洞,一旦建立连接出现异常,异常连接成本嫁接到客户端。
- 握手的本质是在验证双方通信信道的通畅情况,而三次握手是验证全双工通信信道通畅的最小成本!
- 客户端通过①和②就能够验证自己的通信的收和发的通信信道是没有问题的。
- 服务端通过①和③就能够验证自己的通信的收和发的通信信道是没有问题的。
四次握手
三次握手在理论上就已经能够完成可靠连接建立,所以不需要使用更多的通信次数。
4、四次挥手
由于双方维护连接都是需要成本的,因此当双方TCP通信结束之后就需要断开连接(双方都可以主动断开连接),断开连接后主机中的「资源」将被释放,断开连接的这个过程我们称之为四次挥手。
这里我们还是以服务器和客户端为例,当客户端与服务器通信结束后,需要与服务器断开连接,此时就需要进行四次挥手。
- 第一次挥手:客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入
FIN_WAIT_1
状态。 - 第二次挥手:服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入
CLOSE_WAIT
状态。 - 客户端收到服务端的 ACK 应答报文后,之后进入
FIN_WAIT_2
状态。 - 第三次挥手:等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入
LAST_ACK
状态。 - 第四次挥手:客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入
TIME_WAIT
状态。 - 服务端收到了 ACK 应答报文后,就进入了
CLOSE
状态,至此服务端已经完成连接的关闭。 - 客户端在经过
2MSL
(两个报文最大生存时间,单位是秒) 时间后,自动进入CLOSE
状态,至此客户端也完成连接的关闭。
报文最大生存时间在Linux中是可以查看的,通过下面的命令
cat /proc/sys/net/ipv4/tcp_fin_timeout
当然这个值也是可以修改的,修改以后想要生效需要重新编译 Linux 内核。
至此四次挥手结束后双方的连接才算真正断开,此外你可以看到,每个方向都需要一个 FIN
和一个 ACK
,因此通常被称为四次挥手。
这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT
状态。
为什么是四次挥手?
由于TCP是全双工的,建立连接的时候需要建立双方的连接,断开连接时也同样如此。
- 关闭连接时(客户端主动调用
close
函数),客户端向服务端发送FIN
时,仅仅表示客户端不再发送数据了但是还能接收数据的。 - 服务端收到客户端的
FIN
报文时,先回一个ACK
应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时(服务器主动调用close
函数),才发送FIN
报文给客户端,表示表示同意现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK
和 FIN
一般都会分开发送,因此是需要四次挥手。但是在特定情况下,四次挥手是可以变成三次挥手的。
5、四次挥手中的一些状态
CLOSE_WAIT
- 双方在进行四次挥手时,如果只有客户端调用了
close
函数,而服务器不调用close
函数,此时服务器就会进入CLOSE_WAIT
状态,而客户端则会进入到FIN_WAIT_2
状态。 - 但只有完成四次挥手后连接才算真正断开,此时双方才会释放对应的连接资源。如果服务器没有主动关闭不需要的文件描述符,随着是时间的积累,在服务器端就会存在大量处于
CLOSE_WAIT
状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少,因此一定要及时关闭不用的文件描述符!!!
LAST_ACK
如果客户端给服务器发送了FIN
报文并收到了ACK以后会进入FIN_WAIT_2
状态,这个状态并不稳定,这个状态会等待服务端发送FIN
报文,如果服务端一直长时间不调用close
函数不发送FIN
报文,客户端会强制断开连接直接进入CLOSE
状态,于是等客户端已经进入CLOSE
状态了,服务器才调用close
函数,此时,服务器的状态变为LAST_ACK
,但是客户端是不会再对服务器进行响应(ACK),由于服务端一直收不到ACK,就会不断进行重传(此时一直处于LAST_ACK
状态),直到达到指定次数,于是服务器也就主动进入了CLOSE
状态了。
当服务器中存在大量CLOSE_WAIT
的情况下关闭服务器进程(文件描述符是随进程的,进程关闭相当于主动调用close
)就会发现CLOSE_WAIT
状态变为了大量的LAST_ACK
状态。
TIME_WAIT
主动断开连接的一方在发送完最后一个ACK以后,就会进入TIME_WAIT
状态,然后等待2MSL
时间以后才会进入CLOSE
状态。
那么为什么要有TIME_WAIT
状态呢,为什么不是发送完最后一个ACK以后直接进入进入CLOSE
状态呢?
主要有下面的两个原因:
- 保证「被动关闭连接」的一方,能被正确的关闭。
如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。
假设客户端没有 TIME_WAIT
状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSE
状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,对于收到服务端重传的 FIN 报文不会进行响应,于是服务器在经过若干次超时重发后得不到响应,最终也会将对应的连接关闭,但在服务器不断进行超时重传期间还需要维护这条废弃的连接,这样对服务器是非常不友好的。
- 保证双方通信信道上的数据在网络中尽可能的消散。
假设 TIME-WAIT
没有等待时间或时间过短,当一个服务器重启以后,服务端以相同的四元组(源IP,目的IP,源端口,目的端口)重新打开了新连接,重启之前的被延迟的数据包这时抵达了服务端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。
为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT
状态,状态会持续 2MSL
时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
TIME_WAIT
的优化
TIME_WAIT
状态的设计是好事,但是在有些情况下却可能带来糟糕的影响,例如当我们的服务器挂掉了,这时主动断开连接的一方就变成了服务器,由于TIME_WAIT
状态导致我们的服务器不能够立即启动,这时我们重新启动服务器就会显示端口已经被占用了。
由于我们是服务器程序,我们又不能随意改变端口,于是我们只能等待120s
,但是这对于服务器程序来说是不允许的,因此我们要想办法让服务器程序在成为主动断开连接的一方时也能够忽略TIME_WAIT
状态立即启动。
我们知道计算机中被使用的端口,还是可以继续对另外一个主机发起连接的,于是Linux
给我们提供了一个函数,通过这个函数我们能够复用套接字。
#include <sys/types.h> #include <sys/socket.h> int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
- 作用: 设置套接字有关信息
- 参数 :
sockfd
:将要被设置的套接字。level
:要设置的选项所在的协议层,这里我们设置为SOL_SOCKET
。optname
:需要访问的选项名,这里我们设置为SO_REUSEADDR
。optval
:对选项要设置的的值的起始地址,这里我们定义一个变量opt为1,然后填写这个opt变量的地址就行了。optlen
:optval
指向的缓冲区的长度,这里的长度为sizeof(opt)
。 - 返回值
成功返回0,失败返回-1,错误码被设置。
至此TCP的连接管理机制讲解完毕,下面是一张TCP的完整的连接过程:
八、滑动窗口
我们在前面说过:双方在进行TCP通信时可以一次向对方发送多条报文,这样可以将发送和等待多个响应的时间重叠起来,进而提高数据通信的效率。
虽然双方在进行TCP通信时可以一次向对方发送多条的报文,但是其必须收到「流量控制」的限制,即一次发送多条数据时不能超出接收方的接收能力。
1、滑动窗口的一般原理介绍
首先滑动窗口是在发送方的发送缓冲区里面的,为了便于理解我们可以将发送缓冲区看作是一个char
类型的数组(便于体现其字节流的特性)其有两个下标,winstart
指向滑动窗口的起始位置,winend
指向滑动窗口的结束位置。
窗口里面的数据就是指这部分数据是可以进行并发发送,暂时不用收到应答的数据。
根据滑动窗口可以将发送缓冲区当中的数据分为三部分:
- 已经发送并且已经收到ACK的数据。
- 已经发送还但没有收到ACK的数据。
- 还没有发送的数据。
问:那么这个滑动窗口的大小是多少呢?
由于TCP有「流量控制」和「拥塞控制」,所以滑动窗口的机制不能违背其他机制,结论是其大小为: