TCP的理解

Dec 22, 2017


概要

  1. OSI模型和网络术语
  2. TCP报文段结构
  3. 重连机制
  4. RTT算法
  5. 滑动窗口
  6. 拥塞处理
  7. Socket

网络术语和OSI模型


网络术语

1、MSL(Maximum Segment Lifetime) 报文最大生存时间 他是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃,因为tcp报文 (segment)是ip数据报(datagram)的数据部分

2、ip头中有一个TTL域,TTL是 time to live 生存时间 而是存储了一个ip数据报可以经过的最大路由数,每经 过一个处理他的路由器此值就减1,当此值为0则数据报将被丢弃,同时发送ICMP报文通知源主机。 MSL要大于等于TTL

3、RTT是客户到服务器往返所花时间(round-trip time,简称RTT),TCP含有动态估算RTT的算法。TCP还持续估算一个给定连接的RTT,这是因为RTT受网络传输拥塞程序的变化而变化

4、2MSL即两倍的MSL,TCP的TIME_WAIT状态也称为2MSL等待状态。在TIME_WAIT状态 时两端的端口不能使用,要等到2MSL时间结束才可继续使用。当连接处于2MSL等待阶段时任何迟到的报文段都将被丢弃。不过在实际应用中可以通过设置 SO_REUSEADDR选项达到不必等待2MSL时间结束再使用此端口

5、URG:Urget pointer is valid (紧急指针字段值有效) 6、SYN: 表示建立连接 7、FIN: 表示关闭连接 8、ACK: 表示响应 9、PSH: 表示有 DATA数据传输 10、RST: 表示连接重置。

OSI模型

第一层:物理层 涉及到0 1 0 1二进制数据在媒介(电缆、网线)上传输

第二层:链路层 对应的数据称为数据帧 Frame 网桥、交换机

第三层:网路层 数据包 Packet 路由的选择、ARP地址转换 ip到ip传输

第四层:运输层 报文段 Segment 提供了端(端口)到端的传输 流量的控制

第五层:会话层

第六层:表示层

第七层:应用层

TCP报文段数据结构


props

前两个字节为:source tcp port 源端口号 2-4个字节是dest port目的端口号 ,sequence number、acknowledge number、sliding windows 、 tcp flag

1、sequence number:报文段的需要,为了重新给报文段排序

2、acknowledge number: 确认收到,解决不丢包的问题

3、sliding windows 滑动窗口,流量拥塞控制

4、tcp flag 用于操控tcp状态机

props

为什么是三次握手?四次挥手?

三次握手: 主要是要初始化Sequence Number 的初始值。通信的双方要互相通知对方自己的初始化的Sequence Number(缩写为ISN:Inital Sequence Number)——所以叫SYN,全称Synchronize Sequence Numbers。也就上图中的 x 和 y。这个号要作为以后的数据通信的序号,以保证应用层接收到的数据不会因为网络上的传输的问题而乱序(TCP会用这个序号来拼接数据)。 如果client端最后不发送ack Server就开始建立连接,如果一开始client的连接包因网络延迟又到达了服务端,此时服务端又会建立连接,而客户端已经建立了连接,而此时服务端不知道一直等到客户端发送数据,浪费了服务端资源。如果使用三次握手的话,服务端必须等到最后一次ack才会建立连接,而此时客户端知道此包已经被重传过所以并不会发送ack给服务端,因此就不会建立连接

四次挥手:

由于tcp是全双工的(两端都可以在发送数据的同时接受数据) 断开连接时 每端必须发送 ACK 和 FIN

下图是双方同时断连接的示意图

props

相关的问题

1、SYN连接超时: 当客户端发起SYN连接时,server已经回复了 ACK+SYN,此时客户端掉线了,那么服务端超时就继续向客户端端发送 ACK+SYN 会每隔一定时间发送五次, 时间 1+2+4+8+16+32=63s,63s后仍然没有连上则断开连接

