一、TCP报文段
每个TCP报文段分为:TCP首部和TCP数据部分;以下是TCP首部的结构图。
对于TCP首部,我们可以这么看,前面是20字节的固定部分,包括五大块,每块4个字节(32位),所以从前到后依次为:源端口,目的端口,序号,确认号,数据偏移,保留,。。。。。。
对各个组成部分的解释:
- 源端口和目的端口:各占2字节,因为一台计算机端口号为0-65535,最多需要两个字节来存储。
- 序号(seq):4字节,表示数据部分第一个字节的编号,TCP以字节流的方式传输数据,并且每一个字节都按顺序编号,比如此时序号为100,数据长度200字节,那么下一个报文的序号为301。
- 确认号(ack):占4个字节,是期望收到对方下一个报文的第一个数据字节的序号,就是下一次对方发过来的报文的序号的值。例如,B收到了A发送过来的报文,其序列号字段是100,而数据长度是200字节,因此,B期望收到A的下一个数据序号是301,于是B在发送给A的确认报文段中把确认号置为301。
- 数据偏移,占4位,它指出TCP报文的数据距离TCP报文段的起始处有多远。
- 保留,占6位,保留今后使用,但目前应都位0。
- 紧急URG,当URG=1,表明紧急指针字段有效。告诉系统此报文段中有紧急数据。
- 确认ACK,仅当ACK=1时,确认号字段才有效。TCP规定,在连接建立后所有报文的传输都必须把ACK置1。
- 推送PSH,当两个应用进程进行交互式通信时,有时在一端的应用进程希望在键入一个命令后立即就能收到对方的响应,这时候就将PSH=1。
- 复位RST,当RST=1,表明TCP连接中出现严重差错,必须释放连接,然后再重新建立连接。
- 同步SYN,在连接建立时用来同步序号。当SYN=1,ACK=0,表明是连接请求报文,若同意连接,那么操作系统就需要初始化一个序号作为此次传输我方的起始序号并让对方知道,响应报文中应该使SYN=1,ACK=1。SYN这个标志位只有在TCP建产连接时才会被置1,握手完成后SYN标志位被置0。
- 终止FIN,用来释放连接。当FIN=1,表明此报文的发送方的数据已经发送完毕,并且要求释放。
- 窗口,占2字节,指的是通知接收方,发送本报文你需要有多大的空间来接受。
- 检验和,占2字节,校验首部和数据这两部分。
- 紧急指针,占2字节,指出本报文段中的紧急数据的字节数。
- 选项,长度可变,定义一些其他的可选的参数,比如后面要提到的MSS。
根据序号seq,确认号ack,同步SYN,确认ACK的解释,一次普通的TCP连接建立可以这么描述:
A:老哥,我想和你建立连接,如果可以的话我们先来同步一下序号(SYN=1),我的序号seq=x;
B:收到(ACK=1),我们来同步一下序号(SYN=1),我的序号seq=y,下一次给我发数据从x+1开始;
A:收到(ACK=1),此次序号seq=x+1,下一次给我发数据从y+1开始;
二、TCP建立连接(三次握手)
过程分析:
- TCP服务器进程先进入LISTEN(监听)状态,时刻准备接受客户进程的连接请求;
- TCP客户进程向服务器发出连接请求报文,这是报文首部中的同部位SYN=1,同时随机产生一个初始序号seq=x ,此时,TCP客户端进程进入了SYN-SENT(同步已发送状态)状态。TCP规定,SYN报文段(SYN=1的报文段)不能携带数据,但需要
消耗掉一个序号
。 - TCP服务器收到请求报文后,如果同意连接,则发出确认报文。确认报文中应该 ACK=1,SYN=1,确认号是ack=x+1(因为对方的SYN消耗了一个序号),同时也要为自己初始化一个序号 seq=y,此时,TCP服务器进程进入了SYN-RCVD(同步收到)状态。这个报文也不能携带数据,但是同样要
消耗一个序号
。 - TCP客户进程收到确认后,还要向服务器给出确认。确认报文的ACK=1,ack=y+1,此次的序号为seq=x+1,此时,TCP连接建立,客户端进入ESTABLISHED(已建立连接)状态。TCP规定,ACK报文段可以携带数据,但是如果不携带数据则不消耗序号。
- 当服务器收到客户端的确认后也进入ESTABLISHED状态,此后双方就可以开始通信了。
说明:
-
客户端收到服务端的ACK的时候,它就以为连接已经建立了并进入ESTABLISH状态,但是此时属于
半连接状态
,因为服务端还没有接收到客户端的ACK,直到服务端收到了此ACK,并进入ESTABLISH状态(此时服务端应用程序调用socket_accept会有返回),双方才可以顺利通讯。可使用命令查看连接 netstat -anp |grep 8888
如图,在本机建立了两个客户端连接本机8888,
第一个代表Listen的服务端;
第三,四为客户端到服务端的连接,因为是本机连本机,所以就一起查出来了;
第二,五为服务端到客户端的连接,只是相反的展示,从服务端角度看,8888端口可以同时管理多个连接而不会导致数据错乱依赖于后面提到的TCP四元组。实际上连接建立之后就不存在服务端和客户端之分了,而是一对一的通讯。
要正确理解服务端的状态流转,还需要有实际的网络编程经验:
step1:服务端创建一个socket1
资源,并Listen 8888,;客户端创建socketA
,去连socket1
;
step2:服务端收到SYN请求,创建一个socket2
(上图第二行),发送ACK,此时socket2
处于SYN-RCVD状态;
step3:服务端收到ACK,于是socket2
处于ESTABLISN状态;socketA
与socket2
即可通讯;
step4:应用程序调用socket_accept获取socket2
。 -
客户端连接报错 Operation now in progress
Operation now in progress 连接超时(默认0.5s),客户端如果没有收到ACK,就会在一定时间内不断重试直到超时。
情况1:要连接的服务端IP无法访问。
情况2:服务器忙碌,无法接收连接。
操作系统收到客户端的ACK之后,就会将建立好的连接放在连接队列里面暂存起来,等着应用程序发起accept调用来一个一个取走,如果应用程序没有及时取走的话,会造成队列被占满的情况,队列的长度是由backlog和maxconn中最小值决定的,当操作系统的参数tcp_abort_on_overflow=0时,系统就不会响应后来的连接请求了,然后客户端不断重连直到超时,可通过命令ss -lt
查看队列的长度 。这种情况也很好模拟,写一个server和client,使client循环连接server,而server却不调用socket_accept,这样client就报错了。当tcp_abort_on_overflow=1时,系统会响应RST包,客户端就会报“Connetion reset by peer”。同时在服务端也可以查看是否有队列满的报错
netstat -s|grep ‘times the listen queue of socket overflowed’
netstat -s|grep ‘drop’ -
如果客户端不发送最后的ACK包会怎样?
在网络不好的情况下,或者人为攻击的情况下会遇到。服务端操作系统收到SYN之后会将其放在一个syn queue
中,然后由另一个线程从syn queue取出并响应ACK,然后等待对方的ACK,如果没收到对方的ACK,它会重试一定的次数然后丢弃,但是syn queue的长度是受tcp_max_syn_backlog
参数决定的,也是有限的,当syn queue被占满后,系统将丢弃后来的SYN包。遇到这种问题可以调大tcp_max_syn_backlog,并调小重试次数tcp_synack_retries
,然后开启syn_cookies
,它的原理是服务端响应ACK的同时带上cookie,客户端最后一个ACK的时候要带上这个cookie,于是服务端才分配资源建立连接,如果不开启的话,服务端收到SYN就会去创建资源,这种操作的初衷是延迟分配资源。 -
一个socket只能建立一个连接,对于被监听的socket,它会派生出多个子socket来与对方通讯,并不是它能和多个端同时建立连接,查看服务端进程打开的fd.
-
客户端向服务端建立了连接,此时服务端会多出一个编号为6的fd,然后客户端正常断开连接,服务端会销毁6fd,然后客户端再连接过来,此时还是6fd,也就说fd的
编号
是复用的,但是socket后面的序列号是不一样的,说明指向的不同的内核缓冲区地址,是不同的资源。
三、TCP断开连接(四次握手)
过程分析:
- A发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,此时,A进入FIN-WAIT-1(终止等待1)状态。 TCP规定,FIN报文段即使不携带数据,也要
消耗一个序号
。 - B收到连接释放报文,发出确认报文,ACK=1,此时,B就进入了CLOSE-WAIT(关闭等待)状态。A向B的方向就释放了,这时候处于半关闭状态,即A已经没有数据要发送了,但是B若发送数据,A依然要接受。这个状态还要持续一段时间,也就是整个CLOSE-WAIT状态持续的时间。
- A收到B的确认请求后,此时,A就进入FIN-WAIT-2(终止等待2)状态,等待B发送连接释放报文(在这之前还需要接受B发送的最后的数据)。
- B将最后的数据发送完毕后,就向A发送连接释放报文,FIN=1,由于在半关闭状态,B很可能又发送了一些数据,假定此时的序号为seq=c,但是A是不会有数据来的,所以ack还是a+1,此时,B就进入了LAST-ACK(最后确认)状态,等待A的确认。
- A收到B的连接释放报文后,必须发出确认,ACK=1,此时A就进入了TIME-WAIT(时间等待)状态。注意此时TCP连接还没有释放,必须经过2*MSL(最长报文段寿命)的时间后,才进入CLOSED状态。
- B只要收到了A发出的确认,立即进入CLOSED状态。就结束了这次的TCP连接。可以看到,B结束TCP连接的时间要比A早一些。也就是谁主张谁等待。
说明:
-
在操作系统看来,建立连接之后,对方不管发的是数据包还是指令包,内核都会将该socket标记为可读,这使得上层应用程序可以更好的管理此连接。当A发送FIN包后,此时A并没有断开只是无法向B发数据了,因为此时的数据只能单向传输,并且A在等着B发送数据;当B收到FIN包后,操作系统自动回复ACK包并且进入CLOSE_WAIT状态,对于php而言,socket_read会读到空字符串,可以以此来判断A想要断开(onClose事件),并做相应的处理,
B向A发数据不会报错
。如果B觉得没什么要操作的了,就可以调用close方法来应答,也就是发送FIN到A,表示B也不再给A发数据了。
但是在调用close方法之前,也就是处理onClose事件的时候如果产生了阻塞,异常,报错终止,比如:
那么B就有连接处于CLOSE_WAIT状态,对应的A就是FIN_WAIT2状态
-
唯一标识一个TCP连接的方法(
TCP四元组
):客户端地址,客户端PORT,服务端地址,服务端PORT。那么同一个局域网的两台机器A和B使用同一个端口号去连接一个外网的TCP服务器,这样会有问题吗?显然不会,因为经过链路层和物理层的包装后,它们的地址是不一样的(虽然他们的外网IP是一样的),此处说的地址不一定是IP,而是一个全网唯一的标识。 -
为什么要有TIME_WAIT状态,并持续1分钟之久?
一方面,A收到FIN后,会发送ACK,如果此ACK没有到达B,那么B会重传FIN,所以A应该等待这个时间,这样才能优雅的断开连接。另一方面,网络延迟很高的情况下,有些数据包会驻留在某个公网路由器当中很久,如果不等它的话,那么很有可能它会被发送到另一个新的连接上面去(由TCP四元组可知,是有这种可能的),所以要有一个TIME_WAIT状态来防止这个端口
在短时间内被重复利用了而引起数据混乱。
举个例子,服务器A并发的去连服务器B上的redis服务,连完之后立即关闭。
立马就会报错 Cannot assign requested addess ,实际上就是服务器A的端口被用完了,而且几乎都处于TIME_WAIT
状态了,所以在PHP-FPM运行模式下,这也是单机并发的一个瓶颈。一般通过设置SO_REUSEADDR
来允许处于TIME_WAIT状态的端口
重用来解决这个问题。服务端或者客户端在创建好socket后可以socket_get_option($socket, SOL_SOCKET, SO_REUSEADDR);
或者设置系统参数
net.ipv4.tcp_timestamps=1
net.ipv4.tcp_tw_reuse=1
net.ipv4.ip_local_port_range 调大
不要开启 net.ipv4.tcp_tw_recycle=1如果端口不是处于TIME_WAIT状态,那么SO_REUSEADDR也无法做到重用。举个例子:
服务端正常启动,并设置了SO_REUSEADDR;客户端出现了阻塞。
客户端连上服务端之后,服务端立即ctrl+c结束进程,又立即启动服务就会报错socket_bind(): unable to bind address [98]: Address already in use
也就是8888端口被占用了,查看连接情况
ctrl+c是一个进程终止信号,操作系统会去释放掉该进程的所有资源,也就是说此时是服务端主动断开连接看成A,客户端看成B,对照来看,A发FIN,B发ACK(是操作系统自动响应的),B阻塞无法发出FIN,所以才有了上面的现象。
四、序号
- MSS:Maximum Segment Size 最大报文段长度,是TCP协议定义的一个选项,MSS选项用于在TCP连接建立时,收发双方协商通信时每一个报文段所能承载的最大数据长度,一般MSS值1460字节。
最大报文段长度MSS这个名词很容易引起误解。MSS是TCP报文段中的数据字段的最大长度。数据字段加上TCP首部才等于整个的TCP报文段。所以MSS并不是TCP报文段的最大长度,而是:MSS=TCP报文段长度 减去 TCP首部长度。 - 序号字段和确认号字段是TCP报文段首部中两个最重要的字段,这两个字段是TCP可靠传输服务的关键部分。
- 序号的初始化是随机算法产生的,类型是 unsigned 32位整型,随着通讯的进行,序号很有可能就达到最大值了,此后序号便会回绕到0,即使如此,操作系统内核依然可以在一定范围内判断出序号的先后,现实中足够满足需求了。
- 序号的计算
假设主机A的一个进程想通过一条TCP连接向主机B上的一个进程发送一个数据流,主机A中的TCP将隐式地对数据流中的每一个字节编号。假定数据流由一个包含500 000字节的文件组成,其MSS为1000字节,数据流的首字节编号是1,如下图所示:
该TCP将为该数据流构建500个报文段,给第一个报文段分配序号1,第二个报文段分配序号1001,以此类推,每一个序号被填入到相应TCP报文段首部的序号字段中。
五、确认号
- TCP是全双工的,即主机A在向主机B发送数据的同时,也许也在接收来自主机B的数据。从主机B到达的每个报文段中都有一个序号用于从B流向A的数据。主机A填充进报文段的确认号是主机A期望从主机B收到的下一字节的序号。
- 假设主机A已收到主机B的包含字节0-535字节的报文段,以及另一个包含字节900-1000的报文段。由于某种原因,主机A还没有收到字节536-899的报文段。在这个例子里,主机A为了重新构建主机B的数据流,仍在等待字节536(和其后的字节)。因此,A到B的下一个报文段将在确认号字段中包含536。因为TCP只确认该流中到第一个丢失字节为止的字节,所以TCP提供的是累积确认。
- 主机A虽然收到了字节900-1000的报文段,但是并不会在下一个发往主机B的报文段的确认号字段中填1001,因为535后面的字节还没有得到确认,而收到的900-1000字节的报文段属于失序到达,对于失序到达的报文段的处理方法由TCP编程人员去具体实现,有两个基本选择:一是丢弃失序报文段,二是保留失序字节并等待缺少的字节以填补该间隔(这是实践中采用的方法)
六、其他
-
为什么初始序号不是从0或1开始而是随机生成?
同一台机器A,使用同一个端口先后与主机B建立多个TCP连接,那么在网络阻塞的情况下,后面的连接很有可能收到先前连接本该收到单未收到的消息,如果初始序号都是从一个固定的数开始,那么此消息的序号很有可能符合新连接的序号顺序,从而造成错乱,所以随机生成序号可以降低这种概率。 -
建立连接时为什么客户端最后还要发送一次确认?
情况1:A向B发起连接请求,但是阻塞在网络中没有达到B,然后A重新发起连接请求,这次成功建立连接。然而如果第一次的请求又达到了B,B发送SYN到A,但是此时A不会发送ACK了,造成B就等待着这A的ACK, -
为什么连接的时候是三次握手,关闭的时候却是四次握手?
因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉Client端,“你发的FIN报文我收到了”。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。 -
如果已经建立了连接,但是客户端突然出现故障了怎么办?
TCP还设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为2小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75秒钟发送一次。若一连发送10个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。 -
粘包拆包
在tcp通讯中,客户端向服务端发送指令,默认是一次一个指令,但是如果一次发个指令,但是如果服务端不做处理的话会以为只接收到一个指令,这就是粘包的情况。所以在服务端需要做拆包的处理,其实就是一个协议的问题,规定指令的结束符号,来做拆分处理。