目录
TCP协议
TCP协议段格式
确认应答(ACK)机制
超时重传机制
连接管理机制
理解TIME_WAIT状态并解决由此引起的bind失败的方法
流量控制
滑动窗口
拥塞控制
延迟应答
捎带应答
面向字节流
粘包问题
TCP异常情况
TCP/UDP对比
用UDP实现可靠传输
TCP协议
TCP协议段格式
TCP,传输控制协议,就是要对数据的传输进行一个详细的控制。
我们理解一个协议,一定要从如何解包、如何分用两个方面考虑。
从图中可以看到,TCP报头的标准长度是20字节,报头的宽度是4字节(32bit)。第4行有一个4位首部长度。在拿到TCP后,肯定先解析前20个字节,然后先提取4位首部长度(范围是[0,15]),那看起来貌似[0,15]不可能把20的字节全部表示清楚啊,这是不是有问题。实际上,4位首部长度的单位是4字节(为什么是4字节,因为4字节也是报文的宽度),即如果4位首部长度如果为1,这不代表一个1个字节,而是代表1*4个字节,这就决定了其取值范围是[0,60],这也就能表示清楚20字节的TCP报头标准长度以及40字节的选项(选项可以没有)。假如选项没有内容,那TCP报头长度就是20,4位首部长度=20/4=5=0101,即4位首部长度是0101。
所以,如果想让报头和有效载荷分离,①读取TCP报头的标准长度的20字节②提取4位首部长度③根据首部长度-20,看还剩不剩数字了,如果为0,那表明选项没有内容;如果为16,那再从报文里提取16个字节,这16个字节就是选项内容,剩下就是数据。所以可以做到将报文解包。
现在,已经可以做到将报文进行解包,那又如何进行向上交付呢?在报头中有目的端口号,通过目的端口号就可以将指定的报文交给指定的进程。
以上是我们目前可以理解的报头字段,下面我们边学习TCP边理解报头字段。
确认应答(ACK)机制
我们再来看一个故事,两个人在隔着100m的操场在喊话,小王(发送方)说你吃饭了吗,小王(接收方)并不确定小张(接收方)是否听到我说的话了。如果小张说你说的消息我接受到了,我刚刚吃的是饺子,发送方一旦收到了接收方发回来的消息,发送方就立马意识到我历史发送的(你吃饭了吗)已经被收到了。可是,站在接收方的角度,接收方说的话(我刚刚吃的是饺子,你吃的什么呀),如果发送方接受到了这个消息,那就证明发送方历史发送的消息对方收到了,但此时接收方也发了消息给发送方,此时接收方发的消息也无法保证对方有没有收到。此时发送方又对接收方说:你发的消息我收到了,我刚刚吃的是面,我们一会儿去打篮球吧。对于接收方一旦收到了来自发送方的应答,接收方就能保证我刚发的历史消息(我刚刚吃的是饺子,你吃的什么呀)对方已经收到了。可是,站在发送方的角度,我刚说的话(你发的消息我收到了,我刚刚吃的是面,我们一会儿去打篮球吧)也并不确认接收方是不是收到了,依次往复。
通过这个例子我们发现,在生活中,要保证严格时间的数据100%可靠通信给对方这种可能性是0,就是说没有100%可靠的协议,因为在通信时总有最新的消息没有应答!这就无法保证最新的消息被对方收到。但对于某一方来讲,曾经发过的历史消息,只要有应答,对方就一定收到了。
从发送方到接收方发送的可靠性,只要能收到应答,就能保证发送到接收方的可靠性,对方一定收到了;
客户端给服务端发消息,如果服务端给客户端返回应答了,并且客户端收到了应答, 那就是证明客户端上次发送的消息100%被对方收到了;对于客户端,如果收不到应答,就认为报文丢失。所以,保证可靠性不是保证最新的报文不丢,而是保证历史报文被对方收到了。什么叫做可靠性?你把事无论有没有办成(客户端根据有没有收到应答判断),我都知道!这就是客户端对服务端单向的可靠性。服务端发的消息的可靠性就不管了,再管的话就是鸡生蛋蛋生鸡的问题了!
客户端给服务器发消息,服务器给客户端应答,客户端给服务器发消息,服务器给客户端应答,只要客户端收到了应答,就能保证客户端发出消息的可靠性。
以上只是保证了客户端给服务端发消息的可靠性,那有可能服务器给客户端主动发消息,这样的可靠性如何保证呢?很简单,当服务器给客户端发消息,客户端也要自动给服务器应答,当服务器收到了应答,就证明服务器给客户端发的消息被收到了,如果服务器没收到应答,就证明服务器给客户端发的消息没有被收到。
这样,双方都使用确认应答机制,就能保证两个朝向上数据通信的可靠性。可靠就是要对发送的数据保证可靠,不需要考虑应答是否可靠。
用户层的数据只是从应用层拷贝到内核,读取接口只是把数据拷贝上来,真正的网络发送是双方的操作系统做的,双方的TCP协议自动做的,发消息是发送方的TCP协议来发的,由对方的TCP协议来自动应答。发数据和响应应答的过程是由双方的OS自动完成。我们平时没有管过这块,是因为底层OS自动帮我们做了发消息以及确认应答。所有的通信过程我们在应用层一概不知,只是双方的OS一直在做这些工作。
在TCP通信时,由两种基本的通信格式,这两种都有可能被采用。
上面所说的发送应答机制,是串行的,是第一种,效率比较低。还有第二种,客户端可以一次给服务器发送很多条报文,因为服务器要做确认应答,理论上客户端给服务器发过来的消息,服务器都需要分批做应答,这样就允许客户端给服务器一次发过去多个报文。发多条报文,在时间上是重叠的,所以可以提高发送效率。那我们具体用哪一种呢?更常规的是第二种,特殊情况下是第一种。
那我们可能会想,如果我一次发送了4条报文,但是只收到三个应答,我们并不能确定是哪一条报文出了问题,这就很尴尬了~为了解决这样的问题,就引入了序号和确认序号。在发出去的多条报文中,每一个都会携带序号,比如第一个报文序号是1000,第二个报文序号是2000,...。一旦接收方收到了报文,接收方要做应答,应答中就会携带确认序号,确认序号的原则是收到的报文序号+1,分别是1001,2001,...。我们来理解一下确认序号的意义:比如客户端收到了确认序号1001,1001代表什么意思呢?表示1001之前的数据已经全部收到,而并不是说1000序号的报文已经收到。换句话说,序号和确认序号保证应答和曾经的数据报文之间的对应关系我们就知道了。
然而,我们需要明确,发的数据以及应答中的序号和确认序号不是一个数字,而发的都是TCP报文,要么只有报头,要么是带报头的数据。只不过报头中的32位序号被置为1000。在应答的报文中没有数据,只有一个裸的TCP报头,只不过32位确认序号被设置了。
那我们会有疑惑,这样说的话,在TCP报头里有一个序号就行了啊,为什么协议里有两个序号呢?这是因为,客户端在给服务器发消息后,服务器要应答,但如果此时服务器也要给客户端发送消息呢?那就可以把服务器应答报文以及给客户端发送消息的报文合并到一起变成一个报文,也就是捎带应答,这样就提高了效率。这个报文即是对客户端的应答,又是给客户端发消息,这种机制叫做捎带应答。如果一个客户端收到了报文,这个报文也会携带数据,注定了报文既要有自己的序号来保证客户端给服务器应答,所以必须有序号。
现在,如果有多个客户端给服务器发消息,服务器可能收到多种报文,有可能是建立连接的请求报文,也有可能是断开连接的请求,也有可能是确认报文,当然有可能是正常的数据报文,也有可能是确认+数据,等等。这就说明服务器要有处理不同类型的报文的能力,即TCP的报文是有不同的类型的!所以,为了区分是哪一种报文,在TCP报头中,就有了6个最常见的标记位,对应报文的类型,每一个标记位就是一个bit位。
- ACK:如果是1,那就是一个应答报文,就要重点关注确认序号。我们以后会见到ACK:1001,表示报文是ACK,确认序号是1001.
如何理解序号呢?
这个图中,数据1~1000,序号就是1000,主机B会ACK1001,主机A收到1001表示服务器告诉主机A1001之前的我已经收到了,下次发从1001开始。那主机A下次发的也有长度啊,下次发的是2000(1001+你要发的报文长度,才是你下一个序号的开始)。
我们把TCP的发送缓冲区当成一个char sendbuffer[65535],只要把数据从应用层拷贝到传输层,每一个有效数据天然就有了序号。所谓的序号,就是该缓冲区的数组下标。
超时重传机制
如果主机A给主机B发送数据时丢包,那主机B就不会给主机A应答。如果主机A在等了一段时间之后,没有得到应答,就判定这个报文丢包了,具体有没有丢主机A并不清楚,主机A就会再重新发一次。这叫做超时重传机制。
我收到ACK,就是对方收到数据;但是,我没有收到ACK,就规定对方没有收到数据;但实际上我没有收到ACK,可能是因为对方应答丢包了。那如果是后者原因,主机A就会重传,主机B就会收到重复报文。这就要求TCP协议需要能够识别出来哪些包是重复的包,并把重复的包丢掉。这就可以利用序号来实现去重。因此,序号不仅可以让对方ACK,还可以让对方进行去重。
另外,对方收到报文的顺序不一定是发送的顺序(由于网络复杂),因此,还可以通过序号进行排序,从而让报文按序到达。
总结一下序号作用:
1.确认应答ACK的确认序号 2.收到报文的去重 3.保证TCP的按序到达
这些都叫TCP的可靠性。
那上面提到的特定的时间间隔到底是多长呢?太短了太长了都不好,因为网络状态是会变化的,所以一个报文从主机A到主机B和从主机B到主机A是浮动变化的,所以,特定的时间间隔应该和网络状态相关联,网络状态好的时候,时间间隔应该短一点;网络状态差的时候,时间间隔应该长一点。
- 在Linux中,超时时间以500ms为一个单位,每次判定超时重发的超时时间都是500ms的整数倍。
- 如果重发一次之后,仍然得不到应答,等待2*500ms后再进行重传。
- 如果仍然得不到应答,等待4*500ms进行重传。以此类推,以指数形式递增。
- 累计到一定重传次数,TCP认为网络或者对方主机出现异常,强制关闭连接。
连接管理机制
在正常情况下,TCP要经过三次握手建立连接、四次挥手断开连接。
我们在客户端创建套接字需要向服务器发起连接请求connect,服务器也要把自己设为listen状态,等待别人来连接我。connect的本质是要求客户端构建一个TCP报头,将报头中的SYN标记位(同步标记位)置1,只要SYN标记位被置1,表示这个报文是一个连接建立的请求。换句话说,正常通信时,SYN都是0。服务器一旦收到SYN被置1的报文,就知道这个报文是有人想和我建立连接,所以服务器会应答一个报头,应答报头中要设置SYN标记位为1,并且还要带ACK(SYN+ACK)。紧接着,客户端会再发一个报文,只用将报头中标记位ACK置1,SYN不用置1了,给SYN+ACK做应答。这样,双方就完成了建立连接前的协商工作,我们称这个工作为三次握手。实际上,connect只是发起了三次握手,可以理解成connect只是要求客户端OS向服务器发送SYN,从此往后,双方三次握手的具体过程由双方OS自动协商。一旦三次握手完成,服务器的accept就会返回新的文件描述符和客户端通信;如果三次握手没有完成,accept就会阻塞。我们认为accept不参与三次握手的过程,而是在三次握手完成后,在accept被调用时,把已经建立好的连接拿到应用层去用。最后的结论是,三次握手由一方主动发起,握手的过程是TCP协议层自主自动完成的。
我们再来感性认识一下为什么要三次握手,比如你在校园里偶然看见一个让你一见钟情的女孩子,你跑到人家面前说做我女朋友吧,那个女孩子一看这个小伙子看起来不错,女孩子就说,好啊什么时候开始,你说,就现在。双方以最简洁的语言,瞬间在双方的大脑中建立了男女朋友的关系,连接瞬间建立好。
三次握手在发送的时候,双方的套接字状态会发生变化,在发送SYN的时候,发送方会立马变成SYN_SENT状态,如果服务端收到了SYN,此时服务端的状态就会变成SYN_RCVD,同时会给客户端发送SYN+ACK,一旦客户端收到了SYN+ACK,就可以发出去自己的ACK,客户端只要把ACK发出,三次握手就完成了,保证这个ACK被服务端收到连接才可以建立好。毫无疑问,客户端最后发出的ACK有没有发给服务器,客户端根本就保证不了。但是,一旦客户端把ACK发出,客户端的三次握手就完成了,而服务端是要等到收到了ACK才认为三次握手完成了。客户端和服务器把连接建立成功会有一个非常短暂的时间差。所以,建立连接的本质就是在赌,赌最后一个ACK对方一定收到了!前两次SYN以及SYN+ACK我们一点都不担心丢包,前两次都有应答,丢包可以超时重传。
三次握手建立连接一定要建立成功吗?不一定,但是一旦三次握手成功就认为建立连接成功了,没有任何人能保证一定能成功建立连接。
万一最后一次的ACK丢包了,而客户端误以为已经成功建立连接了,就开始给服务端发送数据,服务端一看,不对啊,还没建立好连接怎么就开始传数据了,然后服务端就会发送一个RST标志位置1的报头,客户端收到这个报头后,立马就意识到原来三次握手没建立好,服务器要求客户端进行连接重置,把刚才建立的异常连接reset掉释放掉,重新发起三次握手,因此,收到该RST标记位的主机,要对异常连接进行重置,重新建立。所以,TCP建立连接,会有各种各样建立失败的情况,即便连接建立好,后续通信时也可能因为某些原因导致连接失效了,各种奇怪的问题都会发生。但是我们不需要担心了,因为双方任何一端出问题,而对方不知道,对方给我发消息,我就立马给对方应答RST进行连接重置。
在连接建立好之后,双方就可以通过write/read进行通信。
当某一方不想通信了,就可以关闭文件描述符close(fd),就会发送FIN标记位,一旦FIN标记位置1,就表示客户端想给服务器断开连接,服务器收到FIN之后,直接进行ACK,表示两次挥手完成。后面服务器也想和客户端断开连接,服务器也要发FIN,客户端再进行ACK,至此,双方四次挥手完成。
下面再以一个故事理解四次挥手。在断开连接时,一定要征得双方都同意,双方地位是对等的。为什么要四次挥手呢?因为我发一个FIN,你要回一个ACK,证明我想和你断开连接,你已经同意了。你要和我断开连接,我也要同意。站在客户端角度,我和对方以及对方和我都断开连接了,所以客户端已经和服务端没有任何连接了。服务器也如此。所以,四次挥手采用的是最小次数的报文交换,在双方断开连接时快速形成共识。
一旦客户端和服务器断开连接了,其本质是服务器啊,客户端要给你发送的数据已经发完了,我后面没有数据发送了,我断开连接了!但服务器还有可能给服务端发消息,这是允许的,服务端可以给客户端继续发消息,而且客户端必须给服务器进行应答。刚才说,客户端不给服务器发消息了,那客户端给服务器的应答不是消息吗?客户端不给服务器发消息了,指的是客户端应用层不再有用户数据要发送了,但OS内维护可靠性的ACK空报文这个工作,客户端还要配合。
如果客户端调用close(fd),会触发两次挥手,此时把文件描述符已经关了,但服务器能给我发消息,客户端上层怎么读到呢?其实,除了close,还有shutdown接口,
其第二个参数how可以通过选择关闭写还是关闭读还是关闭读写。因此在客户端没有数据可发的时候,可以调用shutdown(fd, SHUT_WR),将这个文件描述符关了一半。四次挥手,使用了最小的通信成本,建立了断开连接的共识,双方都不和对方通信了,并且知道对方不和我通信了!
先发出断开连接的一方,发出FIN后,其状态就会变成FIN_WAIT_1,而服务器一旦收到FIN,其状态就会变成CLOSE_WAIT,并且进行ACK。如果服务器再调用close,那么服务器的状态就会变成LAST_ACK,并发FIN给客户端,客户端变成TIME_WAIT,此时客户端再把ACK响应给服务端,至此,四次挥手完成。
我们再来解释一个问题,为什么要三次握手?
什么叫握手?就是给对方发报文。为什么一次握手和两次握手不行?
一次握手:客户端给服务器发SYN,默认连接就建立好了。服务器会收到来自非常多的客户端的连接,所以服务器在运行的时候,有100个客户端向我建立了连接,同时后面有新的客户端不断向我发起连接,有的连接在建立,有的连接在被关闭。在系统中,所有的连接建立、维护、断开、释放这样的工作都在服务器的TCP层去维护,也就是OS在维护。OS内有不同的连接,这些连接有不同的状态,所以,需要对这些建立好的连接进行管理,先描述在组织!连接本质也是一个内核数据结构,建立连接就意味着在服务器上内存中malloc出一块空间,这个内核数据结构包含套接字、什么时候谁建立的连接,源IP目的IP源端口目的端口,还要提供对应的缓冲区,还要维护连接的状态。维护连接,是有成本的(时间+空间)。当拿一台机器疯狂给服务器发送大量SYN,称之为SYN洪水,客户端机会没什么成本,服务器就挂满了大量连接,这些连接还不会被使用,那这些连接就会占很多资源了,导致服务器的可用资源越来越少。
两次握手:和一次握手一样,服务器给客户端发的ACK,客户端可以不受理,这样也会让服务器挂满大量连接。
三次握手:有人说三次握手也会有上面的问题啊?确实存在,但是三次握手最后一次报文一定是客户端给服务器发的,所以如果客户端要把三次握手建立好,客户端要先把三次握手完成,相对来说比较复杂。而一次和两次握手存在明显的bug,很容易被利用,三次握手相对来说会好一点。更充分的两个理由是:
- 验证全双工。建立连接之前,双方都要维护一点成本来把连接建立好,前提是得先验证双方的通信信道是通畅的,即先验证双方的通信网络是OK的,需要验证网络的连通性。三次挥手就验证了客户端能100%收消息和发消息,客户端的全双工得到验证。站在服务器的角度,也验证了服务器端的全双工。验证了网络的连通性,是支持能够通信的前提。三次挥手用最小次数验证网络是通畅的。这样也就验证了一次和两次握手为什么不可以,一次握手只能验证服务器能收,二次握手只能验证客户端能收发以及服务器能发。
- 建立双方通信的共同意愿。客户端给服务器发消息,我想和你建立连接SYN,服务器说好啊,ACK+SYN,客户端再给服务器ACK。客户端来和服务器建立连接,服务器也必定要和客户端建立建立连接,所以,三次握手只不过把中间的SYN和ACK进行了捎带应答。既然四次挥手能够做到断开连接建立的共识,那么三次握手就当成四次握手,那就建立了双方之间的连接。
通过1和2,网络保证了没问题,双方还都愿意,那就可以建立连接了。三次握手已经达到目的了,为什么还要更多次呢?
实际上,如果客户端和服务器同时想断开连接,四次挥手中间的ACK和FIN捎带应答,那就是三次挥手了,那为什么一般都会说四次挥手呢?在建立连接时,客户端想要和服务端建立连接,服务器一般是无偿建立连接,客户端都要同意,SYN+ACK一起就过来了,但是断开连接不一样,客户端给服务器发完消息了,服务器不一定发完了,ACK和FIN可能分开发送。至此,我们现在理解,三次握手和四次挥手没有本质区别,只不过它们分别是三次和四次更常见罢了。
理解TIME_WAIT状态并解决由此引起的bind失败的方法
我们来理解一下四次挥手中的CLOSE_WAIT和TIME_WAIT两种状态,如果客户端给服务端连接断开FIN,服务端给客户端ACK,此时如果服务器不关闭自己的文件描述符,服务器必然处于CLOSE_WAIT的状态。如果服务器端想看到自己CLOSE_WAIT的状态,服务器不关闭文件描述符就可以。如果我们服务器比较卡,查一下是不是存在大量的close_wait状态(文件描述符泄漏)。
现在做一个测试,首先启动server,然后启动client,然后使用Control+C终止server,这时再马上运行server,结果是:
这时因为,虽然server的应用程序终止了,但TCP协议层的连接没有完全断开,因此不能再次监听同样的server端口。
主动开始断开连接,自己最终要处于TIME_WAIT状态。主动断开连接的一方,会在第四次挥手完成后,等待一定时长(2*MSL)。这就是为什么之前在写HTTP的时候,服务器突然挂掉,可是之前有人正在访问服务器,服务器就处于主动断开连接的一方,就进入TIME_WAIT状态,四次挥手还没完成,服务器还没走到CLOSED的状态,只要处于TIME_WAIT状态,说明连接还在,说明端口号还在被占用,所以当再次启动服务器绑定这个端口时,因为这个端口被占用,绑定就失败了。为了解决这个问题,需要用到setsockopt函数,设置套接字的属性,
套接字sockfd一般为listen套接字,level一般为套接字层。我们在Socket.hpp中增加一个方法ReUseAddr,
MSL在RFC1122中规定为2min,但各OS实现不一样,Centos默认值是60s,
- 可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看MSL的值。
那为什么TIME_WAIT的时间是2MSL?
- MSL是TCP报文的最大生存时间。TIME_WAIT持续存在2MSL的话,就能保证在两个传输方向上尚未被接收或者迟到的报文都已经消失(否则,如果服务器断开后立即重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)
- 同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会重发一个FIN,这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK。)
流量控制
当通信双方在使用TCP通信时,本质是把发送方TCP缓冲区的数据拷贝给对方的接收缓冲区,这个时候如果拷贝太快,对方缓冲区满了,发送方还一直在拷贝,就会导致对方接受能力不行了,就可能把后来的报文丢掉。那丢掉就丢掉呗,还可以重传,实际上,某一个报文千里迢迢来到接受方这里,耗费了很多资源,结果发过去被对方丢了,这是不合理的。如果接受方来不及接收,就需要让发送方发的慢一点,这种根据发送方在发送数据时根据对方的接受能力来控制自己的发送速度,称为流量控制。什么叫做接收方的接受能力呢?就是接收缓冲区中剩余空间的大小!那作为发送方,如何知道接收方的接受能力呢?因为发送方发出去的报文,接收方要对报文进行ACK应答,可以把接收缓冲区剩余空间大小填到16位窗口大小中,发送方从16位窗口大小中读到对方缓冲区剩余大小,也就知道对方的接收能力了,从而动态调整自己的发送速度。
由于客户端和服务器双方都在互发消息,所以流量控制在两个朝向上都要做,16位窗口大小填写的都是自己的!
16位数字最大表示65535,那么TCP窗口最大就是65535字节吗?实际上,TCP首部40字节选项还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位。
那有没有可能发送方发的数据太慢呢?这样也不合理。流量控制,就是可以加快也可以减慢。
那如果是双方第一次通信呢?比如三次握手后,首次发数据的量应该是多少呢?实际上,双方在进行三次握手的时候,已经协商交换过各自的报头信息了,就是双方的接受能力。
如果接收端缓冲区满了,就会将窗口置为0,这时发送方不再发送数据。那当接收端缓冲区有空间了,发送端如何知道呢?一方面,接收端会主动给发送方推送窗口更新通知(就是一个TCP报头);另一方面,这个窗口更新通知有可能丢掉,发送发需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。这两种策略在网络中同时存在。
那如果接收方一直不更新,发送方一直在探测得到窗口大小一直为0,能不能让接收方把缓冲区数据尽快向上交付呢?其实,还有一个PSH标志位(PUSH),如果报文中携带了PSH标志位,对方收到之后,就知道这是让我上层尽快把数据取走。当然,如果我们想让对方尽快处理数据,都可以设置PSH标志位。
至此,还剩下最后一个标记位URG--紧急指针标记位,只要URG为0,那16位紧急指针无意义,一旦URG为1,16位紧急指针就起效了,16位紧急指针标识哪部分数据是紧急数据,紧急指针指的是紧急数据相对于报文开始对应的偏移量,TCP中紧急数据只有一个字节!紧急指针用的很少,一般是用来对TCP通信做管理的。比如现在向百度云盘上传10G,在上传7个G的时候,不想上传了,就停止上传。把紧急数据设为2,接收方读取到后,就停止上传。recv和send中都有一个flag参数,可以设置为MSG_OOB,携带紧急指针的数据称为带外数据(正常的数据叫带内数据),一个进程读带外数据,一个线程读带内数据,一旦这个线程读到带外数据,就让另一个进程取消读取就行。
滑动窗口
我们以两个问题作为切入点:
- 流量控制:发送方如何根据对方的接受能力,发送数据?答:流量控制就是通过滑动窗口实现的!
- 超时重传:超时时间以内,已经发送的报文不能被丢弃,而是要保存起来!保存在哪里?答:保存在滑动窗口中。
我们上面说的确认应答机制,每发送一个报文,都要有一个ACK确认应答,收到ACK后再发送下一个报文。这样的串行发送,导致性能较差。尤其是数据往返的时间较长的时候。但其实可以一次发送多条数据,大大提高性能。
一次发送多条数据,前提是保证对方能来得及接收。为此发送方规定一个概念---滑动窗口,在滑动窗口以内的数据,可以直接发送,暂时不用收到应答!滑动窗口是发送缓冲区中的一部分,由于滑动窗口的存在,发送缓冲区被分割成三部分。在滑动窗口部分,暂时不用应答,可以直接发送;滑动窗口的左边是已发送已确认的数据;在滑动窗口的右侧是待发送的数据。在有些数据被应答后,滑动窗口可以向右滑动,可以纳入更多待发送的数据。这样就可以支持一次发送多条数据。
我们目前认为:滑动窗口的大小=对方同步给我的窗口大小,即对方的接收能力。下面是一个滑动窗口的例子:
现在有几个问题:
1.滑动窗口只能向右滑动吗?能不能超左滑动?
因为左侧的是已发送已确认的数据,所以不能向左滑动。
2.滑动窗口是一直不变的吗?可以变大、变小吗?
可以变大,因为接收方的接受能力可以变大。同理,可以变小。
3.滑动窗口可以为0吗?
可以,当对方接受能力为0的时候,再怎么发也要保证对方能接受。
我们再来进一步理解滑动窗口:
我们把发送缓冲区想象成char类型数组,滑动窗口的本质就是两个指针,int win_start、int win_end,所谓窗口整体向右滑动,就是让win_start++,win_end++。窗口在减小就是win_start++的快,win_end++的慢。窗口在增大反之。滑动窗口为0就是win_start==win_end。
当客户端给服务端发数据1-1000后,客户端收到了ACK报文,让客户端下次从ack_seq=1001发,即一瞬间win_start=ack_seq,win_end=win_start + win,win就是对方告诉我的接受能力,由于win可以变大变小,这就使滑动窗口变大变小。
如果发送方发送的报文丢包了,如何保证滑动窗口正确向右滑动呢?如果报文丢失,有三种情况:
- 最左侧报文丢失:根据确认序号的定义,确认序号之前的报文,我已经全部收到了。1001-2000、2001-3000、3001-4000、4001-5000,只有1001-2000丢了,那后面几个应答的确认序号只能是1001。一旦前面有报文丢失,后面报文会对前面的报文做确认,如果主机A连续3次收到重复的报文,可以让客户端判定丢包了,主机A立即对丢包的报文进行补发,这种高速重发机制称为快重传。 那如果不仅仅丢了1001-2000,3001-4000的报文也丢了呢?那补发的1001-2000报文的确认序号只能是4001,后面的报文的确认序号还是4001,就会不断触发快重传。最左侧报文丢失:a.确认序号规定的约束,滑动窗口左侧不动;b.根据快重传&超时重传,对最左侧报文进行补发。
- 中间报文丢失:如果2001-3000对应的报文丢失,后面的确认序号只能传2001,win_start更新为2001,这不就变成了最左侧报文丢失问题了吗。
- 最右侧报文丢失:如果4001-5000的报文丢失了,确认序号就是4001,win_start就更新为4001,这不就也变成了最左侧报文丢失问题了吗。
快重传这种机制即快又能重传,那为什么还有超时重传呢?其实并不冲突,因为快重传要连续收到三个确认应答,那如果接近末期,只能有两个报文确认应答,这样就无法触发快重传了,这时就会由超时重传来接管,超时重传是给快重传做兜底的,快重传是为了在超时重传的基础上提高效率的。
那要是数据报文没丢,而应答丢了呢?没关系,只要保证最新的应答没丢就行。最新的丢了就看次新的,如果最新的和次新的都收不到应答,那就判定最新的两个丢失了,超时重传。
有人会觉得滑动窗口一直向右滑动不会越界吗?其实,可以参考环形队列那样,就不会越界了。
滑动窗口左侧的已发送已确认的数据,用不用把它清除呢?不需要!这部分就是废弃数据,所以才敢向右滑动。
拥塞控制
我们上面考虑的所有特点,都是从一端到另一端,从来没有考虑过网络状况。在通信有问题时,如何识别出来是网络的问题呢?
我们通过一个小故事来理解,我们考了操作系统这门课,我们班25人,这个专业200人,8个班,这次挂了8个人,平均一个班挂一个。可是这次一共考过8个人,平均一个班考过一个,这个时候我们会认为这不是我的问题,是考试除了出问题了。今天客户端给服务器发送了1000个报文,丢失了1个报文,客户端就会想丢一个报文正常,把这个报文补发就行了。那如果只收到1个报文,丢了999个报文,有理由认为不是服务端的问题,因为TCP做了流量控制,只能说明网络出现了严重的网络拥塞问题,按照正常的策略,发送方要对丢失的报文立即进行重发,这个时候就不能进行快重传了,因为网络已经非常拥堵了,如果立即快重传,只会加剧网络拥塞。此时只能发一个报文检测是不是网络拥堵了,这样就给网络足够的缓冲时间把里面所有的数据处理完,等慢慢恢复过来,再慢慢处理数据。解决网络拥塞问题,最大的价值,在于多个使用同一个网络进行通信的主机,有拥塞避免的共识!
因此,TCP引入慢启动机制,先发少量的数据,探探路,摸清楚当前网络的拥堵状态,再决定按照多大的速度传输数据;
我们可以先发1个报文,得到应答后发2个报文,再得到应答发4个报文,以此类推。这种叫慢启动机制。
之前我们在学滑动窗口时,认为暂时接收方剩余缓冲区大小就是滑动窗口的大小,这是暂时定的。然而,当从客户端向服务器发数据的时候,不仅要考虑对方来不来得及接收的问题,还要考虑网络能不能扛得住的问题。万一在网络传输时,发生了网络拥堵的情况,报文也不可能发出去,所以不能理想地认为滑动窗口就是对方的接收窗口。而是应该既考虑对方的接收能力,还要考虑网络,。所以,需要一个指标能得知网络的拥堵情况,所以就有了拥塞窗口。拥塞窗口是发送方TCP规定出来的概念。拥塞窗口的定义是如果向网络中发送的单次数据量在拥塞窗口范围以内,那么此时不会引起网络拥塞,否则,极有可能引发网络拥塞。所以,单次发送的数据量到底该多大,一定既要保证对方能接收,又要在拥塞窗口以内。所以到这里需要修正一个概念:滑动窗口=min(应答窗口,拥塞窗口)。因为网络的状况是浮动的,所以拥塞窗口的大小也必然是浮动的。问题是主机应该怎么样得知拥塞窗口的接近大小是多少呢?必须经过多轮尝试才知道!拥塞窗口是一个经验数据。
在发送开始时,定义拥塞窗口大小是1(此时min(应答窗口,拥塞窗口)=拥塞窗口,刚开始发1个报文),每次收到一个ACK应答,拥塞窗口+1,每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小值作为实际发送的窗口。
像这样的拥塞窗口增长速度,是指数级别的。“慢启动”只是初始时慢,但是增长速度非常快。那解决拥塞问题为什么要用指数级增长(2^n)来做呢?指数增长的增幅快,前期增长慢。前期增长慢,主要用来检测网络是否已经可以正常连通了,刚开始发了几个轮次之后发现已经能正确连通了,网络的拥塞问题在主机的判断里已经被缓解了,那就应该把通信过程尽快恢复,不应该慢慢增长。
网络恢复,我们的通信过程也要恢复起来,中后期增长快。
- 为了不增长那么快,不能使拥塞窗口单纯加倍。单纯加倍会使得更新出来的拥塞窗口的值力度太大,不准。引入一个叫慢启动的阈值,当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
假设对方接受能力为8,在超过上一次阈值的一半后,转为线性增长,为什么呢?此时发送数据最多为8,为什么还要增长呢?因为在不断探测新的拥塞窗口,所以如果后面继续用指数方式增长,只会导致拥塞窗口的值变得特别大且不准,线性探测可以更精细化不断更新拥塞窗口的大小,来探测出非常准确的拥塞窗口的值。下一次发生网络拥塞时,引起拥塞的窗口大小就是24,这个数字比较准确,因为是线性探测出来的。我们需要解决两个问题,1发生拥塞时怎么办?能做的就是发送数据量少一点2当数据量一旦稳定了,这个算法还身兼一个职--更新出一个准确的拥塞窗口大小。
如果网络状态还好,也按照对方的接收能力来发送,网络也一直不拥塞,难道拥塞窗口的值一直要增长吗?不可能一直增长,拥塞窗口的值是有上限的(和系统有关)。
少量的丢包,仅仅触发超时重传;大量的丢包,才认为网络拥塞,触发慢启动。
延迟应答
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。
- 假设接收端缓冲区为1M,一次收到了500K的数据,如果立刻应答,返回的窗口就是500K;但实际上接收端处理数据的速度可能很快,10ms之内就能把500K的数据从缓冲区拿出来,在这种情况下,如果接收端如果稍微等一会再应答,比如等待200ms再应答,这个时候返回的窗口大小就是1M。
在接收方接收到数据后,不立即返回,而是积压一会,数据被接收方从缓冲区取走了,这样有可能给对方通告一个更大的接受窗口,这就是延迟应答!目的是提高TCP传输效率。
窗口越大,网络吞吐量越大,传输效率越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。但是也不是所有的包都能延迟应答。
- 数量限制:每隔N个包就应答一次;
- 时间限制:超过最大延迟时间就应答一次;
具体数量和超时时间,依OS不同也有差异。一般N取2,超时时间200ms。
捎带应答
当客户端给服务器发送数据后,服务器需要给客户端返回应答ACK,并且此时如果服务器也想给客户端发消息,那就可以把要发的数据报文捎带上ACK,这就是捎带应答。
三次握手中第一次SYN禁止捎带应答,连接还没建立好。第二次SYN+ACK也不可以捎带应答,而第三次当客户端发出ACK后,客户端就认为已经三次握手完成了,可以捎带应答。
下面我们来谈几组关于TCP的常见面试题:面向字节流、粘包问题。
面向字节流
创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区:
- 调用write,数据会先写入发送缓冲区。
- 如果发送的字节数太长,会被拆分成多个TCP的数据包发出。
- 如果发送的字节数太短,就会先在缓冲区里等待,等待缓冲区的长度差不多了再发出去。
- 接收数据的时候,数据是从网卡驱动程序到达内核的接收缓冲区,应用程序可以调用read从接受缓冲区拿数据。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,比如,
- 写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节。
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read100个字节,也可以一次read一个字节,重复100次。
粘包问题
粘包问题中的“包”,是指应用层的数据包。在TCP的协议头中,没有UDP“报问长度”这样的字段,但是有序号这样的字段。站在传输层的角度,TCP是一个一个报文传过来的,按照序号排好放在缓冲区中。站在应用层的角度,看到的只是一串连续的字节数据,应用层看到这么一连串的字节数据,就不知道从哪个部分开始到哪个部分是一个完成的应用层数据包了。
如何避免粘包问题呢?就是一句话,明确两个包之间的边界。
- 对于定长的包,保证每次都按照固定大小读取即可。
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,这就知道了包的结束位置。
- 对于变长的包,还可以在在包和包之间使用明确的分隔符(自己定的)。
对于UDP协议,不存在粘包问题,因为UDP报头里有报文长度这个字段,UDP是一个一个把数据交给应用层,有很明确的数据边界。站在应用层,在使用UDP时,要么收到完整的UDP报文,要么不收。
但TCP不同,服务器收到报文之后,整个完整的报文前20个字节是报头,没有一个值表明报文自身长度,但是有序号,能保证按序到达,将报头拆出来,剩下的就是数据,因为TCP不对数据做任何解释,面向字节流,只需要将报头和选项摘离,将有效载荷的数据放入接受缓冲区里,上层读到的就是字节流数据。
我们之前在语言学到的字节流的概念,和这里一模一样,向文件中读写可以通过序列化和反序列化的方式进行。文件时面向字节流,处理文件的做法和网络一模一样,
TCP异常情况
进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有区别,正常进行四次挥手。
机器重启:和进程终止的情况一样。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
TCP/UDP对比
TCP是可靠连接,但是TCP不一定优于UDP,它们之间不能简单绝对地进行比较,
- TCP用于可靠传输的情况,如文件传输等。
- UDP用于对高速传输和实时性要求比较高的通信领域。
用UDP实现可靠传输
这是比较经典的面试题。参考TCP的可靠传输机制,在应用层实现类似的逻辑。
- 引入序列号,保证数据顺序;
- 引入确认应答,确保对端收到了数据。
- 引入超时重传,如果隔一段时间没有应答,就重发数据。