2、SYN Flood 攻击 黑客模拟client 不断的向server发送SYN后就不操作了,这是server会不断的重试,请求不断增加,最后会导致SYN-REVD 未完成队列满了,正常的请求不能处理。tcp_syncookies就可以解决此事,server端会将source port dest port 和时间错发给客户端,如果正常连接会响应server,那么就通过cookie建立了连接(不在SYN-RECVD队列中的连接),但是一般不建议处理正常的大负载的连接,syncookies是妥协版的tcp协议,不严谨。 可以通过调节 tcp_syn_retries 重试次数 tcp_syn_backlog:增大SYN-RECVD对队列长度,tcp_abort_overflow直接丢弃处理不过来的连接

3、关于ISN(initial sequence number)的初始化。 如果ISN始终是1 当客户发送了30个Segment后由于网络原因重连了,此时向server发送数据序号从1开始,但是当之前的30个Sement到了server被认为是新连接发来的包,server认为client后面的sequence是30以后的,但是实际上client的sequence可能是3,顺序全乱了。 解决:客户端会和一个假的时钟进行绑定,每隔4微妙对ISN加1,直到超过2的32次方后又从0开始,一个周期大概有4.55小时。而一个报文在网络上存活时间 MSL远远小于4.55个小时,这样就保重不会重用ISN

4、关于 MSL 和 TIME_WAIT

等待的时间为 2*MSL(RFC793定义了MSL为2分钟,Linux设置成了30s)再关闭连接

原因:1)TIME_WAIT确保有足够的时间让对端收到了ACK,如果被动关闭的那方没有收到Ack,就会触发被动端重发Fin,一来一去正好2个MSL,2)有足够的时间让这个连接不会跟后面的连接混在一起(你要知道,有些自做主张的路由器会缓存IP数据包,如果连接被重用了,那么这些延迟收到的包就有可能会跟新连接混在一起)

TCP重传机制


TCP要保证所有的数据包都可以到达,所以,必需要有重传机制。   注意,接收端给发送端的Ack确认只会确认最后一个连续的包,比如,发送端发了1,2,3,4,5一共五份数据,接收端收到了1,2,于是回ack 3,然后收到了4(注意此时3没收到),此时的TCP会怎么办?我们要知道,因为正如前面所说的,SeqNum和Ack是以字节数为单位,所以ack的时候,不能跳着确认,只能确认最大的连续收到的包,不然,发送端就以为之前的都收到了。

超时重传    一种是不回ack,死等3,当发送方发现收不到3的ack超时后,会重传3。一旦接收方收到3后,会ack 回 4——意味着3和4都收到了。   但是,这种方式会有比较严重的问题,那就是因为要死等3,所以会导致4和5即便已经收到了,而发送方也完全不知道发生了什么事,因为没有收到Ack,所以,发送方可能会悲观地认为也丢了,所以有可能也会导致4和5的重传。   对此有两种选择: 一种是仅重传timeout的包。也就是第3份数据。 另一种是重传timeout后所有的数据,也就是第3,4,5这三份数据。 这两种方式有好也有不好。第一种会节省带宽,但是慢,如果后面的报文没被接收端接收 ,依然需要超时重传后面的数据。第二种会快一点,但是会浪费带宽,也可能会有无用功。但总体来说都不好。因为都在等timeout,timeout可能会很长(Timeout是动态地计算出来的)。     

快速重传

于是,TCP引入了一种叫Fast Retransmit 的算法,不以时间驱动,而以数据驱动重传。也就是说,如果,包没有连续到达,就ack最后那个可能被丢了的包,如果发送方连续收到3次相同的ack,就重传。Fast Retransmit的好处是不用等timeout了再重传。   比如:如果发送方发出了1,2,3,4,5份数据,第一份先到送了,于是就ack回2,结果2因为某些原因没收到,3到达了,于是还是ack回2,后面的4和5都到了,但是还是ack回2,因为2还是没有收到,于是发送端收到了三个ack=2的确认,知道了2还没有到,于是就马上重转2。然后,接收端收到了2,此时因为3,4,5都收到了,于是ack回6。示意图如下    props

