目录
一.认识TCP协议
1.从命名上解析TCP协议
TCP协议,即传输控制协议,我们在代码层面,调用write、read、recv、send函数的本质,其实是把数据从传输层TCP的接收缓冲区拷贝出来、或将数据拷贝到传输层的发送缓冲区内,至于传输层对数据什么时候发送、发送多少、出错如何解决...等问题,都是由TCP协议控制的,所以TCP名为传输控制协议。
图解如下:
2.TCP协议的报头字段和解析
a.TCP报头
b.TCP报头解析
--- 16位源端口号,即报文发送端主机中,发送该报文的进程的端口号。
--- 16位目的端口号,即报文接收端主机中,接收该报文的进程的端口号。
--- 4位首部长度,其描述的是TCP的整体报头长度(标准报头+选项),其二进制范围是[0000,1111], 即0~15,但是,它的单位是4字节,所以它的取值范围是0~60字节。
--- 16位窗口大小,当接收方收到一个报文后,需要向对方回一个应答报文,告诉对方自己已经收到了报文,同时会告诉对方自己“接收缓冲区”的剩余空间大小,以免对方下次发送来的报文过大,自己“装不下”,这就是该字段的意义,它是TCP进行“流量控制”的一个重要手段。
--- 32位序号,当发送方一次发送多条报文时,接收方收到的报文顺序与发送方发送报文的顺序可能不一样,进而导致乱序,而乱序本身就是数据不可靠的一种,所以,为解决报文收发的乱序问题,我们要在报头中添加“序号”这一属性,序号可以保证数据的有序性,序号本质就是发送缓冲区的数组最后一个元素的下标,故接收方可以根据序号对收到的多条报文进行排序重组,进而保证数据收发的顺序性。
--- 32位确认序号,当接收方接到来自同一用户的多条报文,给对方响应时,对方怎么知道接收方应答的是哪条报文呢??通过确认序号!!
确认序号=接收方收到报文的序号+1,表示确认序号之前的报文已经全部收到了,并告诉发送方,下次在发送的话,从确认序号开始,继续发下一个报文
--- 16位紧急指针,本质是一个偏移量,通过该偏移量,能够在“有效载荷”中寻找需要紧急处理的数据位置,而一般一个报文中紧急数据的大小是一个字节。
为什么要有六个标记位字段??
---服务器收到的报文一定是有各种“类型”的,如:tcp通信前要建立链接、正常的数据通信、断开链接...等,不同类型的报文决定了服务器要执行不同的命令,而报文的类型是由标记位决定的。
--- 六个标记位
ACK:标记报文是否为应答报文,若是,ACK=1; 若不是ACK=0。
SYN:发起握手、建立链接的请求标记位。
FIN: 断开链接。
PSH:通知对方立即将接收缓冲区里的内容立即读走。
URG:标记16位紧急指针是否有效,服务器对报文“按序接收”、“按序处理”,但在某些情况下,如果我们想让服务器优先处理某个报文,就将URG字段设为1,表示该报文中的16位紧急指针有效。
RST:要求对方重新建立链接RST。
RST的功能之一示例如下:
在三次握手中,对客户端来说,只要第三次握手的ACK应答报文发出,就认为已经将链接建立成功了,进而创建描述该链接的结构体,但是该ACK报文可能会丢失,导致服务端无法接收,此时,客户端与服务端对“链接是否建立成功”的判断出现差异化,若果在这种情况下,客户端继续向服务端发送通信报文,服务端就会对客户端回以携带RST的报文。客户端收到该报文后,会删除已建立的通信结构体,向服务端重新发送握手请求。
OK,上述已经将完成了TCP协议的介绍和报头解析的工作,接下来就要谈两个“任何协议都没法避开的问题”,即:①TCP报文中报头与有效载荷如何分离? ②TCP协议如何将有效载荷交付给上层?
3..TCP协议的对有效载荷的处理
a.TCP报文中报头与有效载荷如何分离?
--- TCP的标准报头大小是20字节的固定长度, 通过读取标准报头内的“4位首部长度”字段,就能得到报头的总体长度(标准报头长度+选项长度),然后就能分离得到有效载荷数据。
b.TCP协议如何将有效载荷交付给上层?
--- TCP的标准报头大小是20字节的固定长度, 通过读取标准报头内的“源端口号”字段,就能知道自己应该将有效载荷交付给上层的哪一个进程。
二.TCP保证通信可靠性的机制
1.确认应答机制(ACK和确认序号)
接收端每收到一个报文,就要向对方回一个ACK应答报文,并且,16位确认序号=接收方收到连续报文的最大序号值+1,即保证应答报文与发送报文一一对应。
2.超时重传机制
情景:由于发送端在没收到应答报文之前,无法判断对方是否成功收到了报文,所以发送端需要自行处理这种情况,即设定一个最大超时时间,若在该时间内未收到对方的应答报文,则直接认为这个报文已经丢失了,要向对方进行报文补发。
a.最大超时时间改如何设定?
--- 最理想的情况下,找到一个最小时间,保证“确认应答一定能在这个时间段内返回”,但是这个时间的长短,在不同的网络环境下是有差异的,如果超时时间设的太长,会影响整体的重传效率,如果超时时间设的太短,就有可能会频繁发送重复的报文。
b.超时重传的断开机制
---所以,OS会以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍,如果重发一次仍得不到应答,就等待2*500ms后再进行重传,如果依旧得不到应答,就等待4*500ms再进行重传,以此类推,以指数的形式递增,而累计到一定重传次数,TCP就认为网络或对端主机出现异常,强制关闭链接
3.链接管理机制
a.三次握手
首先,客户端向服务器发送一个SYN握手请求报文;然后,服务端回以一个SYN+ACK的应答报文,表示服务端接收到了来自客户端的握手请求,并向客户端发起握手请求,这一过程属于“捎带应答”;最后,客户端向服务器回以ACK的报文,此时,对客户端来讲,连接已经建立成功了。
①三次握手的底层机制
对服务器:①创建通信套接字,即listenfd=socket(); ②绑定通信套接字,即bind(listenfd,...);③将套接字设为监听状态,即listen(listenfd,...);此时,客户端就可以向服务器发送握手请求了。当服务器与客户端成功建立连接后,通过accept(listenfd,...)将连接从“全链接队列”中提到上层。
对客户端:①创建通信套接字,即fd=socket();由于OS会自动bind客户端的通信套接字,所以我们可以直接向服务器发送握手请求,即调用ret=connect(fd,...)。
当服务器的监听套接字listenfd收到该请求后,会向客户端回以ACK+SYN报文,此时,服务器进入SYN_RCVD状态,当客户端收到该ACK+SYN报文后,会向服务器回以ACK报文,同时connect()函数会自动建立连接并返回给上层;当服务器收到ACK报文后,会在服务端建立通信连接,并通过accept函数将其传给上层。
OS在双方进行三次握手的过程中,就已经协商了双方接收数据的能力,即,告诉对方自己的接收缓冲区的大小,所以在第三次握手的时候,就已经可以携带数据了,即“稍等应答”。
②全连接队列
当服务端与多个客户端链接建立成功后,服务端变成ESTABISHED状态,并生成多个描述相关链接的结构体,这些结构体会被放在一个"全链接队列"中排队,等待上层accept取走,而这个队列的最大长度是由上层listen的第二个参数控制的,全连接队列最大长度=listen第二参数值+1。
全链接队列存在的意义是什么??--- 存放暂时性链接,确保服务器时刻处于“负载”状态,充分利用服务器资源,以解决服务器“忙闲不均”问题!!
全链接队列为什么不能太长??--- 当全链接队列中有大量链接等待上层accept时,若上层一时“没空”去处理它们,此时连接就会占用大量的服务器资源,并且无法创造任何价值,这是服务器无法容忍的!!
③半连接队列
server端收到来自客户端的SYN报文,并向客户端回应SYN+ACK报文后,处于SYN_RCVD状态,若此时ESTABISHED队列已经满了,服务端就不会再接收来自客户端的ACK响应报文,也就不会再生成ESTABISHED状态的结构体了,而服务端对SYN_RCVD状态的结构体同样需要进行管理,它们也会被放在一个队列中,这个队列叫“半链接队列”,服务端对全链接队列中的结构体不会长时间维护,会定时清理!
④“SYN洪水”
由于所有的通信连接在进入“全链接队列”前,都会进入“半链接队列”,而当“半链接队列”被填满后,服务器会自动拒绝客户端的SYN,导致其他用户无法连接服务器,这就是SYN洪水。
b.四次挥手
第一次挥手:客户端向服务器发送带有FIN=1的报文,表示客户端向服务器发送断开链接的请求;
第二次挥手:服务器回应带有ACK=1的应答报文,此时,客户端不能再向服务端发送数据,但服务端仍能向客户端发送数据;
第三次挥手:服务器向客户端发送带有FIN=1的报文,想要与客户端断开链接,此时服务器进入TIME_WAIT状态。
第四次挥手:客户端回应带有ACK=1的应答报文;
四次挥手的本质,就是可靠的保证双方都能得知对方不想继续通信的意愿,所以需要四次挥手!
①为什么三次握手中能用捎带应答,而四次挥手通常不能呢??
--- 因为当客户端向服务器发送挥手请求时,服务器可能仍有想要向客户端发送数据,所以服务器对客户端的应答和FIN报文通常是分开的,但在一些巧合的场景下,客户端也是可能存在“捎带应答”的,即四次挥手也可能变成三次挥手。
②为啥要三次握手,两次不行吗??
1.需要验证“全双工通路”是否畅通,即验证自己发送的消息对方是否能收到.
2.两次握手的话,服务端优先建立链接,而一旦客户端崩坏,服务器的链接在一定时间内就变成了无效资源,代价由服务端承受了,而奇数次握手,建立链接失败的代价是由客户端承受的,而服务端对客户端往往是一对多的,所以由客户端承受失败的代价的可接受的。
③TIME_WAIT状态
主动断开链接的一方,在向对方发送FIN报文后,进入FIN_WAIT_1状态,对方收到报文,此时,前者进入CLOSE_WAIT状态,随后向其回以ACK应答报文,进入LAST_WAIT状态,待主动方收到ACK报文后,主动方与对方的链接就已经断开了,此时,主动方处于FIN_WAIT_2状态。待主动方收到来自对方的FIN报文后,主动方会向对方回以ACK报文,此后的一段时间内(两个msl,即30~60s),服务器中的这个通信连接不会被立即释放,主动方处于TIME_WAIT状态。
msl——报文在网络中最大的存在时长,称为“msl”,由于网络状况时刻变化,所以msl也是动态的变量。
*那么,为什么要有这个所谓的“time_wait状态”??※
1.让通信双方历史数据得以消散,防止其对后续通信产生干扰。报文一来一回最大也就是两倍的msl,而这个时间足以保证以往的报文被对方收到。
2.让四次挥手具有较好的容错性。主动断开链接的一方,最后向对方发送ACK应答报文时,如果主动方立即释放自己的链接的话,万一最后一次ACK报文丢失,对方再次向自己发送FIN请求,由于自己的结点已经释放,就无法对其再次回应ACK报文。而time_wait的存在就是为了解决这种情况的。
④序号的初始值
为保证新连接的初始序号与上次断开链接时阻塞在网络中的报文序号不重复,在双方进行三次握手时,会协商出一个随机值(通常取两方中较小的那个),然后后“序号=数组下标+随机值”的方式计算出初始序号的值。
⑤地址复用
如果服务端进入TIME_WAIT状态,那么对服务端而言,这个链接就属于没有完全断开状态,即ip和port还在被使用,当服务端重启后,由于服务端每次绑定的port必须是同一个,所以服务端就无法在短时间内再次bind这个端口号!
解决方法:int opt=1; //标记位的值
setsockopt(fd, SOL_SOCKET,SO_REUSEADDR, &opt, sizeof(opt));
该函数可以修改对应套接字中相关标记位的一个函数,使其允许地址复用。函数功能:使服务器能够立即使用那些正处于“TIME_WAIT”状态的port。
总的来说,链接的断开往往是客户端主动的,对客户端而言,就算该port端口号在一定时间内没有彻底断开,但由于客户端每次建立链接的port都是随机而唯一的,因此,TIME_WAIT状态对客户端来说没有影响。
4.流量控制
1.窗口更新和窗口探测
当接收方的接收缓冲区被填满后,发送端会进入一个等待时间,在这期间,如果接收端的接受缓冲区“空”出位置了,接收端会向发送方发生“窗口更新”报文,告诉接收方可以继续发送数据了;而如果在发送端的等待周期结束后,发送方依据没有收到来自接收端的“窗口更新”通知,发送方就会主动向接收方发送“窗口探测”报头,直到收到来自接收方的“窗口更新通知”。
这样的“两头通信”设计,可以在最短时间内使双方的通信恢复正轨,从而提高双方的通信效率;另一方面,可以避免“一方报文在网络传输过程出问题时,另一方能及时填补这一空缺”问题,提高协议的容错性。
2.接收端如何把自己的接收缓冲区大小告诉对方呢??
--- TCP首部报头中有一个“16位窗口”字段,其存放的就是自己的接收缓冲区的大小。
那么问题来了,16个比特位能够表示的最大值是2^16-1=65535,那么TCP接受缓冲区最大就是65535吗??--- 实际上,TCP首部“40字节选项”字段中还包含了一个“窗口扩大因子M”,实际窗口大小是:窗口字段的值左移M位,即*(2^M)
流量控制的存在既保证了通信的可靠性,又保证了通信的效率!!
5.拥塞控制
由于网络上有很多的计算机,网络中随时都会有大量的报文流动,当报文过多时,网络可能会阻塞住,此时,我们的计算机应该具备探测到网络状况的能力,否则当出现网络拥塞情况时,若仅通过滑动窗口向网络中无脑的发送大量报文,无疑是雪上加霜!
当网络出现“报文拥塞”情况时,会导致我们发送的报文大量丢失,此时,发送端主机应该停止“超时重传”机制,启动拥塞控制机制,即慢启动。
在我们学习慢启动机制前,我们需要先认识一个名词,即拥塞窗口。
那么, 啥是拥塞窗口?--- 当我们的主机察觉到网络中出现“报文拥塞”情况时,会自动定义一个探测窗口来探测网络拥塞情况,即拥塞窗口。
慢启动
--- 初始定义的拥塞窗口大小为1,每次收到一个ACK应答后,拥塞窗口 *= 2,当拥塞窗口达到某个阈值时(慢启动的阈值 = 上一次发生网络拥塞时的窗口大小/2),不再指数级增大,而是趋于线性变化。
实际发送窗口大小=min(拥塞窗口大小,16位窗口大小,滑动窗口大小),即取“网络数据传输能力”、“对方主机接收能力”、“自身发送缓冲区内剩余数据”中较小的那个!
三.TCP保证通信效率的手段
1.滑动窗口
情景:由于通信报文“一发一收”机制效率低下,所以通过流量控制,得知对方的接收能力后,发送方可以一次向对方发送多个报文,那么发送多少?发送哪些报文?如何做到最高效通信?
由上述问题,咱们可以对发送缓冲区进行一个简单的“区域划分”,分别是:①已发送已应答的数据;②已发送无应答的数据;③未发送的数据。
我们将“已发送无应答”这一区域称为“滑动窗口”,若该区域内的对应报文收到应答,则通过控制left指针,将其划分到“已发送已应答”区域,同时,OS会一直发送“未发生的数据”区域内的数据,而通过控制right指针,可以将发送的数据纳入“已发送无应答区域”,left和right指针的流动,控制的整个窗口的滑动,而滑动窗口的大小,与对方的接收缓冲区剩余空间大小有关。
由于接收方的接收缓冲区大小是在不断变化的,所以滑动窗口的大小在滑动的过程也是在动态变化的!!
在收到多个ACK应答报文时,如果部分ACK报文丢失,滑动窗口会如何滑动??
--- 根据确认序号的定义,接收方只会查看最大的确认序号,默认该确认序号之前的所有报文都已经被对方成功接收,窗口的left直接滑动到“最大确认序号”的位置。
如果丢包,接收端在回应的应答报文的最大“确认序号”就是所丢失的“最小序号”报文之前报文的确认序号,以此来保证滑动窗口线性、连续的向后更新,不会出现跳跃的情况。
2.快重传机制
当接收方一连收到3个或3个以上相同的确认序号时,说明一定有报文在发送的过程中丢失了,这就会直接触发快重传机制,即发送方无需等一个超时周期,直接向对方重新发送报文。
3.延迟应答
接收方收到报文后,不会立即向对方回以应答报文,而是会“稍稍”等待片刻,在这等待的时间内,上层可能会将缓冲区内的数据取走部分,这样的话缓冲区内空出的空间就会变大,从而使发送方的“滑动窗口变大”,一次发送更多的数据,提高网络通信数据的吞吐量,提高通信的效率!
四.TCP数据交互的特点
1.*面向字节流
由上述学习可知,创建一个TCP的socket,同时会在内核中创建一个“发送缓冲区”和一个“接收缓冲区”。
当我们调用write()时,数据会先写入“发送缓冲区”中,如果发送的报文字节数太长,则会被拆分成多个TCP的数据包发出;如果发送的字节数太短,就会被暂时放到发送缓冲区中等待,待缓冲区内的数据达到一定长度再发出去;我们调用read()将数据从接收缓冲区中读取出来,我们可以将100字节的数据一次全部读出,也可以分多次将其读出。这就是流式数据的IO特点!
一句话,上层对缓冲区中的数据进行读和写的操作次数不需要一一匹配,这就是“面向字节流”!
2.数据粘包问题
由于TCP通信是面向字节流的,接收端无法将从接收缓冲区内读到上层的数据流正确的划分成一个个完整请求,即不同的报文都“粘”在一块了!
这时就需要我们能够对不同的报文进行区分,即在发送单个请求前对其进行Encode(加包),对收到的请求进行Decode(解包),这也是应用层(如:http、https)要做的事!
具体方法:
1.定长报文
2.使用特殊字符
3.使用自描述字段+定长报头
4.使用自描述字段+特殊字符
有关数据粘包问题,博主会在后续文章中以代码的形式“手撕http”协议,帮助大家深入理解应用层存在的意义!
3.TCP异常情况
①进程终止:建立的链接是和文件相关的,而文件的生命周期随进程,进程结束,文件自动关闭,链接断开,正常的进行四次挥手
②机器重启:关机前OS会自动关闭所有的进程,正常四次挥手
③机器断电/断网:由于网络是先断开的,所以无法进行正常挥手
五.小结
TCP保证通信可靠性的机制:①校验和 ②序号和确认序号 ③ACK应答 ④超时重传 ⑤三次握手和四次挥手 ⑥流量控制 ⑦拥塞控制
TCP保证通信高效性的机制:①滑动窗口 ②快重传 ③延迟应答 ④捎带应答
六.手写一个基于TCP协议的CS通信
1.客户端和服务器的代码
1.创建、绑定、监听套接字,这些代码博主在上一篇的“手撕UDP协议”中详细讲解过,不会的同学可以去考考古哦!
设置服务器套接字的“地址复用”属性:
accept拿到通信连接,然后服务端就可以正常通信啦!
2.客户端的代码,与UDP几乎一模一样,哎,都是老套路啦!
3.OK,废话不多说,咱直接把代码跑起来
2.本地环回测试
127.0.0.1 本地环回地址,无法进行网络通信,只能本地通信,通常用来cs测试,与telnet连用。
格式:telnet + 服务器ip地址 + 服务器端口号
示例:
3.源码链接
https://gitee.com/Coder-Li-YuJie/client-and-server-based-on-tcp-protocol