文章目录
- 前言
- 预备
- netstat
- pidof
- cat /etc/services
- 一、UDP协议
- UDP协议端格式
- UDP的缓冲区
- 基于UDP的应用层协议
- 二、TCP协议
- 1.TCP协议段格式
- 确认应答(ACK)机制
- 三次握手
- 疑问1 最后一次客户端发给服务端的ACK请求怎么保证服务端能够收到?
- 四次挥手
- 疑问2 为什么挥手是四次,握手只有三次?
- 4位首部长度
- 32位序号
- 32位确认序号
- 16位窗口大小
- 16位紧急指针
- 什么场景才会用到发送紧急数据
- 六个标记位
- 超时重传机制
- 重复报文如何处理
- 超时重传,那我们要超时多久才重传呢?
- 连接管理机制
- 三次握手状态
- ESTABLISHED
- 模拟三次握手状态变化
- 全连接队列
- 半连接队列
- 四次挥手状态
- TIME_WAIT等待多长时间呢?
- 什么是MSL?
- 模拟四次挥手环境
- setsockopt
- 流量控制
- 滑动窗口
- 延迟应答
- 阻塞控制
前言
上一章我们已经学习了HTTPS协议,知道了HTTPS协议的基本原理。
本章我们将回过头来重新深度讲解UDP协议和TCP协议,
预备
这里再介绍一下之前用过的工具
netstat
netstat是一个用来查看网络状态的重要工具。
- -n 拒绝显示别名,能显示数字的全部转化成数字
- -l 仅列出有在 Listen (监听) 的服務状态
- -p 显示建立相关链接的程序名
- -t (tcp)仅显示tcp相关选项
- -u (udp)仅显示udp相关选项
- -a (all)显示所有选项,默认不显示LISTEN相关
pidof
之前我们想查看一个进程的pid,然后kill杀死它,我们一直都是使用的ps axj来查看的,pidof [进程名] 可以直接查看进程的pid。
cat /etc/services
可以查看服务器绑定的特殊知名端口号
比如
mysql服务器,使用3306端口
ssh服务器, 使用22端口
ftp服务器, 使用21端口
telnet服务器, 使用23端口
http服务器, 使用80端口
https服务器, 使用443端口
一、UDP协议
我们本次重谈UDP协议通过UDP报头的分析和UDP的发送缓冲区来讲解。
UDP协议端格式
首先对于16位的源端口号和16位的目的端口号,很好理解,这里就不再重复。
16位UDP长度是什么? 代表的是下面携带的数据的字节长度,通过16位的UDP长度,我们就可以分析出一个UDP报文可以携带的数据最多为2^16 - 1= 65535字节,64kb的数据。
16位UDP检验和我们这里不讲。
UDP的缓冲区
UDP只有接收缓冲区,没有发送缓冲区。
这是因为UDP协议属于不可靠、无连接协议。
不可靠代表UDP只需要向目标主机发数据即可,不考虑数据丢失等其他问题,也就不需要像TCP协议一样如果数据发送失败或者数据丢失、乱序,需要重传数据,也就不需要发送缓冲区。
无连接代表使用UDP协议的主机不需要与目标主机建立连接。
因为没有发送缓冲区,所以我们调用sendto就是直接将数据从应用层直接交给OS的传输层,再向下交给网络层。
UDP具有接收缓冲区,是因为我们可能会同时收到一连串的报文,而应用层未必能来得及全部读完,所以就需要有接收缓冲区。
不过需要注意的是,如果接收缓冲区满了,之后再接收的报文会被丢失。
基于UDP的应用层协议
NFS: 网络文件系统
TFTP: 简单文件传输协议
DHCP: 动态主机配置协议
BOOTP: 启动协议(用于无盘设备启动)
DNS: 域名解析协议
二、TCP协议
本章主要重点是放在TCP协议上,首先我们需要再重新重视一下TCP的英文全称(Transmission Control Protocol)传输控制协议,那么它这里的"控制"是如何体现的?
我们也从TCP的报头来分析
1.TCP协议段格式
16位的源端口号和16位目的端口号我们这里也不再重复了。
由于TCP协议的报头格式比较复杂,所以我们拆开来讲
确认应答(ACK)机制
Tcp为确保可靠性,要尽可能的保证我发出去的报文是已经被对方收到的,如果没有被对方收到,就需要补发重传。 那么发数据的一方是怎么知道对方是否收到呢? 需要对方来告知,也就是确认应答。、
三次握手
SYN就是发送连接请求,我们等会还会再讲SYN。
ACK这里就是确认应答。
所以看上图的三次发送数据,也就是三次挥手。
第一次是客户端发给服务端SYN连接请求。
第二次是服务端给客户端发送第一次SYN的ACK确认应答,同时也向客户端也发送SYN连接请求。
第三次是客户端给服务端发送ACK确认应答。
这就是三次握手,同时我们可以发现客户端和服务端都通过至少发送和接受一次数据来验证了自身socket的全双工是否工作正常。
三次握手是Tcp协议中十分重要的一环,等会我们还会讲到。
疑问1 最后一次客户端发给服务端的ACK请求怎么保证服务端能够收到?
不能保证,不过大家可以想一想,就算服务端也再发一次ACK请求给客户端,那客户端要不要响应呢? 如果又发送响应是不是就一直这么循环下去了? 所以干脆我们就只需要三次握手就行了。
四次挥手
这里的四次挥手,与上面的三次握手也有异曲同工之妙。
第一次挥手是 客户端向服务器发送的FIN断开连接请求,就是告诉服务器我没有数据向你再发了,我想断开连接。
第二次挥手是 服务器对于客户端的断开连接请求发出ACK应答,证明我收到了你了连接请求了,但是不代表我同意关闭。 因为你客户端没数据发了,不代表我服务端没有数据再发了,所以中间服务端可以再向客户端发消息,而这个时候客户端也是能接受的,因为四次挥手还没有完成,不会断开连接。
第三次挥手是 当服务端也没有数据可以再发了,而且自身也处于CLOSE_WAIT状态,它也会向客户端发送FIN断开连接请求。
第四次挥手是 客户端对服务端的FIN断开连接请求发送ACK确认应答。当服务器收到了ACK之后,立马就将建立的连接断开并把资源释放了。
四次挥手是Tcp协议中十分重要的一环,等会我们还会讲到。
疑问2 为什么挥手是四次,握手只有三次?
因为我们可以看到握手的第二次,服务端在发送SYN请求的同时,还做了捎带应答ACK,所以才能压缩成三次。
那为什么我的挥手不能压缩成三次呢?
这是因为服务端在收到客户端的FIN断开连接请求之后,我的服务器可能还会有数据要继续发送,但是又要给客户端作应答表示我收到了你的FIN断开连接请求,所以也就不能作握手一样的捎带应答,所以也就必须要四次挥手。
4位首部长度
4位首部长度代表的就是TCP协议的报头长度,也就是整个报文除了正文数据的长度。
那么4位可以代表多少字节呢? 2 ^ 4 - 1 = 15,可是我们光16位源端口号到16位紧急指针都已经共有20字节了,这还不包括选项的长度。 所以这里的15的单位不是1字节,而是4字节。 这样通过4位首部长度我们就可以代表15 * 4 = 60字节了,而选项我们通过计算可以知道 ,选项最多可以有40个字节。
32位序号
讲32位序号之前,我们先来了解一些前备知识,当我们建立好连接之后,我们是可以同时连续发送多次请求给服务器的,而当报文数据在传输的时候,能不能保证我们传送过去的报文数据会被按序被目标主机接收?
答案是不能的,因为可能会存在不同报文不通过同一个网络传输过去的情况,而有些网络传输可能会比较缓慢。
那么如果目标主机接收到了这些没有按指定顺序到达的报文,并直接对报文做解析,势必会出现问题,TCP对于可靠协议是不能容忍这种问题的出现,所以也就需要保证报文能够按序被目标主机解析。 虽然不能控制报文到达的顺序,但是我们可以在报头中添加关于顺序的说明,这个顺序的说明,就是我们现在要讲的32位序号。
只需要对比我们的24位确认序号,我们就可以知道这一连串报文数据的顺序,服务端也就能按序解包报文。
需要注意的是,实际两个主机在进行通信时,我们最初通信时的起始序号可能并不是从1开始的,而是一个随机值,这个随机值是服务端和客户端在开始建立通信,也就是在三次握手的时候就开始协商好的,至于这是为什么?我们后面再讲。
32位序号等会我们还会详细再次讲解。
32位确认序号
既然Tcp要保证可靠性有了确认应答机制,那么我们就需要在确认应答中携带32位确认序号来保证我们已经读取了多少字节的数据。
当我们收到序号为1000的报文请求时,我们要做出ACK应答,应答就需要带上确认序号,确认序号是你的序号+1。 比如说我发了1000字节的数据给服务端,确认序号是1000,服务端就需要在它的应答报文里的确认序号里填上1001,代表1001之前的数据我读取到了。
通过这样的方式,我们就可以保证客户端知道我们的服务端已经读了多少字节的数据。
如果说我分四次发报文数据一共发了4000个字节,服务端只收到了其中3个或两个,那么服务端就必不可给我发一个确认序号为4001的响应报文,当客户端发现服务端没有完全读完,然后根据响应报文中的确认序号,知道对方收到了多少字节数据,就知道我需要重发,从多少开始重发。
32位确认序号等会还会再相信讲解。
16位窗口大小
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。
当我们的服务端压力比较大的情况下,接收缓冲区的内容没办法短时间内快速取出,而客户端又在不断向服务端发送报文数据的时候,接收缓冲区的剩余空间就在不断缩小。
这个时候就需要告知客户端,服务端的接收缓冲区快顶不住了,让客户端发慢一点或者别发了,再发接受缓冲区满了,满了的话再发的报文就会被丢弃。 所以也就需要16位窗口大小来告诉客户端服务端的接收缓冲区剩余大小,也可以理解为可以继续发送数据的最大值。
16位窗口等会我们还会详细讲解。
16位紧急指针
这个我们基本很少会用到,只有在传送紧急数据的时候,16位紧急指针才会被设置,并且还有URG标记位也会被设置,当URG被置1时,说明有紧急数据。
16位紧急指针是报文正文的偏移量,且紧急数据最多只能有1个字节。
我们可以通过在send的flags参数设置MSG_OOB来携带带外数据(也就是紧急数据)
什么场景才会用到发送紧急数据
一般我们很少几乎用不到发送紧急数据,很少的应用程序会用到这个。
但是有一种场景我们可以了解一下,就是当客户端向服务端发送一个报文,确始终得不到应答,但是我又能确认连接是没问题的,我的消息发送是没问题的。 我就需要知道服务器那边是处于一种什么情况,为什么对我的报文不做回应,这个时候就可以穿一个紧急数据过去,然后服务端也专门用一个线程用来接收紧急数据,然后再应答回去告诉客户端服务端可能处于一种什么状态,没办法及时处理你的报文。
六个标记位
URG:标记紧急指针是否有效,是否携带紧急数据。
ACK:只要有确认应答属性,就必须要带上ACK标记位!确认序号是否有效。
PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走,
RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段。
SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段。
FIN: 通知对方, 本端要关闭了。
超时重传机制
为了确保数据会被对方接收,我们已经知道了有确认应答机制,只有接收数据一方收到数据后发出ACK确认应答,并且这个应答被发数据的一方也接收到了,至此,才完成一次数据的通信。
那么如果数据在中途丢包了呢? 或者应答在网络传输过程中也丢包了呢? 又或者我们的数据被由于网络状况不好,阻塞在网络中了呢?
以上图为例,只要我们的主机A没有收到确认应答(确认应答是携带确认序号的),就无法确定主机B是否真的收到了数据,那么我们就要在特定的时间间隔进行数据重传。
所以不管是上面提出的哪三种情况,我们的超时重传机制都能够处理这些情况,其中如果是网络状态不好,造成主机A发出的数据阻塞在网络中,主机A也需要进行超时重传,如果这个时候第一次发出的数据由于网络又通畅了,被主机B接收到了,两次发出的数据就都有可能会被主机B收到,那么对于主机B就收到了重复的报文数据,对于重复的报文数据我们应该如何处理呢?
重复报文如何处理
因为我们的报文数据是携带了序号的,所以完全不用担心收到重复报文,只需要对比序号,将多余的重复报文直接丢弃即可。
超时重传,那我们要超时多久才重传呢?
超时重传的这个时间间隔是比较难以界定的,如果设置的时间间隔太短,可能由于网络本身传输就需要一定时间,就会导致时间间隔内一直都收不到应答,并不断重发重复的报文数据。 那如果设置的时间间隔太长,又会影响整体的重传效率,
所以,针对这一问题,Tcp采用的是一种动态的时间间隔。
这个时间间隔一500ms为单位,之后判定重发的时间间隔都是这个单位的整数倍。
比如第一次超时重传的时间间隔为500ms,500ms内没有收到应答之后,就重传一次。然后第二次的时间间隔就变为了500 * 2 = 1000ms,而如还是没有收到应答,就再重传一次,以此类推。 等累计到了一定次数,Tcp就会认为网络或者对方出现问题,强制关闭连接。
连接管理机制
这里的连接管理机制,我们主要详谈客户端与服务器在三次握手、四次挥手过程中的状态变化。
三次握手状态
当客户端第一次发送SYN请求的时候,自身tcp连接处于SYN_SENT状态。
当服务端收到SYN请求后,向对方发送应答和SYN请求时,将自身置为SYN_RCVD状态。
当客户端接收到服务端的SYN请求和应答时,给服务端发送ACK应答的同时,将自身tcp连接置为ESTABLISHED,ESTABLISHED代表自身连接已经建立好了。
ESTABLISHED
只有tcp连接处于ESTABLISED才认为是建立好了连接!
所以,对于第一次发送握手请求的主机来说,当我发出应答时,tcp就默认建立好了连接。
对于另一台主机而言,只有我收到了应答,才能算式建立好了连接。
那么为什么这样设计有什么好处呢?
如果我们的请求连接的过程造成了请求报文或应答丢包的情况,前两次握手都还好,因为我们双方都没有建立好连接,都是处于请求连接的状态。 那么如果第三次握手的应答丢失了,就会造成了发送第一次握手请求的主机已经建立好了连接,但是由于应答丢失,另一台主机收到确认应答也就没有真正建立好连接。 这样就会造成发送第一次握手请求的主机单方面以为自己建立好了连接。而为了维护这个连接,OS就需要耗费资源去维护,也就浪费了系统资源。
一般实际情况其实都是客户端向服务端发起连接,所以这种机制就将上面会发生的情况的后果成本转移到了客户端身上,而不是服务端。毕竟服务端是要为许多客户端服务的,本身就需要可能维护大量的tcp连接,OS资源的浪费就有可能导致服务器挂掉。
如果发生上述情况,我们可以再进一步向下推演,客户端单方面以为双方都建立好了连接,于是开始传数据,但是我们的服务端还正处于SYN_RECV状态,突然收到了一段不是应答的数据,服务端就大概知道什么情况了,于是就发送一段将RST标记位为1的报文报头数据,要求客户端重新建立连接。
现在再回过头来看三次握手的这些状态。
我们能否看到这三次握手的状态的变化呢?
模拟三次握手状态变化
通过我们之前写的TcpServer代码,我们进行一些改动
为什么这里要特意不进行accept呢? accept函数其实并不参与连接过程,Accept的作用主要在于将内核传输层中建立好的连接拿到应用层。 不进行accept可以更好的让我们看到模拟实验现象。
将backlog设置为1,backlog是我们listen的参数,之前我们一直不理解这个参数是什么意思,等会我们就知道了。
我们提前准备好3个客户端窗口,1个服务端窗口,2个检测tcp连接的窗口。
现在现将服务端运行起来,并让两个客户端连接服务端,我们就会看到这样的一个现象。
而现在,我们开始让第三个客户端连服务端,会发生什么?
这里我们第三个客户端的连接在服务端是处于SYN_RECV状态。 可是为什么呢?
导致的原因有两个,一个原因是我们没有进行accept,我们的backlog被设置为了1。
全连接队列
当我们的客户端和服务端进行好了三次握手之后,我们成立建立了连接,也就是处于了ESTABLISHED状态的tcp连接,就需要进行管理起来,而OS就是通过全连接队列来进行管理的,它会将三次握手成功的tcp连接都放入这个队列,等待我们的accept将它们拿到应用层。
所以这我们特意让服务端不调用accept,也就是把那些已经建立好连接的tcp连接一直留在了全连接队列当中。
那为什么第三个连接是处于SYN_RECV状态呢?
先再来看看三次握手的这些状态。
SYN_RECV也就是处于收到了客户端第一次发来的SYN请求并给客户端发ACK+SYN的时间段。
那当客户端再发ACK应答时,我们这里的tcp连接为什么还没处于ESTABLISHED状态呢?
这是因为我们的全连接队列满了!
至于为什么会满,是因为我们的backlog被我们设置为了1。
全连接队列的长度等于logback+1!
半连接队列
对于已经建立好连接的tcp连接,我们需要进行管理,对于那些还正在进行三次握手的tcp连接,我们也需要进行管理,我们会将这些连接放置在半连接队列中!
而当我们的半连接队列中有连接如果在一定时间内还未收到应答,就会被自动释放。
所以当我们过段时间再调用netstat -ntp查看连接状态时,刚刚处于SYN_RECV的那个连接就消失了。
之前我们说了是因为我们的全连接队列满了所以我们第三个连接才无法进入全连接队列,并不是因为我们没有收到应答,这个应答其实是被tcp协议忽略掉了,因为全连接队列满了!
四次挥手状态
通过上面的三次握手,四次挥手其实也差不多,不过我们需要注意的是这里的TIME_WAIT状态。
第一次发送FIN请求的主机在收到对方也发来的FIN请求之后,不光也要返回一个ACK应答,同时将自身状态设置为TIME_WAIT状态,并在一段时间之后,将连接关闭。
首先想一想,为什么要等待一点时间再关闭?
因为我们不知道对方主机是否收到了我们的ACK应答,万一它没收到,它就需要补发重传。 如果我在发出应答就直接关闭连接,出现上述情况,我们就没办法收到它补发的FIN请求,就会造成另一个主机迟迟收不到应答,虽然最后的重传机制在重传一定次数后仍会关闭连接,但是这段时间也是耗费OS资源的。所以我们就需要有TIME_WAIT时间。
那么这段等待时间我们是尽量让客户端来维持的, 原因与三次握手一样,服务器的资源要尽可能的利用,尽量少一些浪费资源的行为。
TIME_WAIT等待多长时间呢?
等待时间长度也是有讲究的,我们一般设定时间为 2MSL时间。
什么是MSL?
MSL就是一段TCP报文数据在网络中最大生存时间。
所以,等待2MSL的时间就可以在出现问题的时尽可能的让服务端有时间进行补发重传。
还能让一些中间由于网络堵塞原因的一些报文数据消散,因为我们要考虑到这一种情况,我突然又想再次建立连接,那么这些上次连接的残余报文就有可能会影响我们新的连接,不过这种情况是几乎不可能发生的,因为我们的客户端在连接服务端时采用的是随机端口机制。 并且我们还有24位序号能保证这种情况的发生,也是因为这种原因,我们刚刚上面讲到的,随机序号也是为了避免残余报文影响正常连接。
模拟四次挥手环境
我们一个客户端一个服务端即可,然后一个窗口来观察tcp连接状态。
先启动服务端,然后客户端连接服务端。
此时我们ctrl+c关掉服务端,服务端的tcp就需要向客户端发送FIN断开连接请求,客户端并发送ACK回应,此时我们就可以看到服务端的tcp连接处于FIN_WAIT2状态。
然后接着我们也关闭掉客户端,就能看到服务端的连接处于 TIME_WAIT状态了。
等过一点时间再检查连接状态,我们就发现连接消失了。
现在我们再来回过头来想一想之前碰到的问题,我们以前在关闭服务端的时候,然后再重启启动服务端,是不是会碰到
现在不是就明白了呢?
这是因为我们虽然关闭掉了服务端的进程,但是tcp连接还是处于TIME_WAIT状态,也就是这个端口号正在被使用,自然也就不能重复绑定被使用的端口号。
而我们之前也使用过setsockopt函数能够解决这个情况,可以让刚刚被关闭的服务端,立马重启不用改变端口号,也就是针对这个问题的解决方案,因为对于服务端而言,我虽然出于各种原因挂掉了,但是我需要立马重启服务,否则即使短短几秒没办法重启服务也可能会造成我较大的经济损失。
setsockopt
int opt = 1;
setsocket(listensocket, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof opt)
流量控制
刚才我们学习32位窗口大小的时候,我们就已经介绍了,双方主机可以通过ACK回应中设置32位窗口大小,就可以让对方主机知道我们的接收缓冲区还剩多少空间,还能接收的最大数据长度。
如果接收方缓冲区满了, 就会将窗口置为0。这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收方把窗口大小告诉发送方。
这种流量控制的好处就在于,可以避免因为接收缓冲区空间不足,而导致接收方不断丢包,发送方不断重传的情况,有效地提高了传输的效率。
滑动窗口
要想理解滑动窗口,我们先来重新再理解一下接收缓冲区。
我们可以将接收缓冲区分为三个部分
第一个部分是确认应答的数据,已经确认被对方收到的数据我们其实就可以进行覆盖了,不过对于如何覆盖,我们等会再说。
第二个部分是我发送但未应答的数据,这个部分,我们就可以视他为滑动窗口,因为滑动窗口,是受对方缓冲区大小也就是收到的24位窗口大小影响的。
通过这样的一个窗口,我们不仅可以同时维护发送但未应答的数据,区分其他两个部分的数据,也不必再重新开辟一段空间来维护,直接在发送缓冲区放置即可。 这个窗口的区间是通过双指针来划分的。
滑动窗口的大小取决于对方的接收缓冲区的窗口大小,所以我们就可以将滑动窗口内的数据一次性连续发送出去,然后开始等待应答。
滑动窗口大小 = min(对方缓冲区窗口大小,有效数据,阻塞窗口) 这里的阻塞窗口我们等会再谈。
那么它的滑动体现在哪里呢?
这就又要涉及到我们之前说的 序号和确认序号了。
begin = 确认序号。
end = 确认序号 + 窗口大小
当我们将滑动窗口的报文数据连续发送过去之后,我们开始等待应答报文,再通过应答报文中的确认序号和窗口大小,再动态调整滑动窗口的位置和大小。
这就是滑动窗口。
延迟应答
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小。
我们的服务器在一定时间内是会把接收缓冲区内的数据取走一部分的,所以延迟应答,可能就可以让我们返回的窗口大小更大一些。
那么这个延迟多久呢?
对于不同的操作系统有不同的方案,不过大多数都是这两种方案之一。
- 数量限制: 每隔N个包就应答一次; N一般取2
- 时间限制: 超过最大延迟时间就应答一次; 时间一般取200ms
阻塞控制
有的时候,我们的网络环境会变得拥堵,发出的数据迟迟阻塞在里面。
所以如果当出现大面积丢包的情况,tcp就需要考虑到有可能是因为网络拥堵阻塞的原因,于是就需要做出相关策略。 我们称这种策略就叫做阻塞控制。
当被tcp判断可能是网络阻塞时,就要开始引入慢启动机制和阻塞窗口(一个阻塞窗口为一个报文数据),所谓的慢启动机制就是第一次一个阻塞窗口的数据,如果能收到应答,下一次就发送2倍的阻塞窗口数据,一次类推,期间如果又发生网络阻塞大面积丢包则再重新轮回一次。
每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;
所以我们就能理解上面的 滑动窗口大小 = min(对方缓冲区窗口大小,有效数据,阻塞窗口)。
但是,如果是以2倍的方式增大阻塞窗口,对于这种指数级的增长,后面的增长是十分夸张的,但是指数级增长的前期是相对比较缓慢的,我们对于阻塞的试探只需要慢启动即可,所以我们就需要有一个阈值来限制指数的增长。
当阻塞窗口达到阈值后,就不再以指数进行增长,而是+1。
这里的乘法减小是对于再次碰到网络阻塞做出的算法调整阈值,将阈值设为碰到拥堵时堵塞窗口/2。