Fast Retransmit只解决了一个问题,就是timeout的问题,它依然面临一个艰难的选择,就是重转之前的一个还是重装所有的问题。对于上面的示例来说,是重传#2呢还是重传#2,#3,#4,#5呢?因为发送端并不清楚这连续的3个ack(2)是谁传回来的?也许发送端发了20份数据,是#6,#10,#20传来的呢。这样,发送端很有可能要重传从2到20的这堆数据(这就是某些TCP的实际的实现)。可见,这是一把双刃剑。

SCK(Selective Acknowledgment)

 另外一种更好的方式叫:Selective Acknowledgment (SACK)(参看RFC 2018),这种方式需要在TCP头里加一个SACK的东西,ACK还是Fast Retransmit的ACK,SACK则是汇报收到的数据碎版。参看下图:    props    为了改进快速重传,需要有接收端维护一个已经收到的报文段范围表,每次和ack一起返回给发送端。发送端根据此选择性的重发。发送端不能全依赖SCK,因为接收端不能保证一定回传SCK(如果有更重要的数据需要内存空间,就会清掉这个报文范围表),因此重传机制需结合超时重传和快速重传来使用。 这样,在发送端就可以根据回传的SACK来知道哪些数据到了,哪些没有到。于是就优化了Fast Retransmit的算法。当然,这个协议需要两边都支持。在 Linux下,可以通过tcp_sack参数打开这个功能(Linux 2.4后默认打开)。
这里还需要注意一个问题——接收方Reneging,所谓Reneging的意思就是接收方有权把已经报给发送端SACK里的数据给丢了。这样干是不被鼓励的,因为这个事会把问题复杂化了,但是,接收方这么做可能会有些极端情况,比如要把内存给别的更重要的东西。所以,发送方也不能完全依赖SACK,还是要依赖ACK,并维护Time-Out,如果后续的ACK没有增长,那么还是要把SACK的东西重传,另外,接收端这边永远不能把SACK的包标记为Ack。 注意:SACK会消费发送方的资源,试想,如果一个攻击者给数据发送方发一堆SACK的选项,这会导致发送方开始要重传甚至遍历已经发出的数据,这会消耗很多发送端的资源。      

D-SCK:Duplicate-SCK 表示重叠的报文段

1、ack包含了SCK 那么此时就是D-SCK

2、SCK报文段范围之间有重叠

网络延时:当发送端连续3次收到同个ack=1000,那么就开始重传,但是此时接收端发送的ack已经到了3000了,而SCK=1000-1500说名序号为1000 len为500的报文已经到了,ack包含了SCK说明是网络延迟导致的

	Transmitted    Received    ACK Sent
	
	Segment        Segment     (Including SACK Blocks)
	
	500-999        500-999     1000
	
	1000-1499      (delayed)
	
	1500-1999      1500-1999   1000, SACK=1500-2000
	
	2000-2499      2000-2499   1000, SACK=1500-2500
	
	2500-2999      2500-2999   1000, SACK=1500-3000
	
	1000-1499      1000-1499   3000

                  1000-1499   3000, SACK=1000-1500  

RTT算法


   RTO 超时重传的时间需要设置影响tcp传输效率,若设置长了影响传输性能,设置短了重发的快了,又会造成网络阻塞,导致超时,从而导致更多的重发。

RTO 超时重传的时间不是根据采集几个RTT(Round Trip Time)数据样本计算的,而是需要根据当时网络情况动态计算的

Jacobson / Karels 算法

  

滑动窗口


窗口结构

tcp内部缓冲区的数据结构

props

接受端:

LastByteRead表示应用程序读取缓存区数据的最后的位置 NextByteExpected 表示接受到连续包的最后一个位置 lastRecvd 表示最后一个到达数据的位置

其中中间空白的表示中间那些数据还没有到达

发送端:

