首页 > 其他 > 详细

TCP一些概念(汇总自网络)

时间:2020-12-07 19:46:49      阅读:55      评论:0      收藏:0      [点我收藏+]

tcp总结

根据https://blog.csdn.net/u013256816/article/details/84001583总结

作为一个程序员要是连tcp都不知道还敲什么代码,嗯,说的就是我。鉴于此,在此总结一下tcp中一些重要的点

说到tcp我们总会想到udp

  • udp是面向无连接的不可靠的传输协议,是以报文形式发送而且不对上层数据切分,不保证可靠交付,就是发出去我就不管了

tcp简介

传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议

互联网络与单个网络有很大的不同,因为互联网络的不同部分可能有截然不同的拓扑结构、带宽、延迟、数据包大小和其他参数。TCP的设计目标是能够动态地适应互联网络的这些特性,而且具备面对各种故障时的健壮性。 [2]

不同主机的应用层之间经常需要可靠的、像管道一样的连接,但是IP层不提供这样的流机制,而是提供不可靠的包交换。 [3]

应用层向TCP层发送用于网间传输的、用8位字节表示的数据流,然后TCP把数据流分区成适当长度的报文段(通常受该计算机连接的网络的数据链路层的最大传输单元(MTU)的限制)。之后TCP把结果包传给IP层,由它来通过网络将包传送给接收端实体的TCP层。TCP为了保证不发生丢包,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收。然后接收端实体对已成功收到的包发回一个相应的确认(ACK);如果发送端实体在合理的往返时延(RTT)内未收到确认,那么对应的数据包就被假设为已丢失将会被进行重传。TCP用一个校验和函数来检验数据是否有错误;在发送和接收时都要计算校验和。 [3]

每台支持TCP的机器都有一个TCP传输实体。TCP实体可以是一个库过程、一个用户进程,或者内核的一部分。在所有这些情形下,它管理TCP流,以及与IP层之间的接口。TCP传输实体接受本地进程的用户数据流,将它们分割成不超过64KB(实际上去掉IP和TCP头,通常不超过1460数据字节)的分段,每个分段以单独的IP数据报形式发送。当包含TCP数据的数据报到达一台机器时,它们被递交给TCP传输实体,TCP传输实体重构出原始的字节流。为简化起见,我们有时候仅仅用“TCP”来代表TCP传输实体(一段软件)或者TCP协议(一组规则)。根据上下文语义你应该能很消楚地推断出其实际含义。例如,在“用户将数据交给TCP”这句话中,很显然这里指的是TCP传输实体。 [2]

IP层并不保证数据报一定被正确地递交到接收方,也不指示数据报的发送速度有多快。正是TCP负责既要足够快地发送数据报,以便使用网络容量,但又不能引起网络拥塞:而且,TCP超时后,要重传没有递交的数据报。即使被正确递交的数据报,也可能存在错序的问题,这也是TCP的责任,它必须把接收到的数据报重新装配成正确的顺序。简而言之,TCP必须提供可靠性的良好性能,这正是大多数用户所期望的而IP又没有提供的功能。 [2]

? 以上来自百度百科

TCP报文格式

TCP大家都知道是什么东西,这个协议的具体报文格式如下:

技术分享图片

标志位

  • URG:指示报文中有紧急数据,应尽快传送(相当于高优先级的数据)。
  • PSH:为1表示是带有push标志的数据,指示接收方在接收到该报文段以后,应尽快将这个报文段交给应用程序,而不是在缓冲区排队。
  • RST:TCP连接中出现严重差错(如主机崩溃),必须释放连接,在重新建立连接。
  • FIN:发送端已完成数据传输,请求释放连接。
  • SYN:处于TCP连接建立过程。 (Synchronize Sequence Numbers)
  • ACK:确认序号标志,为1时表示确认号有效,为0表示报文中不含确认信息,忽略确认号字段。

校验和

首先,把伪首部、TCP报头、TCP数据分为16位的字,如果总长度为奇数个字节,则在最后增添一个位都为0的字节。

把TCP报头中的校验和字段置为0(否则就陷入鸡生蛋还是蛋生鸡的问题)。

其次,用反码相加法累加所有的16位字(进位也要累加)。

最后,对计算结果取反,作为TCP的校验和。用来做差错控制

紧急指针

紧急数据字节号(urgSeq)=TCP报文序号(seq)+紧急指针(urgpoint)?1

