一、TCP协议报文
1. TCP协议的概念
TCP是TCP/协议簇中最重要也是最复杂协议,它位于OSI七层协议模型的网络层,它提供了一种全双工的、面向连接的、可靠的字节流服务。 TCP协议是两台主机进程进行通信的基石,TCP使用连接(connection)作为最基本的抽象,同时将TCP连接的端点称为插口或者套接字(socket)。
- 全双工:通信两端在任意时刻可以互相发送数据,既可以是客户端也可以是服务端。
- 面向连接:通信前需要先三次握手建立连接,通信后四次挥手释放连接。
- 可靠的:通过序号解决报文乱序/丢失、超时重传、拥塞控制、滑动窗口、检验和。
- 字节流:没有固定的报文边界。
1.1 Socket
如上图所示,客户端ip+port和服务端ip+port的四元组,组成一个socket可以唯一标识一个连接,所以理论上来说服务器的一个端口就能连接成千上万的连接。
2. TCP首部格式
TCP的首部包含了20字节的固定部分和长度可变的选项部分,如下图所示:
-
端口号:每个TCP段都包含各占两个字节的源端和目的端的端口号,用于寻找发端和收端应用进程。这两个值加上IP首部中的源端IP地址和目的端IP地址唯一确定一个TCP连接。
-
序号:TCP协议在数据传输的过程中是基于字节流的并且在传输给传输层的时候会将字节流进行分段,序号用来表示这个报文段的第一个字节(报文段的第一个字节号就是序号)。这个序号是由特定的算法生成的随着时间逐渐递增的数字,最大值是2^32-1。这样设计序号主要有三个原因:
- 标识两个主机间前后传输报文段的关系。
- 防止由于网络延迟有的分组被重发后,而导致某一方连接做出错误的解释。
- 防止黑客TCP劫持。
-
确认号:是期望收到下个报文段数据的第一个字节号。
-
首部长度: 共有多少4个字节,TCP首部长度可以在20-60个字节之间。
-
标志位:
- URG:紧急bit,当URG=1时,代表该报文段有紧急数据应该尽快传输(优先级高)。
- ACK:当ACK=1时代表确认号有效,否则无效。
- PUS:当PUS=1时代表该报文段应当尽快传输给接收应用进程,而不用等待缓存填满。
- RST:当RST=1时代表TCP连接出现了严重差错,必须释放连接然后重新建立。
- SYN:当SYN=1时代表这是一个请求连接或者连接接受报文。
- FIN:当FIN=1时代表报文段的发送端已经发送完数据,并要求释放连接。
-
窗口:窗口字段用来控制发送方的的发送数据量,接收方根据设置的缓存确定自己接收窗口的大小,然后通知对方以确定对方发送窗口的上限。
-
校验和:检验部分包括TCP首部和报文段部分,要加上12字节的伪首部。
-
紧急指针:指出本报文段中紧急数据的最后一个字节的序号,并且URG标志位置为1。
-
选项:TCP只规定了一种选项,即最大报文段长度MSS(max segment size),MSS会通知发送方我所能接收的报文段的数据字段的最大长度是MSS个字节。
二、TCP的三次握手
TCP是面向连接的,那么TCP是如何建立连接的那?答案就是三次握手,如果你已经了解了TCP的三次握手,在面试的时候一定被问过为什么是三次握手?两次或者四次可以吗?带着这些问题,接下来我们使用wireshak抓包工具,看看TCP是如何进行三次握手的。
首先我们使用Java的Socket或者使用nc -l port
监听一个端口号。例如:
然后我们再使用nc ip port
建立连接并发送一些数据。lsof查看下连接状态已经是ESTABLISHED。
接下来我们用wireshark看下抓的包数据。
上图中1、2、3号包就是三次握手的数据包,[xxx]表示TCP数据段标志位,例如1号包是[SYN],3号包是[ACK]。关于标志位在上篇文章里已经讨论过了,这里就不详细展开了。
三次握手的过程
不知道大家是否看明白了三次握手的过程,大家可以自己参照我画的三次握手的时序图仔细理解下,里面的TCP状态机,Win,Mss我会在后面给大家分析请暂时不要捉急,先搞清楚三次握手的流程就可以了。
介绍几个概念
- Seq: Seq表示该报文段的序列号,TCP的每个报文段的序列号在正常传输下都是递增的,在三次握手中的客户端和服务端的第一个Sep称为ISN(初始序列号),因为TCP是全双工的,所以双方都需要一个ISN。我们不需要知道序列号是如何生成的,但是我们需要知道它是怎么增加的,因为在TCP的数据传输中收到乱序的数据包是根据序列号重新排序的。
为什么这里的ISN(初始序列号)为0,因为我们要频繁的对Seq进行计算,在wireshark中提供了Relative sequence number的相对序列号方式,简化我们的计算。
- Len:表示数据包的长度,但是不包含TCP头的长度,三次握手中的数据。
- ACK:表示确认收到了ACK号之前的数据包,如果对方没有发送数据包那么确认号保持不变,例如,A发送给B的数据包的Seq=x,Len=y那么B的回复的Ack号就是x+y。
- 序列号的增长方式:
下个数据包的Seq=上个数据包的Seq + 上个数据包的Len。
也就是说收端的ACK号正是发送端下次的Seq号。
三次握手中的ACK和Seq增长方式有所不同。
三次握手可以是两次吗?
答案:可以,但是不够可靠。
站在客户端的角度来看,客户端丢出去了SYN,并且收到了服务端的SYN和ACK,那么客户端的状态是ESTABLISHED的,已确认的状态。
站在服务端的角度来看,收到了SYN,丢出去了SYN和ACK,如果没有收到客户端的ACK的话,服务端的状态是SYN_RECVD的,不是确认的状态。
也就是说客户端和服务都需要发出SYN和ACK,并且收到SYN和ACK,那么才都是ESTABLISHED已确认的状态。
三次握手可以是四次吗?
答案:可以,但是没必要。因为服务端的ACK不携带任何数据,一般会立即响应,多加一次ACK浪费流量。
三、TCP四次挥手
TCP作为可靠的连接协议,不仅体现在连接的建立上,也体现在其释放连接上,但是世界上是不存在百分之百可靠的通信机制的,我们来看下TCP释放连接,也就是四次挥手是如何尽可能保证TCP的可靠性的。 使用Wireshark抓包,如图:
四次挥手的过程
-
当客户端已经发送完数据后,调用close方法向服务端发送FIN包,请求关闭连接,此时客户端进入FIN_WAIT1状态,代表客户端已经不再发送数据,但是还能接收服务端数据,这个状态也叫半关闭状态。
因为TCP是全双工协议,客户端和服务端能互相发送数据,都有可能先传输完数据请求关闭连接,所以先请求关闭的一方称为主动关闭方,而另一方称为被动关闭方。
-
服务端收到客户端的FIN包后进入CLOSE_WAIT状态,并返回一个ACK给客户端,客户端端收到后,进入FIN_WAIT2状态。
-
当服务端没有数据向客户端发送时,向客户端发送FIN包,然后服务端进入LAST_ACK状态,客户端收到FIN包,会进入TIME_WAIT状态,这是一个比较特殊的状态,后面会单独讲解。
-
服务端收到ACK后进入CLOSE状态,客户端在TIME_WAIT等待2MSL后会进入CLOSE状态。
MSL是Maximum Segment Lifetime英文的缩写,即报文的最大生存时间,超过这个时间的报文将会被丢弃。
在TCP中还存在一种特殊的情况,就是同时关闭。就是客户端和服务端同时发送FIN包,但是这种情况并不常见,知道这么个概念就行了,这里就不过多阐述了。
TCP四次挥手改为三次可以吗
这是因为服务端的 LISTEN 状态下的 SOCKET 当收到 SYN 报文的建连请求后,它可以把 ACK 和 SYN(ACK 起应答作用,而 SYN 起同步作用)放在一个报文里来发送。但关闭连接时,当收到FIN 报文通知时,如果能将ACK、FIN放在一个报文里那么就有了三次挥手,但是这是不可能,因为ACK是服务器B一收到FIN报文底层就回发的,而服务器B的FIN是应用层调用close()激发的,所以它这里的 ACK 报文和 FIN 报文在发送的时间上都是分开的,不可能同时发送
四、TCP状态机
下图来自RFC 793
-
CLOSED
CLOSED状态是TCP连接彻底释放或者没有建立时的状态,通过netstat或者lsof命令没有办法查看到。CLOSED状态执行
主动打开(active OPEN)
将会进入SYN_SENT状态。 CLOSED状态执行被动打开(passives OPEN)
将会进入LISTEN状态。 -
LISTEN
通常来说服务端调用bind或者listen方法绑定一个端口,然后进入LITEN状态,等待客户端发送SYN包建立三次连接。例如Java:
ServerSocket socket = new ServerSocket(8081); 复制代码
或者
ServerSocket socket = new ServerSocket(); socket.bind(new InetSocketAddress(8081)); 复制代码
-
SYN_SENT
一般来说处于CLOSED状态的客户端发送SYN包,等待SYN+ACK就会进入SYN_SENT状态。但是LISTEN下也能发送SYN包进入SYN_SENT状态。
-
SYN_RCVD
服务端收到客户端的SYN包,并回复SYN+ACK包后,进入SYN_RCVD状态。还有一种特殊情况就是SYN_SENT收到ACK也可以进入SYN_RCVD状态,这种情况就是同时打开。
-
ESTABLISHED
经过三次握手后,客户端和服务端连接后进入ESTABLISHED状态。
-
FIN_WAIT1
主动关闭方发送FIN包,等待对方ACK时进入FIN_WAIT1状态。SYN_RCVD下也可能调用CLOSE方法,发送FIN包,进入FIN_WAIT1状态。
-
CLOSE_WAIT
被动关闭方收到FIN包,回复ACK后,进入CLOSE_WAIT状态。
-
FIN_WAIT2
主动关闭方在FIN_WAIT1状态下,收到被动关闭方的ACK包后,将会进入FIN_WAIT2。
-
LAST_ACK
被动关闭方,发送完数据数据,向主动关闭方发送FIN包后,由CLOSE_WAIT状态进入LAST_ACK状态。最后在收到主动关闭方的ACK后进入CLOSED状态。
-
TIME_WAIT
主动关闭方收到被动关闭方的FIN包,返回ACK后,会进入TIME_WAIT状态。在TIME_WAIT状态等待2MSL后进入CLOSED状态。
-
CLOSING
CLOSING状态比较特殊,出现在同时关闭的时候。主动关闭方在FIN_WAIT1状态下,收到被动关闭方的FIN包,将会进入CLOSING状态,然后收到ACK后,再进入TIME_WAIT状态,随后释放连接。
五、TCP的全连接和半连接队列
当服务端调用listen函数监听端口的时候,内核会为每个监听的socket创建两个队列:
半连接队列(syn queue)
: 客户端发送SYN包,服务端收到后回复SYN+ACK后,服务端进入SYN_RCVD状态,这个时候的socket会放到半连接队列。全连接队列(accept queue)
: 当服务端收到客户端的ACK后,socket会从半连接队列移出到全连接队列。当调用accpet函数的时候,会从全连接队列的头部返回可用socket给用户进程。
半连接队列
半连接队列的大小由/proc/sys/net/ipv4/tcp_max_syn_backlog
控制,Linux的默认是1024。 当服务端发送SYN_ACK后将会开启一个定时器,如果超时没有收到客户端的ACK,将会重发SYN+ACK
包。重传的次数由/proc/sys/net/ipv4/tcp_synack_retries
控制,默认是5次。
全连接队列
全连接队列的大小通过/proc/sys/net/core/somaxconn
指定,在使用listen函数时,内核会根据传入的backlog参数
与系统参数somaxconn
,取二者的较小值。
listen函数:
int listen(int sockfd, int backlog)
Nginx和Redis默认的backlog值等于511,Linux默认的backlog 为 128,Java默认的backlog等于50。
默认情况下,全连接队列满以后,服务端会忽略客户端的ACK,随后会重传SYN+ACK,也可以修改这种行为,这个值由/proc/sys/net/ipv4/tcp_abort_on_overflow
决定。
tcp_abort_on_overflow=0
:表示三次握手最后一步全连接队列满以后服务端会丢掉客户端发过来的ACK,服务端随后会进行重传SYN+ACK。tcp_abort_on_overflow=1
:表示全连接队列满以后服务端发送RST给客户端,直接释放资源。
六、TIME_WAIT状态
主动关闭方在收到被动关闭方的FIN包后并返回ACK后,会进入TIME_WAIT状态,TIME_WAIT状态又称2MSL状态
,每个TCP连接都必须有一个最大报文段生存时间MSL,在网络传输中超过这个时间的报文段将被丢弃。当TCP连接发起一个主动关闭,并发出最后一个ACK时,必须在TIME_WAIT状态停留两倍MSL时间,在2MSL等待期间,定义这个连接的插口(客户端IP地址和端口号,服务器IP地址和端口号的四元组)将不能再被使用。2MSL状态存在有两个理由:
-
允许老的重复报文分组在网络中消逝。
-
保证TCP全双工连接的正确关闭。
第一个理由是假如我们在192.168.1.1:5000和39.106.170.184:6000建立一个TCP连接,一段时间后我们关闭这个连接,再基于相同插口建立一个新的TCP连接,这个新的连接称为前一个连接的化身。老的报文很有可能由于某些原因迟到了,那么新的TCP连接很有可能会将这个迟到的报文认为是新的连接的报文,而导致数据错乱。为了防止这种情况的发生TCP连接必须让TIME_WAIT状态持续2MSL,在此期间将不能基于这个插口建立新的化身,让它有足够的时间使迟到的报文段被丢弃。
第二个理由是因为如果主动关闭方最终的ACK丢失,那么服务器将会重新发送那个FIN,以允许主动关闭方重新发送那个ACK。要是主动关闭方不维护2MSL状态,那么主动关闭将会不得不响应一个RST报文段,而服务器将会把它解释为一个错误,导致TCP连接没有办法完成全双工的关闭,而进入半关闭状态。
为什么要维持2MSL
虽然按道理,四个报文都发送完毕,我们可以直接进入CLOSE状态了,但是我们必须认定网络是不可靠的,有可能最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。在Client发送出最后的ACK回复,但该ACK可能丢失。Server如果没有收到ACK,将不断重复发送FIN片段。所以Client不能立即关闭,它必须确认Server接收到了该ACK。Client会在发送出ACK之后进入到TIME_WAIT状态。Client会设置一个计时器,等待2MSL的时间。如果在该时间内再次收到FIN,那么Client会重发ACK并再次等待2MSL。所谓的2MSL是两倍的MSL(Maximum Segment Lifetime)。MSL指一个片段在网络中最大的存活时间,2MSL就是一个发送和一个回复所需的最大时间。如果直到2MSL,Client都没有再次收到FIN,那么Client推断ACK已经被成功接收,则结束TCP连接。
TIME_WAIT会造成什么问题
一个web服务器最大的端口数量是65535个,如果客户端不停的和服务端不停的创建短连接,就会导致有大量的TCP进入TIME_WAIT状态,导致端口耗尽。当服务端是主动关闭方,因为有TIME_WAIT状态的存在,在重启程序的时候可能会出现java.net.BindException: Address already in use
的错误,这是因为这个端口TIME_WAIT状态需要等待2MSL。在RFC793中规定MSL的时间为2min,在实际使用中一般是30s或者1min,在高并发的情况下毫无疑问,这将造成大量连接无法建立的问题,那么有什么方法可以处理这些问题那?
七、TCP的滑动窗口机制
如果每次传输数据都只能发送一个MSS,就需要等待接收方的ACK,这显然会极大的影响传输的速率。在发送数据的时候,最好的方式是一下将所有的数据全部发送出去,然后一起确认。但是现实中会存在一些限制:
接收方的缓存(接收窗口)可能已经满了,无法接收数据。网络的带宽也不一定足够大,一口发太多会导致丢包事故。 发送方要知道接收方的接收窗口和网络这两个限制因素中哪一个更严格,然后在其限制范围内尽可能多发包。这个一口气能发送的数据量就是传说中的TCP发送窗口。首先TCP在进行数据传输的时候都是先将数据放在数据缓冲区中的,TCP维护了两个缓冲区,发送方缓冲区和接收方缓冲区。
发送方缓冲区
: 发送方缓冲区用于存储已经准备就绪数据和发送了但是没有被确认的数据。接收方缓冲区
:接收方缓冲区用于存储已经被接收但是还没有被用户进程消费的数据。
滑动窗口机制是TCP的一种流量控制方法,该机制允许发送方在停止并等待确认前连续发送多个分组,而不必每发送一个分组就停下来等待确认,从而增加数据传输的速率提高应用的吞吐量。
TCP的包可以分为四种状态
- 已发送并且已经确认的包。
- 已发送但是没有确认的包。
- 未发送但是可以发送的包。
- 不允许被发送的包。
滑动窗口协议的基本工作流程就是由接收方通告窗口的大小,这个窗口称为提出窗口
,也就是接收方窗口。接收方提出的窗口则是被接收缓冲区所影响的,如果数据没有被用户进程使用那么接收方通告的窗口就会相应得到减小,发送窗口取决于接收方窗口的大小。可用窗口的大小等于接收方窗口减去发送但是没有被确认的数据包大小。
滑动窗口机制示意流量图
滑动窗口的动态性
零窗口(TCP Zero Window)
在接收方窗口大小变为0的时候,发送方就不能再发送数据了。但是当接收方窗口恢复的时候发送方要怎么知道那?在这个时候TCP会启动一个零窗口(TCP Zero Window)定时探测器,向接收方询问窗口大小,当接收方窗口恢复的时候,就可以再次发送数据。