LastByteAcked 被接受的ack过的位置 LastByteSent最后一个发送出去的数据但未收到ack LastByteWritten应用程序正在写入的数据

接收端在给发送端回ACK中会汇报自己的AdvertisedWindow = MaxRcvBuffer – LastByteRcvd – 1;

接收端可以根据处理的速度,通过 advertise windows size来控制发送端发送数据的长度,每次ack时可以动态的改变 windows size

下面是发送端的窗口接口

props

上图中分成了四个部分,分别是:(其中那个黑模型就是滑动窗口) 1已收到ack确认的数据。 2发送还没收到ack的。 3在窗口中还没有发出的(接收方还有空间)。 4窗口以外的数据(接收方没空间)

下面是个滑动后的示意图(收到36的ack,并发出了46-51的字节):

props

下面我们来看一个接受端控制和发送端的图示

props

ZWP(Zero Windows Probe)

当接收端发送 windows size=0时 发生不再发送数据了,但是发送方连续三次发送ZWP数据包询问接受方的 windows size,如果连续三次依然都是0则有的tcp会发RST请求把链接断开。 注意:这里有可能被DDos攻击,当攻击者建立http连接发送GET请求后马上将windows size设置成0 此时服务端会不断向客户端发送ZWP请求,如果攻击多次发送这样的请求,导致服务器资源耗尽。 注:有等待的地法就会有可能收到DDos攻击。

DDos(Distribute Denial of Service):攻击者模拟客户端形式与客户端进行不正常的通讯,影响其他正常客户通讯 方式: 使网络过载干扰正常通讯 提交大量请求导致服务器超负荷 阻断某一用户与服务通信 阻断服务器与某系统或个人通信

Silly Window Syndrome 问题

傻窗口综合症 当接受者处理速度变慢时,窗口不断减小,导致减小到几个字节时,发送方也要发送—>浪费了网络资源) MTU:对于以太网来说 1500字节-20字节IP头部-20字节TCP头部 MSS(Max Segment Size) RFC定默认定义为536个字节的数据 大于MTU的包有两种结局,一种是直接被丢了,另一种是会被重新分块打包发送

分别从接收端和发送端解决:

接收端:当windows size < 某个值时向发送端发送 ack(0) windows size=0 告诉发送暂时不要发数据了,当windows size大于等于 MSS或 有一半可用(接受缓冲区有一半可用)通知发送端

发送端:延迟处理,有个Nagle’s algorithm 算法 1)要等到 Window Size>=MSS 或是 Data Size >=MSS,2)等待时间或是超时200ms,这两个条件有一个满足,他才会发数据,否则就是在攒数据 另外,Nagle算法默认是打开的,所以,对于一些需要小包场景的程序——比如像telnet或ssh这样的交互性比较强的程序,你需要关闭这个算法。你可以在Socket设置TCP_NODELAY选项来关闭这个算法。

拥塞处理-Congestion Handling


慢启动算法:cwnd全称Congestion Window

1)连接建好的开始先初始化cwnd = 1,表明可以传一个MSS大小的数据。

2)每当收到一个ACK,cwnd++; 呈线性上升

3)每当过了一个RTT,cwnd = cwnd*2; 呈指数让升

4)还有一个ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”

所以,我们可以看到,如果网速很快的话,ACK也会返回得快,RTT也会短,那么,这个慢启动就一点也不慢。下图说明了这个过程。 所以,我们可以看到,如果网速很快的话,ACK也会返回得快,RTT也会短,那么,这个慢启动就一点也不慢。下图说明了这个过程。

如图:props

拥塞避免:

拥塞避免算法 -Congestion Avoidance

前面说过,还有一个ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”。一般来说ssthresh的值是65535,单位是字节,当cwnd达到这个值时后,算法如下:

1)、收到一个ACK时,cwnd = cwnd + 1/cwnd

2)、当每过一个RTT时,cwnd = cwnd + 1

  这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。

