文章目录
TCP协议
TCP(传输控制协议)
是一种基于连接的协议,用于在计算机网络中实现可靠的数据传输。它是互联网传输层的核心协议之一。
TCP协议格式
TCP 报文中每个字段的含义如下:
- 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
- 32位序号/32位确认号: 分别代表TCP报文当中每个字节数据的编号以及对对方的确认,是TCP保证可靠性的重要字段.
- 4位首部长度: 表示该TCP头部有多少个32位bit(有多少个4字节); 所以TCP头部最大长度是15 * 4 = 60
- 6位标志位:
URG
: 紧急指针是否有效ACK
: 确认号是否有效PSH
: 提示接收端应用程序立刻从TCP缓冲区把数据读走RST
: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段SYN
: 请求建立连接; 我们把携带SYN标识的称为同步报文段FIN
: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
- 16位窗口大小: 保证TCP可靠性机制和效率提升机制的重要手段
- 16位校验和: 发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分.
- 16位紧急指针: 标识紧急数据在报文当中的偏移量,需要配合标志字段当中的URG字段统一使用.
- 选项字段: TCP报头当中允许携带额外的选项字段,最多40字节.
💕 4位首部长度的意义:
4 位首部长度是用来表示 TCP 报头的长度
的,也能表示数据相对于 TCP 报文起始位置的偏移量,其单位是 4 字节,所以其能表示的范围是 0 到 60 字节。因为 TCP 固定的报头长度是 20 字节,那么 4 位首部长度的最小值就是 5 了。
💕 TCP是如何将报头和有效载荷分离的?
提取 TCP 报文的前 20 个字节的固定报头,然后提取出4位首部长度。如果 4 位首部长度不等于 5,则说明报文中的可选项字段占 (4 位首部长度大小 * 4 - 20) 个字节。将报头提取完后,剩下的就是有效载荷了。那么再根据固定报头中的目的端口,就可以向上交付数据给特定的进程了。
当 TCP 报文被封装在 IP 报文中传输时,IP 首部中有一个字段叫做总长度,它表示整个 IP 报文的长度。由于 IP 报文包括 IP 首部和 TCP 报文,因此可以通过减去 IP 首部长度来得到 TCP 报文的总长度。然后再减去 TCP 首部长度,就可以得到 TCP 有效载荷的长度了。
💕 TCP是如何决定将有效载荷交付给上层的哪一个协议的?
应用层的每一个网络进程都必须绑定一个端口号。而TCP报头当中涵盖了目的端口号,因此TCP可以提取出报头当中的目的端口号,找到对应的应用层进程,进而将有效载荷交给对应的应用层进程进行处理。
窗口大小
💕 TCP 的接收缓冲区和发送缓冲区
TCP 本身是具有接收缓冲区和发送缓冲区的:
- 接收缓冲区用来暂时保存接收到的数据
- 发送缓冲区用来暂时保存还未发送的数据
- 这两个缓冲区都是在 TCP 传输层内部实现的
- TCP发送缓冲区当中的数据由上层应用层程序进行写入的,当上层调用 write / send 这样的系统调用接口时,就会将应用层的数据拷贝到 TCP 的发送缓冲区中,并不是将数据直接发送到网络中。
- TCP接收缓冲区也是由上层应用层程序进行读取的,当上层调用 read/recv 这样的系统调用接口时,就会将 TCP 接收缓冲区的数据拷贝到应用层中,并不是直接从网络中获取数据。
- 这就好比调用read/write进行文件读写时,并不是直接从磁盘读取数据,也不是直接将数据写入到磁盘上,而是对文件缓冲区进行读写操作。
当数据拷贝到 TCP 中的发送缓冲区中后,对应的 write / send 函数就能够返回,无须关系数据如何发送以及如何发送,这些问题都是 TCP 协议来解决的。
💕 TCP 发送缓冲区和接收缓冲区存在的意义
发送缓冲区和接收缓冲区的作用:
- 数据在网络中传输时可能会出现某些错误,此时就可能要求发送端进行数据重传,因此TCP必须提供一个发送缓冲区来暂时保存发送出去的数据,以免需要进行数据重传。只有当发出去的数据被对端可靠的收到后,发送缓冲区中的这部分数据才可以被覆盖掉。
- 接收端处理数据的速度是有限的,为了保证没来得及处理的数据不会被迫丢弃,因此TCP必须提供一个接收缓冲区来暂时保存未被处理的数据,因为数据传输是需要耗费资源的,我们不能随意丢弃正确的报文。此外,TCP的数据重排也是在接收缓冲区当中进行的。
经典的生产者消费者模型:
- 对于发送缓冲区来说,上层应用不断往发送缓冲区当中放入数据,下层网络层不断从发送缓冲区当中拿出数据准备进一步封装,此时上层应用扮演的就是生产者的角色,下层网络层扮演的就是消费者的角色,而发送缓冲区对应的就是 "交易场所"。
- 对于接收缓冲区来说,上层应用不断从接收缓冲区当中拿出数据进行处理,下层网络层不断往接收缓冲区当中放入数据。此时上层应用扮演的就是消费者的角色,下层网络层扮演的就是生产者的角色,而接收缓冲区对应的就是 "交易场所"。
- 因此引入发送缓冲区和接收缓冲区相当于引入了两个生产者消费者模型,该生产者消费者模型将上层应用与底层通信细节进行了解耦,此外,生产者消费者模型的引入同时也支持了并发和忙闲不均。
💕 窗口大小
当发送端将数据发送给对端时,本质就是将自己发送缓冲区中的数据发送到对端的接收缓冲区中。但是缓冲区是有大小的,当接收端处理数据的速度小于发送端发送数据的数据,缓冲区就有可能会被填满。这时发送端再发送数据过来就会造成数据丢包,进而引起丢包重传等一系列的连锁反应。
TCP 报头中包含了 16 位窗口大小
,16 位窗口大小中填充的是自己的接收缓冲区剩余空间的大小,也就是通过 16 位窗口大小告知对方自己的接收能力。
接收端在对发送端发来的数据进行响应时,就可以通过16位窗口的大小告知发送端自己当前接收缓冲区的剩余空间大小此时发送端就可以根据这个窗口大小字段来调整自己发送数据的速度。
- 窗口大小字段越大,说明接收端接收数据的能力越强,此时发送端可以提高发送数据的速度。
- 窗口大小字段越小,说明接收端接收数据的能力越弱,此时发送端可以减小发送数据的速度。
- 如果窗口大小的值为0,说明接收端接收缓冲区已经被打满了,此时发送端就不应该再发送数据了。
理解现象:
- 在编写TCP套接字时,调用read/recv函数从套接字中读取数据时,可能会因为套接字当中没有数据而被阻塞住。本质是因为TCP的接收缓冲区当中没有数据了,我们实际是阻塞在接收缓冲区当中了。
- 当调用write/send函数往套接字中写入数据时,可能会因为套接字已经写满而被阻塞住,本质是因为TCP的发送缓冲区已经被写满了,我们实际是阻塞在发送缓冲区当中了。
- 在生产消费者模型当中,如果生产者生产数据时被阻塞,或消费者消费数据时被阻塞,那么一定是因为某些条件没有就绪而被阻塞。
六个标志位
💕 为什么会存在标志位?
- TCP 报文的种类是多种多样的,除了正常通信的常规报文,还有建立连接时发送的建立连接报文以及断开连接时发送的断开连接报文等等。
- 服务端接收到大量的不同类型的报文时,服务端就要根据报文的类型来进行相应的处理。例如:服务端接收到正常通信的常规报文时,会将该报文放入到接收缓冲区中等待上层应用来读取。当服务端接收到建立连接和断开连接的报文时,操作系统会在 TCP 层内进行三次握手和四次挥手的动作。
- 正是因为不同类型的报文需要对应不同的处理逻辑,我们就需要通过某种策略来区分报文的类型。而 TCP 就是通过报头中的六个标记位来区分报文的类型,这六个标记位都只占一个比特位。其中,1 表示真,0 表示假。例如:如果 TCP 报头中的 ACK 的比特位为 1,说明该报文是一个确认报文。
SYN
(Synchronize Sequence Numbers)同步序列号,是建立连接时使用的握手信号。
- 如果 TCP 报头中的 SYN 被设置为 1,则说明该报文是一个请求建立连接的报文。
- ACK 只会在建立连接阶段被设置为 1,在正常通信阶段时均被设置为 0。
ACK
(Acknowledge Character)确认应答的标记位,该标记位被设置为 1,表示对收到的报文进行确认应答。
- 报文当中的ACK被设置为1,表明该报文可以对收到的报文进行确认。
- 一般除了第一个请求报文没有设置ACK以外,其余的报文基本都会设置ACK,因为发出去的数据本身就对对方发送过来的数据具有一定的确认能力,因此双方在进行数据通信时,可以顺便对对方上一次发送的数据进行响应。
FIN
(Finish)断开连接时使用的结束信号。
- 报文当中的FIN被设置成1,表明该报文是一个连接断开的请求报文。
- 只有在断开连接阶段,FIN才被设置,正常通信时FIN不会被设置。
URG
(Urgent)标记紧急指针是否有效的标记位
双方使用 TCP 协议进行网络通信时,TCP 协议是保证报文按序发送和按序到达的,即便到达接收端的接收缓冲区的顺序和发送时的顺顺序不一样,也可以根据序列号来排序,从而实现按序到达。此时对上层来说从接收缓冲区读取数据时也必须是按顺序读取的。但是有时候发送端可能会发送一些紧急数据,要求接收方优先读取这些数据,此时应该怎么办呢?
此时我们需要用到URG
标志位,以及TCP报头当中的16位紧急指针。
- 当URG标志位被设置成1时,需要通过TCP报头当中的16位紧急指针来找到紧急数据,否则一般情况下不需要关注TCP报头当中的16位紧急指针。
- 16位紧急指针代表的就是紧急数据在报文当中的偏移量。
recv
和 send
函数的第四个参数都提供了一个选项,上层如果想要发送或接收紧急数据,就可以设置此选项。
PSH
(Push)告知对方是否立即读取缓冲区的数据的标记位
- PSH 标记位被设置为 1,则表示发送端要求接收端立即读取缓冲区中的数据。
- 接收端给发送端应答时会在 TCP 报头中填充窗口大小,告诉发送端自己接收缓冲区剩余空间的大小。如果窗口大小为 0,则说明接收缓冲区没有剩余空间,那么发送端会过一段时间再给接收端发送一个 TCP 报头来得知接收端接收缓冲区剩余空间的大小。如果缓冲区剩余空间多次为 0,发送端会发送 PSH 数据包要求接收端立即从接收缓冲区中读取数据。
RST
(Reset)非正常关闭连接的标记位
- RST标志位被设置成1,表示需要让对方重新建立连接。
- 在通信双方连接未建立好的情况下,一方向另一方发数据,此时另一方发送的响应报文当中的RST标志位就会被置为1,表示要求对方重新建立连接。
- 在双方建立好连接进行正常通信时,如果通信中途发现之前建立好的连接出现了异常也会要求重新建立连接。
确认应答机制
TCP 协议是可以保证可靠性的协议,这儿有两个问题就是:
- 是什么原因导致传输的不可靠的呢?
- TCP 协议是如何可靠性的呢?
传输的不可靠是因为传输的距离变长而导致的,因为数据在传输的过程中可能会出现各种各样的错误。
TCP 保证可靠性的一个重要的机制就是 确认应答机制
。当发送端发送数据时,它会等待接收端的确认应答(ACK)。如果接收端成功接收到数据,它会返回一个确认应答给发送端。如果发送端在一定时间内没有收到确认应答,它会认为数据丢失,并重新发送数据。那么这种机制就称为 TCP 协议的确认应答机制,而这种机制可以保证数据的可靠传输。
当一端收到对方的响应消息后,才能保证自己上一次发送的数据被对端可靠的接收到了,但是双方通信时总会有一条最新的消息,因此无法百分之百保证可靠性。
所以我们需要保证双方通信时发送的每一个核心数据都有对应的响应就可以了。而对于一些无关紧要的数据(比如响应数据),我们没必要保证它的可靠性,因为对端如果没有收到这个响应数据,会判定上一次发送的报文丢失了,此时对端可以将上一次发送的数据进行重传。
确认应答机制
不是保证双方通信的全部消息的可靠性,而是只要一方收到了另一方的应答信息,就说明上一次发送的数据被另一方可靠的收到了。
序号和确认序号
序号
- 序号是发送方给每个发送的报文分配的一个编号(TCP 将每个字节的数据都进行了编号),用来标识该报文在数据流中的位置。接收方可以根据序号对接收到的报文进行排序,以确保数据按顺序传输。注:这里的报文指的是携带完整 TCP 报头的 TCP 报文。
在连续发送多个报文时,由于各个报文在进行网络传输时选择的路径可能是不同的,因此这些报文到达对端主机的先后顺序就可能和发送报文的顺序不同,但是报文有序也是可靠性的一种
,因此TCP报头当中的32位序号的作用之一就是来保证报文的有序性的。
比如现在发送端要发送3000字节的数据,如果发送端每次发送1000字节,那么就需要用三个TCP报文来发送这3000字节的数据。此时这三个TCP报文当中的32位序号填的就是发送数据中首个字节的序列号,因此分别填的是1、1001和2001。
此时接收端收到了这三个TCP报文后,就可以根据TCP报头当中的32位序列号对这三个报文进行顺序重排(该动作在传输层进行),重排后将其放到TCP的接收缓冲区当中,此时接收端这里报文的顺序就和发送端发送报文的顺序是一样的了。
确认序号
- 确认序号是接收方返回给发送方的一个编号,表示该编号前的的所有报文都已经接收到了,也可以用来表示接收方期望接收的下一个数据包的序号。发送方可以根据确认序号判断哪些数据包已经被接收方成功接收,哪些数据包需要重新发送。
例如:当服务端收到客户但发送过来的32位序号为1的报文时,由于该报文中含有1000字节的数据,因此服务端已经收到了序列号为1-1000的字节数据,因此服务端给客户端响应时报头当中的32位确认序号的值就会变成1001。意在告诉客户端序列号1001之前的数据我已经收到了,下次发送数据时从序列号为1001的位置开始发送。
部分确认应答:
假设客户端给服务端发送序号为 1000、2000 和 3000 三个报文,服务端也成功接收到了这三个报文,那服务端就可以给客户端回复一个确认序号为 3001 的报文,表示该序号前的报文都接收到了,而不需要回复 1001 或 2001 的报文,这就是部分确认应答。
不给应答:
假设客户端给服务端发送序号为 1000、2000 和 3000 三个报文,服务端只接受到序号为 1000 和 3000 的报文,没有接收到序号为 2000 的报文。那么服务端只能给客户端回复一个确认序号为 1001 的报文,尽管服务端已经接收到了序号为 3000 的报文,这就是不给应答的情况。而这种情况就可能会涉及报文的重传了。
只用序号一个字段就可以表示序号和确认序号两个字段,那么为什么还要使用 序号和确认序号
两个字段呢?
TCP 协议是全双工的通信发送,也就是说 TCP 协议中的通信双方既可以收数据,也可以发数据。那么就可能会出现这种情况:服务端在给客户端应答的时候,也想给客户端发送数据。发送数据就需要有序号,因此这就要求 TCP 报头中要有序号和确认序号两个字段。只有序号一个字段无法区分该序号究竟是序号还是确认序号,也无法实现全双工的通信方式。
超时重传机制
在 TCP 协议中,当发送数据的一方在规定的时间内未收到接收方的确认信号时,就会触发超时重传机制。具体来说,TCP 将每个发送的数据包都标记一个时间戳,如果在规定的时间内未收到确认信号,就会重新发送该数据包。这个时间戳
是根据之前的数据包的发送时间、传输距离以及网络拥塞程度等因素计算得出的。
💕 丢包的两种情况:
发送的数据报文丢失
- 发送的数据报文丢失,此时发送端在一定时间内收不到对应的响应报文,就会进行超时重传。
对方发送的响应报文丢失
- 当对方发来的响应报文丢包时,此时发送端也会因为收不到对应的响应报文,而进行超时重传。
- 当出现丢包时,发送方是无法辨别是发送方的数据报文丢失了,还是对方发来的响应报文丢失了,因为这两种情况下发送方都收不到对方发来的响应报文,此时发送方就只能进行超时重传了。
- 如果是因为接收方发送的应答报文丢失而引起超时重传的话,那么接收方就收到多个重复的报文。但此时也不需要担心,因为接收方可以根据报文的序列号对报文进行去重。
- 当发送缓冲区中的数据被发送出去后,操作系统并不会立即将刚发送出去的数据从缓冲区中移除或者覆盖掉。只有当接收方回复了对应的应答后,我们才能认为对方收到了该数据,否则该数据必须保留在发送缓冲区中等待超时重传。
💕 超时重传的时间
- 超时重传的时间设置的太长,会导致丢包后对方长时间收不到对应的数据,进而影响整体重传的效率。
- 如果超时重传的时间过短,会导致发送方频繁地重传数据。因为应答报文有可能正在网络中传输,并没有丢包,但是超时重传的时间过短,此时发送方就开始重传数据了,这样会导致发送方重传了大量的重复数据,浪费网络资源,降低了网络传输的效率。
最理想的情况就是找到一个最小的时间,保证确认应答一定能在这个时间内返回。但是这个时间是不固定的,其应该根据网络状况和数据传输的要求进行调整。当网络状况好的时候,超时重传的时间可以设置成短一点,提高网络传输的效率;而当网络状况差时,超时重传的时间可以设置成长一点,降低重传的概率和避免网络拥塞。
TCP为了保证无论在任何环境下都能有比较高性能的通信,因此会动态计算这个最大超时时间。
- Linux 中(BSD Unix 和 Windows 也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。
- 如果重发一次之后,仍然得不到应答,等待 2 * 500ms 后再进行重传。
- 如果仍然得不到应答,等待 4 * 500ms 进行重传。依次类推,以指数的形式进行递增。
- 当累计到一定的重传次数时,TCP 认为网络或者对端主机出现异常,进而强制关闭连接。
连接管理机制
TCP 协议是一种面向连接的可靠的通信协议。使用 TCP 协议通信前,客户端和服务器之间需要建立一个连接,并维护这个连接的状态,才能进行数据的传输。
比如一台服务器启动后可能有多个客户端前来访问,如果TCP不是基于连接的,也就意味着服务器端只有一个接收缓冲区,此时各个客户端发来的数据都会拷贝到这个接收缓冲区当中,这些数据可能就会互相干扰。
面向连接是TCP可靠性的一种,只有在通信建立好连接才会有各种可靠性的保证,而一台机器上可能会存在大量的连接,此时操作系统就需要对这些连接来进行管理。
- 操作系统通过先描述再组织的方式来管理连接。在操作系统内部里一定会有一个描述连接的结构体,结构体有着各种字段来描述着连接的各种属性,最终所有定义出来的连接结构体会以某种数据结构的管理起来。此时,操作系统对连接的管理就转换成对该数据结构的增删查改了。
- 建立连接,实际上就是在操作系统内部用该结构体定义一个结构体变量,然后填充该结构体变量的各种属性字段,然后将变量插入到数据结构中。
- 断开连接,实际上将该连接对应的结构体变量从数据结构中移除,然后释放该连接占用的各种资源。
- 操作系统对连接进行管理是有成本的,这个成本包括管理连接的时间成本和存储连接的空间成本。
三次握手
💕 三次握手的过程
客户端和服务端进行 TCP 通信前需要建立好连接,而建立的连接的过程就称之为三次握手。
以客户端和服务端为例,客户端想要和服务端进行通信。客户端主动向服务端发起建立连接的请求,然后客户端和服务端的 TCP 层就自动三次握手建立好连接。
第一次握手
:客户端向服务器发送的报文当中的SYN位被置为1,表示请求与服务器建立连接。第二次握手
:服务器收到客户端发来的连接请求报文后,对客户端发来的连接请求进行响应并向客户端发起连接建立请求。此时服务器向客户端发送的报文当中的SYN位和ACK位均被设置为1.第三次握手
:客户端收到服务器发来的报文后,得知服务器收到了自己发送的连接建立请求,并请求和自己建立连接,最后客户端再向服务器发来的报文进行响应。
客户端向服务器发起的连接建立请求,是请求建立从客户端到服务器方向的通信连接,而TCP是全双工通信,因此服务器在收到客户端发来的连接建立请求后,服务器也需要向客户端发起连接建立请求,请求建立从服务器到客户端方法的通信连接。
💕 为什么是三次握手?
连接建立并不是百分之百能成功的,通信双方在进行三次握手时,其中前两次握手能够保证被对方收到,因为前两次握手都有对应的响应,但是第三次握手是没有对应的响应报文的,如果第三次握手时客户端发送的ACK报文丢失了,那么连接建立就会失败。
对于客户端来说,当他发起第三次握手后就完成了三次握手,但此时服务器并没有收到客户端发来的第三次握手,此时服务器就不会建立对应的连接,所以建立连接时无论采用几次握手,最后一次握手的可靠性都是无法保证的。
三次握手是验证双方通信信道的最小次数
- 对于客户端来说,当它收到服务器发来第二次握手时,说明自己发出的第一次握手被对方可靠的收到了,证明了自己和服务器都是能发能收的。
- 对于服务器来说,当他收到客户端发来的第一次握手时,证明客户端能发且自己能收。当它收到客户端发来的第三次握手时,证明了自己能发且客户端能收。此时就证明了自己和客户端都是能发能收的。
- 因为TCP是全双工通信的,而连接建立的核心要务实际是,验证双方的通信信道是否是连通的。三次握手就足以验证双方通信信道是否正常了。
三次握手能够保证连接建立时的异常连接挂在客户端
- 对于客户端而言,当它收到服务器发来的第二次握手时,客户端就已经认为双方通信信道是连通的了,因此当客户端发出第三次握手后,这个连接就已经在客户端建立了。但是对于服务器来说,只有当它收到客户端发来的第三次握手后,服务器才知道双方通信信道是连通的,此时服务器才会建立对应的连接。
- 因此双方在进行三次握手建立连接时,双方建立连接的时间点是不同的,如果客户端最后发出的第三次握手丢包了,此时在服务器端就不会建立对应的连接,而对于客户端来说就需要短暂的维护一个异常的连接。
- 而维护连接是需要时间成本和空间成本的,因此三次握手还有一个好处就是能够保证连接建立异常时,这个异常连接是挂在客户端的,而对服务器的影响较小。
- 虽然客户端此时需要短暂维护这个异常,但客户端的异常连接不会特别多,不像服务器,一旦多个客户端建立连接时都建立失败了,此时服务器端就需要耗费大量资源来维护这些异常连接。
- 此外,建立连接失败时的异常连接不会一直维护下去。如果服务端长时间客户端发来的第三次握手,就会将第二次握手进行超时重传,此时客户端就有机会重新发出第三次握手。或者当客户端认为连接建立好后向服务器发送数据时,此时服务器会发现没有和该客户端建立连接而要求客户端重新建立连接。
因此,在这里给出两个建立连接时采用三次握手的理由:
- 三次握手是验证双方通信信道的最小次数,能够让能建立起来的连接尽快建立起来。
- 三次握手能够保证连接建立时的异常连接挂在客户端(风险转移)。
💕 为什么不是一次、两次、四次等次数的握手呢?
- 如果是一次握手的话,服务端接收到客户端的 SYN 包就认为建立连接成功的话,这样会带来 SYN 洪水攻击问题。SYN 洪水攻击是攻击者向被攻击主机发送大量的伪造的 TCP 连接请求,从而使得被攻击主机服务器的资源耗尽(CPU 满负荷或内存不足)的攻击方式。
- 如果是两次握手的话,也无法避免 SYN 洪水攻击。因为服务端收到客户端的 SYN 数据包后,会给客户端发送 SYN + ACK 数据包,但服务端无法保证该数据包一定能够被客户端接收到,所以服务端只能在发出 SYN + ACK 数据包后就把连接建立好。如果黑客向服务端发送大量的 SYN 数据包,服务端就只能把连接建立好,进而导致资源耗尽。
- 如果采用三次握手,当服务端接收到客户端的 ACK 数据包才会进行连接的建立,而客户端在发出 ACK 数据包时就把连接建立好了。此时,如果客户端对服务端进行 SYN 洪水攻击,那么客户端中也会存在大量的连接,它也是要付出代价的。
- 三次握手还有验证全双工的作用。客户端通过发送 SYN 数据包和接收 SYN + ACK 数据包来证明自己即能收也能发,服务端通过发送 SYN + ACK 数据包和接收 ACK 数据包来证明自己即能收又能发,而一次握手和两次握手都无法验证 TCP 是全双工的。
💕 套接字和三次握手之间的关系
- 在客户端发起连接建立请求之前,服务器需要先进入LISTEN状态,此时就需要服务器调用对应listen函数。
- 当服务器进入LISTEN状态后,客户端就可以向服务器发起三次握手了,此时客户端对应调用的就是connect函数。
- 需要注意的是,connect函数不参与底层的三次握手,connect函数的作用只是发起三次握手。当connect函数返回时,要么是底层已经成功完成了三次握手连接建立成功,要么是底层三次握手失败。
- 如果服务器端与客户端成功完成了三次握手,此时在服务器端就会建立一个连接,但这个连接在内核的等待队列当中,服务器端需要通过调用accept函数将这个建立好的连接获取上来。
- 当服务器端将建立好的连接获取上来后,双方就可以通过调用read/recv函数和write/send函数进行数据交互了。
四次挥手
客户端和服务端结束通信时需要断开连接,断开连接的过程就是四次挥手。
当客户端与服务器通信结束后,需要与服务器断开连接,此时就需要进行四次挥手:
- 第一次挥手:客户端向服务器发送的报文当中的FIN位被设置为1,表示请求与服务器断开连接。
- 第二次挥手:服务器收到客户端发来的断开连接请求后对其进行响应。
- 第三次挥手:服务器收到客户端断开连接的请求,且已经没有数据需要发送给客户端的时候,服务器就会向客户端发起断开连接请求。
- 第四次挥手:客户端收到服务器发来的断开连接请求后对其进行响应。
这里需要注意的是:四次挥手当中的第二次和第三次挥手不能合并在一起,因为第三次挥手是服务器想要和客户端断开连接时向客户端发起的请求。但是当服务器收到客户端断开连接的请求并响应后,服务器并不一定会马上发起第三次挥手,因为服务器可能还有某些数据要发送给客户端,只有当服务器将这些数据发送完以后才会向客户端发起第三次挥手。
如果收到客户端的 FIN 报文时,服务端没有数据需要进行处理,那么 ACK 和 FIN 会在同一个报文中同时设置为 1,此时四次挥手就变成了三次挥手。
💕 四次挥手的状态变化
- 客户端向服务端发送 FIN 报文后,进入 FIN_WAIT_1 状态。
- 服务端向客户端响应 ACK 报文后,立马进入 CLOSE_WAIT 状态。
- 客户端收到服务端发送的 ACK 报文后,客户端的状态会从 FIN_WAIT_1 变成 FIN_WAIT_2。
- 服务端向客户端发送 FIN 报文后,服务端的状态会从 CLOSE_WAIT 变成 LAST_ACK。
- 客户端收到服务端发送的 FIN 报文后,客户的状态会从 FIN_WAIT_2 变成 TIME_WAIT,并给服务端发送 ACK 报文。
- 服务端收到客户端发送的 ACK 报文后,服务端的状态会从 LAST_ACK 变成 CLOSED。
- 主动关闭连接的一方进行第四次挥手后,需要维持 TIME_WAIT 状态一段时间,这个时间的大小是
2 MSL
(Max Segment Lifetime, 报文最大生存时间),然后才能进入 CLOSE 状态。
套接字和四次挥手之间的关系:
- 客户端发起断开连接请求,对应就是客户端主动调用close函数。
- 服务器发起断开连接请求,对应就是服务器主动调用close函数。
- 一个close对应的就是两次挥手,双方都要调用close,因此就是四次挥手
四次挥手不能够保证一定成功。四次挥手可能会失败的情况包括:客户端或服务器发送的 FIN 数据包丢失,客户端或服务器发送的 ACK 应答报文丢失。如果出现这些情况,客户端或服务器会重传丢失的数据包,直到连接断开或达到最大重传次数。
CLOSE_WAIT
状态
服务端会在被动关闭连接的情况下,在接收到 FIN 数据包但尚未发送自己的 FIN 数据包时,进入 CLOSE_WAIT 状态。通常,CLOSE_WAIT 状态在服务器上的停留时间应该很短。但是,如果服务器上出现大量的 CLOSE_WAIT 状态,那么可能意味着被动关闭的一方没有及时发出 FIN 数据包,也就是说应用层没有调用 close 函数关闭文件描述符。
TIME_WAIT
状态
客户端在进行四次挥手后进入TIME_WAIT状态,如果第四次挥手的报文丢包了,客户端在一段时间内仍然能够接收服务器重发的FIN报文并对其进行响应,能够较大概率保证最后一个ACK被服务器收到。\n客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方。因此客户端四次挥手后进入TIME_WAIT状态,还可以保证双方通信信道上的数据在网络中尽可能的消散。
TIME_WAIT
的等待时长既不能太长也不能太短。TCP协议规定,主动关闭连接的一方在四次挥手后要处于TIME_WAIT状态,等待两个MSL(Maximum Segment Lifetime,报文最大生存时间)的时间才能进入CLOSED状态。虽然MSL在RFC122中规定为两分钟,但各个操作系统的实现不同,比如在Centos7上默认配置的值是60s。
流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应。TCP支持根据接收端的接收数据能力来决定发送端发送数据的速度,这个机制就叫做 流量控制(Flow Control)
。
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
- 窗口大小字段越大, 说明网络的吞吐量越高;
- 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
- 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
- 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.
三次握手时,会通过 TCP 报头中的窗口大小字段来告知对方自己的接收缓冲区剩余空间的大小。那么在正式进行网络通信时,就已经得知对方的接收能力了,可以根据对方的接收能力来发送数据。
16位窗口字段最大能表示的值是 65535,那么 TCP 窗口最大就是 65535 字节么?并不是,实际上,TCP 报头 40 字节选项中还包含了一个窗口扩大因子 M,实际窗口大小是窗口字段的值左移 M 位,M 的取值范围是 0 到 14。
滑动窗口
TCP 是每发送一个数据,都要进行一次应答。当上一个数据收到了应答,再发送下一个数据。这样的模式有非常明显的缺点就是性能较差。当数据往返时间越长时,网络的吞吐量会越低。
实际上双方在进行TCP通信时可以一次向对方发送多条数据,这样可以将等待多个相应的时间重叠起来,进而提高数据通信的效率。
需要注意的是,虽然双方在进行TCP通信时可以一次向对方发送大量的报文,但不能将自己发送缓冲区当中的数据全部打包发送给对端,在发送数据时还要考虑接收方的接受能力。
这里我们可以将发送缓冲区分成三部分:
- 已经发送出去且受到ACK确认的数据
- 已发送但还没有收到ACK确认的数据
- 未发送的数据
发送缓冲区第二部分数据所占的空间就是滑动窗口的空间。滑动窗口的大小是指无需等待 ACK 应答而可以继续发送数据的数量的最大值。
滑动窗口存在的最大意义就是可以提高发送数据的效率:
- 滑动窗口的大小等于对方窗口大小与自身拥塞窗口大小的较小值,因为发送数据时不仅要考虑对方的接受能力,还要考虑当前网络的状况。
- 这里先不考虑拥塞窗口的大小,假设对方的窗口大小一直是4000,此时发送方不用等待对方应答一次所能发送的数据就是4000字节。因此滑动窗口的大小就是4000字节。
- 现在发送方可以直接向对方连续发送 1001 到 2000、2001 到 3000、3001 到 4000 和 4001 到 5000 这四个段的数据,不需要等待对方的应答。
- 当收到对方响应的确认序号为2001时,说明1001-2000这个数据段已经被对方收到了,此时滑动窗口可以继续向右移动,因为滑动窗口的大小固定为 4000(对方窗口的大小),所以 5001 到 6000 这段数据可以直接向对方发送,以此类推。
- 滑动窗口越大,则网络的吞吐率越高,同时也说明对方的接受能力很强。
当发送方发送出去的数据段陆陆续续收到对应的ACK时,就可以将收到的ACK数据段移出滑动窗口,置于滑动窗口的左侧。而窗口是否会向右移动则取决于对方的接收能力(窗口大小)如果对方可以接收更多的数据,那么窗口可以向右移动将位于窗口右侧的数据包含进窗口中,进行数据的发送。
TCP的重传机制要求暂时保存发出但是未收到确认的数据,而这部分数据实际就位于滑动窗口当中,只有滑动窗口左侧的数据才是可以被覆盖或者删除的。因此,滑动窗口不仅能够保证能向对方一次性发送多条数据,而且还保证了数据的超时重传。
💕 滑动窗口的本质
滑动窗口的本质就是winstart和winend之间的一段空间,窗口的左边界和右边界进行有移就是指针或下标进行加等操作。
窗口大小的更新:
- winstart = 收到应答报文中的确认序号seq
- winend = winstart + 收到应答报文中的窗口大小
💕 滑动窗口一定能够向右移动吗?滑动窗口如果一直向右移动会造成越界问题吗?
滑动窗口不一定能够向右移动。因为滑动窗口的大小是受对方窗口大小的限制的,如果对方应用层没有从接收缓冲区中拿出数据,这样对方的窗口会越来越小,因此滑动窗口会越来越小,无妨向右移动。
滑动窗口并不一定是整体向右移动的,可能是窗口的左边界进行移动而右边界不移动,因为对方的窗口大小是不固定的,随时在变化,所以滑动窗口的大小也不是固定的,也是随时都在变化。
滑动窗口一直向右移动并不会造成越界问题。因为发送缓存区是被看成环形队列的,当滑动窗口向右移动时,如果超出了缓冲区的范围,那么就会进行模除运算,重新回到缓冲区的起始位置进行向右移动。
滑动窗口的大小可以为零。当对方一直不从接收缓冲区中拿取数据,会导致发送方的滑动窗口越来越小直至为零。
💕 丢包问题
数据已经被接收方接收,ACK丢包
在发送端连续发送多个报文数据时,部分ACK丢包并不要紧,此时可以通过后续的ACK进行确认。
数据包直接丢失了
当1001-2000的数据包丢失后,发送端会一直收到确认序号为1001的响应报文,以此来提醒发送端 "下一次应该从序号为1001的字节数据开始发送"。如果发送端连续收到三次确认序号为1001的响应报文,此时就会将1001-2000的数据包重新进行发送。这个时候接收端收到了 1001 之后,再次返回的 ACK 就是7001了,因为 2001 - 7000 的数据接收方之前就已经收到了,被放到了接收的接收缓冲区中。
这种重传机制就是 快重传机制
。快速重传机制的实际方式是:当发送方收到三个相同的 ACK 时,就认为是前一个数据包已经丢了,并立即重传。这个机制提高重传的效率。
💕 快重传 VS 超时重传
- 快重传能够进行数据的快熟重传,当发送方连续收到三个相同的 ACK 时,就会触发快重传机制。而快重传机制不需要向超时重传那样通过设置重传定时器,在一定的时间后进行重传。
- 虽然快重传能够快速判断数据包丢失,但是快重传并不能完全取代超时重传,因为有时候数据包丢失后并没有收到对方三次重复的应答,此时快重传机制就触发不了,而只能进行超时重传。因此超时重传是所有重传机制的保底策略,也是必不可少的。
拥塞控制
双方通过 TCP 进行通信时,出现丢包问题是非常正常的,此时就通过超时重传或者快重传进行数据的重传。但是如果双方进行通信时出现了大量的丢包,此时就不能认为这是正常现象了。
TCP 协议不仅考虑到了两个主机端到端的可靠性问题,还考虑了网络状态的问题。
双方网络通信时出现少量的丢包是允许的,但是一旦出现大量丢包,此时TCP就不再推测是双方接收和发送数据的问题,而是判断双方通信信道网络出现了拥塞问题。
💕 如何解决网络拥塞问题?
网络拥塞时影响的不只是一台主机,而几乎是该网络当中所有的主机,此时所有使用TCP传输控制协议的主机都将执行拥塞避免算法。
因此拥塞控制看似只是谈论一台主机上的通信策略,实际上这个策略是所有主机在网络崩溃后都会遵守的策略,一旦出现网络拥塞,该网络中的所有主机都会受到影响。此时所有的主机都要执行拥塞避免,这样才能有效缓解网络拥塞问题。通过这样的方式就能保证雪崩不会发生,或者雪崩发生后可以尽快恢复。
💕 拥塞控制
虽然 TCP 协议有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量数据,就可能会引起网络拥塞问题。
因此TCP引入了慢启动机制,在刚开始通信时先发送少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输控制。
- TCP除了有窗口大小和滑动窗口的概念以外,还有一个窗口叫做拥塞窗口。拥塞窗口是可能引起网络拥塞的阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
- 刚开始发送数据的时候拥塞窗口大小定义以为1,每收到一个ACK应答拥塞窗口的值就加一。
- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,即滑动窗口的大小。
每收到一个 ACK 应答,拥塞窗口的大小就增加一,那么拥塞窗口的大小是以指数的形式进行增长的。指数形式增长只是初始时增长较慢,也就是所谓的慢启动,但是越往后增长就后越快,这时候就有可能短时间内又造成了网络拥塞的问题。
- 为了避免短时间内有造成网络拥塞的问题,那么就不能让拥塞窗口大小一直以指数的形式增长下去。
- 此时就引入了一个慢启动的阈值,当拥塞窗口的大小超过这个阈值时,拥塞窗口大小就不在意指数的形式进行增长了,而是以线性的形式进行增长。
- 当 TCP 刚开始启动的时候,慢启动的阈值被设置为对方窗口大小的最大值。
- 在每次超时重传的时候,慢启动的阈值会变成当前拥塞窗口大小的一般,同时拥塞窗口的大小变成一,重新开始增长。
- 指数增长:刚开始进行TCP通信时拥塞窗口的值为1,并不断按指数的方式进行增长。
- 线性增长:慢启动的阈值初始时被设置为对方窗口大小的最大值,上图中的慢启动阈值为 16。当拥塞窗口的大小增长到慢启动阈值时就不再以指数形式进行增长,而采用线性增长的方式。注意:线性增长阶段时,还未发生网络拥塞问题。
- 乘法减小。拥塞窗口在线性增长的过程中,在增大到24时如果发生了网络拥塞,此时慢启动的阈值将变为当前拥塞窗口的一半,也就是12,并且拥塞窗口的值被重新设置为1,所以下一次拥塞窗口由指数增长变为线性增长时拥塞窗口的值应该是12。
少量的丢包,我们仅仅是触发超时重传或快重传。大量的丢包,我们就认为网络拥塞。当 TCP 通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。拥塞控制,归根结底是 TCP 协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
延迟应答
如果接收数据的主机收到数据后立即进行ACK应答,此时返回的窗口可能比较小。
- 假设对方接收端缓冲区剩余空间大小为1M,对方一次收到500K的数据后,如果立即进行ACK应答,此时返回的窗口就是500K。
- 但实际接收端处理数据的速度很快,10ms之内就将接收缓冲区中500K的数据消费掉了。
- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
- 如果接收端稍微等一会再进行ACK应答,比如等待200ms再应答,那么这时返回的窗口大小就是1M。
接收方在接收到数据后,并不会立即发送 ACK 应答,而且是等候一段时间(一般是200ms),等接收方上层处理完数据后再给发送方发送一个更大的窗口大小,这种机制就是 TCP 协议的 延迟应答
机制。延迟应答机制并不是为了保证可靠性的,而是为了提高效率的一种策略。
有了延迟应答机制,发送给对方的窗口大小就会越大,网络吞吐量就越大,传输效率就越高。延迟应答机制的目标是在保证网络不拥塞的情况下尽量提高传输效率。
需要注意的是,并不是所有的数据包都可以延迟应答。
- 数量限制:每隔 N 个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次(这个时间不会导致超时重传)。
具体的数量和超时时间,依操作系统不同也有差异。一般 N 取2,超时时间取 200ms。
捎带应答
捎带应答
是双方进行 TCP 通信时最常见的一种方式。TCP 协议的捎带应答机制就是发送的同一个 TCP 数据包中即包含数据又包含 ACK 应答的一种机制。
比如:主机 A 给主机 B 发送一条消息,主机 B 收到该消息后就回复给出 ACK 应答。但如果主机 B 也有消息需要给主机 A 发送,那么这个 ACK 应答就可以和该消息放在同一个报文中,而不需要再单独发送一个 ACK 应答了。此时,主机 B 发送的这个既完成了对收到的数据的应答,又完成了自己数据的发送。
捎带应答最直观的角度实际也是发送数据的效率,此时双方通信时就可以不同再发送单纯的确认报文了。
由于捎带应答的报文中携带了有效数据,因此当对方收到该报文后会进行响应,当收到这个响应报文时不仅能够确保发送的数据能够被对方可靠的收到,同时也确保了捎带的ACK应答也被对方可靠的收到。
面向字节流
当创建一个TCP的套接字时,会在内核中创建一个发送缓冲区 和一个接收缓冲区。
- 调用write函数就可以将数据写入发送缓冲区中,此时write函数就可以进行返回了,接下来发送缓冲区当中的数据就是由TCP自行进行发送的。
- 如果发送的字节数太长,TCP会将其拆分成多个数据包发出。如果发送的字节数太短,TCP可能会先将其留在发送缓冲区当中,等到合适的时机再进行发送。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,然后通过调用read函数来读取接收缓冲区当中的数据。
- 而调用read函数读取接收缓冲区中的数据时,也可以按任意字节数进行读取。
由于缓冲区的存在,TCP 程序的读和写不需要一一匹配。
- 写 100 个字节数据时,可以调用一次 write 函数写 100 个字节,也可以调用 100 次 write 函数,每次写一个字节。
- 读 100 个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read 100 个字节,也可以一次 read 一个字节, 重复 100 次。
对应 TCP 来说,根本就不关心发送缓冲区中存储的是什么数据,在它看来就是一个个字节的数据,它只需要将这些数据可靠地发送到对方的接收缓冲区中就行了。而对方的上层也不会关心接收缓冲区的数据是什么数据,只需要将其一个字节一个字节地读取上来就行了。像这种不关心数据格式的数据通信流程,就被称为面向字节流。
而 UDP 的面向数据报就是将上层应用层交下来的数据看做一个独立的报文,既不进行拆分,也不进行合并,添加上 UDP 报头后向下交付给网络层处理。而上层应用层收到数据时,就认为该数据是一个完整的报文,可以直接对其进行处理。面向数据包就好像是我们平时收快递的样子,当我们收到一个快递时,我们就知道对方发送了一个快递。当我们收到五个快递时,对方就发送了五个快递。这种数据通信流程就被称为面向数据报。
粘包问题
💕 什么是粘包?
- 首先要明确的是,粘包问题中的 "包",是指的应用层的数据包。
- 在 TCP 的协议报头中,没有向 UDP 协议一样的 "报文长度" 这样的字段,但是有一个序号这样的字段。
- 站在传输层的角度,TCP 协议是一个一个报文传输过来的,并且按照序号排好序放在缓冲区中。
- 但是站在应用层的角度,看到的只是一串连续的字节数据。
- 那么应用程序看到了这么一连串的字节数据,并不知道从哪个部分开始到哪个部分是一个完整的应用层数据包,这就是所谓的粘包。
💕 如何解决粘包问题?
- 对于定长的包,保证每次都按固定大小读取即可。
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如 HTTP 报头使用 Content-Length 字段来表示正文的长度。
- 对于变长的包,还可以在包和包之间使用明确的分隔符。应用层协议可以由程序员自己来定制的,只要保证分隔符不和正文冲突即可。
💕 UDP是否存在粘包问题?
- 对于 UDP协议,如果还没有向上层交付数据,UDP 的报文长度仍然存在。同时 UDP 协议是一个一个把数据交付给应用层,就有很明确的数据边界。
- 站在应用层的角度,使用 UDP 协议进行通信的时,要么是收到完整的 UDP 报文,要么就是没有收到 UDP 报文,不会出现读取到半个 UDP 报文的情况。
因此UDP是不存在粘包问题的,根本原因就是UDP报头当中d额16位UDP长度记录UDP报文的长度,因此UDP在底层的时候就把报文和报文之间的边界明确了,而TCP存在粘包问题就是因为TCP是面向字节流的,TCP报文之间没有明确的边界。
TCP异常情况
💕 进程终止
当客户端正常访问服务器时,如果客户端进程突然崩溃了,此时建立好的连接会怎么样?
当一个进程退出时,该进程曾经打开的文件描述符都会自动关闭,因此当客户端进程退出时,相当于自动调用了close函数关闭了对应的文件描述符,此时双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源。所以即使客户端进程退出了,还是能与服务器完成 TCP 四次挥手释放连接的。
💕 机器重启
当客户端和服务端正常通信时,客户端机器重启,那么建立好的连接会怎么办呢?
当客户端选择机器重启时,操作系统会把正在运行的所有进程杀掉再进行机器重启,因此机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。
💕 机器掉电/网线断开
当客户端机器掉电或网线断开时,客户端的连接会自动关闭掉,但是服务端是无法感觉客户端已经关闭连接了,因此服务端还会保持连接。但是这个连接并不会一直保持,因为 TCP 协议具有保活机制。
- 服务器会定期检查客户端的存在状况,每隔一个时间间隔,服务器就会向客户端发送一个探测报文,该探测报文包含的数据非常少。如果连续发送几个探测报文都没有得到应答,此时服务器就会关闭这条连接。
- 此外,客户端也可能会定期向服务器"报平安", 如果服务器长时间没有收到客户端的消息,此时服务器也会将对应的连接关闭。
另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ断线之后, 也会定期尝试重新连接。
TCP小结
为什么TCP这么复杂? 因为要保证可靠性,同时又尽可能的提高性能。
可靠性:
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
💕 TCP定时器
- 重传定时器:为了控制丢失的报文段或丢弃的报文段,也就是对报文段确认的等待时间。
- 坚持定时器:专门为对方零窗口通知而设立的,也就是向对方发送窗口探测的时间间隔。
- 保活定时器:为了检查空闲连接的存在状态,也就是向对方发送探查报文的时间间隔。
- TIME_WAIT定时器:双方在四次挥手后,主动断开连接的一方需要等待的时长。
基于TCP的应用层协议
常见的基于TCP的应用层协议如下:
- HTTP(超文本传输协议)
- HTTPS(安全数据传输协议)
- SSH(安全外壳协议)
- Telnet(远程终端协议)
- FTP(文件传输协议)
- SMTP(电子邮件传输协议)
当然,也包括我们自己写TCP程序时自定义的应用层协议。
TCP与UDP的对比
TCP 协议和 UDP 协议的区别主要有一下几个方面:
连接
:TCP 是面向连接的协议,而 UDP 是无连接的协议。可靠性
:TCP 协议提供可靠的数据传输,即数据包在传输过程中会进行确认、重传和流量控制等机制,而 UDP 协议则不提供可靠性保证。传输效率
:由于 TCP 协议提供了许多可靠性保证机制,因此其传输效率相对较低;而 UDP 协议则没有这些机制,因此传输效率相对较高。
TCP 协议和 UDP 协议没有明显的好坏之分,我们需要根据具体的应用场景来选择合适的协议。TCP 协议和 UDP 协议的具体应用场景:
- TCP 协议适用于需要可靠传输保障的应用场景,如:文件传输、电子邮件和网页访问等等。
- UDP 协议适用于要求传输效率高、实时性高的应用场景,如:实时音视频传输、游戏直播等等。
💕 用UDP实现可靠传输
可以参考 TCP 协议保证可靠性的做法,在应用层实现类似的功能。例如:
- 引入序列号:为每个数据包分配一个唯一的序列号,接收方可以使用序列号来检测数据包的丢失和顺序错误,并进行必要的处理。
- 引入确认应答:发送方在发送数据包后等待一段时间,如果没有收到确认响应,就会认为数据包丢失,然后重新发送。接收方在收到数据包后发送确认响应。
- 引入超时重传:发送方在发送数据包后启动一个计时器。如果在一定时间内未收到确认响应,就认为数据包丢失,然后进行重传。
因为TCP保证可靠性的机制太多了,所以当我们被面试官问道这个问题时,最好问一问这个用UDP实现可靠传输的应用场景是什么?在某些场景下可能只需要引入TCP的部分机制就行了,因此在不同的应用场景下模拟实现UDP可靠传输的侧重点也不同。
TCP相关实验
CLOSE_WAIT状态实验
当客户端想要与服务器断开连接时,双方会进行四次挥手。当客户端第一次主动向服务器发起断开连接请求时,客户端操作系统底层就会向服务器发起FIN请求,服务器收到该请求后就会对其进行ACK响应。
如果服务器收到客户端的FIN请求后,服务器不调用close函数关闭对应的文件描述符,那么服务器也就不会给客户端发送FIN请求。也就是四次挥手只完成了前两次,此时客户端和服务器的状态就会分别变成 FIN_WAIT_2
和 CLOSE_WAIT
。
我们可以修改一下之前的HTTP服务器代码,让客户端向服务器发起断开连接请求时,服务器不要调用close函数关闭对应的文件描述符。
断开连接前:
telnet退出(客户端退出后):
这里我们可以看到,客户端维护的连接的状态变成了FIN_WAIT_2,而服务器维护的连接的状态变成了CLOSE_WAIT。
TIME_WAIT状态实验
当主动发起四次挥手的一方在四次挥手后,不会立即进入CLOSE状态,而是进入短暂的TIME_WAIT状态等待若干时间,最终才会进入CLOSE状态。
当上面的代码在telnet退出后让服务器进程退出就可以让客户端和服务器完成最后的两次挥手了,因为文件描述符的生命周期是随进程的,当进程退出后,该进程所对应的文件描述符就会自动关闭,服务器和客户端也就可以完成剩下的两次挥手了。
TIME_WAIT状态引起的bind失败解决方案
主动发起四次挥手的一方在四次挥手后,会进入TIME_WAIT状态,如果有客户端连接服务器的情况下服务器进程退出了,就相当于服务器主动发起了四次挥手,此时服务器维护的连接在四次挥手后会进入TIME_WAIT状态。
在连接处于TIME_WAIT状态期间,如果服务器想要再次重新启动,就会出现bind失败的问题。
其实这是因为在TIME_WAIT期间,这个连接并没有被完全释放掉,也就意味着服务器绑定的端口号仍然是被占用的,此时服务器想要继续绑定该端口号,就只能等待TIME_WAIT状态结束。
如果想要让服务器在崩溃后的TIME_WAIT期间也能立马重新启动,需要让服务器在调用socket函数创建套接字后,继续调用setsockopt
函数设置端口复用,这也是编写服务器代码时的推荐做法。
setsockopt函数
cpp
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数说明:
- sockfd:需要设置的套接字对应的文件描述符。
- level:被设置选项的层次。比如在套接字层设置选项对应就是SOL_SOCKET。
- optname:需要设置的选项。该选项的可取值与设置的level参数有关。
- optval:指向存放选项待设置的新值的指针。
- optlen:待设置的新值的长度。
返回值说明:设置成功返回0,设置失败返回-1,同时错误码会被设置。
这里我们需要设置监听套接字,将监听套接字在套接字层设置端口复用选项 SO_REUSEADDR,该选项设置为非零表示开启端口复用。
listen的第二个参数
其中listen函数的第一个参数就是需要设置为监听状态的套接字,而listen的第二个参数我们一般设置为5,那么listen函数的第二个参数具体的含义是什么呢?
这里我们可以做一个实验,来说明listen的第二个参数的具体含义:
先编写TCP套接字的服务端代码,服务器初始化时依次进行套接字创建、绑定、监听,但服务器初始化后不调用accept函数获取底层建立好的连接。这里为了方便验证,将listen函数的第二个参数设置为1。
这说明了服务器没有对第三个客户发来的连接请求进行响应。此时就算我们再次向服务器发起连接请求,在服务器端也不会再新增任何状态的连接了。
实际上TCP在进行连接管理时会用到两个连接队列:
- 全连接队列(accept队列): 用于保存处于ESTABLISHED状态,但没有被上层调用accept取走的连接。
- 半连接队列,用于保存处于SYN_SENT和SYN_RCVD状态的连接,也就是还未完成三次握手的连接。
全连接队列的长度实际会受到listen第二个参数的影响,一般TCP全连接队列的长度就等于listen第二个参数的值加一。
这次实验设置的listen的第二个参数值为1,在服务端全连接队列的长度就为2,因此服务器最多只允许有三个处于ESTABLISHED状态的连接。
状态为SYN_RCVD 的连接实际上是放在半连接队列当中的。
💕 为什么连接队列不能太长?
如果队列太长,也就意味着在队列较尾部的连接需要等待较长时间才能得到服务,此时客户端的请求也就迟迟得不到响应。而且,服务器维护连接也是需要成本的,连接队列设置的越长,系统就要花费越多的成本去维护这个队列。与其这样,还不如将维护队列的成本节省出来给服务器使用,让服务器更快的为客户端提供服务。
💕 全连接队列的长度
全连接队列的长度由两个值决定:
- 用户层调用listen时传入的第二个参数backlog
- 系统变量net.core.somaxconn,默认值为128
全连接队列的长度实际等于listen传入的backlog和系统变量net.core.somaxconn中的较小值加一。