文章目录
- 一、传输控制协议TCP
- 二、TCP报文格式
- 三、确认应答和超时重传机制
- 四、TCP连接管理机制
-
- [4.1 三次握手状态变化](#4.1 三次握手状态变化)
- [4.2 三次握手的原因](#4.2 三次握手的原因)
- [4.3 四次挥手状态变化](#4.3 四次挥手状态变化)
- 五、TCP提高传输效率策略
-
- [5.1 流量控制](#5.1 流量控制)
- [5.2 滑动窗口](#5.2 滑动窗口)
- [5.3 快速重传](#5.3 快速重传)
- [5.4 延迟应答](#5.4 延迟应答)
- 六、拥塞控制
- [七、 面向字节流和粘包问题](#七、 面向字节流和粘包问题)
- [八、TCP 小结](#八、TCP 小结)
一、传输控制协议TCP
TCP全称为传输控制协议,它既要高效,更加要保证可靠性。TCP协议具有控制性,应用层使用 write、send
等接口把数据拷贝到内核缓冲区里,至于内核什么时候通过网络发送到目标主机、发多少、出错了怎么办,等问题由TCP协议自主决定,所以我们把TCP叫做"传输控制协议"
而数据发送的本质就是把数据,从发送方的发送缓冲区拷贝到接收方的接收缓冲区。而之前我们讲过一切皆文件,当struct file
不再指向磁盘文件,而是指向网卡文件时,便是通过网络进行通信。write、read、recv、send
等接口本质就是一个拷贝函数,而我们能用同一个文件描述符来同时进行读写操作,是因为每一个文件描述符都配备了一对读写缓冲区。各自的一对读写缓冲区保证TCP既能接收也能发送数据,通信双方的地位是对等的,因此TCP是全双工的
二、TCP报文格式
2.0 协议字段图示
TCP报文由如下部分组成:
1、端口号: 标明数据是从哪个进程来, 到哪个进程去
2、序列号和确认序号:序列号:表示本报文段所发送数据的第一个字节的编号
确认序号:该序号之前的报文已经全部收到了,希望对方的下一个报文从该序号开始发送
3、4位首部长度: 基本单位为4字节,最大为15*4字节,因此选项最大为60-20=40
字节
4、6位保留字段: 预留空间
5、6个标记为:用来分辨不同报文的类型URG:紧急指针是否有效
ACK:确认序号是否有效
PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走
RST:对方要求重新建立连接,该报文称为复位报文段
SYN:请求建立连接,该报文称为同步报文段
F I N:断开连接,通知对方本端要关闭了,该报文称为结束报文段
6、16位窗口大小: 告诉对方自己的剩余接收缓冲区空间大小,用于流量控制(选项字段可以扩充其大小)
7、校验和: 检验报文的完整性
8、16位紧急指针: 配合URG标志位使用,用于指出紧急数据在该报文的位置,一般为一个字节。(处理完紧急数据后,TCP会通知应用程序恢复常规操作。即使接收方窗口大小为 0,也可发送紧急数据,因紧急数据无需缓存)
9、选项字段: 长度不定,但必须是4字节的整数倍,需要首部长度来获得具体长度
三、确认应答和超时重传机制
确认应答机制
确认应答机制是通过TCP报头中的32位序号 和32位确认序号 来实现的,它主要是通过发送方收到接收方的确认应答来确保发送方的通信是可靠的,而当双方都采用这种机制时,就间接保证了整个通信过程的可靠性。
序列号: 表示本报文段所发送数据的第一个字节的编号
确认序号: 接收方期望收到对方下一个报文数据块的第一个字节编号,即 32位确认序号=收到的32报文序号+1
。TCP双方通信时,并不是一次只发一个报文,而是一次发一批报文。只要收到了最后一个报文的ACK,哪怕之前报文的ACK丢了也不影响,因为确认序号表示该序号之前的报文,对方都已经全部收到了
为什么需要俩个序号? 因为双方通信是对等的,确认序号收到了对方的数据,而序号保证自己发送的数据按序到达
超时重传机制
双方在进行网络通信时,如果在一个特定时间内收不到应答,无论是发送的报文丢了或者是应答丢了,我们都认定为超时或者丢包,需要重传。而重传意味着对方可能会收到重复的TCP报文,对此需要对方对报文进行去重,这就是32位序号的另外一个作用。显然发送方在收到这个报文的应答以前,也必须具备保存这个报文的能力,后面滑动窗口我们会提到。
超时重传的时间设定
- 超时重传的时间的设置,太长会影响整体效率;太短会造成资源浪费,它和网络状态应该是强相关的。
- Linux中(BSD Unix和Windows也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的时间都是500ms的整数倍
- 如果重发一次之后,仍然得不到应答,等待2x500ms后再进行重传
- 如果仍然得不到应答,等待 4x500ms 进行重传,依次类推,以指数形式递增
- 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接
四、TCP连接管理机制
在现代网络通信中,TCP的面向连接特性是实现可靠传输的基础。服务器需要同时管理大量连接,操作系统为此设计了一种结构体来描述每个连接的属性,并通过数据结构对这些连接进行组织和管理。建立连接时,操作系统创建并填充结构体,将其加入管理数据结构;断开连接时,则移除结构体并释放资源。
客户端和服务器是通过三次握手来建立连接,四次挥手来断开连接。双方通过确认应答机制来保证可靠性,而第三次握手,客户端发出 ACK 是没有应答的。此时,客户端发出ACK后就认为连接已经建立好了,服务器如果没有收到这个 ACK,就认为连接没有建立好。此时,如果客户端发来数据,服务器就会发送RST报文给客户端,要求重新建立连接
三次握手其中有一个 SYN+ACK 被合并在一起了,形成捎带应答,因为服务器是被动的,必须无偿接受客户端发来的连接。而四次挥手,是因为客户端发来断开连接的请求,表明自己没有数据要发了,但是此时服务器可能还有数据要向客户端发送,所以把 FIN 和 ACK 分开了,变成了四次挥手而不是三次挥手
4.1 三次握手状态变化
三次握手时:
服务端状态转化:
[CLOSED -> LISTEN]
服务器端调用 listen 后进入LISTEN
状态,等待客户端连接[LISTEN -> SYN_RCVD]
客户端发来 SYN,服务器将该连接放入内核半连接队列中,并发回 SYN + ACK 确认报文[SYN_RCVD -> ESTABLISHED]
客户端发出最后一个 ACK,服务器收到后进入ESTABLISHED
状态,并把该连接从半连接中拿出来,放到全连接队列中
客户端状态转化:
[CLOSED -> SYN_SENT]
客户端调用 connect,发送SYN[SYN_SENT -> ESTABLISHED]
收到 SYN + ACK,发出 ACK后 则进入ESTABLISHED
状态,开始读写数据
补充:sock API和三次握手的关系
- 服务器调用
listen
函数时,套接字从 CLOSED 状态转变为 LISTEN 状态。此时,服务器开始等待客户端的连接请求。在 LISTEN 状态下,服务器的套接字会创建两个队列,半连接和全连接队列- 服务器调用
accept
函数时,会检查全连接队列。如果队列中有连接请求,就会将其从队列中取出,并创建一个新的套接字,这个新套接字专门用于与该客户端进行数据交互。此时,原始套接字仍然处于 LISTEN 状态,继续等待其他客户端的连接请求。三次握手的过程和accept
函数没有关系,它只负责从全连接队列中提取连接进行通信- 客户端调用
connect
函数返回时,表示三次握手过程已经完成。如果三次握手成功,connect 函数会返回0,客户端的套接字进入 ESTABLISHED 状态,connect 函数会返回错误码
补充:半连接和全连接队列以及listen
函数backlog参数
半连接队列:用于存放已收到客户端的 SYN(同步序列号)报文,但尚未完成三次握手的连接请求,即在SYN_RCVD状态。当全连接队列满时,服务器在完成第二次握手(发送SYN+ACK)后,会丢弃客户端的第三次握手(ACK)报文,以保持在SYN_RCVD状态,避免进入ESTABLISHED状态
全连接队列:用于存放已完成三次握手,等待服务器通过accept
函数拉取的连接,即在 ESTABLISHED 状态。如果队列已满,服务器会丢弃客户端的第三次握手(ACK)报文,避免三次握手成功。即使新的连接完成了三次握手,也无法进入队列,导致服务器无法处理这些新连接,多次ACK被丢弃后,客户端可能会收到连接拒绝的错误
listen
的第二个参数backlog
表示全连接队列的最大长度(实际为backlog + 1
),该队列用于存放已完成三次握手、等待通过accept
函数拉取的连接。用一个餐厅来举例,全连接就是餐厅里面的客桌,直接服务客人。半连接就是让客人在餐厅外等待的客桌,以便餐厅里面的客人吃完后,外面的客人快速进来补齐里面的空桌
对此backlog
不能太小,否则全连接队列容易被打满,新的连接会因为ACK被服务器丢弃,而三次握手失败,而继续留在半连接队列,即使成功也无法进入全连接队列。多次发送ACK,浪费网络传输资源。若服务器处理数据较快,具备处理更多连接的能力,也会使得服务器上层资源得不到充分利用。同时,backlog
也不能太大,全队列太长会导致服务器会存在有些链接来不及被上层accept
处理,但仍需维护这些连接,占用资源且降低效率
4.2 三次握手的原因
为什么偏偏是三次握手,而不是一次,两次,或者多次?原因主要有以下几种
1、三次握手,保证双方至少各收发一次,可靠验证全双工通路,两次握手不能确定对方是否收到。同时还能交换彼此接收缓冲区剩余空间大小和滑动窗口的头指针位置(32位起始序号)(后面讲)
2、如果一次握手可以的话,如果客户端给服务器发大量的 SYN 报文,服务器都得接着,那么服务器连接很容易被打满。且连接维护是有成本的,当这样的客户端数量一多,服务器挂掉是迟早的事,我们称为"SYN洪水"
3、两次握手会优先让服务器建立连接,若第二次握手ACK丢了,连接建立异常的成本便在服务端。服务器和客户端的关系是1:N
的,对此我们应该让连接建立异常的成本留在客户端,避免服务器去长期维持一个异常连接
4、奇数次握手保证连接建立异常成本在客户端,且要验证全双工,则三次握手恰好是满足条件的最少操作次数
4.3 四次挥手状态变化
服务端状态转化:
[ESTABLISHED -> CLOSE_WAIT]
客户端调用close
发送 FIN,服务器返回 ACK 并进入 CLOSE_WAIT 状态[CLOSE_WAIT -> LAST_ACK]
服务器处理完之前的数据后,调用close
向客户端发送 FIN,进入 LAST_ACK 状态[LAST_ACK -> CLOSED]
服务器收到了最后一个 ACK,彻底关闭连接
客户端状态转化:
[ESTABLISHED -> FIN_WAIT_1]
客户端调用close
向服务端发送 FIN,进入FIN_WAIT_1
状态[FIN_WAIT_1 -> FIN_WAIT_2]
收到服务端发来的 ACK 后,进入FIN_WAIT_2
状态,开始等待服务端的 FIN 报文[FIN_WAIT_2 -> TIME_WAIT]
客户端收到服务器发来的 ACK,进入TIME_WAIT
状态,并发出 LAST_ACK[TIME_WAIT -> CLOSED]
客户端要等待一个 2MSL(Max Segment Life,报文最大存在时间)后,进入CLOSED
状态
为什么主动断开连接的一方,要进入TIME_WAIT状态,等待两个 MSL 才变成 CLOSED 状态?
1、如果第四次挥手的 ACK 丢了,能够让对方有时间补发 FIN ,使得四次挥手的断开具有更好的容错性
2、等待两个 MSL 时间,让服务器和客户端丢弃历史报文,对历史数据进行消散,避免它对后续通信产生影响
注意若时服务端没有accept
把连接拿上来,双方没有进行通信过,断开连接就不需要进入 TIME_WAIT 状态,没有历史数据需要进行消散。之前服务器断开后,不能立即重启,就是因为进入了 TIME_WAIT 状态,此时 IP 和端口号不能复用。而客户端不会出现这样的问题,是因为客户端每次启动用的都是随机端口,而且大部分情况下都是主动断开连接的一方
现实中服务端一般要有能够立即重启的能力,以防止服务器意外挂掉的情况。又服务端的 IP 和端口号都是固定的,需要进行复用,对此我们通过setsockopt
函数对套接字属性进行设置。这个函数的作用:如果复用的 IP 和端口号属于正常的服务,则启动失败。如果是处于 TIME_WAIT 状态的服务,则立即重新启动
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
五、TCP提高传输效率策略
5.1 流量控制
流量控制的核心,是通过接收方反馈的 16 位窗口大小
(最大65535)来控制发送量。那么TCP窗口最大就是65535字节吗?实际上,TCP首部40字节选项中,还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移 M 位。TCP 通过接收方的接收能力,来调节发送方的发送速率,防止接收方因处理能力不足而丢弃数据,从而避免大量数据包丢失,直接保证了数据传输的可靠性,也间接也避免了丢包进行超时重传,从而提高了效率
** 双方第一次发送数据,发送多少数据量较为合适呢?在前面三次握手的原因中,我们就提到了前两次握手可以交换双方接收缓冲区的剩余空间大小。对此在第三次握手的时候,就已经可以携带数据,进行捎带应答了,也可以提高效率**
如果接收端缓冲区满了, 就会将窗口置为0。此时发送方会定期发送一个窗口探测报文,不携带数据,主动询问对方窗口是否更新。接收端就算没有收到这个窗口探测报文,在窗口更新后也会主动告知发送方。这种双向的主动性,能够有效避免因单向通信中,探测报文丢失而导致的通信停滞问题,从而使得通信过程具有更好的容错性
5.2 滑动窗口
TCP 发送数据时,并不是一次发送一个报文,而是一次发送一批报文。而每一个报文都是要有应答的,对于还没有收到应答的报文,我们要具有保存它的能力,以便出现意外时进行超时重传。这些已发送未应答的报文并不需要单独保存,它们就在发送缓冲区里。我们可以把缓冲区可以大致分为三个部分:已发送已确认,已发送未确认,待发送
,其中已发送未确认的部分就是所谓的 滑动窗口
** 流量控制和滑动窗口密切相关,流量控制是通过滑动窗口来实现的。而滑动窗口的实现就是简单的双指针思想,两个指针根据对方的接收能力大小(不考虑网络),一起向右移动,即滑动窗口大小是动态变化的。缓冲区是有长度的,即使一直右移也不会越界,因为滑动窗口采用了环状算法,类似环形队列中的 %模运算
**
** 缓冲区天然就是线性、有序的,这里我们回顾确认序号的定义:该序号之前的报文已经全部收到了,希望对方的下一个报文从该序号开始发送
,即我们找到了滑动窗口的左指针。但实际上,确认序号和滑动窗口的左指针Left,是有偏移的。前面讲过 TIME_WAIT 状态会等待数据消散,但是还有可能出现新连接获取老数据的概率,这里 TCP 会引入一个起始随机序号,双方在三次握手时,交换对方的起始序号,提高 TCP 通信的容错性。
即:滑动窗口的Left指针 = 确认序号 - 起始序号
即:滑动窗口的大小 = min(有效的待发送数据,对方接收缓冲区的剩余空间大小,拥塞窗口(后面解释))
**
5.3 快速重传
** 快重传是一种基于滑动窗口保证可靠性、提高效率的策略。它的触发机制是当连续收到三个重复的 ACK 时,判定丢包,立即重传丢失的报文,而无需等待超时重传。下面我们结合滑动窗口分析 TCP 为什么能快速找到丢失的报文**
** 丢包要么就是数据包丢了,要是就是应答ACK丢了,合并为丢包一种情况。首先我们再次明确确认序号的定义:该序号之前的报文已经全部收到了,希望对方的下一个报文从该序号开始发送
。现在我们向对方发送了A、B、C、D四个报文,如果是A包丢了,后面的没丢,那么B、C、D应答报文的确认序号填的都是1001。同时收到三个一样的ACK,马上就能定位到是起始序号为1001的A包丢了,快速补发。对方收到了补发的A包后,发来的确认序号直接变成4001,让 Left 移动到4001处。如果B包丢了,那么A、C、D应答报文的确认序号都是2001,此时 Left 移动到2001处,并补发起始序号为2001的B包。后面C、D丢包处理与B一致**
已经有了快重传了,为什么还有超时重传呢?
主要是因为快重传是有条件的,收到三个相同的 ACK。而数据丢失,并不一定能收到三个相同的 ACK,此时快重传机制就不能触发,只能进行超时重传。超时重传具有兜底的作用,快重传更多是为了提高效率
5.4 延迟应答
延时应答是指接收方在收到发送方的数据后,并不立即发送 ACK,而是等待一段时间再发送 ACK。如果在这段时间内,接收方又收到了新的数据,它会将这些数据的确认信息合并到一个 ACK 中发送出去。等待一段时间,上层就有较大的概率把数据取走,给对方一个更大的接收窗口。窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率,当然,延时时间也不能超过超时重传时间
那么所有的包都可以延迟应答么?肯定也不是
数量限制:每隔N个包就应答一次
时间限制:超过最大延迟时间就应答一次
具体的数量和超时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms
六、拥塞控制
上面我们提到的所有策略,起作用的都是在两端机器上的,并没有考虑网络。少量丢包我们会认为是可能是一方主机出现问题,滑动窗口内大量数据都超时了,大量丢包 TCP 就会判断网络出了问题,我们叫做网络拥塞。在这种情况下,TCP 不会立即重传大量数据,因为这可能会进一步加重网络拥塞。相反,TCP 会采用慢启动机制,先发送少量数据来探测网络状态,然后根据反馈逐步调整发送速率
慢启动机制:
在初始阶段或网络发生拥塞后,会以较慢的速度开始发送报文,即每次只发送少量报文来探测网络状态。如果这些报文都能成功传输,说明网络趋于健康,此时 TCP 会以指数增长,加快恢复到正常通信速度。但为了避免拥塞窗口后期增长过快导致再次拥塞,慢启动设置了阈值(ssthresh)。这个阈值通常是上次发生网络拥塞时,拥塞窗口大小的一半。当拥塞窗口超过这个阈值时,增长方式变为线性增长,以更平稳地控制数据传输速率。同时,若再次检测到拥塞,拥塞窗口会直接重置为新的慢启动阈值,重新开始增长。
** 注意,即使拥塞窗口一直增大,网络也不是必然拥塞,对方接收窗口的大小也会限制你。如果大于了对方的接收能力,拥塞窗口更新归更新,发送的数据量又没有变大,不会导致网络拥塞。但拥塞窗口可以间接影响到滑动窗口的大小,确保网络拥塞时,滑动窗口不会过大,发送过多的数据。此时,滑动窗口大小 = min(对方的接受能力,有效数据,拥塞窗口)
**
七、 面向字节流和粘包问题
UDP 就是面向数据报的,读写次数必须一一对应
,写几次,读几次。而 TCP 是面向字节流的,它将数据视为一个连续的字节序列,不保留数据的边界信息。TCP 会根据网络条件(如 MTU、MSS 等)自动对数据进行分段和重组。不关心上层协议,不关心上层报文格式,只有字节的概念。发送方和接收方读写次数并不固定,但通过序列号、确认应答机制,也能保证数据按序到达
发送数据量的大小是由 TCP 自主决定的,并没有报文的概念。而在上层应用层看来,发来的数据可能是一个报文,也可能是半个报文,也可能是多个报文,这就是数据包粘包问题。解决粘包问题,就要找到两个报文之间的边界。这就涉及到应用层协议的制定,常见有以下方法
** 1、采用定长报头
**
** 2、定特殊字符作为报文的边界
**
** 3、使用自描述字段 + 定长报头
**
** 4、用自描述字段 + 特殊字符
**
八、TCP 小结
文件描述符生命周期随进程,进程终止时,文件描述符会被释放,但仍可发送FIN,与正常关闭无异;机器重启情况类似。但机器掉电或网线断开,接收端可能仍认为连接存在。此时,如果双方有写入操作触发,就会发现连接已断并执行 RESET。即使无写入操作,TCP的保活定时器也会定期检测对方状态,若发现对方掉线,则释放连接。
TCP 和 UDP各有优缺点,不能简单绝对地比较优劣。TCP提供可靠传输,适用于文件传输、重要状态更新等对数据完整性和顺序要求高的场景;UDP则传输速度快、实时性高,适合视频传输、早期QQ等对速度和实时性要求高的场景,还可用于广播。归根结底,TCP和UDP都是工具,具体使用需根据需求场景判断。
** 如果需要使用UDP实现可靠传输时,可在应用层实现类似TCP保证可靠性的相关策略,从而实现类似TCP的可靠性传输。如 连接管理、校验和、序列号(按序到达)、确认应答、流量控制、超时重传
**