拥塞状态算法:前面我们说过,当丢包的时候,会有两种情况:

  1)等到RTO超时,重传数据包。TCP认为这种情况太糟糕,反应也很强烈。 sshthresh = cwnd /2 cwnd 重置为 1 进入慢启动过程

 2)Fast Retransmit算法,也就是在收到3个duplicate ACK时就开启重传,而不用等到RTO超时。 TCP Tahoe的实现和RTO超时一样。 TCP Reno的实现是: cwnd = cwnd /2 sshthresh = cwnd

进入快速恢复算法——Fast Recovery

  上面我们可以看到RTO超时后,sshthresh会变成cwnd的一半,这意味着,如果cwnd<=sshthresh时出现的丢包,那么TCP的sshthresh就会减了一半,然后等cwnd又很快地以指数级增涨爬到这个地方时,就会成慢慢的线性增涨。我们可以看到,TCP是怎么通过这种强烈地震荡快速而小心得找到网站流量的平衡点的。

拥塞发生和快速恢复

快速恢复算法 – Fast Recovery   TCP Reno这个算法定义在RFC5681。快速重传和快速恢复算法一般同时使用。快速恢复算法是认为,你还有3个Duplicated Acks说明网络也不那么糟糕,所以没有必要像RTO超时那么强烈。注意,正如前面所说,进入Fast Recovery之前,cwnd 和 sshthresh已被更新: cwnd = cwnd /2 sshthresh = cwnd   然后,真正的Fast Recovery算法如下: cwnd = sshthresh + 3 * MSS (3的意思是确认有3个数据包被收到了) 重传Duplicated ACKs指定的数据包 如果再收到 duplicated Acks,那么cwnd = cwnd +1 如果收到了新的Ack,那么,cwnd = sshthresh ,然后就进入了拥塞避免的算法了。

如果你仔细思考一下上面的这个算法,你就会知道,上面这个算法也有问题,那就是——它依赖于3个重复的Acks。注意,3个重复的Acks并不代表只丢了一个数据包,很有可能是丢了好多包。但这个算法只会重传一个,而剩下的那些包只能等到RTO超时,于是,进入了恶梦模式——超时一个就减半一下,多个超时会超成TCP的传输速度呈级数下降,而且也不会触发Fast Recovery算法了。 通常来说,正如我们前面所说的,SACK或D-SACK的方法可以让Fast Recovery或Sender在做决定时更聪明一些,但是并不是所有的TCP的实现都支持SACK(SACK需要两端都支持),所以,需要一个没有SACK的解决方案。而通过SACK进行拥塞控制的算法是FACK(后面会讲)TCP New Reno于是,1995年,TCP New Reno(参见RFC 6582)算法提出来,主要就是在没有SACK的支持下改进Fast Recovery算法的—— 当sender这边收到了3个Duplicated Acks,进入Fast Retransimit模式,开发重传重复Acks指示的那个包。如果只有这一个包丢了,那么,重传这个包后回来的Ack会把整个已经被sender传输出去的数据ack回来。如果没有的话,说明有多个包丢了。我们叫这个ACK为Partial ACK。 一旦Sender这边发现了Partial ACK出现,那么,sender就可以推理出来有多个包被丢了,于是乎继续重传sliding window里未被ack的第一个包。直到再也收不到了Partial Ack,才真正结束Fast Recovery这个过程   我们可以看到,这个“Fast Recovery的变更”是一个非常激进的玩法,他同时延长了Fast Retransmit和Fast Recovery的过程。   

Socket


Socket:是一种建立在tcp基础之上,供给开发人调用的完成网上计算机之间的通讯的接口。

	server: socket + bind + listen + accept + ------- + close
	client:  socket + connect + --------- + close

队列:SYN+RVD和ESTABLISHED队列,当Server端接收到Client的SYN请求就进入 SYN+RCVD状态,那么就会将连接放入SYN+RCVD 未完成队列中,当Server收到Client ACK后进入已完成 ESTABLISEHED队列中,等待上层应用程序accept掉。 默认ESTABLISHED队列长度为128 SYN+RCVD队列长度为2048。

