目录
TCP协议的格式我们可以看到下图
源/目的端口号:表示数据是从哪个进程来,到哪个进程去;
4位TCP报头长度:表示该TCP头部有多少个32位bit(有多少个4字节);所以TCP头部最大长度是 15 * 4 = 60
6位标志位:
URG:紧急指针是否有效 ACK:确认号是否有效
PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走
RST:对方要求重新建立连接;我们把携带RST标识的称为复位报文段
SYN:请求建立连接;我们把携带SYN标识的称为同步报文段
FIN:通知对方,本端要关闭了,我们称携带FIN标识的为结束报文段
16位校验和:发送端填充,CRC校验。接收端校验不通过,则认为数据有问题。此处的检验和不 光包含TCP首部,也包含TCP数据部分。
16位紧急指针:标识哪部分数据是紧急数据; 4
0字节头部选项:暂时忽略;
32位序号/32位确认号和16位窗口大小我们会在后面提到时再解释 。
TCP对数据传输提供的管控机制,主要体现在两个方面:可靠性和效率。
这些机制和多线程的设计原则类似:保证数据传输安全的前提下,尽可能的提高传输效率。
这里我们需要辨析两个概念:
可靠性:指数据发送后发送方能够知道接收方是否收到了数据
安全性:指的是数据被截获后不容易被理解内部意思或者篡改,通过加密实现
确认应答就是发送出去的数据对方有没有收到发送方能够知道
所以TCP要对发送的数据进行编号,然后才能明确应答报文是在应答哪个数据。
TCP将每个字节的数据都进行了编号。即为序列号。
下面就是一个确认应答的经典例子:
第一次发送数据(1-1000)代表一次性发送了1000字节的数据(一个TCP数据报,长度是1000,序号是1)。
而此时应答报文中的确认序号就是1001,应答报文可以视为只有TCP报头,没有载荷。在1001这个报头里,意思是<1000的数据都已经收到了,接下来A要从1001开始往后发送
如何区分一个报文是普通报文还是应答报文?
在TCP报头中有一个6位的标志位,其中ACK就代表是否为应答报文。ACK为0代表不是,ACK为1代表是应答报文。
我们这里再解释一下UDP面向数据报传输和TCP面向字节流传输,这个指的其实是应用层的角度。他们两个协议在发送数据的过程中实际都是采用数据报的形式。
我们先看UDP:
假如B这边调用receive方法,第一次调用读出来的是1111,第二次读出来的是2222,第三次读出来的是3333。而UDP 的接收缓冲区,就相当于一个链表,里面有三个节点,每次读都得以节点为单位。这就是所谓的面向数据报。
再看到TCP:
假如B调用InputStream.read(buffer)
byte[1] buffer 读出来就是1
byte[3] buffer 读出来就是111
byte[5] buffer 读出来就是11112
byte[7] buffer 读出来就是1111222
TCP的接收缓冲区更像一个数组,若干个TCP数据报的载荷会一直追加到这个数组里,彼此之间融为一体,这就是面向字节流
在确认应答的情况下,如果收到了ACK那么一切照常,但是假如因为丢包等因素没有收到ACK那么就会进入超时重传机制。
比如业务数据丢了:
或者ACK丢了:
这两种情况在发送方等待一段时间后没有收到ACK就会触发重传,重新去发送数据。
需要注意第二种情况(当然也可能数据只传输一半就丢包了),此时超时重传机制就会导致发送了部分的重复数据,但是此时接收方是可以根据序号来进行数据去重的,所以大家可以不用担心。
几点需要注意的:
1.在数据发送出去之后,就会同时发数据内容+校验和,数据接收方会按照同样的规则计算校验和并比较,在任意传输的过程中,如果某个中间节点发现校验和不对都会触发主动丢包
2.超时重传的数据仍有丢包的可能,假如超时重传后又丢包则超时重传触发的时间就会延长,这里的等待间隔是会随着时间推移越来越大的,因为频繁丢包代表当前网络环境差,所以会等待更久再去重传。累积到一定次数就会认为网络异常,强制关闭连接
确认应答与超时重传是保证TCP可靠性的最核心机制。
在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接。这就是我们常说的三次握手四次挥手。
主动的一方是客户端,三次握手一定是客户端发起的。这个过程我们可以用下图表示:
其中SYN:请求建立连接;我们把携带SYN标识的称为同步报文段
看上去好像是四次对吧,但其实我们习惯将中间两部合并成一步,因为B返回的SYN和ACK都是操作系统内核收到A的SYN后立刻返回的。于是就有了下图
其中确认报文与同步报文的确定也用到了我们之前提到了6为标志位
这就是我们三次握手的过程,此时A与B的连接就建立好了。
这个操作相当于“投石问路”,在正式通信之前要先确定通信链路是否通畅,如果不通畅那后面大概率会丢包。其次在建立连接的过程中服务器与客户端还会协商一些重要数据。
那为什么是三次而不是四次或者更多呢,可以,但是没必要,因为效率会降低,而低于三次则无法起到判断通信链路是否通畅的作用。具体我们看到下面的解释。
假如张三和李四准备开黑打游戏,那么开黑前就得验证他们各自的耳机和麦克风工作是否正常。那么就有了下面的场景
1.张三:喂喂喂,听得到吗(相当于SYN)
2.李四:OKOK(相当于SYN+ACK),此时就验证了张三的麦克风和李四的耳机没问题
3.张三:OK,听得到(相当于ACK),此时验证了 李四的麦克风和张三耳机正常
通过这三步我们就可以验证通信链路是否通畅
断开连接的过程客户端和服务器都可以发起。具体过程看下图:
其中FIN:通知对方,本端要关闭了,我们称携带FIN标识的为结束报文段
那为什么四次挥手中间的两次操作不能合并呢?
B返回ACK是内核行为。操作系统内核收到FIN后,就会立刻返回ACK。而接下来B的FIN是用户代码行为。用户在代码中调用socket.close()方法才会触发FIN。正因为B发送ACK和FIN有着不可忽视的时间间隔,所以才不能合并
我们先看一下TCP在三次握手与四次挥手的过程状态示意图:
图上主要有三部分信息:
1.三次握手四次挥手中间数据传输的流程
2.三次握手四次挥手过程中,TCP状态的转换
3.每个环节涉及到的socket API(由于Java使用的是Java自己包装过的API,所以在这里我们不讨论)
我们重点来谈一下各种状态的意义:
1.LISTEN :服务器状态,代表服务器启动完毕,已经绑定端口成功
2.ESTABLISHED:连接建立完毕,随时可以进行通信
3.CLOSE_WAIT:被动接受FIN的一方会进入 CLOSE_WAIT。这个状态就是自己收到了FIN也返回了ACK,在自己发送FIN之前的状态。这个状态的意思就是等待代码中调用close方法,发送FIN。如果服务器存在大量CLOSE_WAIT说明代码可能某处忘记了close socket
4.TIME_WAIT:主动发起FIN的一方,会进入TIME_WAIT。主动的这一方,收到对方的ACK之后就会进入TIME_WAIT状态,而不是直接进入CLOSE状态,而是会等待一段时间后才进入CLOSE状态彻底释放连接。
为什么是TIME_WAIT的时间是2MSL?
MSL是TCP报文的最大生存时间,
因此TIME_WAIT持续存在2MSL的话 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重 启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的);
同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会再重 发一个FIN。这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发 LAST_ACK);
效率和可靠性本身是冲突的,但TCP还是会在保证可靠性的前提下尽量提高效率。
滑动窗口本质上就是把等待ACK的时间重叠起来,减少等待时间,相当于提高了效率。
下面是我们原本的一发一收方式示意图
既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多 个段的等待时间重叠在一起了)。 这就是滑动窗口的原理。于是就变成了下图
不再是发送一条等待一条,而是发送一批,等待一批ACK
首先批量发送1001-2000;2001-3000;3001-4000;4001-5000的数据
针对这四个数据等待ACK,当2001ACK返回A时,此时代表1001-2000的数据已经被收到
滑动窗口就可以右移,发送5001-6000的数据
那么如果出现了丢包,如何进行重传?这里分两种情况讨论
情况一:数据包已经抵达,ACK被丢了。
如果1001丢了,2001到了,此时对于A来说,就知道1-1000这个数据也是到了。后一个会覆盖前一个。
这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认;
情况二:数据包就直接丢了。
当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 "我想要的是 1001" 一样;
如果发送端主机连续三次收到了同样一个 "1001" 这样的应答,此时客户端就会明白1001丢了,就会将对应的数据 1001 - 2000 重新发送;
这个时候接收端收到了 1001 之后,再次返回的ACK就是7001了(因为2001 - 7000)接收端 其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中;
流量控制本质上就是对滑动窗口的制约。
滑动窗口的窗口大小越大,发送速率越快,流量控制就是针对发送速率进行制约。
接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control);
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段,通过ACK端通知 发送端;
窗口大小字段越大,说明网络的吞吐量越高;而流量控制就是通过接收缓冲区的剩余空间来控制下一次发送时窗口大小
接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端; 发送端接受到这个窗口之后,就会减慢自己的发送速度;
如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送 一个窗口探测数据段,使接收端把窗口大小告诉发送端。
接收端如何把窗口大小告诉发送端呢?在TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息;
那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么? 实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是 窗口字段的值左移 M 位;
下图是我们发送一次数据的简易流程
从A发送数据到B,期间还需要经过许多中转设备,因此我们还需要考虑中间设备的转发能力,这就是拥塞控制的意义。那我们该如何去衡量中间设备的处理能力呢?
由于中间设备的数量和设备参数等我们全部都不知道,所以我们采取的方式是“做实验”。
1.刚开始按照小的窗口来发送
2.如果不丢包,说明中间网络比较通常,可以逐渐放大发送窗口的大小
3.放大到一定程度,速率比较快,网络就容易出现拥堵,进一步出现丢包。当发送方发现丢包后,就会减小发送窗口
反复在2和3之间循环,通过这个过程来达到一个动态平衡,而以上过程可以量化成下图
初始的时候拥塞窗口从一个很小的值开始,指数增长(慢增长)
如果窗口大小达到设定的阈值后,就改为线性增长
当线性增长到一定程度,可能会丢包,此时把窗口大小回归到一个特别小的窗口
之后重复上述过程,同时也会把刚才线性增长的阈值进行调整(变为网络拥塞时的)
注意:流量控制和拥塞控制都能影响发送方滑动窗口的大小,而最终滑动窗口的大小取流量控制的窗口和拥塞控制窗口的较小值
在前面我们知道,流量控制通过ACK告知发送方窗口大小多少合适,这是根据接收方剩余缓冲区大小来决定的。那我们如果让ACK等待一段时间后再返回会怎么样呢?
假设我们立刻返回ACK,可能缓冲区大小是5KB
那我们稍等一会(例如200ms), 在这段时间内应用程序可能取走了很多数据,缓冲区的空间可能就变成了100KB,这样我们就能在保证网络通常的前提下提高效率
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况 下尽量提高传输效率;
那么所有的包都可以延迟应答么?肯定也不是;
数量限制:每隔N个包就应答一次;
时间限制:超过最大延迟时间就应答一次;
具体的数量和超时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms;
捎带应答是建立在延时应答的基础上的。我们发现,很多情况下,客户端服务器在应用层也是 "一发一收" 的,但是由于我们知道ACK和响应数据的返回一般是有一定的时间差的,于是我们就可以让ACK延时一会,这样就有可能让响应数据和ACK的返回时间重合。我们可以通俗地理解成搭“顺风车”。
捎带应答机制可能发生在任何阶段,但也可能不发生。
关于这一点我们在文章的上部分已经讲过了,大家忘记的可以回去看看。
但是面向字节流的一个问题就是可能会带来粘包问题。
首先要明确,粘包问题中的 "包" ,是指的应用层的数据包。
在TCP的协议头中,没有如同UDP一样的 "报文长度" 这样的字段,但是有一个序号这样的字 段。
站在传输层的角度,TCP是一个一个报文过来的。按照序号排好序放在缓冲区中。
站在应用层的角度,看到的只是一串连续的字节数据。
那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个 完整的应用层数据包。 通俗地说就是应用程序不知道缓冲区的数据哪一部分是属于那一部分的,仿佛全部数据黏在了一起。
归根结底就是一句话,明确两个包之间的边界。
正如我们之前自定义应用层协议那样,我们可以采用以下方法来避免粘包问题:
1.使用分隔符,如“;”或者“\n”等
2.约定每一部分数据的长度
(1)主机关机(正常关机):按照程序关机,此时会先杀死所有进程,然后TCP线程就会释放PCB,之后释放文件描述符表上对应的文件资源(相当于调用close)。在这个过程中会发生四次挥手,假如一切正常就会继续关机,如果四次挥手还没执行完就已经关机,对端重传FIN多次,没有响应也就放弃。
(2)程序崩溃:同上。虽然进程没了,但是本身TCP连接由内核负责,内核仍会继续完成四次挥手的过程
(3)主机掉电:
1.接收方掉电:对方尝试发送数据,发现没有收到ACK,尝试重传,仍然没有ACK,发送方尝试重新连接,如果重连也不成功,就认为网络出现严重问题,也就自然放弃
2.发送方掉电:接收方在等待发送方发送数据,由于发送方没了这个数据显然发送不过来了。但是接收方不知道,到底是对方没发还是对方出现了问题。(接收方区分不了)
接收方如果一段时间没有收到数据,就会定期给发送方发送“心跳包”,接收方给发送方发送一个特殊的报文(ping),对方回一个(pong),如果对方回了(pong)就认为处于正常状态,否则就认为对方挂了。而“心跳包”的作用就是周期性判定对方是否存活。
(4)网线断开:和主机掉电相同
1.如果关注可靠性,优先考虑TCP
2.如果单个传输数据较大(UDP报文上限64KB),优先考虑TCP
3.使用UDP,对于可靠性要求不高,但对于性能要求很高
4.如果要进行“广播”(一个发送方N个接收方),优先考虑UDP
当然我们还有同时兼顾可靠性与效率的KCP协议,适用于MOBA对抗类游戏。(但是KCP效率不如UDP可靠性也不如TCP,是在两者之间折中)
在应用层代码里面参考TCP策略实现