窗口
滑动窗口大小,这个字段是接收端用来告知发送端自己还有多少缓冲区可以接受数据。于是发送端可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。(以此控制发送端发送数据的速率,从而达到流量控制。)窗口大小时一个16bit字段,因而窗口大小最大为65535。

头部长度(首部长度)
由于TCP首部包含一个长度可变的选项和填充部分,所以需要这么一个值来指定这个TCP报文段到底有多长。或者可以这么理解:就是表示TCP报文段中数据部分在整个TCP报文段中的位置。该字段的单位是32位字,即:4个字节。TCP的滑动窗口大小实际上就是socket的接收缓冲区大小的字节数。

选项和填充部分
TCP报文的字段实现了TCP的功能,标识进程、对字节流拆分组装、差错控制、流量控制、建立和释放连接等。其最大长度可根据TCP首部长度进行推算。TCP首部长度用4位表示,那么选项部分最长为:(2^4-1)*(32/8)-20=40字节。

全连接半连接队列

在握手阶段存在两个队列:

  • 全连接队列(accept queue) 三次握手完成
  • 半连接队列(syns queue) 三次握手中间 默认为1024

当第一次握手(client客户端的SYN到达server服务端时)TCP会在未完成连接队列中创建一个新项,这一项会一直保留在未完成连接队列中直到第三次握手(客户对服务器SYN的ACK)结束为止。如果三次握手全部正常完成,该项则会从未完成连接队列移到已完成连接队列的队尾。当进程调用accept()时,已完成连接队列中的队头项将返回给进程。

三次握手

技术分享图片

  • 第一次握手 客户端发出连接请求 SYN=1,seq=x 进入SYN-SENT(也就是SYN已发送)状态
  • 第二次握手 服务器收到后进行确认 ack=x+1 发送自己的seq=y,同时将SYN=1 ACK=1进入SYN-RCVD(SYN已接受)状态
  • 第三次握手 客户端收到服务器的seq后发送ack=y+1,ACK=1,将自己前面发送的seq加一,也就是发送seq=x+1,进入ESTABLISHED

至此连接建立

过程中可能出现的问题

SYN flood攻击:攻击者首先伪造地址对服务器发起SYN请求(我可以建立连接吗?),服务器就会回应一个ACK+SYN(可以+请确认)。而真实的IP会认为,我没有发送请求,不作回应。服务器没有收到回应,会重试3-5次并且等待一个SYN Time(一般30秒-2分钟)后,丢弃这个连接。

如果攻击者大量发送这种伪造源地址的SYN请求,服务器端将会消耗非常多的资源来处理这种半连接,保存遍历会消耗非常多的CPU时间和内存,何况还要不断对这个列表中的IP进行SYN+ACK的重试。TCP是可靠协议,这时就会重传报文,默认重试次数为5次,重试的间隔时间从1s开始每次都番倍,分别为1s + 2s + 4s + 8s +16s = 31s,第5次发出后还要等32s才知道第5次也超时了,所以一共是31 + 32 = 63s。

也就是说一共假的syn报文,会占用TCP准备队列63s之久,而半连接队列默认为1024,系统默认不同,可 cat /proc/sys/net/ipv4/tcp_max_syn_backlog c查看。也就是说在没有任何防护的情况下,每秒发送200个伪造syn包,就足够撑爆半连接队列,从而使真正的连接无法建立,无法响应正常请求。 最后的结果是服务器无暇理睬正常的连接请求—拒绝服务。

需要三次握手的原因

有人说为什么需要三次呢 两次不行吗。当服务器收到客户端的同步请求后,再给个响应不就好了。

在我看来,最后一次主要是确定网络状态确实没什么问题,如果说服务器对连接进行确认后客户端没有收到确认,那不就出问题了。所以需要客户端再次确认一下,当然不能一直确认下去,那样太耗费时间了 所以一般认为三次后网络就没啥问题了

四次挥手

技术分享图片

四次挥手在我看来就是:客户端(假如客户端主动断开)说我不想连接了,我想断开,服务器说好的知道了亲,但是要等我把数据发完哦,然后客户端就等着服务器发送剩下的数据,当服务器数据发送完毕后说我发完了,断开吧。客户端说:好的,拜拜。将以上白话文转换成专业术语来说就是

  • 第一次客户端发送FIN报文表示断开连接并设置一个序列号seq=u;进入FIN-WAIT-1状态
  • 第二次 服务器收到后发送ACK确认报文并附上对上个报文序列号的确认也就是ack=u+1 并附上自己的序列号seq=v进入close-wait状态,发送剩余的数据,客户端进入FIN-WAIT-2状态
  • 第三次挥手 服务器发送完数据后,发送一个FIN报文,ACK=1,附上序列号seq=w,附带ack=u+1,进入last-ack状态
  • 第四次挥手 客户段收到服务服务器的FIN报文后发送ACK=1,seq=u+1,对服务器的确认ack=w+1,进入TIME-WAIT状态

