文章目录
端口号
端口号(port)标识了一个主机上进行通信的不同的应用程序。
比如一个服务器部署了以下服务.
http的端口就是80,注意:这个端口不能改变,因为这是服务端口,必须是众所周知的,https默认端口是443,ssh协议是22等等。
底层数据会根据IP给传输到特定的主机,但是主机上可能部署了很多服务,这是就需要根据端口号确定消息被交付到哪个服务里。
在TCP/IP协议中,用“源IP”,“目的IP”,“源端口号”,“目的端口号”、“协议号”这五元组标识一个通信
假设有一个服务器和两个主机,主机A向服务器发起了一个http请求,主机B向服务器发起了两个http请求,服务器则会根据这两个主机的ip不同,给他们返回对应的响应,而主机B虽然发送了两条请求,但是他的套接字的端口号肯定是不一样的,所以服务端也可以把正确的消息返还给主机B的正确的进程上
所以就有了“源IP”,“目的IP”,“源端口号”,“目的端口号”这四元组用来标定一个通信的socker,中间的tcp是我们收到的报文将来要交给那个协议,所以才有了协议号
端口号的划分
端口号一共216个——16个bit位。
0~1023:知名端口号
HTTP,HTTPS,FTP,SSH等这些广为人知的应用层协议,他们的端口号都是固定的,一般我们不能修改,但是有些特殊的可以改比如MySQL。
1024-65535:操作系统动态分配的端口号
客户端的程序端口号,就是操作系统从这个范围里分配的。
常见的知名端口号(Well-Know Port Number)
ssh服务器:22
ftp服务器:21
telnet服务器:23
http服务器:80
https服务器:443
可以查看配置文件看到知名端口号
cat /etc/services
因为端口号是用来标识主机上的一个进程的,所以一个端口号不可以被多个进程绑定,但是一个进进程可以绑定多个端口号
相关命令
pidof
功能:通过进程名查看进程的pid
用法:pidof [进程名]
netstat
功能:查看网络状态
用法:netstat [选项]
常用选项:
- n(number):能显示数字的全显示成数字
- l(listen):专门查监听状态的套接字,普通状态不带
- t(TCP):查看tcp协议的服务
- u(UDP):查看udp协议的服务
- a(all):显示所有选项,默认不显示LISTEN相关
- p(process):加上会显示进行的pid和程序名
UDP协议
问题:
1.如何做到封装和解包
2.如何向上交付(分用)
UDP报文
整体结构如下:
其中的16位UDP长度包含整个报文的最大长度
UDP如何进行解包和分用呢?
接收到报文以后,首先进行校验,如果校验和出现错误,报文就会被丢弃
因为udp的报头是定长的(8字节),所以封装就是加上一个定长报头,解包就是读取到定长报头截至,分用则是是通过报头的16位目的端口号确定交付给应用层的某个进程。
这也就解释了我什么我们写代码的时候需要绑定端口号,因为udp收到报文会把数据转给特定端口号的进程。
报头和有效载荷分离
根据目的端口号,交付有效载荷给上层应用
如何看待udp报文,位段结构:
struct udp_hdr{
uint32_t src_port: 16
uint32_t dst_port: 16
uint32_t total: 16
uint32_t check: 16
}
添加报头?哪这个结构体定义一个对象,和上层的数据一拷贝,形成一个报文。
UDP特点
无连接:知道对端的IP和端口号就可以传输,客户端不需要建立连接。
不可靠:udp在发送报文时,如果出现丢失,udp本身不会进行任何重发,只有上层应用层的人看是否需要重发,udp本身不会做任何可靠性机制。
面向数据报。
什么叫做面向数据报?
UDP属于底层协议,数据读取时是在应用层在调系统调用接口把数据读上去。当读数据时,udp可能有多个报文,读取的时候要么就不读,要读就一次性读一个完整的报文。客户端发送多少次,服务端就要接收多少次,发送的每一个报文服务端必须全部收到,这种特点就叫做面向数据报。
原样发,原样收,既不拆分,也不合并
UDP的缓冲区
系统调用接口就是read,write,recv,send,recvfrom,sendto这样的接口。以read/recv、wirte/send为例,其参数都会有一个缓冲区和缓冲区大小,还要一个文件描述符。其实这批接口与其说是收发函数,不如说是拷贝函数。应用层无论是http,https,还是套接字编写的基本代码,其本质是要把自己发送的数据拷贝到TCP,UDP的缓冲区里。
当我们调用read,并不是把数据从网络里读上来,而是从传输层的tcp的接收缓冲区拷贝上来,当我们调用write时,也并不是把数据直接写到网络中,而是把数据拷贝到对应的发送缓冲区中。
拷贝完成之后,该数据具体什么时候发,发多少,完全由OS(传输层)控制。
所以传输层解决的是什么时候发,发多少以及有的协议会解决发送失败了怎么处理的问题,传输层是给我们提供传输数据的策略,UDP的策略就是越简单越好,能发立马就发。
传输层提供的一些传输策略对后续保证可靠性,各种流量控制滑动窗口等机制都会在这一层(tcp)实现,udp几乎没有策略
为什么要有这个缓冲区?一方面,可以让传输层定制很多发送的策略。另一方面他将应用层协议和下层通信细节进行了解耦
UDP 没有真正意义上的发送缓冲区,调用sendto会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作。
UDP 具有接收缓冲区。网络读到数据,会把数据自底向上的交付给udp,如果上层没有调用接口读取,他会把数据暂存在缓冲区里,上层读的时候就直接在缓冲区读。
但是这个接收缓冲区不能保证UDP报文的接收顺序和发送顺序严格一致,因为UDP并不保证可靠性。并且如果缓冲区满了, 再到达的UDP数据就会被丢弃,丢弃之后也不关心。
udp的这个缓冲区策略就是他的一个典型的特征,而udp的套接字既能读也能写,这个概念叫做全双工
全双工:两个人通信的时候,都可以发送消息,但是一个人发的时候另一个人不能发,待等对方发完自己才能发,这叫做半双工。管道就是最经典的一种半双工通信。在同一个信道中,两个人可以同时收发,这就是全双工。也就是说recv和send可以被同时调用
UDP注意事项
udp的协议首部有一个16位的最大长度,也就是说一个udp能传输的数据的最大长读就是216个字节(64K),这还是包含报头长度的。64K在当下的网络环境下是一个非常小的数字,所以如果你要发送超过64K的数据,就需要在应用层手动分包,多次发送,并在接收端手动拼接。
基于UDP的协议
NFS:网络文件系统
TFTP:简单的文件传输协议
DHCP:动态主机配置协议
(联网的时候自动给你一个IP地址,是因为路由器支持DHCP协议,能够给入网的主机自动分配IP地址)
BOOTP:启动协议(用于无盘启动)
DNS:域名解析协议(用域名解析出对应的IP地址)
TCP协议
可靠性
面向连接
传输层就是一个做决策的角色,它会定各种各样的策略
为什么网络传输会不可靠
同一台设备的不同部件之间是相互独立的,但是要进行数据交互,是用各种各样的“线”连接起来的,称为总线,内存与输入输出设备之间的线称为IO总线,内存与系统之间的线称为系统总线。这些线是会真实存在的,大部分都被集成化到了电路板上,也就是主板。
一般情况这些线都在一台设备上,也就意味着它非常短,那它发生错误的概率就大大降低了,如果我们的设备之间距离非常远,就像是分布式系统,每个部件都在不同的区域,那就等于是这个通信的“线”变得更长了,那设备和设备之间出错的概率就更高了,而部件与部件之间通信也有对应的协议,就像是网络通信,如果部件变成主机,那网络通信就像是这个线变得非常长,那自然出错的概率就大大增加了。
TCP协议的格式
一行4个字节,TCP的标准报头是前5行,一共20字节,除了标准报头,也可以携带一些选项。
4位首部长度
TCP要进行解包分用,首先就要把报头和报文分离,这里涉及到的字段就是4位首部长度
首先:TCP标准长度是20字节,不会少于20字节
4位首部长度如果基本单位是1字节,哪它表示的范围就是24=16字节?这是不可能的。所以这里的四位首部长度的基本单位是4字节,报文的真正长度应该是首部长度的值×4字节。
所以TCP首部的最大长度就是15×4=60字节,标长20字节,那就意味着选项最多是40字节。而没有选项时,首部长度的默认值就应该是20÷4=5(0101)。
所以想要分离报头,可以首先提取到报文的前20个字节,然后提取出他的首部长度的值,然后计算出报头的大小,以此做到分离报头与报文。
并且报头里面还包含了目的端口号,有这个内容就可以把有效载荷向上交付,实现解包与分用。
序号与确认序号
确认应答机制
基于序号的确认应答机制是TCP保证可靠性的最底层机制之一,也是最核心的机制。
我给别人发了一条消息,我是不能保证对方已经收到了我的消息,因为在长距离传输的时候,报文万一丢了,错了…对方与我相隔千里之外,我是得不到任何反馈的。什么时候我才能知道对方一定收到了我的消息呢?那就是对方给我们又发了一条反馈,哪这时候我就能确定对方一定收到了我的消息。而这时候对方也同样无法确认他的消息是否被我收到了,所以我再给他发一条响应,他收到以后就可以确认他的消息成功的被我收到了。这个应答的意义不在于我收到了以一个应答,而是我收到应答以后我就能确定我的上一条消息是一定被收到了的。
所以确认应答机制是通过应答,来保证上一条消息100%被对方收到了。
但是双方通信时,最新的一条消息是没有应答的,所以我们也就无法保证整个通信是彻底可靠的。所以TCP并不是100%可靠的。互联网上也不存在长距离传输100%可靠的协议!!
确认应答的工作方式:
一般情况下,cilent和server互相通信时,client向server发送数据是无法确认发送的消息是否被server接收到的,这时候server给cilent发送一个确认,哪client就可以确定刚刚发送的消息已经被对方收到了。如果我们单向的发。cilent不断的向server发,只要server在给client发送确认,即使server不能确认cilent是否收到了确认信息,但是client只要收到确认信息,他就能保证自己的消息已经被对方收到了,这时我们就能保证数据从cilent端到server端的可靠性。
反过来也一样,无论是client端给server端发还是server端给client端发,只要我发出去的数据都有对应的确认,那我就能保证这条消息被对方可靠的收到了。如果我收不到这个确认,那我就认为这个数据就丢了。所以可靠性不仅要判断对方100%收到了,也要能判断对方没收到。确认应答机制在双放都给对方作应答时,就能保证历史数据被对方可靠的收到了,这种可靠性就是100%的。所以TCP的可靠性体现的是对历史数据的可靠性
上面的通信过程是一来一回,通信的过程是串行的。假设cilent一次性发送了5个报文,那server端对这5个报文分别做应答,server虽然无法确认自己的5条应答是否被对方收到,但是cilent端收到了这些应答就可以确认自己的报文被对方收到了。这个过程中如果发送报文的顺序是1,2,3,4,5,接收放收到的顺序不一定也是1,2,3,4,5,虽然能够保证数据被你收到了,但是这不等于我能保证数据被你按顺序收到了。网络传送时,可能报文之间的路径选择不同,或者收到网络环境的影响,那server端收到的报文就可能是一个乱序的报文。可靠性除了保证能被收到,也要保证按序到达!一旦乱序则可能导致业务逻辑出现紊乱。
如何保证按序到达呢?所以TCP报头里就涵盖了一个32位序号,相当于给报文编号,这样就接收时就可以按照编号的顺序给报文排序,从而保证数据被对方收到的同时还能保证它按序到达!
当发送放发送了多条报文时,接收方对接收到的时候可能时乱序,没关系,我可以通过编号对报文排序,这样我收到的就是有序的,那我现在要让对方确认,这时候会有多条确认,就会产生确认信息和发送信息的对应关系的问题。TCP报头中,涵盖一个32位确认序号,值是对历史确认报文的序号值+1。当发送方收到确认应答tcp报文之后,可以通过确认序号,来辨别该确认报文是对哪一个报文的确认。
如果我收到的确认序号是X,那就表明X之前的所有报文我都收到了,下次发送请从X号报文开始发送!!
上面的通信过程中,无论是数据还是应答,本质都是一个完整的TCP报文(可以不携带数据,但是一定包含一个完整的tcp报头)
在TCP的报文结构中,我们可以发现一个报头中既有序号也有确认需要,为什么把这两个设计成两个独立的字段?能不能在我收到消息后我把序号直接改成原序号+1改成确认序号发回去?根本原因是因为tcp是一个全双工的通信协议,你给我发消息的同时,我也可以给你发消息,那么你给我确认的时候,我也可以给你确认,这两个动作是可能同时进行的。也就是说,一个报文被发送时,可能它既可以携带它自己想发的消息,有自己的序号,也可能还有对对端上一条历史消息的确认。所以他就必须待携带序号和确认序号。
16位窗口大小
因为TCP要保证通信的可靠性,那就表明如果对方没有收到消息,那你就要进行重传。我把一个数据发送出去,我并不能确保对方一定能收到,所以发送消息时,我不能立即把消息删掉,因为有可能我会进行重传,所以发送缓冲区就要可以临时保存数据。接收方在收到消息时,可能要收的是一些列有序的报文,但是我实际收到的是一些乱序的报文,后面的先到,前面的后到,或者我收到的报文上层没有来的即接收,那我总不能把这些报文都丢弃了,所以也就要求接收方能够有一个可以暂存数据的区域,即接收缓冲区。在这个缓冲区里就可以对收到的数据进行各种各样的重排,或暂存。
接受和发送缓冲区都是在TCP内部(操作系统)去维护和实现的。
TCP通信时,上层调用一些接口比如write或send,把数据拷贝给发送缓冲区,TCP再从缓冲区把数据拿走交给下层发送给对方,这就是一个经典的生产者消费者模型。所以当你把你的数据拷贝到发送缓冲区以后,你的任务就结束了,接下来这个数据怎么发,什么时候发,发多少就完完全全由TCP决定,所以TCP叫做传输控制协议。数据在下层的通信细节上层不用管,在上层的使用方法下层也不用管,这就是通过缓冲区,将应用于操作系统进行解耦。
通信双方的TCP协议是一样的,双方的地位也是对等的,一端在把自己发送缓冲区的消息发送给对方的接收缓冲区时,对方也可以做同样的事,这就是全双工。
内核如何进行收发?
单纯的发数据也是有分险的,假设我现在已经有了序号和确认序号,那我就能给对方发数据了,对方机器的状态你并不清楚,对端机器还有多大内存,tcp的接收缓冲区还剩多少,接收数据的能力是多少…我都不知道。万一你发送的数据太过频繁导致对方来不及接收,那这个报文就只能被丢弃,虽然tcp有策略可以保证丢包可以重传,但是报文没有任何错误,并且花费了大量网络资源到啊目的主机,这是一种浪费资源的状态。这种因为频繁发送导致对方来不及接收进而导致对方丢弃报文的现象称为因为没有做流量控制而导致对方丢包的问题。所以在我向对方发消息时,报文的总数一定要在对方的可承受范围之内,这个功能就是通过16位窗口大小实现的。
接收方收到数据给对方响应时,会报告自己的接收能力,发送方就可以根据这个信息来控制自己的发送速度,支持流量控制。这种报告自己的接受能力的区域就叫做16位窗口大小。里面填的就是自身的接收缓冲区中剩余空间的大小。
因为双方都是TCP协议,并且缓冲区独立,双发可能同时时发送方与接收方,所以就可以支持双向的全双工级别的流量控制。
16位校验和
校验和会对整个报文做校验,如果校验失败,接收方就会把报文丢弃,这时候是因为数据出现问题而进行的丢包,是必须丢的,只能让对方重传。
6个标志位和16位紧急指针
一个服务器可能收到很多报文请求,有的是发送的正常数据的报文,有的可能是建立连接的报文,还有可能是断开连接的报文,或者就是简单的确认报文,所以TCP报文是有种类的。因为不同的报文对应的是不同的处理逻辑,TCP要根据不同的报文执行不同的动作,所以TCP需要对自己的收到的报文种类进行区分。TCP区分报文种类的方法就是使用报头的6个标志字段,对应6种报文,占6个bit位,0表示没有,1表示设置。
一共是以下6种:
URG: 紧急指针是否有效
ACK: 确认号是否有效
PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
SYN和ACK
SYN:连接建立的握手标志位
ACK:收到的报文进行确认,
所以一般而言,ACK字段是被经常设置的,只有在链接建立阶段,才设置SYN字段。
如果一个TCP报文报头的SYN位被设置为1,就代表发送方想和接收方建立连接,接收端就知道了这是一个链接请求报文,然后会进行响应,而这个响应包含两层含义,一是让对方确认自己的消息被我收到了,二是我要告知对方我允许你建立连接。所以接收端发送响应报文因为要让对方确认,所以会把ACK置为1,告知对方允许连接,所以把SYN也置为1,然后给对端进行响应。发送端收到响应后,还要给对方进行一次响应,这时候就只设置自己报文的ACK。
这就是建立链接三次握手的过程,握手成功,那双方就建立好了链接,然后就可以进行通信了。
三次握手属于通信细节,是TCP层在进行数据交互,上层用户不需要关心,由双方的操作系统自动完成!!
用户调用的connect的本质就是发起三次握手,返回时就代表握手成功或失败,accept的本质就是在获取已经建立好(完成三次握手)的链接。
FIN
断开连接
FIN:链接断开报文
断开连接是在通信结束后把链接断开,那之前肯定有一个通信的过程,假设这时候server想要断开连接这时候它给client发送的一个报文里面包含的FIN字段就被设置为1,client为了保证该FIN报文的可靠性,就响应一个ACK,这就完成了断开从server端到client端这个方向的链接。而TCP是全双工的,关闭链接必须是全双工的,双方信道的关闭。所以还要再进行一次从client到server的链接断开的报文,client端也是设置FIN,server也要进行应答,也要发送一个被设置ACK的报文,然后双方的链接才算彻底的关闭。这个过程就是链接断开时的四次挥手。
如何理解连接?
上面的情况都是一个客户端连接一个服务器,但是一般情况下一定是服务器少,客户端多,那就会有很多客户端连接服务端。连接自己的属性,发送接收缓冲区等等的属性都是与连接强相关的,TCP有自己的接收与发送缓冲区,所有的保证可靠性的机制,都与客户端到服务端的某一条连接强相关的,都是基于连接的。
1.面向连接是TCP可靠性的一种
2.当服务器是一对多时,服务器上可能存在大量的连接,操作系统就必须要管理这些连接,连接的创建,连接状态的变化都要体现出来。当连接建立成功,client和server就会在各自的内核中创建出来维护相应连接的struct结构体,这个结构体包含了该连接的所有的属性字段,谁连的,怎么连的,连接对应的数据缓冲区在哪,各自发送方法,封包方法,解包方法,提取报文的方法等等,然后把所有的连接用一个链表组织起来,对连接的管理就变成了对链表的增删查改。哪操作系统要描述和组织连接,是需要花费时间和空间成本的。所以连接的本质就是双方的操作系统为了维护连接需要构建相关的数据结构,构建结构,填充字段,销毁都是需要花时间空间的,所以维护连接是有成本的。
断开连接,本质是释放曾经建立好的连接结构体字段,释放曾经申请好的资源。
URG与紧急指针
cilent端向server端发数据时,TCP是按序到达的,就决定了报文正常情况数据被对方的应用层读取的时候,是被按顺序读取的,这也是设计按序到达的目的,但是有时候可能会有一些优先级更高的数据,需要提前被上层读取,在TCP这里是按序到达,是不可能的,所以就可以通过URG标志位+16位紧急指针完成。
URG标志位大部分情况下都是0,表示我不关心这个字段,但是如果URG是1,那就代表我们需要关注16位紧急指针,这时候紧急指针代表的就是紧急数据在报文中的偏移量,URG的作用就是确认紧急指针是否有效。
TCP协议只能有一个字节的紧急指针字段,但是选项字段则可能包含更多的关于紧急指针的内容。
recv的最后一个参数叫做flag,这个flag里面可以设置一种选项叫做MSG_OOB->out-of-band data(带外数据),对应的就是紧急指针。send的时候也可以设置该选项。
PSH
当双方在进行通信时,接收方在响应时会报告自己的接收缓冲区剩余大小,会保存在窗口大小里,但是如果接收方的上层取数据的速度巨慢,那就可能导致接收方的接收缓冲区满了,这时候返回的窗口大小就是0,发送方收到窗口大小是0的响应报文时,发送方只能等待,但是发送方怎么知道要等到什么时候?有两种策略:
1.如果接收方的上层取走了数据,那它就会更新一条tcpACK确认报文,这个报文里窗口大小就跟新成新的大小,
2.发送端不断的问。
客户端一直问,服务端一直说没有好,问了很多次之后,客户端发了一个携带PSH的报文,发给server端,其含义就是不光在询问接收端缓冲区有没有好,还表示尽快把你缓冲区的数据交付给上层应用。
怎么尽快?read读的时候,如果没有数据,调用read的进程就被阻塞住了,没有就可以返回,缓冲区的数据不是严格的有无,实际接收缓冲区和发送缓冲区都有自己的低水位标示,就相当于缓冲区里有一个线,假设是100字节,只有数据超过100字节,我才让上层读取,避免因为上层频繁的进行读取和返而导致的效率影响,所以缓冲区是在数据到了一定的范围才告知对方,PSH就是在告诉系统即使现在还不满足告知上层进行数据读取的条件,必须让对方读取,这就是推送。
如果TCP愿意,当窗口大小比较小时,它就可以在自己的报文里添加PSH,让对方赶紧向上交付。所以recv接口期望读取的字节和实际读取的字节不一定是吻合的。
RST
建立连接三次握手之后,前两次都有应答,可以确认可靠,但是最新的ACK是没有应答的,无法保证它的可靠性,所以建立连接不是100%建立成功的,是有风险的。
因为报文在网络中传输是需要时间的,报文发出去的时候,对方可能要过一会才能收到,也是为什么讲网络传输的图的线都是斜着划的,这就是为了表示时间的流动。client向server发起建立连接的请求进行三次握手之后,因为最新的ACK没有应答,所以client只能认为只要自己的这条ACK发出以后,那连接就建立好了。server则认为自己建立连接成功的时候就是收到最后一条ACK的时候。而报文在网络中传输是有时间的,client认为一旦发出就建立成功,server认为收到才建立成功,所以client和server认为连接建立成功是有一点时间差的,那一旦这最后一个ACK丢失,client认为连接建立成功,但是server认为连接建立没有完成。无论是任何一方认为连接建立好了,那下一步就是发送数据了,而对端还认为连接还没建立好就收到了对方的数据,所以就会让对方重新建立连接,给对方发送一个设置了RST标志位的响应,对端收到RST以后,就会重新建立连接,释放现在的连接,重新进行三次握手。
这只是RST的一种场景,实际上连接双方有问题时,可以使用RST进行连接重置,这个重置工作是TCP协议自己完成的,属于连接细节。
TCP的各种机制
确认应答机制
和序号和确认序号哪里一样,TCP中基于序号的确认应答机制是TCP保证可靠性的最重要的机制之一,它保证的是对历史数据的可靠性,并不能保证最新的消息的可靠性。这里是对上面的内容的一个补充
每一个TCP连接维护的是接收和发送缓冲区,缓冲区在体系结构的角度就是内存空间,在系统看来就是系统内的某种数据结构维护的内存空间,在应用看来可以看作字符数组,那假设TCP从上层向缓冲区拷贝了100字节的数据,那这里的每个字节就都有序号,假设将来要把这100字节发送出去,那他就可以找到要发送数据的序号区间,然后把最后一个字节的序号填在自己报头的32位序号字段按中发出去。每一个ACK都有一个确认序号,那就代表我已经收到了你的数据,下一次就可以从这个确认序号的位置发,相当于发送起始位置的下标。
数据在发送时,上层可能一个数据只有100字节,然后有很多个这种数据,如果一个一个发,那一次发的太少了,所以TCP就可以在发送的时候选择等上层一共拷贝了1000字节之后再把这1000字节打包一起发出去,同样,接收端TCP接收的可能是1000字节的数据,而上层使用可能是100字节为单位用的,这就叫做面向字节流。发送和接收数据的格式和数据本身的格式没有关系,没有明显的格式要求。TCP协议不关心发送的数据的含义,只负责通过字节流把数据交给上层,而上层要结合自己的协议解释收到的数据。
超时重传机制
TCP保证可靠性的机制一方面是通过协议报头体现的,另一方面是通过TCP的代码逻辑体现的,比如重传。他就类似与设计某种计时器,一定时间内没有收到应答就重新发送,所以是用过编码实现的
发送端认为的丢包实际上分为两种情况:
1.真的丢包了,就是报文真的没了,发送端就会进行超时重传
2.接收端其实收到了消息,但是接收端的确认应答丢了。发送端这时候无法确认是什么情况,发送端也要进行超时重传。
这就是超时重传的特点,只要我没有收到确认,无论什么情况我都重传。但是这样就可能导致接收方数据重复的问题,所以接收方就可以通过序号,对报文进行去重
超时的时是多久?如果过长,那一定会影响效率,太短则会导致大量的重传,所以这个时间一定要是合理的,实际上的超时时间是浮动的,因为网络状况是变化的,Linux系统一般是已500ms为单位,每次判断的超时时间都是500ms的整数倍,如果第一个次发之后还没有应答,的等待2500ms,第二次还是没有就是4500,以指数形式递增,累积到一定的次数之后,TCP就认为网络或对端主机出现异常,会强制关闭连接。
连接管理机制
建立连接为什么是三次握手?
不管是几次,最后一次的可靠性也无法保证,那就看谁的优点更多:
1.TCP叫做全双工协议,也就需要保证连接建立的核心要务首先要验证双方的通信信道是联通的,三次握手是验证双方通信信道的最小次数。前两次握手可以验证client端能收能发,后两次握手可以验证server端是能收能发的,所以三次握手结束后,就可以证明双方都是可以进行通信的。
2.连接建立异常的情况下,已经建立的连接一定是在client端的,因为谁最后发ACK,谁就先维护连接,维护连接是有成本的,如果是偶数次握手,那最后的异常连接就是挂在server端的,而server端是一对多的,会消耗大量的系统资源。所以奇数次的握手次数会使得异常连接会挂在client端,连接建立异常对server端是没有风险的。
建立连接的过程中双方的状态如何变化:
客户端主动建立连接,就是client端向server端发送的报文涵盖了SYN标志位,server给client应答的字段就是SYN+ACK,cilent收到应答后进行相应,涵盖ACK字段,client端只要发出ACK就认为连接建立成功,server端收到ACK就认为连接建立成功。
主动发起连接的一端第一次发起连接时所处的状态叫同步发送(SYN_SENT),接收端如果收到了SYN并进行了应答,那他的状态就叫做 同步收到(SYN_RECV)。只要他们认为连接建立成功,那状态就变成了ESTABLISHED。
四次挥手为什么是四次?
断开连接时不需要验证双方信道是否能够通信了,因为我们之前一直在通信,所以断开时,是要我发送了断开连接的信息,并且收到了对端的响应,就表示对方已经收到了我要断开连接的信息,那client就确认了自己可以释放连接,两次挥手就足够功能性的通知双方单向的连接断开了,所以4次挥手就可以通知双向的信道关闭
四次挥手过程中状态如何变化?
client发起断开连接的消息后他的状态就被设置为了FIN_WAIT_1,当收到ACK后就变成了FIN_WAIT_2,server端收到FIN并且响应了ACK以后,可以把自己的状态设置成半关闭状态CLOSE_WAIT供对方进行关闭。server给client发送FIN之后,它所处的状态就是LAST_ACK,client收到server发来的FIN请求后,不是立刻进入CLOSE状态,而是先进入TIME_WAIT状态,server端收到ACK以后,就会进入CLOSED状态,server端就可以释放连接了,而主动断开连接的client端则必须要经过TIME_WAIT状态,才能进入CLOSED关闭连接。
如果只有client调用了close(),server端会存在大量处于CLOSE_WAIT状态的连接,而只有CLOSED状态才会释放连接资源,如果CLOSE_WAIT状态的连接大量存在,就会消耗系统的连接资源。
四次挥手时,server向client发送FIN时,如果这个FIN丢了,server端就不会收到响应ACK,那他就会向client重传FIN,而cilent最后发送的这个ACK是没有应答的,所以如果client响应完ACK立刻进入CLOSED状态,如果这个ACK丢了,对server端来说和FIN丢了一样,都是没有收到应答,所以他会重传FIN,而这时候client端的连接已经关闭了,那server发送再多的FIN都不会有响应,而server端经过多次尝试之后最终也会关闭连接,但是这对server端来说就会短暂的维护一条废弃的连接,这对server端来说也是不友好的,所以主动断开连接的一方,挥手完毕之后不能立刻进入CLOSE状态,要等一点时间,就会进入TIME_WAIT状态,连接不会被完全释放。
TIME_WAIT可以通过等待,较大概率的保证最后一个ACK被对端收到。并且网络传输的时候因为网络的原因也有可能要关闭连接的报文比正常数据的报文先到了,所以等待一会可以保证双方通信信道上面的正常数据在网络中尽可能的消散。
等多久合适能?把数据从一端发送到另一端的单向通信最大时间我们称为:MSL,可以通过之前历史通信的数据,估算出双方进行正常通信的MSL数据,一般等待的时间是2倍的MSL。
假设现在一个服务器在给给很多客户端提供服务,万一服务器这时候出现问题,主动断开了连接,那我要做的就是要立刻恢复服务,而现在服务器则会处于TIME_WAIT状态,连接还没有完全释放,所以这时候就会绑定失败,而我要求立刻能够恢复连接,所以这里就有一个接口setsockopt(),即便是TIME_WAIT状态,我也要让服务器能够立即进行绑定,建议写服务器时,都带上这个函数
int opt=1;
setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
listenfd:对监听套接字设置的属性
SOL_SOCKET:在sol_socket层
SO_REUSEADDR:设置reuseaddr选项
&opt:设置的具体值为1(真)
在监听套接字后设置该函数。
进程和连接是相互独立的单元,因为连接并不属于进程管理,而是TCP管理
滑动窗口
前面讲的确认应答机制,有一个最致命的缺点就是发送数据的过程是串行的,一发一收的性能非常低,如果一次发送多条数据,就可以提高性能,本质就是把发送多条数据的时间重叠在一起。
既然TCP发送方可以一次发送多个TCP数据段,可不可以一次性把数据发完?还要考虑对方的接收能力。滑动窗口描述的是发送方不用等待ACK就可以一次能发送的数据最大量(没收到应答还可以接着发,相当与一次发多条)。滑动2窗口存在的最大意义就是可以提高发送的效率。
如图,假设我刚开始时窗口的范围是1001-5001,表明这个范围内的都是可以直接发的,那我发了1001-2000这一段,将来我收到响应后整个窗口就可以向右移动,就是通过窗口大小来限制可以发送的区域,收到ACK后就移动窗口。
滑动窗口与报头的窗口大小的差别:
窗口大小:表示的是对方的接收能力,衡量的是对方的接收缓冲区剩余空间的大小。
滑动窗口:是在自己的发送缓冲区中限定的一块区域可以直接发送,暂时不用ACK。
发送过程中,滑动窗口不一定会整体右移,如果对方的接收能力在不断变化,那滑动窗口的大小也会相应的变大或或变小。当发送端收到ACK后,此时窗口的左边界就是ACK报文的确认序号,新的右边界就是新的左边界加对方报头的窗口大小。没有收到应答时,滑动窗口的内的数据不会被删除,滑动窗口左侧的数据才会被删除。所以滑动窗口也可以支持超时重传。
在基于滑动窗口批量化给对方发送数据的情况下,如果我收到了后面的响应而之前的响应有一部分没有收到,那我也认为从我收到的最靠后的响应之前的报文都被对方收到了。即允许丢失ACK响应而不进行重传。这样就要求响应方发送响应时,响应序号必须是这个序号之前的所以报文都已经收到了,如果中间有报文丢失,即使后面的报文你也收到了,你也不能响应。举个例子,假设发送方发送的是1000,2000,3000,4000,而接收端只收到了1000,2000,4000,那接收端的响应就是1001,2001,2001。
如果发送的时候数据包丢了,那后面的响应发送的确认序号都会是丢失报文的起始位置,而发送端如果连续收到了三个同样的应答,就会对响应的报文进行重传,这种机制就叫做快重传机制(高速重发控制),接收端收到重传的报文后,响应ACK就是后面的报文了。
快重传vs超时重传
块重传必须要连续收到三次以上的重复的应答序号,这时候它才能判断报文丢失,如果不够3次就无法触发快重传机制,所以快重传它是效率上的提升,而超时重传是所有重传机制的保底策略。
流量控制
发和收在速度上一定是要适配的,否则接收的速度赶不上发送的速度,数据就直接被丢弃了,浪费了网络的资源,所谓的流量控制,就是让TCP双方能够协调发送的速度,发送不要太快也不要太慢,让对方来得及接收。
通过TCP报头中的窗口大小字段,通知对方我的接受能力,进而实现流量控制。如果接收端发现自己的接收缓冲区快满了,那就会把窗口大小设置成一个较小的值,发送端发现对方的接受能力变小,就会减少自己的发送速度,是要的方法就是减小滑动窗口的宽度,如果对方的缓冲区满了,那滑动窗口会被设置为0,也就不能发了。发送端会向接收端发送一个窗口探测,如果窗口给你的响应还是0,就继续重复探测。或者接收端好了以后自动更新通知,让发送端重新开始发送消息。这两种方法一般同时使用。
作为发送方,第一次发送如何得知对方接收缓冲区的剩余大小?
实际在握手阶段,双方不止是发送连接请求,然后响应+同意连接,在返回应答,双方还做了很多别的事,比如协商了窗口大小。
拥塞控制
发送方给接收方发送报文,如果有少部分丢包,那可以认为是正常现象,但是如果出现了大量的丢包,那就肯定不是正常情况了,TCP就会判断网络出现了拥塞问题。所有TCP不光光考虑了双方主机的情况,还考虑了网络的情况,如果网络出了问题,影响的就不是两个通信的主机了,而是全网所有的主机,如果主机和平时一样进行重传,只会导致拥塞情况加重,所有网络拥塞的时候,正确的做法应该是尽量不发或者少发,等网络恢复正常之后再正常发送。这种策略如果只有一个主机使用是没有效果的,拥塞的时候,因为全网的主机都使用的是TCP协议,所有全网的主机是有共识的,这就是拥塞控制的相关策略。
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题. 因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的. TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
拥塞窗口和缓冲区大小没关系,它就是一个简单的数值,一次发送的数据量如果大于拥塞窗口,就可能引发网络拥塞问题,其代表的是能引起网络拥塞的阈值。
网路的情况是不确定的,可能有时候好,有时候差,所以引发网络拥塞的情况也是变化的,拥塞窗口也是变化的。
因为每一个人都要对网络状况进行判断,所以每台主机都有一个拥塞窗口大小,而每台主机对网络情况的判断(使用的算法)是不一样的,所以每台主机认为的值可能不是完全相同的,但是在大体上都有一个共识。
拥塞窗口的调整方式:
启动时一旦检测到了网络拥塞,那我就会把自己的拥塞窗口设置为1,每收到一个ACK,拥塞窗口就加1,滑动窗口的大小=min(对方的接受能力,自己拥塞窗口大小)。前期拥塞窗口是从1开始以指数(2n)形式增长的,这样设置的目的是为了前期进行少量的尝试检测网络是否通畅,后期检测没问题后可以尽快恢复我们的通信速度。
这样的方案拥塞窗口虽然会变得非常大,如果拥塞窗口变得特别大,那他就失去了他的指导意义,所以我们不能让他一直以指数方式增长下去,所以当拥塞窗口增加到一定大小后,就要减少它的增长速率,从指数增长变成线性增长,这个转换点的阈值就要慢启动阈值
如果在正常通信期间,一旦又检测到了网络拥塞,就立即进行乘法减小,即下一次的慢启动阈值变成当前拥塞窗口的一半,然后重新执行慢开始。
通过这张图起始可以发现,互联网上的主机的工作起始是拥塞窗口开始不断的发,然后发现拥塞,重新慢开始,不断重复,不断的进行动态调整
试探阶段,恢复阶段,正常通信阶段,
延迟应答
当我收到一个报文时,我先不着急给你应答,接收缓冲区在接收的同时,上层也在不断的从接收缓冲区读取数据,那就会有更高的概率接收缓冲区内的数据被上层读走,那我接收缓冲区的剩余空间就更大了,就可以指导发送方一次发更多数据,从而提高效率。
不是所有的数据包都可以延迟应答,一般的策略有两种:
数量限制:每隔N个包就应答一次
时间限制:每过最大延迟时间就应答一次
不同的系统对这两个的设置有差异,一般N取2,延迟时间取200ms。
捎带应答
是现在TCP通信最常规的工作方式
就是发送确认报头的同时,也捎带了我本来要发给对方的数据,一个报文即是我的应答,也是我的数据,所以报头中才必须同时携带序号和确认序号。这就叫做捎带应答,也是大部分主流的TCP通信的策略,其带来的直观好处就是提高效率。
TCP粘包问题
因为TCP是字节流的,所以基于TCP的应用层协议比如http读取的时候如果没有读到一个完成的报文,只读了一部分或者读到了某个报文的一部分,在应用层没有一个完成的报文导致报文失效,这就叫做粘包问题。会导致残留一部分垃圾数据在缓冲区,就像你在一笼包子里拿一个包子,结果拿的时候这个包子和别的包子粘在一块了,你只拿走了一部分,或者你把和你粘在一起的包子给拿烂了,把人家的一部分拿走了。
要解决粘包问题,本质就是要明确报文与报文之间的边界,有以下几种策略:
- 定长报文,读的时候按长度读(需要上层做)。
- 特殊字符,用特殊字符分隔报文
- 自描述+定长:规定报头定长,然后在报头里找自描述字段。
- 自描述+特殊字符:http,用特殊字符“空行”分隔报头,然后在报头找自描述Content-Length
UDP不存在粘包问题的原因是因为它采用的就是定长报头+自描述的策略
TCP异常的情况
两方正在基于TCP协议通信时,突然进程崩了,而打开的连接对应的就是文件描述符,而文件描述符是随进程的,进程奔溃,对应的文件描述符也会被关闭,所以操作系统会自动执行四次挥手,所以进程终止不会导致连接出问题,连接会正常关闭。
双方通信时,我把电脑关机了呢?我们在关机时,如果有的连接还没关闭,那么在关机之前会提示我们,某某某程序还未终止,是否强制关机。也就是说,当你关机时,操作系统会自动杀掉所有进程,也就是说进程退出一定是在机器关机之前的,所以电脑重启也不怕连接出问题。
但是如果通信时断电了或者网线被拔了呢?一个主机的网线被拔了,在短时间内,对端是不知道对面的情况的,因为双方已经无法通信了,客户端想发FIN也发不出去,导致的情况就是客户端掉线,而服务端会维持一个连接,但不会一直维持,它可能会有一些周期性的询问,当客户端长时间不
访问服务端时,服务端就可能会给客户端发送数据请求,如果还是没有响应,那服务端就立马认为对端掉线了,就会自动关闭连接。定期询问对端状况的机制称之为基于保活定时器的一种心跳机制。这个心跳如果由TCP实现时间太久了,所以一般应用层可能会自己实现。
全链接队列和半链接队列
Linux内核协议栈为TCP连接管理使用了两个队列,即全链接(accept)队列和半链接队列。
全链接队列里存放的就是已经建立好的连接,当有大量的客户方向服务器发起连接时,服务器可能当前连接满了,那么这些连接请求就会先被连接好,放在全链接队列里,这样只要服务器上有连接结束了,服务器就可以立刻从全连接队列里拿到一个连接进行处理,保证服务器几乎是100%在工作的。但是这个全链接队列又不能太长,因为维护队列也是有成本的,与其在维护队列上花费大量的成本,不如加快服务器对连接的处理速度。
还有一个半链接队列,这里存放的是处于SYN_RECV状态的半连接,客户端发起请求后,连接首先是在半连接队列的,然后服务端给客户端发起SYN+ACK,该连接就处于半连接状态,收到客户端的ACK之后,连接建立成功,就会被移动到全链接队列。
一般有三种方法可以设置全链接队列的长度:
1.系统默认的全连接队列长度是128
2.系统暴漏给用户的一些配置文件的默认值也是128,但是可以让用户在应用层的全局进行修改
3.listen套接字的第二个参数,设置的全链接长度就是该参数值加1。
实际全链接队列的真实长度是由这三个值的最小值决定的。
半链接的真实长度是由操作系统自己决定的,其有一套专门的算法,和全链接队列长度有关,一般是2的次幂
SYN洪水攻击
只给用户发送SYN,然后自己不会进行任何响应,因为全连接的来源是从半连接来的,如果一直没有响应,那么半链接队列会保持一个满的状态,而半链接队列的长度是有限制的,这会导致全链接队列无法从半链接队列获取连接,服务器也就无法从全连接队列获取连接,而其他用户因为半链接队列里存放的全是废弃的连列,发起的连接请求会被直接拒绝,最终的结果就是服务器无法对外提供服务。
解决方案是这样的
如果我发现你给我发了大量请求但是却从来不给我发响应,那我就可以直接把你拉黑,你的请求我一概不接受。TCP提供了一套syncookie的机制,会再维护一个队列,我给你发的响应里面的序号里会添加一个syncookie,相当于通过某种算法拼接上一个随机值(ID),然后我就把这个队列从半链接队列移到暂存队列里。接下来客户端的响应就会携带刚刚发送过去的syncookie随机值,然后服务端就不在半链接队列校验了而是在暂存队列校验syncookie值,如果匹配,那就放在全链接队列里,相当于增加了一个认证的过程。其最大的意义就是把半链接队列节省出来,所有的非法请求都不会在半链接队列而是暂存队列,这样就可以定期清理暂存队列,而半链接队列是空的,只要有链接请求就可以返回响应,那就一直都可以有客户来连接。大部分情况syncookie选项是默认打开的。
TCP总结
TCP的复杂就在于它又要保证可靠性,又要尽可能的提高效率。
可靠性:
- 校验和
- 序列号(保证按序到达,去重)
- 确认应答机制
- 超时重传
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快重传
- 延迟应答
- 捎带应答