下面是一张比较清晰的TCP协议段格式(来源网络):
源端口号(Source Port)和目标端口号(Destination Port):16位字段,指示发送方和接收方的应用程序或服务的端口号。
序列号(Sequence Number):32位字段,用于对TCP数据流中的每个字节进行编号,确保数据的有序传输。
确认号(Acknowledgment Number):32位字段,表示期望接收的下一个字节的编号,用于确认已成功接收的数据。
数据偏移(Data Offset):4位字段,指示TCP头部的长度,以4字节为单位。在这个字段中,TCP头部可以达到最大60字节的长度。
控制位(Flags):包括URG(紧急指针有效)、ACK(确认号有效)、PSH(推送)、RST(复位连接)、SYN(建立连接)、FIN(结束连接)标志位,用于控制TCP连接的建立、维护和关闭等操作。
窗口大小(Window Size):16位字段,指示接收方的缓冲区大小,用于进行流量控制。
校验和(Checksum):16位字段,用于检测TCP段在传输过程中的错误或损坏。
紧急指针(Urgent Pointer):16位字段,仅在URG标志位被设置时才有效,表示紧急数据的结束位置。
选项(Options):可选字段,用于进行一些可选的功能扩展,如选择确认、时间戳等。
数据(Data):可选字段,用于携带应用层的数据。
从上面的协议格式也能看出来,相比于 UDP,TCP 增加了很多字段,并且这些字段大多都指向:安全和效率。这也正是 TCP 传输协议的核心特性:
- TCP对数据传输提供的管控机制,主要体现在两个方面:安全和效率。
- 这些机制和多线程的设计原则类似:保证数据传输安全的前提下,尽可能的提高传输效率。
对于TCP协议来说,它的一大特点就是可靠传输,这里的可靠性并不是指代100%能够传输过去,而是对于接收方会返回一个应答。确认应答机制是实现TCP传输可靠性的核心机制。
在网络传输中,由于网络环境的瞬息万变,很可能出现一种“后发先至
”的情况。后发先至的情况通常指的是网络中的数据包乱序到达。这种情况下,可能会导致某些后发送的数据包在传输过程中先到达了接收方,而先发送的数据包则被延迟或者还未到达。举个例子:
上面的例子中可以看到,“先发后至”可能导致传输错误,但是我们可以通过对数据进行编号来解决这种错误,在真实的TCP数据传输中,引入了 序号
和 确认序号
。
TCP将每个字节的数据都进行了编号,即序列号:
假设发送方需要传输一个 10000 字节的数据文件,并使用 TCP 协议进行传输。为了把这个数据文件发送出去,发送方会把它分成若干个报文段(segment),每个报文段包含一部分数据和相关的控制信息(如序列号、确认号等)。假设这个数据文件被分成了10个报文段,每个报文段大小为 1000 字节,则发送方会给每个报文段分配一个唯一的序列号,用来标识该报文段中第一个字节在整个数据流中的位置。
应答报文和确认号:
在 TCP 协议中,ACK 报文是一个确认报文,用来告知发送方它已经成功接收到对应的数据包。当接收方收到一个数据包后,会向发送方返回一个 ACK 报文,表示该数据包已经被成功接收。ACK 报文通常包含一个确认号(Acknowledgement Number),表示接收方期望下一个数据包的序列号。
确认序号规则:
1、确认序号指的是所发送过来的数据的最后一个字节的下一个字节的序号。例如发送数据为1-1000,确认序号就是1001。
2、确认序号1001表示 小于1000字节的数据已经收到。
3、确认序号1001表示并期望接收下一个字节的序列号为1001,即接下来想向发送方索要1001开始的数据。
回到“先发后至”问题上:
当接收方收到了序列号为 1-1000、1001-2000 和 2001-3000 的三个数据包,但是由于网络原因导致它们到达的顺序是 2001-3000、1-1000、1001-2000,这样就发生了乱序的情况。接收方会首先将这些数据包进行重新排序(TCP有接收缓冲区,自身承担了“排序”任务),按照正确的顺序为 1-1000、1001-2000、2001-3000 (因此应用层读数据读到的一定是和发送顺序一样的,正确的顺序)。然后接收方会发送一个 ACK 序号,告诉发送方接收到了这些数据包,并期望接收的下一段数据的序列号是 3001(即下一个数据包的序列号)。
在网络传输中由于网络拥堵、介质故障等原因可能导致发送的数据包无法到达,也就是我们常说的丢包。
对于丢包,主要存在以下两种情景:
1、发送到数据包丢了,接收方无法收到,也就不会返回 ACK,发送方无法收到 ACK,认为丢包。
2、接收方收到了数据包,但是 ACK 包丢失,发送方同样无法收到 ACK,认为丢包。
情景一:如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发。
情景二:进行重发,主机B会收到很多重复数据。那么TCP协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。这里就利用前面提到的序列号,就可以很容易做到去重的效果。
超时重传的时间限定
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间:
- Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
- 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
- 如果仍然得不到应答,等待 4*500ms 进行重传。依次类推,以指数形式递增。
- 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
总结起来就是,对于TCP的超时重传:即能重传就重传,重传不了就关闭连接,尽可能的保证传输!
Tcp建立连接:三次握手
握手(HandShake)是指通信双方进行网络交互,三次握手,相当于 客户端服务器之间进行了三次交互,建立了连接(各自记录对方的信息)关系。
- 主机 A 向主机 B 发送一个 SYN 报文,表示请求建立连接。
- 主机 B 收到 SYN 报文后,向主机A返回一个 ACK 报文,用于告知主机 A 已经接收到了其发送的 SYN 报文。同时,B 也会向 A 发送一个 SYN 报文,希望与主机 A 建立连接。
- 主机 A 收到 B 的 SYN+ACK 报文后,会向B发送一个 ACK 报文。此时,主机 A 和主机 B 之间的连接建立成功,可以进行数据传输。
注意:上述过程都是系统内核中自动完成的,应用程序干涉不了,等待连接完成,accept 就把建立好的连接从内核拿到应用程序中。
补充:这里的 SYN 指的是同步报文段,表示一方向另一方申请建立连接,这里的 SYN 其实是 TCP 报文段中的标志位,初始为 0。这里的 ACK+SYN 报文段即将 ACK 和 SYN 保标志位设为1。
三次握手的作用:三次握手本质上是“投石问路
”的过程,验证了客户端和服务器各自的收发能力是否正常。这也是后续进行可靠性传输的基础!
Tcp断开连接:四次挥手
- 主机A向主机B发送一个FIN报文,希望关闭连接。FIN标志位被设置为1,表示结束传输。
- 主机B收到FIN报文后,向主机A返回一个ACK报文,主机B通知主机A已经接受到了其发送的FIN报文。但是此时主机B可能还有未传输完的数据,因此B必须继续发送数据直到发送完毕。
- 主机B完成数据传输后,向主机A发送一个FIN报文,表示主机B已完成了所有数据传输并准备关闭连接。FIN标志位设置为1。
- 主机A收到B发送的FIN报文后,给B返回一个ACK报文,确认其收到了B的FIN报文。此时A通知B可以关闭连接了。
为什么这里的ACK 和 FIN 不能合并?
ACK 和 FIN 是不同时机触发的,ACK 是内核完成的,会在收到 FIN 的第一时间返回。FIN 则是应用程序代码控制的,在调用 Socket 的 close 方法的时候或者进程结束 才会触发 FIN。
补充:假设一次客户端服务器断开连接的过程,虽然客户端进程结束,但是 TCP 连接还在(内核维护),直到四次挥手完成,服务器同理。
在上面的“确认应答机制”中,对每一个发送的数据段,都要给一个 ACK 确认应答,即一发一收机制(下图左)。收到ACK后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差。因此为了提高效率,可以采用“滑动窗口机制”(下图右),即一次发送多条数据,将多个段的等待时间重叠在一起,相比一发一收就可以显著提高效率。
滑动窗口机制原理
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。下图的窗口大小就是四个段。即发送前四个段的时候,不需要等待任何ACK,直接发送。
- 收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据,依次类推。
- 操作系统内核为了维护这个滑动窗口,需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答。只有确认应答过的数据,才能从缓冲区删掉。
- 窗口越大,则网络的吞吐率就越高。
滑动窗口机制下的丢包处理
情景一:数据包到达,ACK 丢失。
不同于“一发一收”情况下ACK丢失,需要进行超时重传。滑动窗口下仅丢失ACK,其实对于可靠性没有影响,因为是批量发送和重叠等待ACK,由于“确认序号”的特性,即表示该序号之前的数据都已收到。如果此时丢失1001ACK 、3001ACK、4001ACK 只要后续收到更大的“确认序号“,例如后续接收到5001ACK 就可以确定5001之前的数据都已收到,所以此时即使丢失部分ACK对整体的可靠性也没有影响,可以通过后续的ACK进行确认。
情景二:数据包丢失。
如果在滑动窗口这种机制下丢失数据包,会触发为 “高速重发控制”(也叫 “快重传”)
- 当某一段报文段丢失之后,发送端会一直收到同样的ACK。例如上面情境中会连续收到 1001 这样的ACK,就像是在提醒发送端 “我想要的是 1001” 一样;
- 如果发送端主机连续三次收到了同样一个 ACK应答,就会将对应的数据重新发送。上述发送方主机连续三次收到了同样一个 “1001” 这样的应答,就会将对应的数据 1001 - 2000 重新发送;
- 这个时候接收端收到了重发的数据之后,再次返回的ACK就是最新的确认序号了。上述接收到 1001 之后,再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。
接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包
,继而引起丢包重传等等一系列连锁反应。
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control);
在返回的ACK报文中,会生效一个“窗口大小”字段,这里面的值就是建议发送方发送的窗口大小。(可以将计算窗口大小理解为,返回接收缓冲区的剩余空间)
发送窗口的大小=流量控制+拥塞控制
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据,但是如果不加以约束,仍然会出现问题。在上述“流量控制”中接收方根据自己的处理能力来反向约束发送速度。其实实际发送中还存在另一个机制“拥塞控制”用来衡量传输路径的传输能力。
拥塞控制,实际上就是摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
- 此处引入一个概念程为拥塞窗口,发送开始的时候 为 慢启动 机制,先给出一个较小的窗口大小,发少量的数据,探探路。
- 每次发送数据包的时候,将拥塞窗口和流量控制窗口大小做比较,取较小的值作为实际发送的窗口。
- 需要注意的是,“慢启动” 只是指初使时慢,但是增长速度非常快。像上面这样的拥塞窗口增长速度,是指数级别的。这样可以让窗口大小短时间内就达到一个比较大的值,从而可以快速接近当前网络传输路径的能力瓶颈。
- 为了避免由于增长过快,发送速度一下超出上限很多,引入一个叫做慢启动的阈值,即当指数增长到一定的阈值就变成平稳的线性增长,可以使传输速度逐渐接近传输上限。
- 此时继续增长,到达一定程度后,出现丢包则认为当前窗口大小已经达到了当前传输的上限了,此时就立即把窗口回归到一个比较小的初始值,重复上述过程。
Tcp中决定效率的关键因素就是“窗口大小
”。我们知道对于发送方和接收方,发送方在不停地发送数据,接收方也在不停地从接收缓冲区消费数据,如果此时接收方收到数据后立即返回ACK,此时ACK报文中携带的窗口大小设为N,倘若等待“一小段”时间,让接收方先消费一些数据,然后再返回ACK,此时携带的窗口大小为N+,易知:N+ > N 。
上述就是延时应答,它实现的效果就是通过延时,让接收方程序趁机多消费点数据,此时反馈的窗口大小就会更大一些,使得满足接收方能够处理的前提下,让发送方的发送数据速率也更快一些。
那么所有的包都可以延迟应答么?肯定也不是;
- 数量限制:每隔N个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次。
很多情况下,客户端服务器在应用层也是 “一发一收
” 的,意味着客户端给服务器发送一个请求,服务器也会返回一个响应。因此在“延时应答”的基础上,ACK 就可以搭顺风车了。ACK 是由内核负责的,一般是收到报文立即返回,而响应则是通过一系列代码执行到才返回,这两个时机本来是不同的,但是通过“延时应答”机制,就很可能使得 ACK 和 响应 合并成一个数据报。
每一次数据报的传输都有封装分用等一系列的复杂过程,两个数据报需要封装分用一次,而两个数据报需要封装分用两次,很明显这种“捎带应答
”机制,可以通过合并的方式减少封装分用的次数,从而提高效率。
正是由于这种“捎带应答”机制,使得“四次挥手”可能三次就完成了。
在Tcp协议中,一个很大的特点就是面向字节流,这样使得 Tcp 的读写更加灵活,但与此同时也暗藏杀机,那就是“粘包问题
”。
在使用Tcp协议的前提下,假设我发送了以下4条数据:
帝国主义者侵略我们
奴役我们
要把我们的地
瓜分掉
试想站在应用层的角度,在会在接收缓冲区里看到如下信息:
帝国主义者侵略我们奴役我们要把我们的地瓜分掉
等等,他们为什么要分我们的地瓜?
上面这个例子就展示了一个由于“粘包问题
”闹出的笑话。当A给B连续发送多个应用层数据报之后,这些数据积累到B的接收缓冲区中,数据之间会紧紧地挨在一起,此时B的应用程序在读取数据的时候,就难以区分从哪到哪是一个完整的应用层数据报,可能读出半个包、一个半包等情况。
那么如何避免呢?其实在之前章节《网络编程》中,在使用传输层协议 Tcp 进行通信时,我们当时写了一个 Tcp 的回显客户端-服务器,当时为了区分应用层数据包,我们做了如下约定:
1.每个请求是个字符串
2.请求和请求之间,使用\n(换行符)分割
其实以上就是一个简单的自定义应用层协议,通常来说,我们处理粘包问题主要有以下两种方案
:
- 定义分隔符(如上)
- 约定长度
冷知识:UDP不会出现“粘包问题”。根本原因是 UDP 协议面向数据报,彼此之间有明显的界限。
进程关闭/进程崩溃:进程终止,socket文件也被随之关闭,但是此时操作系统内核还维护有Tcp连接,直到四次挥手完成,因此和正常关闭没有什么区别。
机器关机/机器重启:关机或重启会先杀死所有的用户进程,同样会触发四次挥手,期待的情况是在此过程中四次挥手完成,如果四次挥手还未完成,比如对端发送来FIN,当前机器还没来得及ACK就关机了,此时对端就会重传FIN,重传几次之后,都发现没有ACK,就尝试重置连接,如果还不行就释放连接。
机器掉电/网线断开:连接瞬间关闭,来不及进行任何挥手操作。此时分为两种情况:
- 假设它的对端是发送方,发送方就会收不到ACK,此时会先进行超时重传,重传失败在进行重置连接,重置连接失败最终释放连接。
- 假设它的对端是接收方,那么接收方无法立即知道异常端是没来的及发送新的数据还是“挂了”。TCP自己也内置了一个
保活定时器
——心跳包。虽然对端是接收方,但是对端会定期(周期性)给发送端发一个心跳包,如果每一个心跳包发送端都有相应的回应,就说明当前对端状态良好。反之心跳没了,判定对端“挂了”。
更多详情内容请参考 TCP RFC标准文档