TCP 协议详解
目录
一.定义
二.TCP 协议报文格式
三.确认应答(ACK)机制
四.捎带应答
五.连接管理机制
六.滑动窗口
七.快重传
八.拥塞控制
九.延时应答
十.面向字节流
十一.粘包问题
十二.异常情况
十三.TCP 小结
一.定义
传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC 793 [1]定义。
TCP旨在适应支持多网络应用的分层协议层次结构。 连接到不同但互连的计算机通信网络的主计算机中的成对进程之间依靠TCP提供可靠的通信服务。TCP假设它可以从较低级别的协议获得简单的,可能不可靠的数据报服务。 原则上,TCP应该能够在从硬线连接到分组交换或电路交换网络的各种通信系统之上操作。
我们主要理解以下TCP协议四个特点:
- 传输层协议
- 有连接
- 可靠传输
- 面向字节
通过下面的讲解,你会明白为什么TCP协议具有上述特点。
二.TCP 协议报文格式
报文结构如下:
总体分为俩部分:正文(即上图中数据)+协议报头(除了数据的其他内容)。
下面我们先对部分内容进行讲解:
1. 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去。
2. 4 位 TCP 报头长度: 表示该 TCP 头部有多少个 32 位 bit(有多少个 4 字节); 所以 TCP 头部最大长度是 15(2的5次方-1) * 4 = 60 字节,即选项内容最多40字节。
3. 6 位标志位(后面详细讲解):
- URG: 紧急指针是否有效
- ACK: 确认号是否有效
- PSH: 提示接收端应用程序立刻从 TCP 缓冲区把数据读走
- RST: 对方要求重新建立连接; 我们把携带 RST 标识的称为复位报文
- SYN: 请求建立连接,我们把携带 SYN 标识的称为同步报文段
- FIN: 通知对方, 本端要关闭了, 我们称携带 FIN 标识的为结束报文
4. 16 位校验和: 发送端填充, CRC 校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含 TCP 首部, 也包含TCP数据部 。
5. 16 位紧急指针: 标识哪部分数据是紧急数据。当紧急指针置为1,操作系统会优先读取该报文,再读取紧急指针找到紧急数据。
6. 16位窗口大小,表示当前接受数据的剩余空间。
其他协议内容后面讲解。
三.确认应答(ACK)机制
当客户端传输报文时,我们应该如何保证报文被服务端收到呢?让服务端进行回应。打个比方,当我们的辅导员在班群里发送重要通知时,总是会跟上一句收到请回复,当所有人回复收到时,就能保证信息被所有人收到。确认应答机制就是这个原理,简单了解就是收到请回复 。
在这里我们就能明白32位序号和32位确认序号的作用。32位序号,用来标识发送报文的序号,确认序号则是在收到报文为了进行应答,将收到32号序号+1组成确认序号发生给对方进行应答。即32位确认序号=收到的32报文序号+1,用来表示确认序号之前的报文都收到了。
过程如下 :
这里我们可能会有疑问:为什么需要俩个序号?只要有一个32位序号,当收到报文后,回复时用这一个32序号+1发生给对方,也能进行应答。需要俩个32位序号的原因是什么?这就是下面的主题。
四.捎带应答
当客户端给服务端发送报文后,服务端需要对发送的报文进行应答,如果在同时服务端也需要发送报文,那么应答和报文就会一起发送,称为捎带应答。举个例子:你和室友聊天,你问室友,你吃了吗?室友回复:吃了,你作业写了吗?这段对话中,室友的回复包含了应答,同时也传输了信息,就是捎带应答。
这里俩个32位序号就很有必要了,32位确认序号表示应答的,32位序号表示传输信息的。
五.连接管理机制
在正常情况下, TCP 要经过三次握手建立连接, 四次挥手断开连接。我们一一讲解。
我们以客户端主动发起连接,主动断开连接为例子。
三次握手时:
服务端状态转化:
- [CLOSED -> LISTEN] 服务器端调用 listen 后进入 LISTEN 状态, 等待客户端连 接;
- [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送 SYN 确认报文.
- [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入 ESTABLISHED 状态, 可以进行读写数据了.
客户端状态转化:
- [CLOSED -> SYN_SENT] 客户端调用 connect, 发送同步报文段;
- [SYN_SENT -> ESTABLISHED] connect 调用成功, 则进入 ESTABLISHED 状 态, 开始读写数据
四次挥手时:
服务端状态转化:
- [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用 close), 服务器会收到结束报文段, 服务器返回确认报文段并进入 CLOSE_WAIT;
- [CLOSE_WAIT -> LAST_ACK] 进入 CLOSE_WAIT 后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用 close 关闭连接时, 会向客户端发送 FIN, 此时服务器进入 LAST_ACK 状态, 等待最后一个 ACK 到来(这个 ACK 是客户 端确认收到了 FIN)
[LAST_ACK -> CLOSED] 服务器收到了对 FIN 的 ACK, 彻底关闭连接。
客户端状态转化:
- [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用 close 时, 向服务器发送结 束报文段, 同时进入 FIN_WAIT_1;
- [FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认, 则进 入 FIN_WAIT_2, 开始等待服务器的结束报文段;
- [FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段, 进入 TIME_WAIT, 并发出 LAST_ACK;
- [TIME_WAIT -> CLOSED] 客户端要等待一个 2MSL(Max Segment Life, 报文 最大生存时间)的时间, 才会进入 CLOSED 状态。
这里我们主要理解 TIME_WAIT状态,主动发起断开连接的一端需要经历TIME_WAIT状态。TIME_WAIT状态主要是为了等待一些在网络中或者其他地方已经发送但是还没有到达的数据。如果不进行等待,当重新进行连接时,这些数据到达会引起混乱。
三次握手原因:
- 双方通过两次SYN报文的发送,可以保证全双工通信信道的畅通,同时双方会交换彼此的窗口大小(即接收缓冲区剩余空间)和滑动窗口的头指针位置(后面讲)
- 三次握手中的最后一次ACK应答的可靠性是无法保证的,如果服务端没有收到客户端的ACK应答,则服务端内核将不会浪费资源构建连接相关的结构体,但是客户端在发送ACK应答后(无论ACK应答是否被对方收到),便默认连接建立成功,这种设计的意义在于连接建立失败的资源消耗成本由客户端来承担,从而减小服务器的负担
- 双方协商滑动窗口的头指针起始位置时,会对滑动窗口的头指针起始位置进行随机数映射处理,防止本次通信受到历史通信残留在网络中的报文的干扰
理解四次挥手
- 双方断开连接之前,必须要确认彼此都没有数据要发送给对端,这是双方彼此主动挥手的一个原因
- 主动断开连接的一端,在完成第四次挥手之后,会进入TIME_WAIT状态,等待时间一般为TCP报文在网络中最大存在时长的两倍,这样设计的主要目的是为了等待网络中残存的本次连接的通信报文消散,防止残存报文影响下次连接通信,同时如果第四次挥手的ACK应答丢包,服务器有足够长的时间重新进行第三次挥手,保证连接正常关闭。
六.滑动窗口
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个 ACK 确认应答. 收到 ACK 后再发送下一个数据段。这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候。那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)这就是滑动窗口。
滑动窗口可以将数据分为三部分,已发送且收到确认的(左边),滑动窗口中的,未发送的数据(右边)
- 滑动窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 上图的窗口大小就是 4000 个字节(四个段).在不考虑网络的情况下就是对方报头中的16位窗口大小
- 发送前四个段的时候, 不需要等待任何 ACK, 直接发送;
- 收到第一个 ACK 后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
- 操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪 些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;
- 窗口越大, 则网络的吞吐率就越高;
TCP报头中的窗口大小字段用于告知对端己方接收缓冲区剩余空间,同时决定对端单次批量发送的报文数量从而决定对端的滑动窗口大小,即
- 对端滑动窗口的尾指针 = 对端滑动窗口的头指针 + 己方窗口大小(接收缓冲区剩余空间).
这样就实现了TCP 协议中的流量控制.达到接受和发送的平衡.
七.快重传
快重传是基于滑动窗口一种确保TCP协议可靠性的保证,在客户端发送消息时可能发送的消息丢失,也可能是服务端传送的应答丢失,这时除了超时重传,还有一种新的重传机制——快重传。
首先,我们要明白客户端发送的32位序号,就是发送缓存区要发送的字节最大的序号,如下面滑动窗口第一段的消息的32位序号就是2001,回应的报文中32位确认序号就是2002,表示2002之前的字节都收到了,这时滑动窗口会向右滑动到2002。
假设,客户端在发送消息时,第一段1001——2001的消息丢失,其他三段正常发送,那么由于服务器没有收到第一段消息,注意32位确认序号是要表示之前的字节都收到了,服务器没有收到第一段消息,应答的32位确认序号只能是1002,这时客户端就会收到多个确认序号位1002的报文,这时客户端就会明白第一段报文有问题,会启动快重传,重新发送第一段报文。
如果丢失的第二端呢?这时第一段报文的应答是正常的,滑动窗口滑动,第二段变成第一段,情况就和上面一样了。
应答丢失情况类似,如果第一段应答丢失,由于客户端依旧收到了所有消息,第二端应答的32位序号依旧是3002,这时滑动窗口依旧会移动到3002,只是一次性移动。因此滑动窗口也保证了不需要每一个应答都需要收到。如果后面的应答丢失,滑动窗口滑动,情况又一样了,后面的应答就变成了第一段应答。
这里最重要的就是注意32位确认序号是要表示之前的字节都收到了,理解了这个就能全部理解。
八.拥塞控制
在发送数据除了要考虑发送方和接受方的缓存区情况,即做到流量控制,我们还需要考虑网络情况做到拥塞控制。
TCP引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。
- 此处引入一个概念称为拥塞窗口
- 发送开始的时候, 定义拥塞窗口大小为 1;
- 每次收到一个 ACK 应答, 拥塞窗口加 1;
- 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;即:
网络阻塞情形下滑动窗口的大小
=min(拥塞窗口,ACK窗口大小)
- 当 TCP 开始启动的时候, 慢启动阈值等于窗口最大值;
- 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回 1; 少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞; 当 TCP 通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;
拥塞控制, 归根结底是 TCP 协议想尽可能快的把数据传输给对方, 但是又要避免给网络 造成太大压力的折中方案。
九.延时应答
如果接收数据的主机立刻返回 ACK 应答, 这时候返回的窗口可能比较小.
- 假设接收端缓冲区为 1M. 一次收到了 500K 的数据; 如果立刻应答, 返回的窗口 就是 500K;
- 但实际上可能处理端处理的速度很快, 10ms 之内就把 500K 数据从缓冲区消费 掉了;
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也 能处理过来;
- 如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的 窗口大小就是 1M;
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络 不拥塞的情况下尽量提高传输效率; 那么所有的包都可以延迟应答么? 肯定也不是 。
- 数量限制: 每隔 N 个包就应答一次;
- 时间限制: 超过最大延迟时间就应答一次;
具体的数量和超时时间, 依操作系统不同也有差异; 一般 N取2,超时时间取200ms。
十.面向字节流
创建一个 TCP 的 socket, 同时在内核中创建一个 发送缓冲区和一个接收缓冲区;
- 调用 write 时, 数据会先写入发送缓冲区中;
- 如果发送的字节数太长, 会被拆分成多个 TCP 的数据包发出;
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或 者其他合适的时机发送出去;
- 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用 read 从接收缓冲区拿数据;
- 另一方面, TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一 个连接, 既可以读数据, 也可以写数据. 这个概念叫做全双工
由于缓冲区的存在, TCP 程序的读和写不需要一一匹配, 例如:
- 写 100 个字节数据时, 可以调用一次 write 写 100 个字节, 也可以调用 100 次 write, 每次写一个字节;
- 读 100 个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次 read 100 个字节, 也可以一次 read 一个字节, 重复 100 次;
十一.粘包问题
首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包.
- • 在 TCP 的协议头中, 没有如同 UDP 一样的 "报文长度" 这样的字段, 但是有一 个序号这样的字段. • 站在传输层的角度, TCP 是一个一个报文过来的. 按照序号排好序放在缓冲区中.
- • 站在应用层的角度, 看到的只是一串连续的字节数据.
- • 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包.
那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界.
- 对于定长的包, 保证每次都按固定大小读取即可; 例如上面的 Request 结构, 是固定大小的, 那么就从缓冲区从头开始按 sizeof(Request)依次读取即可;
- 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
- 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿 自己来定的, 只要保证分隔符不和正文冲突即可)
主要我们是通过应用层协议来避免TCP中的粘包问题。
十二.异常情况
- 进程终止: 进程终止会释放文件描述符, 仍然可以发送 FIN. 和正常关闭没有什么区别.
- 机器重启: 和进程终止的情况相同. 机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已 经不在了, 就会进行 reset. 即使没有写入操作, TCP 自己也内置了一个保活定时器, 会 定期询问对方是否还在. 如果对方不在, 也会把连接释放. 另外, 应用层的某些协议, 也有一些这样的检测机制. 例如 HTTP 长连接中, 也会定期检测对方的状态. 例如 QQ, 在 QQ 断线之后, 也会定期尝试重新连接.
十三.TCP 小结
为什么 TCP 这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能。
可靠性: • 校验和 • 序列号(按序到达) • 确认应答 • 超时重发 • 连接管理 • 流量控制 • 拥塞控制
提高性能: • 滑动窗口 • 快速重传 • 延迟应答 • 捎带应答
其他: • 定时器(超时重传定时器, 保活定时器, TIME_WAIT 定时器等)
基于 TCP 应用层协议 • HTTP • HTTPS • SSH • Telnet • FTP • SMTP 当然, 也包括你自己写TCP 程序时自定义的应用层协议。