至此四次挥手完毕

TIME-WAIT状态

主动要求关闭的机器表示收到了对方的FIN报文,并发送出了ACK报文,进入TIME_WAIT状态,等2MSL后即可进入到CLOSED状态。如果FIN_WAIT_1状态下,同时收到待FIN标识和ACK标识的报文时,可以直接进入TIME_WAIT状态,而无需经过FIN_WAIT_2状态。

为什么客户端最后还要等待2MSL?

MSL(Maximum Segment Lifetime),TCP允许不同的实现可以设置不同的MSL值。

第一,保证客户端发送的最后一个ACK报文能够到达服务器,因为这个ACK报文可能丢失。站在服务器的角度看来,我已经发送了FIN+ACK报文请求断开了,客户端还没有给我回应,应该是我发送的请求断开报文它没有收到,于是服务器又会重新发送一次,而客户端就能在这个2MSL时间段内收到这个重传的报文,接着给出回应报文,并且会重启2MSL计时器。如果客户端收到服务端的FIN+ACK报文后,发送一个ACK给服务端之后就“自私”地立马进入CLOSED状态,可能会导致服务端无法确认收到最后的ACK指令,也就无法进入CLOSED状态,这是客户端不负责任的表现。

第二,防止失效请求。防止类似与“三次握手”中提到了的“已经失效的连接请求报文段”出现在本连接中。客户端发送完最后一个确认报文后,在这个2MSL时间中,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失。这样新的连接中不会出现旧连接的请求报文。

滑动窗口

在我看来滑动窗口类似缓存,是一段buffer,tcp通过滑动窗口进行流量控制。如果说接收方对一个序号就行确认,就表示这之前的都已收到。大佬的解释

TCP滑动窗口技术通过动态改变窗口大小来调节两台主机间数据传输。每个TCP/IP主机支持全双工数据传输,因此TCP有两个滑动窗口:一个用于接收数据,另一个用于发送数据。TCP使用肯定确认技术,其确认号指的是下一个所期待的字节。 假定发送方设备以每一次三个数据包的方式发送数据,也就是说,窗口大小为3。发送方发送序列号为1、2、3的三个数据包,接收方设备成功接收数据包,用序列号4确认。发送方设备收到确认,继续以窗口大小3发送数据。当接收方设备要求降低或者增大网络流量时,可以对窗口大小进行减小或者增加,本例降低窗口大小为2,每一次发送两个数据包。当接收方设备要求窗口大小为0,表明接收方已经接收了全部数据,或者接收方应用程序没有时间读取数据,要求暂停发送。发送方接收到携带窗口号为0的确认,停止这一方向的数据传输。当链路变好了或者变差了这个窗口还会发生变话,并不是第一次协商好了以后就永远不变了。

滑动窗口协议,是TCP使用的一种流量控制方法。该协议允许发送方在停止并等待确认前可以连续发送多个分组。由于发送方不必每发一个分组就停下来等待确认,因此该协议可以加速数据的传输。 只有在接收窗口向前滑动时(与此同时也发送了确认),发送窗口才有可能向前滑动。收发两端的窗口按照以上规律不断地向前滑动,因此这种协议又称为滑动窗口协议。

流量控制:端到端,接收端的应用层处理速度决定和网速无关,由接收端返回的rwnd控制

cwnd:发送端窗口( congestion window )
rwnd:接收端窗口(receiver window)

拥塞控制

拥塞控制: 发送端主动控制cwnd,有慢启动(从cwnd初始为1开始启动,指数启动),拥塞避免(到达ssthresh后,为了避免拥塞开始尝试线性增长),快重传(接收方每收到一个报文段都要回复一个当前最大连续位置的确认,发送方只要一连收到三个重复确认就知道接收方丢包了,快速重传丢包的报文,并TCP马上把拥塞窗口 cwnd 减小到1),快恢复(直接从ssthresh线性增长)。