accept和FD:上层应用会通过accept方法从ESTABLISHED取出一个连接,其实就是获取到ConnFD(连接文件描述符文件)然后通过此文件描述符号进行读写,然而在Client在connect时也会有个ConnFD,此时Client-Server之间的通讯就是通过ConnFD进行。 此时稍微说下异步非阻塞和同步阻塞不同,而Netty没有做到真正的异步非阻塞,它使用的是多路复用的模式,这里会用个select线程不断询问操作系统内核中的读缓冲区是否有数据,如果有,此时会有用户态切换到内核态,从网卡读缓冲区取出数据放入到用户进程中,然后再由线程读区,此时过程是阻塞的。那么真正异步非阻塞是,当操作监听网卡读缓冲区,一有数据就拷贝到进程空间中,然后通知进程进行读取。

ConnFD和listenFD: 如果上层应用不accept连接,并不影响Client和Server之间进行通讯,Client依然可以通过ConnFD发送数据给Server,Server端有listenFD是为了保证可以和多个客户端建立连接,并发处理各个用户请求。

RST异常场景

connection reset by peer 和EOF 是经常在日志里看到的错误。前者是因为收到了对方发来的RST,后者则是正常的FIN。关闭连接时发RST通常是因为close时发现自己缓冲区RevQ还有数据没有读。下面总结几个会发RST的场景。

  • connect不存在的端口,对方会回复RST。

  • 异常终止时接收缓冲还有数据将发RST。

  • 往对方已经close的连接上写数据,对方回复RST。

既然可以支持半关闭,为什么往关闭的连接上写数据会失败?

close实际上是关闭conn的两个流向

下面例子就是client发了5个字节给server,但是server应用层没有read,一直存在于server的RecvQ中,这时server调用close关闭连接,底层发给client的是RST包,表示连接异常关闭。

	c>s: F[S] seq 1761306812, win 43690, options [...]
	s>c: F[S] seq 2690519277, ack 1761306813, win 43690, options [...]
	c>s: [.] ack 1, win 342, options [...]
	c>s: F[P], seq 1:6, ack 1, win 342, length 5
	s>c: [.] ack 6, win 342, options [...]
	s>c: [R] seq 1, ack 6, win 342, options [...]

保活定时器

先总结下tcp的四个定时器及其功能

  • 重传定时器 当超时没有收到某个Seq的ACK后,将以指数退避的方式重传

  • TIME_WAIT定时器 主动关闭连接的一方在收到对方的FIN发送ACK后将等待2MSL时间才关闭连接

  • 坚持定时器 为零窗口设计,当接收方告知发送方窗口大小为0后,启动坚持定时器周期性的询问窗口更新通知。以免窗口通知丢失,导致双方无法通信。

  • 保活定时器 TCP-keepalive 探查连接双方是否还活着。

如果server就是不close,client一直收不到FIN,是否CLOSE_WAIT和FIN_WAIT_2这对状态会无限制保持下去?

FIN_WAIT_2的生命周期由tcp_fin_timeout设置,如果收不到FIN包连接终将CLOSED。但是CLOSE_WAIT的状态可能持续保持下去。如果server配置了keepAlive时间则每个t就发TCP Keep-Alive包看conn是否被关闭。当client的FIN_WAIT_2 状态超时变为CLOSED之后,server的KeepAlive包将引发client的RST,最后连接终止。与之对比的是,当client半连接时,FIN_WAIT_2状态不会超时,那么KeepAlive包将一直有Keep AliveACK回应,连接不会RST终止。

tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(5 * time.Second)

下面例子中keepalive时间是5s,连接建立后没有收发数据,因此KeepAlive包每5秒发一次。10s时client端关闭连接发了FIN包进入FIN_WAIT_2状态,该状态60s后超时变为CLOSED状态。在这之后KeepAlive才失败导致RST重置连接。

props