上篇文章对TCP可靠性机制讲解了一部分,这篇文章接着继续讲解。
在上篇文章中,本喵讲解了TCP的确认应答机制:
如上图所示,主机A每发送一个数据段,主机B都要给一个ACK
确认应答, 主机A收到ACK
后再发送下一个数据段。
这样做有一个比较大的缺点, 就是性能较差,数据段和数据段之间的发送就变成了串行的了,尤其是数据往返的时间较长的时候,效率更低。
为了提高效率,采用一次发送多条数据的方式:
如上图所示,假设一个数据段的大小是1000字节,主机A一次性发送四个数据段,主机B一次给主机A四个ACK
确认应答。
我们知道,TCP协议中有超时重传机制,如果主机A在一定的时间内没有收到主机B的确认应答,那么就会触发超时重传,再次将刚刚的数据段发送一遍。
那么在收到确认应答之前这些暂存的数据段是存放在哪里的呢?答案是存放在发送缓冲区中。
如上图所示,用户层将数据send
到TCP的发送缓冲区,发送缓冲区会存在大量的数据,需要操作系统在合适的时候发出去,由于TCP是面向字节流的,所以势必不会一次性将发送缓冲区中的数据都发出去。
此时就会导致发送缓冲区中的数据有三种不同的状态,同时也将发送缓冲区分成了三部分:
ACK
的数据。如上图序号1所示,这个区域的数据是已经发送了,并且收到了ACK
确认应答的数据,说明这些数据对方完全收到了,就没有存在的必要了,所以新数据来了以后会将其覆盖。
ACK
的数据。如上图序号2所示,这个区域的数据是已经发送了,但是还没有收到ACK
确认应答,说明这部分数据对方可能没有收到,也可能对方的ACK
确认应答信号自己没有收到。
当触发超时重传后,这部数据会被再次发送,所以这部分数据不能被覆盖,也不能被清除。
- 发送缓冲区中,存放已经发送但是没有收到
ACK
数据的区域就是滑动窗口。
如上如序号3所示,这个区域的数据还没有发送,更谈不上有没有ACK
确认应答。
如上图所示,内核中的发送缓冲区可以看成是一个char outbuffer[N]
数组,存在win_start
和win_end
两个指针来标识滑动窗口的范围,窗口滑动的本质就是数组下标的更新。
如上图红色框中所示,在TCP协议的报头中有一个16位的窗口大小,该值就是滑动窗口的大小。
在通信双方进行三次握手建立连接的过程中,接收方将自己的接收能力告诉了发送方(起初是整个接收缓冲区的大小),也就是发送方在发送数据之前就确定了滑动窗口tcp_win
的大小。
伪代码:
win_start = 0;
win_end = win_start = tcp_win;
所以最开始,滑动窗口的大小是从发送缓冲区起始位置开始的tcp_win
个字节,也就是对方通告给我的接收能力大小。
如上图所示,假设现在滑动窗口的大小是4个数据段,也就是4000个字节,主机A先发送一个数据段(1001~2000
),当主机B收到并且返回ACK
时,其中确认序号是2001
。
- 确认序号表示发送端下次发送从这个序号的位置开始发送即可。
此时原本滑动窗口中的第一个数据段就变成了已经发送并且收到ACK
的数据,可以被覆盖了,所以滑动窗口继续向右滑动,第一个数据变成了原本的第二个数据段(2001~3000
)。
如上图所示,假设发送缓冲区中一个黑色小框是一个数据段(大小是1000字节),在没有收到ACK
前,滑动窗口的大小是5个数据段。
收到ACK
确认应答如上图所示,确认序号是1001
,说明0~1000
的数据段对方收到了,下次从1001
处开始发送,所以滑动窗口的win_start
向右滑动一个数据段,指向1001
处。
除此之外,由于对方的接收缓冲区应用层没有读数据,再加上又有新数据到来,所以接收能力下降了,应答信号中的16位窗口大小表示对方此时的接收能力是3000,所以此时滑动窗口的大小就要发生变化。
win_end = win_start + tcp_win(3000)
得到的就是新滑动窗口的结束位置,此时滑动窗口相比原来变小了。
同样的,也有可能对方在发送这次ACK
确认应答的时候,应用层恰好把整个接收缓冲区的数据都读走了,此时接收能力就变大了,确认应答信号中表示接收能力的16位窗口大小的值也比之前要大。
虽然win_start
在向右移动,但是win_end = win_start + tcp_win
向右移动的更多,所以此时滑动窗口和原来相比变的更大了。
- 滑动窗口变化大小的依据是对方接收能力的大小。
发送数据的顺序是从左到右的,理论上收到确认应答的顺序也是从左到右的,收到不是最左边数据的确认应答的情况,一定是丢包了。
丢包又有两种情况,一种情况是发送的数据没有丢,对方也收到了,只是对方的确认应答信号丢了,没有发送过来,如下图:
这种情况下,部分ACK
丢了并不要紧,因为可以通过后续的ACK
进行确认。
- ACK确认序号:该序号前的所有数据全部都收到了,下次从该序号处开始发送。
如上图所示的情况中,即使确认序号为1001
的ACK丢了,但是确认序号为2001
的ACK没有丢。收到2001
的ACK后就知道1~1000
的数据也收到了,滑动窗口可以直接向右移动两个数据段。
第二种情况就是发送的数据丢了,对方没有收到,如下图:
如上图所示的情况中,1~1000
的数据段丢失之后,发送端会一直收到确认序号为1001
的ACK,就像是在提醒发送端 “我想要的是1001
” 一样。
如果主机A连续三次收到了同样一个确认序号是1001
的应答,就会将包含1001
的数据段(1001~2000
)重新发送。
这个时候收到了1001~2000
的数据段之后,返回ACK的确认序号就是7001了
。因为2001 - 7000
其实之前就已经收到了,并且被放到了接收端操作系统的内核接收缓冲区中。
- 这种重传机制被称为高速重发控制。
- 也被叫做快重传。
- 滑动窗口是否滑动的依据是
ACK
中的确认序号。
当发生丢包等情况时,滑动窗口是不会发生滑动的,因为无法确定对方是否收到了发生的数据,此时就会保持不动,等待下一步策略执行。
当对方的接收缓冲区满了,并且应用层没有读走数据时,此时接收能力就是0,所以ACK确认应答中的16为窗口大小也是0,此时发生方滑动窗口大小就会变成0。
滑动窗口如果一直向右滑动,当发生缓冲区的空间不够时,滑动窗口会不会越界呢?答案是不会的。
因为发送缓冲区被操作系统组织成了环状结构,所以滑动窗口无论怎么滑动都不会越界。
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起超时重传等等一系列连锁反应。
- TCP支持根据接收端的接收能力来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。
和滑动窗口中发送端知道接收端接收能力一样,接收端将自己可以接收的缓冲区大小放入TCP首部中的16位窗口大小中,再通过ACK端告诉发送端自己的接收能力。
如上图所示,主机A先发送了一个数据段,得到的ACK中,窗口大小是3000,表示主机B有三个数据段的接收能力,主机A下次发送数据时,调整为一次发送三个数据段。
由于应用层读取数据缓慢等原因,接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端,发送端接受到这个窗口之后,就会减慢自己的发送速度。
如果接收端缓冲区满了,就会将窗口置为0,这时发送方不再发送数据,接收方也不再有ACK确认应答了。
- 发送端定期发送一个窗口探测数据段,让接收端把窗口大小告诉发送端,一但窗口值不再是0了,发送端就可以额继续发送数据。
- 否则通信就会暂停在这里了。
16位变量的最大值65535,那么TCP窗口最大就是65535字节么?
实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M
位。有兴趣的小伙伴自行研究首部中的选项。
在TCP首部的6个标志位中有一个PSH
标志位,该标志位的作用是催促接收端应用层尽快从接收缓冲区中将数据读走。
当接收端的接收能力快为0的时候,在发送数据的时候可以将PSH
置一,接收方收到后发现PSH
为一,就会尽快将接收缓冲区中的数据拿走,好尽快提高接收能力。
如上图所示,此时客户端要发送1000个数据段,服务端接收到以后返回了ACK确认应答,客户端根据确认序号发现丢了两个数据段,直接使用快重传或者超时重传机制重新发送这两个数据段即可。
但是如果有999个数据段丢了,服务端只收到一个数据段,那么此时就是网络出问题了,所以导致大量数据没有发出去。
- 少量的丢包,仅仅是触发超时重传,大量的丢包,就认为网络拥塞。
如果也进行重传,那么就会让已经出现问题的网络雪上加霜。并且一个局域网中不止你一个客户端在发数据,如果都采用这种重传方式,那么整个网络中就会存在大量拥塞的数据,使网络问题更加严重。
- 遇到网络拥塞时,不能使用超时重传机制,而应该使用拥塞控制的策略。
先不管拥塞控制是什么,从TCP有这一机制就可以看出,TCP的可靠性不仅仅考虑了双方主机的问题,还考虑了路上网络的问题!。
- TCP引入慢启动机制来实现拥塞控制:
- 当发送网络拥塞后,先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
如上图所示,当网络发生拥塞时,主机A先发送一个数据段探探路,如果收到这个数据段的ACK,再将发送数据段个数增加。
- 此处引入一个拥塞窗口的概念。
- 表示会产生网络拥塞的数据量。
在网络拥塞后,第一次发送数据时,将拥塞窗口的大小设置为1,即一个数据段(1000
)的大小,每次收到一个ACK应答,拥塞窗口加1。
再次发送时按照拥塞窗口的大小来发,这一就会导致发送数据段的个数按照指数级来增长,每次都是前一次的二倍。
之所以称这种方式是慢启动,是因为最开始只发送一个数据段,开始数据量的增长确实慢,但是这是指数级增长,后面数据量的增长就会越来越快。
- 慢启动的方式,可以让发送方发送的数据量快速恢复到正常水平。
- 这种快速恢复提高了网络通信的效率。
但是不能让这种增长速度不断增加下去,否则就导致非常恐怖的数据量,所以不能使拥塞窗口单纯的加倍。
- 再引入一个概念:慢启动的阈值。
当以指数增长的拥塞窗口大小大于这个阈值的时候,拥塞窗口不再按照指数级增长,而是按照线性方式增长。
网络拥塞控制机制触发,势必是因为已经发生了网络拥塞,当上一次网络拥塞发生时,阈值大小为16,当发送方以慢启动方式开始发送数据后,拥塞窗口按照指数级增长到16后变成了线性增长。
拥塞窗口线性增长到24以后,再次发生了网络拥塞,因为在这个过程中,发送的数据量也在不断增加。此时将阈值更新为24的一半12。
然后发送方再以慢启动的方式发送数据,拥塞窗口变成12以后再线性增长,直到发送网络拥塞,再次更新阈值。
如此反复,不断更新阈值和拥塞窗口的最大值,以便试探出当前网络状况下效率最高的数据传输量(阈值和拥塞窗口最大值不再变化)。
如果说在拥塞控制的过程中,网络状况恢复了,那么拥塞窗口就会一直增长下去,发送数据量也在增长,直到当前良好网络状况极限吗?
接收方也是有接收能力限制的,就算网络情况再好,发送方也不以超出接收方接收能力数据量来通信。
- 发送方在每次发送数据的时候,会将拥塞窗口的大小和接收端反馈接受能力大小作比较,取较小值作为实际发送的数据量。
- 滑动窗口大小 = min(拥塞窗口大小,对方接收能力大小)。
所以在网络状况良好的情况下,发送方的滑动窗口大小取决于接收方的接收能力,在网络拥塞的情况下,发送方的滑动窗口大小取决于拥塞窗口的大小。
当TCP开始启动的时候,慢启动的阈值等于滑动窗口的最大值,如果网络状况良好,那么拥塞窗口在增加到大于接收方的接收能力后变不再增加。
如果发送了网络拥塞后,慢启动阈值就会变成原来的一半,同时拥塞窗口置回1(最小值),逐渐找到最合适的拥塞窗口值和阈值。
- 当TCP通信开始后,网络吞吐量会逐渐上升。
- 随着网络发生拥堵,吞吐量会立刻下降。
假设接收端缓冲区大小为1MB,一次收到了500KB的数据,如果立刻应答,返回的窗口大小就是500KB。
但实际上可能接收端处理接收缓冲区中数据的速度很快, 10ms之内就把500KB数据从缓冲区消费掉了,并且接收端处理速度还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1MB了。
- 窗口越大,网络吞吐量就越大,传输效率就越高。
- 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
所有的数据报都采用延迟应答的方案吗?肯定不是。
常用的方案有两种:
具体的数量和超时时间,依操作系统不同也有差异。一般N取2,超时时间取200ms。
如上图所示,延迟应答的最终表现,就是隔几个数据段确认应答一次。
本喵再讲解协议格式的时候,说协议首部中有两个序号是为了实现全双工,也就是让应答和数据在一个数据段内。
即使有延迟应答,但是很多情况下,通信双方 “一发一收” 的,虽然是收到多个数据段应答一次,但是应答终究还是只有应答,也就是只有确认序号和ACK
标志位,没有数据。
为了通信效率更高,完全可以将确认应答和接收端要发送的数据放在一个数据段中发送出去,发送端收到后既可以知道接收端收到了自己数据,又收到了接收端发送的数据。
如上图所示,确认应答信号中的确认序号和ACK
标志位,坐着数据的顺风车就发送出去了,也就是在通信的过程中,确认应答就被捎带给对方了。
在学C语言文件操作的时候,就听到过面向字节流,在学C++的时候同样也听到过面向字节流,在UDP和TCP的学习中,更是多次见到面向字节流,那么面向字节流到底是什么?
创建一个TCP的socket时,操作系统会同时在内核中创建一个发送缓冲区 和一个接收缓冲区。
应用层在调用write
或者send
时,数据会先写入发送缓冲区中,如果发送的字节数太长,会被拆分成多个TCP的数据包发出。
如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,然后应用层可以调用read
或者recv
从接收缓冲区拿数据。
- 由于缓冲区的存在,TCP程序的读和写不需要一一匹配,如:
- 写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节。
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read100个字节,也可以一次read一个字节,重复100次。
简而言之就是,应用层和TCP层是完全独立的,应用层写的时候不用考虑TCP层的缓冲区是否满,应用层读的时候,也不需要考虑缓冲区中的数据是怎么样的,直接读就可以。
与面向字节流相对的就是面向用户数据报的UDP,UDP协议用户层写入就是一个完整的报文然后给到UDP层,UDP层并不会缓存,而且增加相应的首部后直接发送出去,发送的上一个完整的数据段。
接收方应用层读取的时候,从接收缓存区中读取到的内容也是一个完整的数据段,不能分多次读。
粘包问题:
- 首先要明确,粘包问题中的 “包”,是指的应用层的数据包。
在TCP的协议头中,没有如同UDP一样的 “报文长度” 这样的字段,但是有一个32位序号的字段。站在传输层的角度,TCP是一个一个数据段过来的,按照序号排好序放在缓冲区中。
站在应用层的角度,看到的只是一串连续的字节数据,那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包,此时应用层在读取数据的时候就会产生粘包问题,可能读取的不是一个完整报文,也可能是一个半报文等等情况。
解决这个问题,归根到底就是要明确两个包之间的边界。
在之前的文章协议定制中,采用的是TCP协议,本喵在应用层读取接收缓冲区数据的时候,通过用户层代码来保证每次读取到的是一个完整报文。
对于UDP协议来说,就不存在粘包问题:
TCP异常情况:
FIN
,和正常关闭没有什么区别。一个进程终止后,操作系统会释放这个进程的所有资源,包括文件描述符表,会自动调用close
关闭对应的文件,当TCP套接字被关闭时,同样会发起四次挥手请求,和正常关闭没有区别。
我们平时在关机的时候,会提示有什么什么进程没有结束,要我们强制结束,这个时候也是在终止进程,也会发起四次挥手请求。
发送端来不及发起四次挥手请求,接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了, 就会进行RST
。
如上图所示,首部的六个标志位中有一个RST
标志位,该标志位是用来请求重连的。当发送端在触发超时重发机制后,仍然无法将数据发送到对方,就会发送一个带有RST
的数据段请求,请求和对方重新发起三次握手建立连接。
即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在,如果对方不在,也会把连接释放。
另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态,例如QQ,在QQ断线之后,也会定期尝试重新连接。
紧急指针:
我们知道,TCP协议中缓冲区的数据是按照顺序发送的,接收缓冲区也是按照顺序来接收的,但是如果我们想让某个数据插队呢?让这个数据提前被接收端处理,而不是按照顺序来,此时就用到了紧急指针。
如上图所示,六个标志位中的URG
表示紧急指针是否有效,16位紧急指针指向数据段中具体的某个数。
我们知道缓冲区本质上就是一个char
类型的数组,如上图所示,而紧急指针是一个16位的变量,所以它也是一个值,范围是0~65535
,这个值其实就是缓冲区中有效载荷的偏移量。
当发送端想让某个数据插队时,就将URG
标志位置一,然后让16位紧急指针指向这个紧急数据,再将数据段发送出去。
当接收端收到数据段后,发现URG
置一了,说明有紧急指针,有数据需要优先处理,然后再去16位紧急指针字段中拿到紧急数据的位置,然后先读取这个紧急数据。
在之前我们使用send
以及recv
的时候,最后一个参数是flags
,之前我们都是设为0的,如果将其设置为MSG_OOB
就表示发送或者接收的数据中存在紧急指针,也就是将URG
标志位置一了。
在发送或者接收的时候,需要调用第二个参数是msg
结构体指针的系统调用,这个结构体中包含紧急指针的位置,也就是16为紧急指针位段。
紧急指针的应用场景非常少,一般应用在紧急获取对端状态的场景,比如说客户端给服务端发送了很多条TCP请求,服务端都没有给回应答,客户端就可以发一个紧急数据确认服务端的状态。
TCP机制总结:
TCP非常的复杂,有众多的机制来保证它的可靠性和提高性能。
保证可靠性机制:校验和,序列号,确认应答,超时重传,连接管理,流量控制,拥塞控制。
提高性能的机制:滑动窗口,快速重传,延迟应答,捎带应答。
其他机制:超时重传定时器,保活定时器,TIME_WAIT
定时器。
常见的基于TCP的应用协议:HTTP,HTTPS,SSH,Telient,FTP,SMTP,以及前面本喵自己定制的应用层协议。
前面本喵在创建TCP套接字的时候,没有讲解listen
系统调用的第二个参数backlog
,只是说随便设置一个数,不要太大。
int backlog
表示全连接长度。
如上图所示,服务器进程中有一个listen
状态的套接字用来监听,还有多个recv
后的套接字来进行真正的通信。
系统资源是有限的,当用于通信的套接字数量达到限制以后,系统就无法再维护更多套接字了,新来的已经建立连接的套接字就会由于资源不足而被关闭。
当服务中某个或者几个用于通信的套接字使用完毕后,就会释放出一部分系统资源,此时也没有新的连接到来,那么这部分系统资源就会空闲着。
所以TCP协议维护了一个全连接队列,如上图蓝色部分所示,这个队列中放的是处于等待状态的并且已经完成三次握手建立连接的套接字。
当系统资源不足时,就在全连接队列中等待,当系统资源有空余时,全连接队列中的一个套接字就会被系统维护,进行网络通信。
- 而
listen
的第二个参数backlog
就是用来指定全连接队列的长度,具体长度等于backlog +1
。
为什么说backlog
值不能太大又不能没有呢?
不能没有的原因就是本喵上面所说的,要让系统资源一有空余就有新的套接字被系统维护,提高系统资源利用率。
不能太大是因为,维护全连接队列也要消耗系统资源,如果全连接队列太长,所耗费的资源完全够系统再维护一个套接字用来通信了,属于是捡了芝麻丢了西瓜的做法。
除此之外,全连接队列太长,里面的套接字等待的时间也会很长,此时又会触发超时重传机制等,进而导致其他问题。
将之前写的TCP网络通信代码的backlog
值设置为1,也就是将listen
的第二个参数设置为1,此时全连接的长度为2。
将服务器运行起来,此时服务器不会accept
新的连接,所以当新的连接到来时,被会放入全连接队列中等待,而设置的全连接队列长度是2,所以最多放两个建立连接的套接字。
再创建两个Xshell窗口,充当两个服务端,使用telnet
工具充当两个客户端与服务器进行连接。
使用netstat
查看当前主机上和8080
端口有关的网络进程,如上图所示。
上面两行中表示两个客户端telnet
,它们的端口号分别是48406
和48410
,对端都是服务器,端口号是8080
。
下面两行中表示服务器和两个客户端telnet
,第一行和第三行组成一对通信,通信的端口号是48406 <-> 8080
,第二行和第四行组成一对通信,通信的端口号是48410 <-> 8080
。
此时全连接队列中放的就是端口号为48406
和48410
的两个客户端telnet
进程。
- 全连接队列中的套接字状态是
ESTABLISHED
,说明是完成了三次握手的,已经和服务器建立了连接,只是服务器没有进行维护进行通信。
此时再增加一个客户端,如上图所示,此时一共有3个telnet
和服务器建立连接。
再次查看网络状态,发现多了两个套接字,上面红色框那行表示服务器,下面红色框表示客户端。
SYN_RECV
。telnet
套接字的状态是ESTABLISHED
。
如上图所示,再三次握手的过程中,服务端第一次收到客户端的SYN
请求以后,服务端套接字的状态就变成了SYN_RECV
,只有服务端也发送SYN+ACK
以后,再收到客户端的ACK
以后,服务端才会变成ESTABLISHED
,表示连接建立。
而上面过程中,第三个客户端发起连接请求后,服务端套接字停在了SYN_RECV
状态,说明服务端已经收到了客户端的三次握手请求,但是此时全连接队列已经满了,服务端没有更多资源来维护这个套接字了,所以不祥客户端发起SYN
连接请求。
- 处于
SYN_RECV
状态的套接字叫做半连接状态。
对于半连接状态的套接字,操作系统同样维护着一个半连接队列,里面放着的是处于SYN_SNET
和SYN_RECV
等半连接状态的套接字。
但是可以看到,客户端telnet
的状态是ESTABLISHED
,也就是说客户端是认为建立了连接的,但是服务端没有建立,所以这次通信的建立是失败的。
当客户端发送数据的时候,发现服务端没有对应的套接字,就会发起RST
,请求重新建立连接。
等待一段时间后再次查看网络状态,发现服务器端处于SYN_RECV
状态的半连接套接字没有了,而客户端的ESTABLISHED
状态的套接字仍然存在。
- 处于半连接状态的套接字,在一定时间内没有建立连接变成
ESTABLISHED
状态,操作系统就会将这个套接字释放掉。
半链接队列:用来保存处于SYN_SENT
和SYN_RECV
状态的套接字。
全连接队列:用来保存处于ESTABLISHED
状态的套接字,但是应用层没有调用accept
获取。
全连接队列满了的时候,就无法继续让当前连接的状态进入ESTABLISH
状态了。
TCP协议到此就结束了,可以看到它比起UDP来复杂很多,因为TCP比UDP更加可靠,可靠性的维护是需要付出代价的,增加了序列化,确认应答机制,三次握手四次挥手机制等等很多机制。