如果网络上的延时突然增加,那么TCP对这个事作出的应对只有重传数据,但是重传会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,于是这个情况就会进入恶性循环被不断地放大。试想一下,如果一个网络内有成千上万的TCP连接都这么行事,那么马上就会形成“网络风暴”,TCP这个协议就会拖垮整个网络。所以TCP不能忽略网络上发生的事情,而无脑地一个劲地重发数据,对网络造成更大的伤害。对此TCP的设计理念是:TCP不是一个自私的协议,当拥塞发生的时候,要做自我牺牲。就像交通阻塞一样,每个车都应该把路让出来,而不要再去抢路了。

慢启动
只有在TCP连接建立和网络出现超时时才使用。每经过一个传输轮次,拥塞窗口 cwnd 就加倍。一个传输轮次所经历的时间其实就是往返时间RTT。不过“传输轮次”更加强调:把拥塞窗口cwnd所允许发送的报文段都连续发送出去,并收到了对已发送的最后一个字节的确认。另外,慢开始的“慢”并不是指cwnd的增长速率慢,而是指在TCP开始发送报文段时先设置cwnd=1,使得发送方在开始时只发送一个报文段(目的是试探一下网络的拥塞情况),然后再逐渐增大cwnd。

为了防止拥塞窗口cwnd增长过大引起网络拥塞,还需要设置一个慢开始门限ssthresh状态变量(如何设置ssthresh)。慢开始门限ssthresh的用法如下:

  • 当 cwnd < ssthresh 时,使用上述的慢开始算法。
  • 当 cwnd > ssthresh 时,停止使用慢开始算法而改用拥塞避免算法。
  • 当 cwnd = ssthresh 时,既可使用慢开始算法,也可使用拥塞控制避免算法。

拥塞避免算法:让拥塞窗口cwnd缓慢地增大,即每经过一个往返时间RTT就把发送方的拥塞窗口cwnd加1,而不是加倍。这样拥塞窗口cwnd按线性规律缓慢增长,比慢开始算法的拥塞窗口增长速率缓慢得多。

技术分享图片

无论在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有收到确认),就要把慢开始门限ssthresh设置为出现拥塞时的发送方窗口值的一半(但不能小于2)。然后把拥塞窗口cwnd重新设置为1,执行慢开始算法。这样做的目的就是要迅速减少主机发送到网络中的分组数,使得发生拥塞的路由器有足够时间把队列中积压的分组处理完毕。

  1. 当TCP连接进行初始化时,把拥塞窗口cwnd置为1。前面已说过,为了便于理解,图中的窗口单位不使用字节而使用报文段的个数。慢开始门限的初始值设置为16个报文段,即 cwnd = 16 。
  2. 在执行慢开始算法时,拥塞窗口 cwnd 的初始值为1。以后发送方每收到一个对新报文段的确认ACK,就把拥塞窗口值另1,然后开始下一轮的传输(图中横坐标为传输轮次)。因此拥塞窗口cwnd随着传输轮次按指数规律增长。当拥塞窗口cwnd增长到慢开始门限值ssthresh时(即当cwnd=16时),就改为执行拥塞控制算法,拥塞窗口按线性规律增长。
  3. 假定拥塞窗口的数值增长到24时,网络出现超时(这很可能就是网络发生拥塞了)。更新后的ssthresh值变为12(即变为出现超时时的拥塞窗口数值24的一半),拥塞窗口再重新设置为1,并执行慢开始算法。当cwnd=ssthresh=12时改为执行拥塞避免算法,拥塞窗口按线性规律增长,每经过一个往返时间增加一个MSS的大小。

强调:“拥塞避免”并非指完全能够避免了拥塞。利用以上的措施要完全避免网络拥塞还是不可能的。“拥塞避免”是说在拥塞避免阶段将拥塞窗口控制为按线性规律增长,使网络比较不容易出现拥塞。

如果发送方设置的超时计时器时限已到但还没有收到确认,那么很可能是网络出现了拥塞,致使报文段在网络中的某处被丢弃。这时,TCP马上把拥塞窗口 cwnd 减小到1,并执行慢开始算法,同时把慢开始门限值ssthresh减半。

这是不使用快重传的情况。快重传算法首先要求接收方每收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方)而不要等到自己发送数据时才进行捎带确认。

技术分享图片

接收方收到了M1和M2后都分别发出了确认。现在假定接收方没有收到M3但接着收到了M4。显然,接收方不能确认M4,因为M4是收到的失序报文段。根据可靠传输原理,接收方可以什么都不做,也可以在适当时机发送一次对M2的确认。但按照快重传算法的规定,接收方应及时发送对M2的重复确认,这样做可以让发送方及早知道报文段M3没有到达接收方。发送方接着发送了M5和M6。接收方收到这两个报文后,也还要再次发出对M2的重复确认。这样,发送方共收到了接收方的四个对M2的确认,其中后三个都是重复确认。快重传算法还规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段M3,而不必继续等待M3设置的重传计时器到期。由于发送方尽早重传未被确认的报文段,因此采用快重传后可以使整个网络吞吐量提高约20%。

