传输层的七七八八
TCP
TCP提供一种面向连接、可靠的字节流服务。
面向连接:两端各自维护一份数据结构,传输数据之前,先进行数据结构部分信息的状态同步,就是去建立连接,建立好之后才能传输数据,不需要的时候断开连接,然后释放相关数据结构
可靠性:
- 由TCP将报文段分段为合适的大小后交给IP层
- TCP发出段后启动定时器,目的端在定时器到期之前没有确认应答,TCP会重发该段
- 接收端收到数据段后需要确认应答,告知发送端数据已经接收到哪里了(通过Sequence Number和acknowledgement Number记录)
- TCP的首部校验和由发送端计算和存储,接收端如果校验出错,将包丢弃不发送确认应答,等待重发
- TCP分段后委托下层发送数据段,到达目的端如果出现乱序,TCP会重排序后交给应用层
- 如果数据重复,则丢弃
- 接收端会告知发送端能接受的最大数据段,实现流量控制
首部格式
各字段含义
- Source Port: 发送端端口号,例如http:80
- Destionation Port:接收端端口号
- Sequence Number(Seq):初始值由主机随机生成,TCP流服务会对每个字节进行编号,对传输的Data部分进行计数(不包含数据链路层、IP首部、TCP首部)。目的端的ACK会将Seq+Tcp Segment Data的值返回到发送端,这个值就是发送端下次发送时的seq值。
- 注:虽然SYN、FIN在首部,但传输他们时仍然会计数,单位为1byte,所以三次握手和四次挥手时,虽然Tcp Segment Data的长度为0,回复的ACK值仍然要加1。
- Acknowledgement Number:Seq + Tcp Segment Data计算值后返回给发送端,表明该值和上次Seq值之间的数据我已经收到了,下次发送时以这个值作为Seq值发送
- Data offset:TCP首部的长度,如果没有选项内容,首部长度为固定20 bytes,添加选项后最大首部长度为60 bytes(受限于该字段长度:4 bit,单位为4bytes)
- Reserved:该字段为了以后扩展使用,通常设置为0
- Control Flags:每一位代表一个标志,顺序如上图:
- CWR(Congestion Window Reduced):在网络层的七七八八聊过,ECN(Explicit Congestion Notificat)的实现依靠IP首部记录路由器是否遇到拥塞,在返回包的TCP首部中通知发生拥塞。CWR标志和ECE标志设置为1时,会通知对方网络拥塞
- ECE(ECN-Echo)
- URG(Urgent Flag):为1时表示该数据段中有需要紧急处理的数据
- ACK(Acknowledgement Flag):TCP规定除了SYN包之外,该标志都设置为1,表示应答有效
- PSH(Push Flag):为1时表示将数据立即传给上层协议,不进行缓存
- RST(Reset Flag):强制断开连接
- SYN(Synchronize Flag):为1时表示想要建立连接,并设置Sequence Number的初始值(握手)
- FIN(Fin Flag):为1时表示不再发送数据,希望断开连接。主机收到设置FIN标志的包后,两端主机对对方的FIN标志包进行确认应答。不必立即回复,可以等待缓冲区中的所有数据发送成功并删除后再回复(挥手)
- Window Size:从ACK Number的位置开始,最大可以接收的数据,发送发发送的数据不能超过该窗口大小。窗口为0时,对端可以发送窗口探测包。(1 byte)
- Checksum:校验数据是否正确,覆盖TCP首部和Data部分
- Urgent Pointer:在URG标志位1时该字段有效,seq+Urgen Pointer的这一个字节是紧急数据(紧急数据只有一个字节,Telnet Ctrl + C时会有URG为1的包)
- Options:用于提高TCP的传输性能,长度最大为40bytes(首部最大60bytes - 固定部分20bytes),padding同IP首部的padding一样,作为对options的填充,调整为32 bit的整数倍。options分为多种类型:
- 类型2:MSS(Maximum Segment Size),在建立连接时(发送SYN标志的报文段)中指定MSS,表示本端能接收的最大长度的报文段,通常来说MSS越大,网络利用率越高。长度为 (MTU - IP首部 - TCP首部),对于以太网MSS长度可达1460bytes,默认为536bytes
- 类型3:WSOPT-Window Scale,可以提高TCP吞吐量,首部的windows size长度为16位,只能发送最大64KBytes,使用该选项可以扩展到1GBytes字节,提升了单位RTT的数据传输量,从而增加吞吐。
- 类型8:上面介绍Sequence Number对传输数据的字节进行计数,受32位长度的影响,如果高速传输一个很大的数据包,Sequence Number超出了内核解决序号回绕问题的范围(回绕幅度2^31 - 1),那么接收端就无法判断正确的序号了,加上该选项可以区分新老序号
数据传输
在主机网卡抓包繁杂信息太多,在虚拟机起一个基本的tcp server,回显客户端发送的消息后关闭,代码如下:
import socket
import sys
HOST = "0.0.0.0"
PORT = 8888
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((HOST, PORT))
server.listen(10)
connection, addr = server.accept()
data = connection.recv(1024)
print(data.decode())
connection.sendall(data)
connection.close()
server.close()
客户端输入消息发送,并接收服务端的消息打印
import socket
HOST = "10.211.55.3"
PORT = 8888
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((HOST,PORT))
send_info = input("send message: ")
client.send(send_info.encode("utf-8"))
recv_info = client.recv(1024).decode("utf-8")
print(recv_info)
client.close()
三次握手建立连接
建立连接过程
- 客户端发包(active open):开启SYN标志,客户端的initial seq = x
客户端向服务端发起带SYN标志的段,端口就是我们server.py中定义的8888
,TCP Segment Len为0表示没有传输数据,Acknowledgment Number为0(第一次发起,没什么好应答的),客户端生成的Sequence Number为4176525122,wireshark帮我们分析的relative sequence number为0(下文使用relative sequence number),客户端seq = 0
- 服务器回包(passive open):开启SYN标志,服务端的initial seq = y和ack,ack的值为x+1(SYN占用一个序号)
服务端收到请求后,向客户端发送SYN标志的段,数据段长度为0,服务端的seq为2608063952,同样relative值为0,因为上一个包的SYN是占用sequence number的,所以ack = 1(客户端的seq 0 + SYN标志位1,看raw值的话就是客户端的seq4176525122 + 1 = 41766525123)
- 客户端回包:开启ACK标志,ack的值为服务端的y+1,seq为x+1
客户端收到服务端的回包后,发送ACK段,数据段长度为0,客户端seq为1,同样ack = 1(服务端seq 0 + SYN标志位 1,raw值是2608063952 + 1 = 2608063953)
通过三次握手,客户端与服务端协商好,客户端下一次从2608063953处接收,服务端从41766525123处接收。
状态变迁
server.py
启动后服务端处于LISTEN状态,客户端client.py
发送SYN标志包后处于SYN_SENT状态- 服务端收到该包后会返回SYN标志的应答包,从LISTEN切换为SYN_RCVD状态
- 客户端发送ACK标志的应答包,切换为ESTABLISHED状态
- 服务端收到ACK标志的包后,切换为ESTABLISHED状态
之后就可以进行数据传输了。
数据传输
- 使用我们的client.py给server.py发送hello,数据段长度为5,根据上面的握手协商,本次发送数据的seq为41766525123,ack不变
- 服务端接收到数据后给客户端发送ack,ack的值为 客户端seq 41766525123 + tcp segment len 5 = 41766525128,服务端的seq为握手协商好的2608063953
- 应答后我们的server.py要将hello发回客户端,于是数据段长度为5,seq为2608063953,ack跟第2步一样
- 客户端收到包后给服务端发送ack应答,ack值为服务端seq 2608063953 + 数据段长度 5 = 2608063958
四次挥手断开连接
TCP是双向传输的(全双工),两端都各自维护一份连接状态,因此每个方向必须单独的进行关闭,所以终止连接需要四次挥手(每一方都需要给对端发送FIN标志位的包,并且都需要给对方回复一个应答包):
- 为了实现四次挥手,TCP提供了半关闭的能力,即客户端发送FIN包后表示不会再发送数据,但是仍然可以接收数据,服务器会应答该FIN包。
- 期间服务端可以继续向客户端发送未完成的数据,服务端也不需要再发送时,会向客户端发送FIN包,客户端应答该FIN包,双方连接彻底关闭。
断开连接过程
断开的请求可以由任意一端发起,server.py是让服务端将客户端发来的内容发送出去后直接关闭连接,所以本次抓包是从服务端发起断开的
- 服务端发起关闭:开启FIN标志,seq = x,len = 0,上一个包没有数据,ACK不变
因为之前应答过客户端的hello
,服务端seq变为2608063958,之前数据段的应答包已经发送过了(有时候会合并发送),所以ack不变为4176525128
- 客户端回包:对FIN标志段进行应答,数据段长度为0,ack = x + 1,seq = y,len = 0
同样因为应答过服务端的hello
,客户端seq为4176525128,FIN标志占用一个seq,所以ack为服务端seq 2608063958 + FIN标志 1 = 2608063959
- 客户端发起关闭:开启FIN标志,seq = y, ack = x + 1,len = 0
客户端发起关闭,开启FIN标志,seq为4176525128,第2步回包已经应答过服务端的FIN包,ack不变
- 服务端回包:对FIN标志段进行应答,seq = x + 1, ack = y + 1, len = 0
服务端发起应答,第2步客户端会FIN包后告诉服务端下次从seq = 2608063959发送,所以seq = 2608063959,ack为客户端seq 4176525128 + FIN标志 1 = 4176525129
至此,双方连接彻底关闭。
状态
借用刘超老师的图,根据我们抓包的情况把左边看成服务端,右边看成客户端
- 传输数据过程中客户端和服务端都是ESTABLISHED状态,服务端发出FIN标志数据段后进入FIN_WAIT_1,等待客户端回包。如果服务端收不到ACK回包,会重传该报文(重传次数由
tcp_orphan_retries
控制),超时会断开连接。 - 客户端收到FIN包后,发送ACK包并进入CLOSE_WAIT状态,如果这个ACK丢失,服务端没有收到,服务端会重传FIN包再次等待客户端的ACK。
- 服务端收到ACK包后进入FIN_WAIT_2状态,等待客户端的FIN包发来。
- 如果调用close关闭连接,超过
tcp_fin_timeout
规定的时间,客户端没有发来FIN包,那么服务端会直接关闭 - 如果服务端调用shutdown来关闭连接,仍然可以接收数据,如果客户端没有发送FIN标志的包,那么服务端会一直处于FIN_WAIT_2状态
- 如果调用close关闭连接,超过
- 客户端发送FIN包后进入LAST_ACK,等待服务端的ACK,如果等不到会重传FIN包,超过
tcp_orphan_retries
就会断开连接 - 服务端收到客户端的FIN包,并发送ACK回包后进入TIME_WAIT状态,等待2MSL的时间后关闭连接,如果中间收到了客户端重发的FIN包,会重置2MSL的定时器。如果没有进行2MSL的等待直接退出,可能会出现客户端的FIN包无法收到ACK的情况,这时客户端再次发送FIN包会收到服务端RST的回包(Connection reset by peer)
- 客户端收到服务端的ACK后彻底关闭
TIME_WAIT的2MSL
MSL(Maximum Segment Lifetime)是报文最大生存时间,MSL>=TTL的时间,确保了超过该时间报文会在网络传输中被丢弃,TIME_WAIT设置为2倍MSL的设值允许报文至少被丢弃1次,linux停留在TIME_WAIT的时间为60秒,所以我们的server.py
启动并运行完之后立马再次启动会bind
失败,告知端口占用。
如果TIME_WAIT等待的时间不够可能会将旧的seq插入新的连接数据中(
序列号回绕
并延迟到达)
异常断开连接
TCP是通过内核管理的,应用层需要通过send/recv来发送和接受数据,如果连接断开后应用层继续recv,会收到Connection reset by peer(之前的项目中,Prometheus相关的日志会出现大量的Connection reset by peer,原因是后端收集数据太慢,而server又使用python的WSGI server,无法支持长连接,导致读取时对端已经关闭的情况),如果是send则会收到Broken pipe
无数据传输异常断开(tcp keepalive)
socket通过设置SO_KEEPALIVE
启动keepalive,可以通过sysctl -a
查看系统设置,若开启了keepalive,两端没有数据传输,一端崩溃,另一端发送探测包经过 7200 + 75 * 9 = 7875秒后认为对端挂了
# 过了7200秒后无数据交互启动探测
net.ipv4.tcp_keepalive_time = 7200
# 探测间隔时间为75秒
net.ipv4.tcp_keepalive_intvl = 75
# 一共探测9次,一直无响应就认为对端挂了,关闭连接
net.ipv4.tcp_keepalive_probes = 9
如果服务端没有开启keepalive,这个tcp连接会一直处于ESTABLISHED状态,服务端重启失效
端口失效(RST标志)
一般异常关闭连接的时候会使用RST标志,发出或收到该标志的内核会清理该连接相应的内存资源、端口。接收到RST的一端会收到Connection reset或者Connection refused。大概情况:
- 端口现在不可用
- socket关闭
- 客户端消息发送完之前关闭了socket,会发一个RST到服务端
- 服务端关闭了socket,客户端再发送消息,服务端会回复一个RST包
进程崩溃
上面说到TCP栈是由操作系统管理的,如果一方进程发生了崩溃并被系统感知,操作系统会与对端进行四次挥手结束连接
数据传输中主机崩溃
- 客户端崩溃后重启,服务端利用超时重传机制重传报文,客户端之前连接的上下文都不存在了,会发送RST包到服务端关闭连接
- 客户端永久下线,服务端超时重传达到最大超时时间或最大重传次数,服务端会断开连接并通过socket发送ETIMEOUT到应用程序
TCP状态机
该状态图中A为客户端,B为服务端。由客户端发起连接,也由客户端发起关闭。
- (1)(2)(3)(4)(5)表示发起连接的状态,可以对照三次握手
- (一)(二)(三)(四)(五)(六)表示断开连接的状态,可以对照四次挥手
- 实线为客户端A,虚线为服务端B
触发重传机制
超时重传
上面的连接建立、传输数据、连接关闭都会出现数据包未被接收的情况,发送端触发重传机制,TCP提供可靠传输依靠确认接收端已经收到了数据,也就是说通过数据发出去到收到接收端发来的ack报文才算是完成这一报文段的传输,用收到ack的时间戳减去发送数据的时间戳得到的差值就是这个包的RTT(Round-Trip Time),其中的问题是出去的包可能会丢失,对端收到数据后返回的ack也可能丢失。TCP通过在发送时设定一个定时器,如果超过定时器就重传数据,具体的问题就在于如何设置定时器间隔时间和重传的频率。
- 如果定时器时间设置过大,会出现网络利用率低的情况,丢了很久了才重传
- 如果定时器时间设置过小,可能网络只是延迟略大,第一个包还没到,触发重传的第二个包就发出来了,给链路增加了不必要的负载
所以重传定时器的时间应略大于RTT比较合适,而网络环境的速率是经常变化的。所以跟踪测量RTT并且根据该值来动态设置重传定时器RTO (Retransmission Time Out)
快速重传
快速重传的机制拥塞控制会用到,如果发送端接收到了3个相同的ack后,会在超时重传的定时器过期之前重传丢失的seq,比如发送了seq1~seq5,但是seq2、seq3都丢失了,接收端回复ack时回复的都是seq2的ack,再收到seq3的3个ack后,才能再重传seq3的ack。网络利用率严重下降。现在Linux中会开启net.ipv4.tcp_sack=1
和net.ipv4.tcp_dsack=1
,分别对应SACK( Selective Acknowledgment)重传机制和Duplicate SACK重传机制。
SACK
在TCP首部options里设置SACK,接收端将已经收到的数据信息发送给发送端,这样发送端在收到三次重复ACK后启动快速重传机制,但是根据这个字段可以看到丢失的数据,重发则发送丢失的那些seq就可以了
Duplicate SACK
SACK主要是告诉发送端,哪些数据是重复发送了。可以判断出是ack应答丢了导致的重发,还是发送方的数据包延时到达导致的重发。
流量控制
滑动窗口
在我们数据传输部分的抓包中,本地网络栈加上数据包较小,都是一发一答的顺序来进行的。如果是远端服务器,RRT时间较长的话传输会变得低效,应答包不承载数据,只是告知发送端我的数据接收到哪里了,你下次从哪个seq开始发送,如果应答包丢了,发送端还得等着超时重传再收到ack后发送下一段。所以如果要提高传输效率,引入了滑动窗口的概念:接收端可以告诉客户端,我的缓冲区能放多少数据,你看着发。至于接收端发回的ack,如果中间的某次ack走丢了,比如200299的ack收到了,300399的走丢了,400~499的收到了,那么发送端就认为,500之前的所有数据接收端都收到了,不用重传,继续发送。或者是接收端先不发送ack,直接发送一个500的ack,这种方式叫做累计应答。发送端和接收端都要维护一个窗口用来限制收发数据的大小。
发送端窗口1
- seq 1、2、3都发送并收到了确认
- seq 4~9是已经发送但是未收到ack确认的
- seq 10~12是发送端可以接着发送的
- seq 13~15超过了当前窗口大小,发送端不能发送,否则发出去也会被接收端丢掉
接下来发送端继续发送10、11、12
服务端接收窗口1
- 蓝色部分已经接收并发送了ack,但是应用层还没有读取,不占用窗口大小
- 橙色部分是已经收到了数据,还没有确认,此时可以直接确认ack=7,发送端收到ack后就会知道4、5、6的数据包接收端已经接收到了。因为存在7、8、9还没有收到,所以无法发送ack=11的确认。
- 红色部分超出窗口大小,无法接收
接下来服务端发送ack=7确认4、5、6已经收到,窗口滑动后如下
服务端接收窗口2
- 4、5、6已经ack,窗口向右移动3个
- 之前不能接收的13、14、15现在可以接收
客户端发送窗口2
- 4、5、6已经ack,之前不可发送13、14、15已经发送等待服务端确认
窗口大小变化
应用程序无法及时读取缓存内容
- 窗口的大小是通过两端交互数据段中TCP首部的windows指定的,窗口大小即当前系统给TCP分配的缓存区大小受系统繁忙程度的影响,系统繁忙,应用层无法及时读取TCP缓冲区中的内容(图中蓝色部分),那么TCP的窗口大小就要减小,告诉发送端,那么发送端下次发送的数据就减少。
- 极端情况下减小到0,发送端不能再发送任何数据,这时会启动定时器来发送探测包看窗口何时变大,如果回复的windows大小仍然为0,就重新启动定时器。
操作系统减小TCP缓存
第一种情况缓冲区大小不变,只是改变接收窗口的大小。而更糟糕的情况是操作系统减小接收端缓冲区大小,TCP规定必须先减小窗口大小,然后才能减小缓冲区。如果发送端按照上次窗口的大小发送了120字节的数据,而应用层还没有处理缓冲区中的数据,操作系统将缓冲区减小60字节,此时接收端的窗口为60字节,发送窗口通告告诉客户端,但是消息已经发出,120字节的数据超过了当前窗口大小,发生丢包。
糊涂窗口综合症(Silly Window Syndrome)
接收端会通告一个小窗口,比方5字节的窗口,TCP首部的固定长度就有20字节,再加上IP首部等长度,发送端的一个包实际传输了5字节,但是包大小就有几十字节。显然网络利用率大大降低,这个症状就是糊涂窗口综合症。
为了避免该现象发生,根据TCP首部选项里的MSS大小做控制:
- 发送端满足以下条件之一才能发送
- 可以发送>=MSS长度的报文段
- 数据长度至少为接收端通告窗口大小的一半
- 之前数据的ack接收到之后
- 服务端通告窗口大小的方式
- 如果窗口大小小于MSS与1/2缓存大小中最小的一个,关闭窗口
- 窗口大小至少增长到MSS,或者超过1/2缓存大小,打开窗口
拥塞控制
流量控制是根据主机缓冲区大小调节滑动窗口来限制客户端的发送,而拥塞控制相当于慢慢试探网络带宽有多大,拥塞状况如何来调整发送端的发送速率,这里引入了拥塞窗口,实际的发送窗口等于滑动窗口和拥塞窗口中最小的一个。
拥塞窗口
拥塞窗口是由发送端决定的,试探的意思就是慢慢的增大拥塞窗口,如果触发了超时重传,就认为是网络拥塞,较小拥塞窗口
慢启动
建立连接后,每收到一个ACK就将拥塞窗口加1(单位为1个MSS):
- 收到第一个ACK时,拥塞窗口为1+1 = 2.
- 发送两个报文,收到两个ACK,拥塞窗口为2+2=4
- 4+4 = 8,指数型增长
增长到ssthresh(slow start threshold)65535 bytes这个值后,启用拥塞避免
拥塞避免
- 上面拥塞窗口增长到8,收到8个ACK后,每个增长1/8
- 下次发送9个,收到9个ACK后,每个增长1/9
- 下次发送10个,线性增长
增长到触发重传机制后,表示网络发生拥塞,启动快速重传
快速重传
比如当前窗口是12,将之前的拥塞窗口减半为6,再将减半的值设置给ssthresh=6,再进入快速重传:
- 如果收到了3个重复的ACK(类似快速重传算法),拥塞窗口+3(为了尽快将丢失的包重传)
- 重传丢失的包
- 再收到重复的ACK后,把拥塞窗口+1
- 直到收到了新的ACK,再将ssthresh设置为进入快速重传之前的值6
- 进入避免拥塞算法
UDP
与TCP不同,UDP的首部格式简单,传输时也不需要事先建立连接,即不需要客户端和服务端维护双方交互的状态;因此TCP可以以数据流的形式发送,而进程产生一个UDP数据报,组装成一份待发送的IP数据报,只能发送一个数据报或者接收一个数据报,加上UDP并无控制可言,所以很多行为与IP层类似。
首部格式
各字段含义
- Source Port:发送端端口号,如果不需要回复消息,该字段设置为0
- Destination Port:接收端端口号
- Length:UDP首部长度+Data长度(因为IP数据报的最大长度为65535,UDP头+Data实际长度不大于 65535 - IP头20 = 65515)
- Checksum:与TCP相同,校验和覆盖UDP的首部和Data部分,校验数据包的正确性。与TCP不同的是,UDP可以设置为0表示不校验(包括IP首部的地址和UDP首部都不校验)。与IP层相同,如果发送端没有计算校验和而接收端发现校验和有差错,那么数据包会被直接丢弃而不产生差错报文
场景
- UDP适用于对丢包不敏感并要求时延极低的应用,不考虑网络拥塞,一股脑往出发。
- 因为UDP并不需要维护端对端连接,可以应用于广播或者多播协议。例如基于UDP协议的TFTP、DHCP、VXLAN等
- 因为自身的简单,只承载传输的任务。所以应用层可以更灵活的按照自己的需求来做定制开发,把需要维护的状态放在应用层来做。
传输层的端口号
传输层的端口号用来分辨交给哪个应用程序处理
端口的使用
- 对于UDP和TCP在内核中是独立存在的,所以可以绑定相同的地址和端口
- 如果两个TCP程序要绑定相同的端口,那么需要绑定不同的IP地址
- 对于TCP,如果上文重启server.py后会提示
Address already in use
,开启多路复用(socket设置SO_REUSEPORT)允许第一个socket处于TIME_WAIT的情况下,第二个socket可以使用该地址和端口
端口范围
知名端口号(0~1023)
相当于一种约定,例如HTTP服务用80端口,HTTPs用443端口,FTP用21端口,SSH用22端口
登记端口号(1024~49151)
我们自己实现的服务器,从该范围内申请端口长时间使用
客户端端口号(49152~65535)
操纵系统动态分派,客户端通信临时使用的,用完之后连接关闭,操作系统回收端口号,分配给其他的进程使用
学习自:
《趣谈网络协议》刘超
《图解TCP/IP》
《图解HTTP》
《网络是怎样连接的》
《TCP/IP详解 卷一》
小林coding
https://www.xiaolincoding.com/network/3_tcp/tcp_down_and_crash.html
https://xiaolincoding.com/network/3_tcp/tcp_feature.html
https://blog.csdn.net/GV7lZB0y87u7C/article/details/121186808