传输层协议 TCP
TCP 全称为 传输控制协议(Transmission Control Protocol),需要先将 用户级缓冲区数据调用 send 交给操作系统内的 TCP发送缓冲区,未来数据的发送(什么时候发送,发送多少,出错了怎么办)都由操作系统决定!而 UDP 没有发送缓冲区,做不到传输控制!
一. TCP 协议格式
- 16 位源/目的端口号:表示数据是从哪个进程来,到哪个进程去。
- 32 位序号:用于标识发送的数据段在整体数据流中的位置。
- 32 位确认序号:用于确认已成功接收的数据段的序号。
- 4 位首部长度:TCP 的标准长度是 20 字节 + 选项长度,单位是 4 字节,最大值:(2^4-1) x 4 = 60 字节,TCP 实际长度范围:[20, 60],选项长度:4 位首部长度 x 4 - 20
- 16 位窗口大小:表示自己的接收缓冲区的大小。
- 16 位校验和:检测 TCP 数据报在传输过程中是否出现错误的重要机制。
- 16 位紧急指针:标识哪部分数据是紧急数据。
- TCP 是如何做到解包的?先将前 20 字节的 TCP 标准长度读取(解包)出来,利用 4 位首部长度 x 4 - 20 获取选项长度,再次读取出来,将剩下的有效载荷交给应用层!
- TCP 是如何做到分用的(交给应用层的哪一个协议)?通过 16 位的目的端口号,找到响应的协议!
二. TCP 各种机制
1. 确认应答(ACK)
确认应答:是接收方发送给发送方的一种响应,用于确认已成功接收的数据。
- 序号:TCP 将每个字节的数据都进行了编号。
- 确认序号:表示接收方期望接收的下一个数据段的第一个字节的序号。
- UDP 存在长度字段,但是 TCP 不存在!因为 TCP 是面相字节流的,解包时将报头去掉,将数据拷贝到接收缓冲区中,随着数据的增多,类似流式结构!什么时候读取由操作系统决定!
- TCP 的发送和接收缓冲区大小是固定的,由 TCP 报头中的 16 位窗口大小决定!
- 可以将发送缓冲区看成一个字符数组,每一个字节都存在下标,TCP 报头中的序号就是数组下标,当需要发送数据时,也按照序号(数组下标)拷贝过去,根据字节进行发送和接收,这就叫字节流。
- 以发送缓冲区为例:生产者:用户将数据拷贝到缓冲区中,消费者:操作系统将缓冲区数据发送出去,这就是:生产者-消费者模型!当缓冲区中没数据时:read 将阻塞,需要等待 write,这就是同步机制!
- 管道也是基于字节流,也是典型的生产者-消费者模型,自带互斥同步机制!
2. 超时重传
超时重传:确保发送的数据被可靠地传输。发送方在发送数据后会启动一个定时器,如果在定时器超时之前没有收到确认应答,发送方会重新发送数据。发送纯ACK报文,不会触发超时超时重传!
第一种丢包情况:数据丢失!
- 主机 A 发送数据给 B 之后,可能因为网络拥堵等原因,数据无法到达主机 B
- 如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答,就会进行重发。
第一种丢包情况:确认应答丢失!
- 问题:如果丢包时,主机 A 知道是数据丢失还是应答丢失?答案:不知道,由于网络十分复杂,甚至不知道主机 A 是否丢包(例如:网络拥塞,迟迟接收不到报文),主机 A 只能等待时间间隔,进行超时重传。
- 因此主机 B 会收到很多重复数据,那么 TCP 协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉,这时 TCP 中的序列号,就可以很容易做到去重的效果。
如果超时的时间如何确定?
- 最理想的情况下,找到一个最小的时间,保证确认应答一定能在这个时间内返回。但是这个时间的长短,随着网络环境的不同,是有差异的。
- 如果超时时间设的太长,会影响整体的重传效率。
- 如果超时时间设的太短,有可能会频繁发送重复的包。
TCP 为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
- Linux 中超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。
- 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
- 如果仍然得不到应答,等待 4*500ms 进行重传,依次类推,以指数形式递增。
- 累计到一定的重传次数,TCP 认为网络或者对端主机出现异常,强制关闭连接。
3. 连接管理
重点:TCP 要经过三次握手建立连接,四次挥手断开连接!
- 为什么要建立连接?建立双方主机通信的意愿共识,验证全双工通信的通畅性(网络的通畅性)
- 但是什么是连接?如何理解连接?一条连接,一定会和一个文件对应,因为一个连接一个 sockfd,连接在操作系统内部存在多个,需要先描述后组织管理起来,维护连接存在时间和空间的成本!
- 建立和断开连接,需要征求双方的同意,TCP 是全双工的!需要建立和断开两个朝向的连接!
服务端状态转化:
- [CLOSED -> LISTEN]:服务器端调用 listen 后进入 LISTEN 状态,等待客户端发送 SYN 建立连接请求。
- [LISTEN -> SYN_RCVD]:服务器收到 SYN 建立连接请求,将该连接放入内核等待队列中,并向客户端发送 SYN + ACK 报文,进入 SYN_RCVD 状态。
- [SYN_RCVD -> ESTABLISHED]:服务端收到客户端发送 ACK 确认报文,进入 ESTABLISHED 状态,此时三次握手建立成功,可以读写数据了。
- [ESTABLISHED -> CLOSE_WAIT]:服务器收到 FIN 关闭连接请求,发送 ACK 确认报文,进入 CLOSE_WAIT 状态,服务器处于等待 close 关闭连接(需要处理完之前的数据)
- [CLOSE_WAIT -> LAST_ACK]:当服务器调用 close 关闭连接时,会向客户端发送 FIN 关闭连接请求,此时服务器进入 LAST_ACK 状态,等待最后一个 ACK 到来(这个 ACK 是客户端确认收到了 FIN)
- [LAST_ACK -> CLOSED]:服务器收到了对 FIN 对应的 ACK 确认报文,进入 CLOSED 状态,完成连接的断开。
客户端状态转化:
- [CLOSED -> SYN_SENT]:客户端调用 connect,发送 SYN 建立连接请求,进入 SYN_SENT 状态。
- [SYN_SENT -> ESTABLISHED]:客户端收到服务器发送的 SYN + ACK 报文,并发送 ACK 报文给服务器后进入 ESTABLISHED 状态。
- [ESTABLISHED -> FIN_WAIT _1]:客户端主动调用 close 关闭连接,向服务器发送结束 FIN 报文,进入 FIN_WAIT_1 状态。
- [FIN_WAIT_1 -> FIN_WAIT_2]:客户端收到 ACK 确认报文,进入 FIN_WAIT_2 状态,开始等待服务器调用 close 关闭连接。
- [FIN_WAIT_2 -> TIME_WAIT]:客户端收到 FIN 关闭连接请求,并发出 ACK 确认报文,进入 TIME_WAIT 状态。
- [TIME_WAIT -> CLOSED]:客户端要等待一个 2MSL(Max Segment Life,报文最大生存时间),进入 CLOSED 状态,完成连接的断开。
理解 CLOSE_WAIT 状态:
- 当客户端关闭连接时,服务器不是立即关闭连接的,而是处于 CLOSE_WAIT 状态。
- 若服务器出现大量的 CLOSE_WAIT 状态,原因就是客户端关闭连接时,服务器没有正确的关闭 sockfd,导致四次挥手没有正确完成,这是一个 BUG,只需要加上对应的 close 即可解决问题。
理解 TIME_WAIT 状态:
- TCP 协议规定,主动关闭连接的一方要处于 TIME_ WAIT 状态,等待两个 MSL 的时间后才能进入 CLOSED 状态。
- 在 Ubantu 下,可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看 MSL 等于 60 秒。
- 若 Ctrl + C 关闭服务器,需要立即重启服务器,此时服务器不能绑定相同的端口号,需要经过两个 MSL 的时间,才能绑定。可以使用下面的系统调用解决该问题!
设置为 2MSL 时间的原因:
- 确保最后一个 ACK 报文的可靠传输:超时重传最大消耗的时间,尽可能完成 4 次挥手。
- 避免历史报文干扰新连接:足以让两个方向上的数据报都丢弃。
4. 流量控制
流量控制:确保发送方不会发送超过接收方处理能力的数据量。流量控制的主要目的是防止发送方发送的数据超过接收方的处理能力,从而避免数据丢失和网络拥塞。通过流量控制,可以确保数据传输的高效性和可靠性。
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,通过 ACK 端通知发送端。
- 窗口大小字段越大,说明网络的吞吐量越高,接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值,发送端接受到这个窗口之后,就会减慢自己的发送速度。
- 如果接收端缓冲区满了,就会将窗口置为 0。这时发送方不再发送数据,但是双方需要定期发送窗口探测 和 窗口更新 报文,使接收端把窗口大小告诉发送端。
- 接收端如何把窗口大小告诉发送端呢?回忆我们的 TCP 首部中, 有一个 16 位窗口字段,就是存放了窗口大小信息。那么问题来了,16 位数字最大表示 65535,那么 TCP 窗口最大就是 65535 字节么?
- 实际上,TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M,实际窗口大小是:窗口字段的值左移 M 位。
- 首先发送,如何知道对方的窗口大小?三次握手做过 报文交换 和 窗口协商 的!
- 紧急标志位(URG):用于表示紧急数据,需要优先处理。通常用于实时通信或需要立即处理的情况。
- 上传标志位(PSH):用于指示接收方立即将数据推送给应用层,而不是等待缓冲区满。通常用于实时应用,如聊天软件。
- 重置标志位(RST):用于重置连接。通常在检测到连接错误或异常时使用,如连接超时、端口不可达等。
5. 滑动窗口
滑动窗口:通过动态调整窗口大小,确保发送方不会发送超过接收方处理能力的数据量,从而实现流量控制和拥塞控制。
在确认应答对每一个发送的数据段,都要给一个 ACK 确认应答,收到 ACK 后再发送下一个数据段。缺点:效率低。那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)
- 滑动窗口大小:对方窗口的大小,无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是 4000 个字节,表示:发送前四个段的时候,不需要等待任何 ACK,直接发送。
- 收到第一个 ACK 报文,根据报头中的字段得到:start = 确认序号,end = start + 窗口大小。滑动窗口向后移动。所以流量控制是通过滑动窗口实现的!
- 操作系统内核为了维护这个滑动窗口,需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答。只有确认应答过的数据, 才能从缓冲区删掉。窗口越大,则网络的吞吐率就越高。
- 为什么不能要分为 4 个 1000 字节大小的报文,而不是 1 个 4000 字节大小的报文?因为数据链路层不允许发送较大的报文(1500字节)
- 滑动窗口只能向右移动,可以变大、变小、不变、为零。
- 超过发送缓冲区的大小,导致越界?不会,将滑动窗口想象为环型结构,滑动窗口的左端都是已经发送的数据,可以覆盖!
那么如果出现了丢包,如何进行重传?这里分两种情况讨论:
情况一:数据包就直接丢失!
- 当某一段报文段丢失之后,发送端会一直收到 1001 这样的 ACK,就像是在提醒发送端 “我想要的是 1001” 一样。
- 如果发送端主机连续三次收到了同样一个 “1001” 这样的应答,就会将对应的数据 1001 - 2000 重新发送。
- 这个时候接收端收到了 1001 之后,再次返回的 ACK 就是 7001 了,因为 2001 - 7000 接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。
收到三个同样的确认序号进行重发,这种机制被称为 “快重传”,用于提高效率,超时重传用于兜底!
情况二:数据包已经抵达,ACK 报文丢失!
这种情况下,部分 ACK 丢了并不要紧,因为可以通过后续的 ACK 进行确认。
6. 拥塞控制
虽然 TCP 有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。TCP 引入 “慢启动” 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
拥塞窗口:发送方可以发送但尚未收到确认的数据量(拥塞窗口不断变化,判断网络是否拥塞)
拥塞阈值:控制拥塞窗口的变化,避免网络拥塞。
发送窗口:min(窗口,拥塞窗口)
慢启动:只是指初使时慢,但是增长速度非常快!
- 慢启动开始时,以指数方式增长。
- 当拥塞窗口超过慢启动阈值(ssthresh)的时候,按照线性方式增长。
- 当开始启动时:慢启动阈值等于窗口最大值。
- 当网络拥塞时:慢启动阈值会变成原来的一半,同时拥塞窗口置回 1,进行重传。
少量的丢包,我们仅仅是触发超时重传。大量的丢包,我们就认为网络拥塞!
当 TCP 通信开始后,网络吞吐量会逐渐上升。随着网络发生拥堵,吞吐量会立刻下降。
拥塞控制,归根结底是 TCP 协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
7. 延迟应答
如果接收数据的主机立刻返回 ACK 应答,这时候返回的窗口可能比较小。
- 假设接收端缓冲区为 1M,一次收到了 500K 的数据,如果立刻应答,返回的窗口就是 500K
- 但实际上可能处理端处理的速度很快,10ms 之内就把 500K 数据从缓冲区消费掉了。
- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
- 如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的窗口大小就是 1M
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。那么所有的包都可以延迟应答么?肯定也不是。
- 数量限制:每隔 N 个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次。
具体的数量和超时时间,依操作系统不同也有差异,一般 N 取 2,超时时间取 200ms
8. 捎带应答
在延迟应答的基础上,我们发现很多情况下,客户端服务器在应用层也是 “一发一收” 的。意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine,thank you”。那么这个时候 ACK 就可以搭顺风和服务器回应的 “Fine,thank you” 一起回给客户端。
三. 面向字节流
创建一个 TCP 的 socket,同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区
- 调用 write 时,数据会先写入发送缓冲区中。
- 如果发送的字节数太长,会被拆分成多个 TCP 的数据包发出。
- 如果发送的字节数太短,就等待缓冲区长度差不多了,或者其他合适的时机发送出去。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区。
- 然后应用程序可以调用 read 从接收缓冲区拿数据。
- 另一方面,TCP 的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。这个概念叫做 全双工。
由于缓冲区的存在,TCP 程序的读和写不需要一一匹配,例如:
- 写 100 个字节数据时,可以调用一次 write 写 100 个字节,也可以调用 100 次write,每次写一个字节。
- 读 100 个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read 100 个字节,也可以一次 read 一个字节,重复 100 次。
四. 粘包问题
- 首先要明确,粘包问题中的 “包”,是指的应用层的数据包。
- 在 TCP 的协议头中,没有如同 UDP 一样的 “报文长度” 这样的字段,但是有一个序号这样的字段。
- 站在传输层的角度,TCP 是一个一个报文过来的,按照序号排好序放在缓冲区中。
- 站在应用层的角度,看到的只是一串连续的字节数据。
- 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
那么如何避免粘包问题呢?归根结底就是一句话,明确两个包之间的边界!
- 对于定长的包,保证每次都按固定大小读取即可。
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。
- 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可)
思考:对于 UDP 协议来说,是否也存在 “粘包问题” 呢?
- 对于 UDP,如果还没有上层交付数据,UDP 的报文长度仍然在,同时 UDP 是一个一个把数据交付给应用层,就有很明确的数据边界。
- 站在应用层的角度,使用 UDP 的时候,要么收到完整的 UDP 报文,要么不收,不会出现"半个"的情况。
五. TCP 连接异常
- 进程终止/机器重启:会释放文件描述符,仍然可以发送 FIN,和正常关闭没有什么区别。
- 机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行 reset,即使没有写入操作,TCP 自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
- 另外应用层的某些协议,也有一些这样的检测机制。例如 HTTP 长连接中,也会定期检测对方的状态。例如 QQ 断线之后,也会定期尝试重新连接。
六. TCP 应用层协议
- HTTP:超文本传输协议。
- HTTPS:安全超文本传输协议。
- SSH:安全外壳协议。
- Telnet:远程终端协议。
- FTP:文件传输协议。
- SMTP:简单邮件传输协议。
七. TCP/UDP 对比
我们说了 TCP 是可靠连接,那么是不是 TCP 一定就优于 UDP 呢?TCP 和 UDP 之间的优点和缺点,不能简单,绝对的进行比较!
- TCP 用于可靠传输的情况,应用于文件传输,重要状态更新等场景。
- UDP 用于对高速传输和实时性要求较高的通信领域。例如:早期的 QQ,视频传输等,另外 UDP 可以用于广播。
归根结底 TCP 和 UDP 什么时机用,具体怎么用,还是要根据具体的需求场景去判定!
八. UDP 实现可靠传输(经典面试题)
参考 TCP 的可靠性机制,在应用层实现类似的逻辑:
- 引入序列号,保证数据顺序。
- 引入确认应答,确保对端收到了数据。
- 引入超时重传,如果隔一段时间没有应答,就重发数据。
- …