技术分享图片

与快重传配合使用的还有快恢复算法,其过程有以下两个要点:

  1. 当发送方连续收到三个重复确认,就执行“乘法减小”算法,把慢启动门限ssthresh减半。这是为了预防网络发生拥塞。请注意:接下去不执行慢开始算法。
  2. 由于发送方现在认为网络很可能没有发生拥塞,因此与慢开始不同之处是现在不执行慢开始算法(即拥塞窗口cwnd现在不设置为1),而是把cwnd值设置为慢开始门限ssthresh减半后的数值,然后开始执行拥塞避免算法(“加法增大”),使拥塞窗口缓慢地线性增大。

上图给出了快重传和快恢复的示意图,并标明了“TCP Reno版本”。区别:新的 TCP Reno 版本在快重传之后采用快恢复算法而不是采用慢开始算法。

发送方窗口的上限值 = Min [ rwnd, cwnd ]

  • 当rwnd < cwnd 时,是接收方的接收能力限制发送方窗口的最大值。
  • 当cwnd < rwnd 时,则是网络的拥塞限制发送方窗口的最大值。

差错控制

TCP使用差错控制来提供可靠性。差错控制包括以下的一些机制:检测和重传受到损伤的报文段、重传丢失的报文段、保存失序到达的报文段直至缺失的报文到期,以及检测和丢弃重复的报文段。TCP通过三个简单的工具来完成其差错控制:检验和确认以及超时

粘包与拆包

简介

拆包和粘包是在socket编程中经常出现的情况,在socket通讯过程中,如果通讯的一端一次性连续发送多条数据包,tcp协议会将多个数据包打包成一个tcp报文发送出去,这就是所谓的粘包。而如果通讯的一端发送的数据包超过一次tcp报文所能传输的最大值时,就会将一个数据包拆成多个最大tcp长度的tcp报文分开传输,这就叫做拆包

一些基本概念

MTU

泛指通讯协议中的最大传输单元。一般用来说明TCP/IP四层协议中数据链路层的最大传输单元,不同类型的网络MTU也会不同,我们普遍使用的以太网的MTU是1500,即最大只能传输1500字节的数据帧。可以通过ifconfig命令查看电脑各个网卡的MTU。

MSS

指TCP建立连接后双方约定的可传输的最大TCP报文长度,是TCP用来限制应用层可发送的最大字节数。如果底层的MTU是1500byte,则 MSS = 1500- 20(IP Header) -20 (TCP Header) = 1460 byte。

示意图

如图所示,客户端和服务端之间的通道代表TCP的传输通道,两个箭头之间的方块代表一个TCP数据包,正常情况下一个TCP包传输一个应用数据。粘包时,两个或多个应用数据包被粘合在一起通过一个TCP传输。而拆包情况下,会一个应用数据包会被拆成两段分开传输,其他的一段可能会和其他应用数据包粘合。

技术分享图片

场景实例

下面通过简单实现两个socket端通讯,演示粘包和拆包的流程。客户端和服务端都在本机进行通讯,服务端使用127.0.0.1监听客户端,客户端也在127.0.0.1发起连接。

1. 粘包

a. 实现服务端代码,服务监听55533端口,没有指定IP地址默认就是localhost,即本机IP环回地址 127.0.0.1,接着就等待客户端连接,代码如下:

public class SocketServer {
    public static void main(String[] args) throws Exception {
        // 监听指定的端口
        int port = 55533;
        ServerSocket server = new ServerSocket(port);

        // server将一直等待连接的到来
        System.out.println("server将一直等待连接的到来");
        Socket socket = server.accept();
        // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
        InputStream inputStream = socket.getInputStream();
        byte[] bytes = new byte[1024 * 1024];
        int len;
        while ((len = inputStream.read(bytes)) != -1) {
            //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
            String content = new String(bytes, 0, len,"UTF-8");
            System.out.println("len = " + len + ", content: " + content);
        }
        inputStream.close();
        socket.close();
        server.close();
    }
}

b. 实现客户端代码,连接服务端,两端连接建立后,客户端就连续发送100个同样的字符串;

