TCP协议
TCP协议报文格式
源端口号/目的端口号:发送方端口/接收方端口
- 32位序号:序号是可靠传输的关键因素。TCP将要传输的每个字节都进行了编号,序号是本报文段发送的数据组的第一个字节的编号,序号可以保证传输信息的有效性。比如:一个报文段的序号为300,此报文段数据部分共有100字节,则下一个报文段的序号为401。
- 32位确认序号:每一个确认序号(ACK)对应这一个确认号,它指明下一个期待收到的字节序号,表明该序号之前的所有数据已经正确无误的收到。确认号只有当ACK标志为1时才有效。比如建立连接时,SYN报文的ACK标志位为0。
- 4位首部长度: 表示该TCP头部有多少个32位bit(有多少个4字节),所以TCP头部大长度是15 * 4 = 60。根据该部分可以将TCP报头和有效载荷分离。TCP报文默认大小为20个字节。
- 6位标志位:
URG: 它为了标志紧急指针是否有效。
ACK:标识确认号是否有效。
PSH: 提示接收端应用程序立即将接收缓冲区的数据拿走,接收方应该尽快将本报文段立即传送给其应用层。
RST:它是为了处理异常连接的, 告诉连接不一致的一方,我们的连接还没有建立好, 要求对方重新建立连接。我们把携带RST标识的称为复位报文段。
SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段。该标志仅在三次握手建立TCP连接时有效,
FIN: 通知对方, 本端要关闭连接了, 我们称携带FIN标识的为结束报文段。
- 16位的紧急指针:按序到达是TCP协议保证可靠性的一种机制,但是也存在一些报文想优先被处理,这时就可以设置紧急指针,指向该报文即可,同时将紧急指针有效位置位1。紧急指针指出在本报文段中的紧急数据的最后一个字节的序号,
- 16位窗口大小:如果发送方发送大量数据,接收方接收不过来,会导致大量数据丢失。然后接收方可以发送给发送发消息让发送方发慢一点,这是流量控制。接收方将自己接收缓冲器剩余空间的大小告诉发送方叫做16位窗口大小。发送发可以根据窗口大小来适配发送的速度和大小,窗口大小最大是2的16次方,及64KB,但也可以根据选项中的某些位置扩展,最大扩展1G。
- 16位校验和:发送端填充,CRC校验。如果接收端校验不通过, 则认为数据有问题(此处的检验和不光包含TCP首部也包含TCP数据部分)。
确认应答机制:
接收端收到一条报文后,向发送端发送一条确认(ACK),此确认(ACK)的作用就是告诉发送端:接收端已经成功的收到了消息,并且希望收到下一条报文的序列号是什么。这个确认号就是期望的下一个报文的序号。
每一个确认(ACK)都带有对应的确认序列号,意思是告诉发送者,我们已经收到了哪些数据,下一个发送数据应该从哪里开始。 如上图,主机A给主机B发送了1-1000的数据,ACK应答,携带了1001序列号。告诉主机A,我已经接受到了1-1000数据,下一次你从1001开始发送数据。
重传机制:
TCP在传输数据过程中,还加入了超时重传机制。假设主机A发送数据给主机B,主机B没有收到数据包,主机B自然就不会应答,如果主机A在一个特定时间间隔内没有收到主机B发来的确认应答,就会进行重发,这就是超时重传机制。
当然还存在另一种可能就是主机A未收到B发来的确认应答,也可能是因为ACK丢失了。
重传机制-超时重传:
- 当一个报文段丢失时,会等待一定的超时周期然后才重传分组,增加了端到端的时延。
- 当一个报文段丢失时,在其等待超时的过程中,可能会出现这种情况:其后的报文段已经被接收端接收但却迟迟得不到确认,发送端会认为也丢失了,从而引起不必要的重传,既浪费资源也浪费时间。
所以超时重传比不可少,但是是有缺陷的,所以才有了快速重传的配合辅助。
重传机制-快速重传:
发送端数据丢失,如果接收端收到其它发送端报文,确认报文会一直发需要接收的已经丢失的该次确认序号。如果发送端收到满足3次,总共4次同样的确认,发送端会在超时重传之前启动快速重传。
如果未收满4次并且重传超时,或确认序号根本未收到而超时时间满足,则只能启动超时重传。
快速重传满足条件后,接收端遍历收到的报文序号,确认后续未收到的报文,中间已经收到的直接跳过,只确认连续的最后一次,则可直接确认前面所有数据,
接收端支持一次数据确认,则前面的发送数据均确认,所以快速重传在未收到包后一直发送上次确认包,而不能发送其它确认序号。
超时重发的时间应该如何确定?
在理想的情况下,可以找到一个小的时间来保证 "确认应答"一定能在这个时间内返回。但是这个时间的长短,随着网络环境的不同是有差异的。如果超时时间设的太长,会影响整体的重传效率。如果超时时间设的太短,有可能会频繁发送重复的包。TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
Linux中超时时间以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等待2*500ms后再进行重传。如果仍然得不到应答,等待4*500ms进行重传。依次类推,以指数形式递增,当累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
滑动窗口
每一个发送的数据段, 都要给一个ACK确认应答. 收到ACK后再发送下一个数据段.
这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返时间较长的时候.
那么我们可不可以一次发送多个数据段呢?
一个概念: 窗口
窗口大小指的是无需等待确认应答就可以继续发送数据的最大值.
上图的窗口大小就是4000个字节 (四个段).
发送前四个段的时候, 不需要等待任何ACK, 直接发送
收到第一个ACK确认应答后, 窗口向后移动, 继续发送第五六七八段的数据…
因为这个窗口不断向后滑动, 所以叫做滑动窗口.
操作系统内核为了维护这个滑动窗口, 需要开辟发送缓冲区来记录当前还有哪些数据没有应答
只有ACK确认应答过的数据, 才能从缓冲区删掉.
连接管理机制
3次握手
4次挥手
TIME_WAIT状态: 当我们实现一个TCP服务器时,我们把这个服务器运行起来然后将服务器关闭掉,再次重新启动服务器会发现一个问题:就是不能马上再次绑定这个端口号和ip,需要等一会才可以重新绑定,其实等的这一会就是TIME_WAIT状态。
TCP协议规定主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL的时间后才能回到CLOSED状态。
当我们使用Ctrl-C终止了server,server是主动关闭连接的一方在TIME_WAIT期间仍然不能再次监听同样的server端口。
MSL在RFC1122中规定为两分钟(120s),但是各操作系统的实现不同,在Centos7上默认配置的值是60s可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看MSL的值。
为什么TIME_WAIT时间一定是2MSL:
首先,TIME_WAIT是为了防止最后一个ACK丢失,如果没有TIME_WAIT,那么主动断开连接的一方就已经关闭连接,但是另一方还没有断开连接,它收不到确认ACK会认为自己上次发送的FIN报文丢失会重发该报文,但是另一方已经断开连接了,这就会造成连接不一致的问题,所以TIME_WAIT是必须的。
MSL是TCP报文在发送缓冲区的最大生存时间,如果TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。同时也是在理论上保证最后一个报文可靠到达。(假设最后一个ACK丢失, 那么服务器会再重发一个FIN,这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK,这就会导致问题)
解决TIME_WAIT状态引起的bind失败的方法:
在server的TCP连接没有完全断开之前不允许重新绑定,也就是TIME_WAIT时间没有过,但是这样不允许立即绑定在某些情况下是不合理的:
服务器需要处理非常大量的客户端的连接 (每个连接的生存时间可能很短,但是每秒都有很大数量的客户 端来请求)
这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),这样服务器端就会产生大量TIME_WAIT状态
如果客户端的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip, 源端口, 目的ip, 目的端口, 协议)。其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号和TIME_WAIT占用的连接重复就造成等待。
为什么最后客户端还要等待 2*MSL的时间呢?
MSL(Maximum Segment Lifetime),TCP允许不同的实现可以设置不同的MSL值。
第一,保证客户端发送的最后一个ACK报文能够到达服务器,因为这个ACK报文可能丢失,站在服务器的角度看来,我已经发送了FIN+ACK报文请求断开了,客户端还没有给我回应,应该是我发送的请求断开报文它没有收到,于是服务器又会重新发送一次,而客户端就能在这个2MSL时间段内收到这个重传的报文,接着给出回应报文,并且会重启2MSL计时器。
第二,防止类似与“三次握手”中提到了的“已经失效的连接请求报文段”出现在本连接中。客户端发送完最后一个确认报文后,在这个2MSL时间中,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失。这样新的连接中不会出现旧连接的请求报文。
为什么建立连接是三次握手,关闭连接确是四次挥手呢?
建立连接的时候, 服务器在LISTEN状态下,收到建立连接请求的SYN报文后,把ACK和SYN放在一个报文里发送给客户端。
而关闭连接时,服务器收到对方的FIN报文时,仅仅表示对方不再发送数据了但是还能接收数据,而自己也未必全部数据都发送给对方了,所以己方可以立即关闭,也可以发送一些数据给对方后,再发送FIN报文给对方来表示同意现在关闭连接,因此,己方ACK和FIN一般都会分开发送,从而导致多了一次。
如果已经建立了连接, 但是客户端突发故障了怎么办?
TCP设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为2小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75分钟发送一次。若一连发送10个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。
流量控制:
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被填满, 这个时候如果发送端继续发送, 就会造成丢包, 进而引起丢包重传等一系列连锁反应.
因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度.
这个机制就叫做 流量控制(Flow Control)
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,
通过ACK通知发送端;
窗口大小越大, 说明网络的吞吐量越高;
接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
发送端接受到这个窗口大小的通知之后, 就会减慢自己的发送速度;
如果接收端缓冲区满了, 就会将窗口置为0;
这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 让接收端把窗口大小再告诉发送端.
那么接收端如何把窗口大小告诉发送端呢?
我们的TCP首部中, 有一个16位窗口大小字段, 就存放了窗口大小的信息;
16位数字最大表示65536, 那么TCP窗口最大就是65536字节么?
实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是窗口字段的值左移 M 位(左移一位相当于乘以2).
拥塞控制:
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠地发送大量数据.
但是如果在刚开始就发送大量的数据, 仍然可能引发一些问题.
因为网络上有很多计算机, 可能当前的网络状态已经比较拥堵.
在不清楚当前网络状态的情况下, 贸然发送大量数据, 很有可能雪上加霜.
因此, TCP引入 慢启动
机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态以后, 再决定按照多大的速度传输数据.
在此引入一个概念 拥塞窗口
发送开始的时候, 定义拥塞窗口大小为1;
每次收到一个ACK应答, 拥塞窗口加1;
每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口
像上面这样的拥塞窗口增长速度, 是指数级别的.
“慢启动” 只是指初使时慢, 但是增长速度非常快.
为了不增长得那么快, 此处引入一个名词叫做慢启动的阈值, 当拥塞窗口的大小超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长.
少量的丢包, 我们仅仅是触发超时重传;
大量的丢包, 我们就认为是网络拥塞;
当TCP通信开始后, 网络吞吐量会逐渐上升;
随着网络发生拥堵, 吞吐量会立刻下降.
拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.
延迟应答:
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.
假设接收端缓冲区为1M. 一次收到了500K的数据;
如果立刻应答, 返回的窗口大小就是500K;
但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了; 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
如果接收端稍微等一会儿再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M
窗口越大, 网络吞吐量就越大, 传输效率就越高.
TCP的目标是在保证网络不拥堵的情况下尽量提高传输效率;
那么所有的数据包都可以延迟应答么?
肯定也不是 ,有两个限制
- 数量限制: 每隔N个包就应答一次
- 时间限制: 超过最大延迟时间就应答一次
捎带应答:
在延迟应答的基础上, 我们发现, 很多情况下
客户端和服务器在应用层也是 “一发一收” 的
意味着客户端给服务器说了 “How are you”
服务器也会给客户端回一个 “Fine, thank you”
那么这个时候ACK就可以搭顺风车, 和服务器回应的 “Fine, thank you” 一起发送给客户端
面向字节流:
当我们创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区。
调用write时,内核将数据会先写入发送缓冲区中,如果发送的字节数太长,会被拆分成多个TCP的数据包发出,如果发送的字节数太短,就会先在缓冲区里等待, 等到缓冲区长度达到设置长度,然后等到其他合适的时机发送出去。
调用read接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read从接收缓冲区拿数据。TCP的一个连接,既有发送缓冲区, 也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。所以是全双工的。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配。例如: 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节; 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次 read一个字节, 重复100次
粘包问题:
粘包问题中的 "包"是指的应用层的数据包。在TCP的协议头中,没有如同UDP一样的 "报文长度"这样的字段,但是有一个序号这样的字段。站在传输层的角度, TCP是一个一个报文过来的,按照序号排好序放在缓冲区中,但是站在应用层的角度,它看到的只是一串连续的字节数据。应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分结束是一个完整的应用层数据包,这就是粘包问题。
如何避免粘包问题呢?明确两个包之间的边界
对于定长的包,保证每次都按固定大小读取即可。例如一个Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可
对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。
对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议是程序员自己来定义的, 只要保证分隔符不和正文冲突即可)。
对于UDP协议,如果还没有上层交付数据, UDP的报文长度仍然在。 同时UDP是一个一个把数据交付给应用层,这样就有存在明确的数据边界,站在应用层的角度, 使用UDP的时候要么收到完整的UDP报文要么不收,不会出现"半个"的情况。
TCP连接异常情况:
进程终止:进程终止会释放文件描述符,仍然可以发送FIN,和正常关闭没有什么区别。机器重启和进程终止一样。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。应用层的某些协议, 也有一些这样的检测机制.例如HTTP长连接中, 也会定期检测对方的状态.Q在QQ 断线之后, 也会定期尝试重新连接。