前言
TCP协议是一个复杂而又重要的协议,在TCP/IP协议族中具有极其重要的位置。有多重要?看看命名就懂了。
它位于 OSI 7层模型中的第四层(Transport Layer),这一层的数据叫做 Segment。发送数据时,这一层的数据会切割放入第三层(Network Layer)IP协议的Packet中,再放入第二层(Ethernet Layer)的Frame中。传输到对端后,则由底向上地解包组装。
TCP Header
如上图所示,TCP头部主要由20字节的固定头部和可变长度的非固定头部组成,具体分析一下固定头部的字段含义:
- Source Port和 Destination Port: TCP层只有两个端口值,加上IP层的Source IP、Destination IP以及TCP协议,构成一个五元组,可以唯一确定一个连接。
- Sequence Number:一次TCP通信过程中某一个传输方向上的字节流的每个字节的编号,用来解决网络包乱序(reordering)问题。
- Acknowledgement Number:就是ACK,用于确认收到,用来解决不丢包的问题。
- Offset:头部偏移量,标识该TCP头部有多少个4字节。这个值占四个字节,因此头部最长为60字节。
- Reserved: 6位保留值,置为0。
-
TCP Flags:表示包的类型,用于操控TCP的状态机,其中:
URG 紧急指针是否有效;
ACK 表示确认号是否有效;
PSH 提示接收端应用程序应该立即从TCP接收缓冲区读走数据;
RST 表示要求对方重新建立连接;
SYN 表示请求建立一个连接;
FIN 表示通知对方本端要关闭连接; - Window: 表示窗口的容量,用于解决流控的。16位,因此TCP标准窗口最大为2^16 - 1 = 65535个字节。
- Checksum: 由发送端填充,接收端对报文段执行CRC算法,用以检验TCP报文段在传输中是否损坏。
- Urgent Pointer:一个正偏移量,与序号字段中的值相加表示紧急数据最后一个字节的序号。TCP的紧急方式是发送端向另一端发送紧急数据的一种方式。
TCP状态机
所谓的连接,其实就是发送端与接收端对于状态的一种维护,当两者都处于连接状态,并且五元组是一样的情况下,就可以理解为“建立了连接”。
先祭出一张状态机转换图
再祭出一张TCP建连、发送数据、断开的流程图。
解释一下其中的一些point。
SEQ和ACK
SEQ和ACK是TCP协议可靠性(有序性)保证的关键值。SEQ也就上图中的 x 和 y。SEQ在SYN时由各端生成,作为以后的数据通信的序号。对端发送回应报文时会将ACK值置为收到的连续SEQ最大值+1,也就是期望收到的下一个数据包的号码。
举个🌰
假设A端在不停地发送数据包,B端接收到的SEQ为[0,1,4,5],B发送的ACK值分别为[1,2,2,2],此时B收到了3号数据包,依然会发送ACK为2的回应报文,当B收到2号数据包时,则回复报文中ACK置为6。
快速重传
在上面的🌰中,A要如何重传数据呢?有两种选择,一种是只重传timeout的包,另一种是把timeout包以及后面的包都重传一次。第一种会节省带宽,但是慢,因为如果真的网络不好,可能后面的每一个包都没有收到,每一个包都要等超时后再重传;第二种会快一点,但是会浪费带宽,也可能会有无用功。但总体来说都不好。因为都在等timeout,timeout可能会很长。因此,TCP引入了快速重传机制。
Fast Retransmit :如果发送方连续收到3次相同的ack,就重传。这种机制不和时间挂钩,而是由数据驱动,解决了timeout的问题。而重传哪些数据则由另一个机制 SACK来实现。
Selective Acknowledgment (SACK):在TCP头OPTION段里加一个SACK,ACK还是Fast Retransmit的ACK,SACK用于汇报收到的数据碎版,这样就只需要重传丢掉的包即可。首先,2个设备必须同时支持SACK,建立连接的时候需要使用SACK Permitted的option,如果允许,后续的传输过程中TCP segment中的可以携带SACK option,这个option内容包含一系列的非连续的没有确认的数据的seq range。
TBD
滑动窗口
数据传输阶段发送真正的数据内容,一端可以连续发送多个数据包而不需要等待对端对应的ACK,还能接收多少个数据包,是由包头中的Window字段决定的,也就是著名的滑动窗口算法。
由于TCP是全双工通信,两端都维护了一套滑动窗口。我们只需要研究其中一端即可。
滑动窗口分为发送窗口和接收窗口。对于发送窗口,祭上一张图:
一共分为4块:
- Sent and Acknowledged:这些数据表示已经发送成功并已经被确认的数据,比如图中的前31个bytes,这些数据其实的位置是在窗口之外了,因为窗口内顺序最低的被确认之后,窗口要向右移动相应的偏移量。
- Send But Not Yet Acknowledged:这部分数据称为发送但没有被确认,数据被发送出去,没有收到接收端的ACK,认为并没有完成发送,这个属于窗口内的数据。
- Not Sent,Recipient Ready to Receive:这部分是尽快发送的数据,这部分数据已经被加载到缓存中等待发送,也在窗口中。其实这段窗口接收方是有能力接受这些包的,所以发送方需要尽快的发送这些包。
- Not Sent,Recipient Not Ready to Receive: 这些数据属于未发送,同时接收端也不允许发送的,因为这些数据已经超出了接收的范围。
假设发送端收到了ACK=37的TCP包,则窗口会变为如下模样:
对于接收窗口,则分为3块:
- Received and ACK Not Send to Process:这部分数据属于接收了数据但是还没有被上层的应用程序接收,被缓存在窗口内。
- Received Not ACK: 已经接收,但是还没有回复ACK,这些包可能输属于Delay ACK的范畴了。
- Not Received:有空位,还没有被接收的数据。
ATTENTION
滑动窗口的存在,导致一种攻击模式的产生:TCP零窗口攻击。
首先引入Zero Window的概念:当发送方的发送速度大于接收方的处理速度,接收方的缓冲塞满后,就会告诉发送方当前窗口size=0,请停止发送,发送方此时会停止发送数据。这样这个TCP连接就会被一直hold住。
接收方的Window size大于0后如何通知发送方呢?TCP使用ZWP (Zero Window Probe)技术,即发送方发现Window为0后,想接收端发送ZWP包,一般这个值会发送3次,每次间隔30-60s,如果一直是0,可能有的TCP实现会发送RST主动断开连接。这里为什么不让接收端等待窗口可用后主动上报呢?其实就是为了解决TCP零窗口攻击。
如果有攻击者发送大量0窗口的连接(DDoS攻击),服务端的接口资源就会被耗尽,想让攻击者主动上报窗口变更是不可能的,因此改用发送端主动询问的方式。
Congestion Handling 拥塞控制
TCP的设计中,流控(Flow Control)除了使用滑动窗口外,还根据更下层的网络的情况,做了一些控制。如果网络时延增加,TCP只会不断重试,考虑到网络内有无数个TCP连接,这样只会加重网络拥塞情况,最终拖垮整个网络。因此,TCP提出了四个算法:1)慢启动,2)拥塞避免,3)拥塞发生,4)快速恢复。具体论文参考引用3。
TBD
MSL
TCP Segment在网络上的存活时间最大值为Maximum Segment Lifetime(MSL)。
为什么在四次挥手中客户端要在TIME_WAIT状态后等待2MSL的时间才进入closed的状态?因为需要确认被动端接收到了最后一次ACK包,如果被动端未接收到ACK包,会重发FIN指令,为了保证重试的FIN指令一定能够收到,因此需要等待2MSL的时间。
由此可以推测,我们期望由客户端主动关闭这个全双工连接,否则服务端需要维持很多处在TIME_WAIT阶段的连接,非常消耗资源。
那么服务端如何处理很多TIME_WAIT状态连接的情况呢?有两个参数,分别是tcp_tw_reuse和tcp_tw_recycle。具体使用方式参考引用2。但是打开这两个参数是一个违反TCP协议的危险操作。此外还有一个参数tcp_max_tw_buckets。这个是控制并发的TIME_WAIT的数量,默认值是180000,如果超限,那么,系统会把多的给destory掉,然后在日志里打一个警告(如:time wait bucket table overflow),官网文档说这个参数是用来对抗DDoS攻击的。可以根据实际情况修改。
sequence number如何生成
ISN是不能hard code的,不然会出问题的。
举个🌰
如果连接建好后始终用1来做ISN,如果client发了30个segment过去,但是网络断了,于是 client重连,又用了1做ISN,但是之前连接的那些包到了,于是就被当成了新连接的包,此时,client的Sequence Number 可能是3,而Server端认为client端的这个号是30了,包的顺序无法保证了。
RFC793中说,ISN会和一个假的时钟绑在一起,这个时钟会在每4微秒对ISN做加一操作,直到超过2^32,又从0开始。这样,一个ISN的周期大约是4.55个小时。而我们又假设Segment在网络上的存活时间不会超过MSL,所以只要MSL的值小于4.55小时,那么我们就不会重用到ISN。