public class SocketClient {
    public static void main(String[] args) throws Exception {
        // 要连接的服务端IP地址和端口
        String host = "127.0.0.1";
        int port = 55533;
        // 与服务端建立连接
        Socket socket = new Socket(host, port);
        // 建立连接后获得输出流
        OutputStream outputStream = socket.getOutputStream();
        String message = "这是一个整包!!!";
        for (int i = 0; i < 1; i++) {
            //Thread.sleep(1);
            outputStream.write(message.getBytes("UTF-8"));
        }
        Thread.sleep(20000);
        outputStream.close();
        socket.close();
    }
}

c. 先运行服务端代码,运行到server.accept()时阻塞,打印“server将一直等待连接的到来”来等待客户端的连接,接着再运行客户端代码;
d. 客户端代码运行后,就能看到服务端的控制台打印结果如下:

server将一直等待连接的到来
len = 21, content: 这是一个整包!!!
len = 168, content: 这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!
len = 105, content: 这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!
len = 42, content: 这是一个整包!!!这是一个整包!!!
len = 42, content: 这是一个整包!!!这是一个整包!!!
len = 63, content: 这是一个整包!!!这是一个整包!!!这是一个整包!!!
len = 42, content: 这是一个整包!!!这是一个整包!!!
len = 21, content: 这是一个整包!!!
len = 42, content: 这是一个整包!!!这是一个整包!!!
len = 21, content: 这是一个整包!!!
len = 147, content: 这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!
len = 63, content: 这是一个整包!!!这是一个整包!!!这是一个整包!!!
len = 21, content: 这是一个整包!!!
len = 252, content: 这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!这是一个整包!!!

按照原来的理解,在客户端每次发送一段字符串“这是一个整包!!!”, 分别发送了50次。服务端应该也会是分50次接收,会打印50行同样的字符串。但结果却是这样不寻常的结果,这就是由于粘包导致的结果。

总结出现粘包的原因

  1. 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去;
  2. 接收数据端的应用层没有及时读取接收缓冲区中的数据;
  3. 数据发送过快,数据包堆积导致缓冲区积压多个数据后才一次性发送出去(如果客户端每发送一条数据就睡眠一段时间就不会发生粘包);

2. 拆包

如果数据包太大,超过MSS的大小,就会被拆包成多个TCP报文分开传输。所以要演示拆包的情况,就需要发送一个超过MSS大小的数据,而MSS的大小是多少呢,就要看数据所经过网络的MTU大小。由于上面socket中的客户端和服务端IP都是127.0.0.1, 数据只在回环网卡间进行传输,所以客户端和服务端的MSS都为回环网卡的 MTU - 20(IP Header) -20 (TCP Header),沿用粘包的例子,下面是拆包的处理步骤。

a. mac电脑可以通过ifconfig查看本地的各个网卡的MTU,以下我的电脑运行ifconfig后输出的一部分,其中lo0就是回环网卡,可看出mtu是16384:

lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
    options=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>
    inet 127.0.0.1 netmask 0xff000000
    inet6 ::1 prefixlen 128
    inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
    nd6 options=201<PERFORMNUD,DAD>
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
    ether 88:e9:fe:76:dc:57
    inet6 fe80::18d4:84fb:fa10:7f8%en0 prefixlen 64 secured scopeid 0x6
    inet 192.168.1.8 netmask 0xffffff00 broadcast 192.168.1.255
    inet6 240e:d2:495f:9700:182a:c53f:c720:5f63 prefixlen 64 autoconf secured
    inet6 240e:d2:495f:9700:d96:48f2:8108:2b33 prefixlen 64 autoconf temporary
    nd6 options=201<PERFORMNUD,DAD>
    media: autoselect
    status: active
en1: flags=8963<UP,BROADCAST,SMART,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1500
    options=60<TSO4,TSO6>
    ether 7a:00:5c:40:cf:01
    media: autoselect <full-duplex>
    status: inactive
en2: flags=8963<UP,BROADCAST,SMART,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1500
    options=60<TSO4,TSO6>
    ether 7a:00:5c:40:cf:00
    media: autoselect <full-duplex>
    status: inactive
......

b. 服务端代码和粘包时一样,将客户端代码改为发送一个超过16384字节的字符串,假设使用UTF-8编码的中文字符一个文字3个字节,那么就需要发送一个大约5461字的字符串,TCP才会拆包,为了篇幅不会太长,发送的字符串我只用一小段文字代替。客户端代码如下:

public class SocketClient {

    private final static String CONTENT = "这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很.....长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串";//测试时大于5461文字,由于篇幅所限,只用这一段作为代表

    public static void main(String[] args) throws Exception {
        // 要连接的服务端IP地址和端口
        String host = "127.0.0.1";
        int port = 55533;
        // 与服务端建立连接
        Socket socket = new Socket(host, port);
        // 建立连接后获得输出流
        OutputStream outputStream = socket.getOutputStream();
        String message = "这是一个整包!!!";
        for (int i = 0; i < 1; i++) {
            outputStream.write(message.getBytes("UTF-8"));
        }
        Thread.sleep(20000);
        outputStream.close();
        socket.close();
    }
}

c. 和粘包的代码示例一样,先运行原来的的服务端代码,接着运行客户端代码,看服务端的打印输出。

server将一直等待连接的到来
len = 22328, content: 这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很.....长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串这是一个很长很长的字符串...(有22328字节数组的文字)

通过输出的log,可发现客户端发送的字符串并没有在服务端被拆开,而是一次读取了客户端发送的完整字符串。是不是就没有被拆包呢,其实不是的,这是因为字符串被分拆成两个TCP报文,发送到了服务端的缓冲数据流中,服务端一次性读取了流中的数据,显示的结果就是两个tcp数据报串接在一起了。我们可以通过tcpdump抓包查看数据的传送细节:

在控制台输入sudo tcpdump -i lo0 ‘port 55533‘,作用是监听回环网卡lo0上在55533端口传输的数据包,有从这个端口出入的数据包都会被抓获并打印出来,这个命令需要管理员权限,输入用户密码后,开始监听数据。这时我们按照刚才的测试步骤重新运行一遍,抓包的结果如下:

tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes
23:15:44.641208 IP 192.168.1.8.58748 > 192.168.1.8.55533: Flags [S], seq 2331897419, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 261991443 ecr 0,sackOK,eol], length 0
23:15:44.641261 IP 192.168.1.8.55533 > 192.168.1.8.58748: Flags [S.], seq 3403812509, ack 2331897420, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 261991443 ecr 261991443,sackOK,eol], length 0
23:15:44.641270 IP 192.168.1.8.58748 > 192.168.1.8.55533: Flags [.], ack 1, win 6379, options [nop,nop,TS val 261991443 ecr 261991443], length 0
23:15:44.641279 IP 192.168.1.8.55533 > 192.168.1.8.58748: Flags [.], ack 1, win 6379, options [nop,nop,TS val 261991443 ecr 261991443], length 0
23:15:44.644808 IP 192.168.1.8.58748 > 192.168.1.8.55533: Flags [.], seq 1:16333, ack 1, win 6379, options [nop,nop,TS val 261991446 ecr 261991443], length 16332
23:15:44.644812 IP 192.168.1.8.58748 > 192.168.1.8.55533: Flags [P.], seq 16333:22329, ack 1, win 6379, options [nop,nop,TS val 261991446 ecr 261991443], length 5996
23:15:44.644835 IP 192.168.1.8.55533 > 192.168.1.8.58748: Flags [.], ack 22329, win 6030, options [nop,nop,TS val 261991446 ecr 261991446], length 0
  1. 第三行中,客户端发起连接请求,options参数中有一个mss 16344的参数,就表示连接建立后,客户端能接收的最大TCP报文大小,超过后就会被拆包分开传送;
  2. 前四行都是两端的连接过程;
  3. 第五行客户端口58748向服务端口55533传输了16332字节大小的数据包;
  4. 第六行客户端口58748向服务端口55533传输了5996字节大小的数据包;

从抓包过程就能看出,客户端发送一个字符串,被拆成了两个TCP数据报进行传输。

解决方案

对于粘包的情况,要对粘在一起的包进行拆包。对于拆包的情况,要对被拆开的包进行粘包,即将一个被拆开的完整应用包再组合成一个完整包。比较通用的做法就是每次发送一个应用数据包前在前面加上四个字节的包长度值,指明这个应用包的真实长度。如下图就是应用数据包格式。

技术分享图片

下面我修改前文的代码示例,来实现解决拆包粘包问题,有两种实现方式: 1. 一种方式是引入netty库,netty封装了多种拆包粘包的方式,只需要对接口熟悉并调用即可,减少自己处理数据协议的繁琐流程; 2. 自己写协议封装和解析流程,相当于实现了netty库拆粘包的简易版本,本篇文章是为了学习需要,就通过这个方式实现:

a. 客户端。每次发送一个字符串前,都将字符串转为字节数组,在原数据字节数组前再加上一个四个字节的代表该数据的长度,然后将组合的字节数组发送出去;

public class SocketClient {

    public static void main(String[] args) throws Exception {
        // 要连接的服务端IP地址和端口
        String host = "127.0.0.1";
        int port = 55533;
        // 与服务端建立连接
        Socket socket = new Socket(host, port);
        // 建立连接后获得输出流
        OutputStream outputStream = socket.getOutputStream();
        String message = "这是一个整包!!!";
        byte[] contentBytes = message.getBytes("UTF-8");
        System.out.println("contentBytes.length = " + contentBytes.length);
        int length = contentBytes.length;
        byte[] lengthBytes = Utils.int2Bytes(length);
        byte[] resultBytes = new byte[4 + length];
        System.arraycopy(lengthBytes, 0, resultBytes, 0, lengthBytes.length);
        System.arraycopy(contentBytes, 0, resultBytes, 4, contentBytes.length);

        for (int i = 0; i < 10; i++) {
            outputStream.write(resultBytes);
        }
        Thread.sleep(20000);
        outputStream.close();
        socket.close();
    }
}
public final class Utils {
    //int数值转为字节数组
    public static byte[] int2Bytes(int i) {
        byte[] result = new byte[4];
        result[0] = (byte) (i >> 24 & 0xFF);
        result[1] = (byte) (i >> 16 & 0xFF);
        result[2] = (byte) (i >> 8 & 0xFF);
        result[3] = (byte) (i & 0xFF);
        return result;
    }
    //字节数组转为int数值
    public static int bytes2Int(byte[] bytes){
        int num = bytes[3] & 0xFF;
        num |= ((bytes[2] << 8) & 0xFF00);
        num |= ((bytes[1] << 16) & 0xFF0000);
        num |= ((bytes[0] << 24)  & 0xFF000000);
        return num;
    }
}

b. 服务端。接收到客户端发送过来的字节数组后,先提取前面四个字节转为int值,然后再往后取该int数值长度的字节数,再转为字符串就是客户端端发送过来的数据,详见代码:

public class SocketServer {
    public static void main(String[] args) throws Exception {
        // 监听指定的端口
        int port = 55533;
        ServerSocket server = new ServerSocket(port);
        // server将一直等待连接的到来
        System.out.println("server将一直等待连接的到来");
        Socket socket = server.accept();
        // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
        InputStream inputStream = socket.getInputStream();
        byte[] bytes = new byte[1024 * 128];
        int len;
        byte[] totalBytes = new byte[]{};
        int totalLength = 0;
        while ((len = inputStream.read(bytes)) != -1) {
            //1. 将读取的数据和上一次遗留的数据拼起来
            int tempLength = totalLength;
            totalLength = len + totalLength;
            byte[] tempBytes = totalBytes;
            totalBytes = new byte[totalLength];
            System.arraycopy(tempBytes, 0, totalBytes, 0, tempLength);
            System.arraycopy(bytes, 0, totalBytes, tempLength, len);
            while (totalLength > 4) {
                byte[] lengthBytes = new byte[4];
                System.arraycopy(totalBytes, 0, lengthBytes, 0, lengthBytes.length);
                int contentLength = Utils.bytes2Int(lengthBytes);
                //2. 如果剩下数据小于数据头标的长度,则出现拆包,再次获取数据连接
                if (totalLength < contentLength + 4) {
                    break;
                }
                //3. 将数据头标的指定长度的数据取出则为应用数据
                byte[] contentBytes = new byte[contentLength];
                System.arraycopy(totalBytes, 4, contentBytes, 0, contentLength);
                //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
                String content = new String(contentBytes, "UTF-8");
                System.out.println("contentLength = " + contentLength + ", content: " + content);
                //4. 去掉已读取的数据
                totalLength -= (4 + contentLength);
                byte[] leftBytes = new byte[totalLength];
                System.arraycopy(totalBytes, 4 + contentLength, leftBytes, 0, totalLength);
                totalBytes = leftBytes;
            }
        }
        inputStream.close();
        socket.close();
        server.close();
    }
}

c. 打印结果:

server将一直等待连接的到来
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!
contentLength = 21, content: 这是一个整包!!!

客户端连续发送十个字符串,服务端也收到了分开的十个字符串,不再出现多个数据包连在一起的情况了。

TCP一些概念(汇总自网络)

原文:https://www.cnblogs.com/clion/p/14098888.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!