协议本质上为数据传输和数据解释的规则。随着不断完善和使用人数的增加后面变成了标准协议。
各层中涉及的协议的简单解释:
C/S模式是传统的网络应用设计模式,客户机(client)/服务器(server)模式。需要在通讯两端各自部署客户机和服务器来完成数据通信。如(QQ、微信、LOL、CF、DNF等游戏需要下载客户端)
浏览器(browser)/服务器(server)模式。只需在一端部署服务器,而另外一端使用每台PC都默认配置的浏览器即可完成数据的传输。
C/S优点:
协议选择灵活,由于客户端和服务器都是自己开发,可以自定义协议进行消息传递。例如,腾讯公司所采用的通信协议,即为ftp协议的修改剪裁版
可以本地进行大量缓存,速度快,体验好。例如,知名的网络游戏魔兽世界。3D画面,数据量庞大,使用C/S模式可以提前在本地进行大量数据的缓存处理,从而提高观感。
C/S缺点:
安全性低,客户端的安装可能会导致本地用户信息泄露
跨平台性,需要为不同操作系统进行客户端定制
开发工作量大,由于服务器+客户端都需要一个团队完成开发量倍增
B/S优点:
安全性高,跨平台性好,开发工作量小。因为不需要安装客户端,只需要浏览器即可完成数据交互
B/S缺点:
由于使用第三方浏览器,因此网络应用支持受限。
没有客户端放到对方主机上,缓存数据不尽如人意,从而传输数据量受到限制。应用的观感大打折扣。
必须与浏览器一样,采用标准http协议进行通信,协议选择不灵活。
因此在开发过程中,模式的选择由上述各自的特点决定。根据实际需求选择应用程序设计模式。
计算机之间通信本质上是通过网卡+网线来传递高低电压实现的,单纯的电信号0和1没有任何意义,必须规定电信号多少位一组,每组什么意思,以太网帧协议就是高低电压在网卡间数据传输和数据解释的规则。
以太网帧协议需要注明源地址(从那个网卡来)、到目的地址(那个网卡去)、消息体的类型(相当于咱们websocket消息头里的code)、消息体。
以太网帧协议的主要三大类型:0800(IP)、0806(ARP)、8035(RARP)
以太网帧中的数据长度规定最小46字节,最大1500字节,ARP和RARP数据包的长度不够46字节,要在后面补填充位。****最大值1500称为以太网的最大传输单元(MTU)****,不同的网络类型有不同的MTU,如果一个数据包从以太网路由到拨号链路上,数据包长度大于拨号链路的MTU,则需要对数据包进行分片(fragmentation)。ifconfig命令输出中也有“宝贝”。注意,MTU这个概念指数据帧中有效载荷的最大长度,不包括帧头长度。
我们在进行网络请求的时候往外只知道服务器IP但是不知道服务器的mac的地址,而以太网协议依靠mac地址才能进行网卡间的信息传递,ARP协议就是用来根据IP查询目标服务器mac地址的(请求分组时广播 响应分组是单播)。
查看ARP抓包,ARP缓存演示,ARP协议抓包分析,广播,单播(192.168.168.162)
每台主机都维护一个ARP缓存表,可以用arp -a命令查看,交换机上没有ARP缓存表,因为交换机是二层协议。
科普:什么是广播风暴?交换机在局域网内信息传递时起到什么作用?
转发:是决定数据帧转向那个端口发出,这个过程需要借助交换机表完成!
过滤:决定一个数据帧是应该转发到接口还是丢弃,需要借助交换机表!
注:比如mac1发送到mac9,广播后找不到mac9会在交集表上记录一个mac9=en0,下次mac1再访问mac9时看表就知道已经广播过了,可以直接过滤了。
自学习:建立MAC地址和端口的一一对应,当收到数据帧后检查目的MAC,通过查找交换表,从而查出要从那个端口把这个数据发出去!
广播风暴就是在一个较大的局域网内多台计算机发起广播导致整个局域网网络拥挤甚至瘫痪,这时候就需要路由器来隔离广播域。路由器有WAN(链接外网)和LAN(链接内网)两种端口,LAN端口和交换机的端口一样可以进行广播转发,但如果有消息到了WAN端口路由器就会分析以太网帧协议内部的IP协议结合路由表来判断是否要转发这个消息(广播)。会将目标IP地址和自己的掩码相与,发现目标IP的网络号和自己接受这个数据包的端口所在的网络号一致,属于同一个网段,数据内部转发,根本不需要通过路由器WAN端口,所以就会把这个数据包丢弃!
RARP协议广泛应用于无盘工作站引导时获取IP地址。RARP允许局域网的物理机器从网关服务器ARP表或者缓存上请求其IP地址。
试想全世界的电脑都在一个局域网内仅仅依靠以太网帧协议根据mac地址进行消息传递,A计算机想访问B计算机但是不知道B计算机的mac地址于是发起了ARP广播询问B计算机的mac地址这个广播将传到全球所以的计算机上,而且世界上每时每刻都有很多计算机直接进行交流,如果采用这种模式将造成全世界级别的广播风暴。所以广播域不能太大,需要将全球的广播域切分成一个个小的广播域。mac地址是由厂商决定的无规律,无法区分广播域,所以产生了IP协议作用与网络层用于区分不同的广播域(子网)。
IP地址分为网络部分和主机部分,网络部分表示ip所属子网,主机部分表示ip所属主机,单纯的ip地址是无法区分改ip所属的子网的,必须配合子网掩码。
所谓”子网掩码”,就是表示子网络特征的一个参数。它在形式上等同于IP地址,也是一个32位二进制数字,它的网络部分全部为1,主机部分全部为0。比如,IP地址192.168.93.101,如果已知网络部分是前24位,主机部分是后8位,那么子网络掩码就是11111111.11111111.11111111.00000000,写成十进制就是255.255.255.0。
知道”子网掩码”,我们就能判断,任意两个IP地址是否处在同一个子网络。方法是将两个IP地址与子网掩码分别进行AND运算(两个数位都为1,运算结果为1,否则为0),然后比较结果是否相同,如果是的话,就表明它们在同一个子网络中,否则就不是。
比如判断 192.168.93.101/24和192.168.93.103/24是否属于同一子网
192.168.93.101——》11000000.10101000.10111010.11001010
255.255.255.0——》 11111111.11111111.11111111.00000000
等于11000000.10101000.10111010.00000000=192.168.93.0
192.168.93.103——》11000000.10101000.10111010.11001110
255.255.255.0——》 11111111.11111111.11111111.00000000
等于11000000.10101000.10111010.00000000=192.168.93.0
所以192.168.93.101/24和192.168.93.103/24 属于同一子网
IP地址的子网掩码设置不是任意的,随意设置过大或者过小都会影响网络数据传输。
如果将子网掩码设置过大,也就是说子网范围扩大,那么,根据子网寻径规则,很可能发往和本地主机不在同一子网内的目标主机的数据,会因为错误的判断而认为目标主机是在同一子网内,那么,数据包将在本子网内循环,直到超时并抛弃,使数据不能正确到达目标主机,导致网络传输错误;如果将子网掩码设置得过小,那么就会将本来属于同一子网内的机器之间的通信当做是跨子网传输,数据包都交给缺省网关处理,这样势必增加网关的负担,造成网络效率下降。
这里主要关注TTL和8位协议,其中TTL(Time to live)代表这个数据包的存活时间,没调一个路由节点减一,如果减到0则丢弃该包,防止由于部分路由器故障导致网络阻塞(画图);8位协议则是记录上层使用的协议如TCP、UDP。
在Linux内核中都维护了一张路由表,可以使用route -n查看,它存储了本地计算机可以到达的网络目的地址范围和如何到达的路由信息。路由表是TCP/IP通信的基础,本地计算机上的任何TCP/IP通信都受到路由表的控制。当我们要访问一个目标IP时数据包从那个网口发出,下一跳的以太网帧目的mac地址填什么都要基于路由表+路由规则计算得到。
列名 | 备注 |
---|---|
Destination/目的地 | Destination 为 default(0.0.0.0)时,表示这个是默认网关,当路由表中匹配不到路径时数据包发到这个网关。 |
Gateway/网关 | 网关地址,0.0.0.0 表示当前记录对应的 Destination 跟本机在同一个网段同一个广播域,通信时不需要经过网关,ip为目标ip,mac为目标ip对应的mac,可以通过ARP广播获取。 |
Genmask/子网掩码 | Destination 字段的网络掩码,Destination 是主机时需要设为 255.255.255.255,是默认路由时会设置为 0.0.0.0,0.0.0.0则会匹配全部目标地址。 |
Flags/标记 | ● U 该路由可以使用。 ● H 该路由是到一个主机,也就是说,目的地址是一个完整的主机地址。如果没有设置该标志,说明该路由是到一个网络,而目的地址是一个网络地址:一个网络号,或者网络号与子网号的组合。 ● G 该路由是到一个网关(路由器)。如果没有设置该标志,说明目的地 是直接相连的。 ● R 恢复动态路由产生的表项。 ● D 该路由是由改变路由(redirect)报文创建的。 ● M 该路由已被改变路由报文修改。 ● ! 这个路由将不会被接受。 |
Metric/路由距离 | 到达指定网络所需的中转数,是大型局域网和广域网设置所必需的。 |
Ref/路由引用次数 | 路由项引用次数 |
Iface/网卡 | 网卡名字,例如 eth0。 |
Use/此路由项被软件查找的次数 | 此路由项被路由软件查找的次数 |
首先当要访问一个ip时首先根据路由规则确定路由项即需要走路由表的的哪一行:
TCP/IP选择记录下的路由项中的最长匹配路由(网络掩码中具有最多“1”位的路由项)来和此目的IP地址进行通信。
如果存在多个最长匹配路由,那么选择具有最低跃点数的路由项。
如果存在多个具有最低跃点数的最长匹配路由,那么:均根据最长匹配路由所对应的网络接口在网络连接的高级设置中的绑定优先级来决定(一般有线(eth0) > 无线 (wlan0) > 移动信号(4G))。
如果优先级一致,则选择最开始找到的最长匹配路由。
(a) 通过路由项中对应的网络接口发送;
(b) 源IP地址为此网络接口的IP地址;
© 源MAC地址为此网络接口的MAC地址;
(d) 目的IP地址为接收此数据包的目的主机的IP地址;
(e) 目的MAC地址为接收此数据包的目的主机的MAC地址;
(a). 通过路由项中对应的网络接口发送;
(b) 源IP地址为路由项中对应网络接口的IP地址;
© 源MAC地址路由项中对应网络接口的MAC地址;
(d) 目的IP地址为接收此数据包的目的主机的IP地址;
(e) 目的MAC地址为网关的MAC地址;
有了IP协议和路由器的帮忙数据已经能进行跨网络传递了,可以从互联网的一个电脑将消息发到互联网的另一台电脑,但是一个电脑上可以有多个服务器进程,每个服务器进程也都可以与多个客户端进行通信,这个时候ip协议就不能满足了,需要一个规则能区分同一个电脑上与其他电脑的多个连接,所以有了传输层协议。
在TCP/IP协议中四元组:源IP地址、目的IP地址、源端口、目的端口是每个连接的唯一标识。比如:计算机A与计算机B的80端口一共可以创建65535的个连接。还可以在和计算机B的81再创建65535的个连接。
注:TCP和UDP也不会产生端口冲突,这时候就要考虑五元组了
我们常见bind:address already in use好像与上面四元组描述的有些不一致,关键就在这个bind,上述四元组一般在客户端会谈到。
在TCP协议栈里有bind和connect两个系统调用,bind为监听某个端口,当某个端口被监听后这个端口就会被缓存到一个数组,每当bind被调用都回去比对,bing函数常用与服务端监听端口。connect入参为目标服务器ip:port,常用在客户端,每当connect被调用时都会去已建立的四元组缓存中去比对。
TCP协议报文由源端口号和目的端口号,通讯的双方由IP地址和端口号标识。32位序号、32位确认序号、窗口大小(可接收的数据大小,取值从接受方滑动窗口和发送方拥塞窗口中取小者)稍后详细解释。4位首部长度和IP协议头类似,表示TCP协议头的长度,以4字节为单位,因此TCP协议头最长可以是4x15=60字节,如果没有选项字段,TCP协议头最短20字节。URG、ACK、PSH、RST、SYN、FIN是六个控制位
SYN表示建立连接,FIN表示关闭连接;ACK表示响应;PSH表示有 DATA数据传输;RST表示连接重置;URG表示紧急指针字段有效。
SYN、FIN、ACK位:其中ACK是可能与SYN,FIN等同时使用的,比如SYN和ACK可能同时为1,它表示的就是建立连接之后的响应。如果只是单个的一个SYN,它表示的只是建立连接。TCP的几次握手就是通过这样的ACK表现出来的。但SYN与FIN是不会同时为1的,因为前者表示的是建立连接,而后者表示的是断开连接。
RST位:用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。如果接收到 RST 位时候,通常发生了某些错误。如请求ip没有端口导致 RST 、取消一个已存在的连接、发送消息时对方已关闭等导致 RST。当RST=1时,表明TCP连接出现了异常,必须释放连接。
PSH位(Push):PSH为1的情况,一般只出现在 DATA内容不为0的包中,也就是说PSH为1表示的是有真正的TCP数据包内容被传递。TCP的连接建立和连接关闭,都是通过请求-响应的模式完成的。当两个应用进程进行交互式的通信中,有时在一端的应用程序希望在键入一个命令后立即收到对应的响应。在这种情况下,TCP就可以使用推送操作。通常的数据中都会带有PSH,但URG只在紧急数据才设置,也称“带外数据”。
URG位(URGent):当URG=1时,表示紧急指针字段有效。他告诉系统次报文段有紧急指针,应该尽快的处理(相当于高优先级的数据),而不要按照原来的排序序列来传送。若不使用紧急指针,那么这两个字符将存储在接收TCP的缓存末尾。只有在所有数据段被处理完毕后这两个字符才能被交付到接收方的网应用进程。URG是一个正偏移,与TCP首部中序号字段的值相加表示紧急数据后面的字节,即紧急指针是指向紧急数据最后一个字节的下一字节。
在这个例子中,首先客户端主动发起连接、发送请求,然后服务器端响应请求,然后客户端主动关闭连接。两条竖线表示通讯的两端,从上到下表示时间的先后顺序,注意,数据从一端传到网络的另一端也需要时间,所以图中的箭头都是斜的。双方发送的段按时间顺序编号为1-11,各段中的主要信息在箭头上标出,例如段2的箭头上标着SYN, 8000(0), ACK11, ,表示该段中的SYN位置1,32位序号是8000,该段不携带有效载荷(数据字节数为0),ACK位置1,32位确认序号是11,
建立连接(三次握手)的过程(半连接、全连接):
问:为什么要三次握手?
看下两次握手会产生什么影响就可以理解为什么要三次握手了。ClientA发送给ServerA 第一次发送SYN申请建立连接,但是因为网络原因阻塞ServerA没收到迟迟没有回复,于是第二次发送SYN申请建立连接,这次ServerA收到了并且回应了ACK给ClientA,在两次握手情景下此时连接建立,互相发送完请求和相应后正常关闭连接。但就在此时ClientA第一次发送的SYN数据包网络顺畅了到达了ServerA,但是ClientA已关闭甚至关机,但是由于两次握手ServerA回复ACK后建立连接,此时此连接会长期存在,会造成服务器资源浪费。
问:为什么要四次挥手?
看下三次挥手会产生什么影响就可以理解为什么要四次挥手了,ClientA发送FIN给ServerA申请关闭连接,如果是三次挥手ServerA接到后就必须回复ACK+FIN 回复ClientA的关闭请求以及向ClientA发起关闭请求,然后ClientA回复ServerA的关闭请求发送ACK。在三次挥手的此次流程里有一个问题就是serverA必须同时回复ACK+FIN,在很多时候client申请关闭连接时ServerA还在处理之前请求或者为响应完数据,此时serverA不应该发送FIN,应该先发送ACK等数据发送结束后再发送FIN申请关闭。所以需要四次挥手才能满足以上场景。
下面再在套接字层面上解释一下四次挥手干了什么:
网络套接字:socket(插座)
一个文件描述符指向一个套接字(该套接字内部由内核借助两个缓冲区实现。)在通信过程中, 套接字一定是成对出现的。
首先在Linux系统中一切皆文件,套接字也是一种特殊的文件为了建立网络通讯。欲建立连接的两个进程各自有一个socket来标识,即socket肯定时成对出现的,socket的包括读缓存区和写缓存区两部分。如下图,client的读缓存区对应server的写缓存区,client的写缓存区对应server的读缓存区。
在client发送FIN时就是告诉server“我要准备关闭啦,不会再给你发消息了,但是还能接收你的消息,你没发完的赶紧发”,server接收到以后回应ACK,此时client的写缓存区就关闭了,server的读缓存区关闭,此时称为半关闭状态。但是server还有消息没发完,等server发完消息以后,发送FIN给client“我消息发送完了,我也准备关闭了”,client收到以后回给server ACK,然后server的写缓存区和client的读缓存区关闭,连接真正关闭,释放资源。
在UDP协议中如果客户端发送消息很快,服务器处理的比较慢会导致服务器的读缓存区满掉,后面的消息就回丢失,在TCP协议中利用“滑动窗口”这一机制解决了这个问题。
发送端发起连接,声明最大段尺寸是1460,初始序号是0,窗口大小是4K,表示“我的接收缓冲区还有4K字节空闲,你发的数据不要超过4K”。接收端应答连接请求,声明最大段尺寸是1024,初始序号是8000,窗口大小是6K。发送端应答,三方握手结束。
发送端发出段4-9,每个段带1K的数据,发送端根据窗口大小知道接收端的缓冲区满了,因此停止发送数据。
接收端的应用程序提走2K数据,接收缓冲区又有了2K空闲,接收端发出段10,在应答已收到6K数据的同时声明窗口大小为2K。
接收端的应用程序又提走2K数据,接收缓冲区有4K空闲,接收端发出段11,重新声明窗口大小为4K。
发送端发出段12,带有1K数据
PS:累计确认?
TCP并不是每一个报文段都会回复ACK的,可能会对两个报文段发送一个ACK,也可能会对多个报文段发送1个ACK【累计ACK】,比如说发送方有1/2/3 3个报文段,先发送了2,3 两个报文段,但是接收方期望收到1报文段,这个时候2,3报文段就只能放在缓存中等待报文1的空洞被填上,如果报文1,一直不来,报文2/3也将被丢弃,如果报文1来了,那么会发送一个ACK对这3个报文进行一次确认。
上图第10步为累计确认,假如第6步报文丢失,则在第快重传的机制下,7/8/9步骤后都会收到服务端的ACK,2049 ;ACK,X确认机制代表了已收到确认序号X之前的全部数据,请继续发送SEQ=X的后续报文。如果服务端接收出现了乱序则会重复发送最后一个正常排序的报文ACK。
在这幅图中,涉及滑动窗口的四种概念:
滑动窗口也是有边界的,这个边界是 Left edge
和 Right edge
,Left edge 是窗口的左边界,Right edge 是窗口的右边界。
当 Left edge 向右移动而 Right edge 不变时,这个窗口可能处于 close
关闭状态。随着已发送的数据逐渐被确认从而导致窗口变小时,就会发生这种情况。
当 Right edge 向右移动时,窗口会处于 open
打开状态,允许发送更多的数据。当接收端进程读取缓冲区数据,从而使缓冲区接收更多数据时,就会处于这种状态。
TCP 滑动窗口的 Left edge 永远不可能向左移动,因为发送并确认的报文段永远不可能被取消,就像这世界上没有后悔药一样。这条边缘是由另一段发送的 ACK 号控制的。当 ACK 标号使窗口向右移动但是窗口大小没有改变时,则称该窗口向前滑动。
如果 ACK 的编号增加但是窗口通告信息随着其他 ACK 的到达却变小了,此时 Left edge 会接近 Right edge。当 Left edge 和 Right edge 重合时,此时发送方不会再传输任何数据,这种情况被称为零窗口
。此时 TCP 发送方会发起窗口探测
,等待合适的时机再发送数据。
接收方也维护了一个窗口结构,这个窗口要比发送方的简单很多。这个窗口记录了已经接收并确认的数据,以及它能够接收的最大序列号。接收方的窗口结构不会存储重复的报文段和 ACK,同时接收方的窗口也不会记录不应该收到的报文段和 ACK。下面是 TCP 接收方的窗口结构。
如上图,假如接收到了5、6此时Left edge不会移动,TCP协议栈会回复客户端ACK,4提示客户端需要发送序号为4的报文,直到接收到4,然后回复ACK,6并移动Left edge。
CLOSED:表示初始状态。
LISTEN:该状态表示服务器端的某个SOCKET处于监听状态,可以接受连接。
SYN_SENT:这个状态与SYN_RCVD遥相呼应,当客户端SOCKET执行CONNECT连接时,它首先发送SYN报文,随即进入到了SYN_SENT状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT状态表示客户端已发送SYN报文。
SYN_RCVD: 该状态表示接收到SYN报文,在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂。此种状态时,当收到客户端的ACK报文后,会进入到ESTABLISHED状态。
ESTABLISHED:表示连接已经建立。
FIN_WAIT_1: FIN_WAIT_1和FIN_WAIT_2状态的真正含义都是表示等待对方的FIN报文。区别是:
FIN_WAIT_1状态是当socket在ESTABLISHED状态时,想主动关闭连接,向对方发送了FIN报文,此时该socket进入到FIN_WAIT_1状态。
FIN_WAIT_2状态是当对方回应ACK后,该socket进入到FIN_WAIT_2状态,正常情况下,对方应马上回应ACK报文,所以FIN_WAIT_1状态一般较难见到,而FIN_WAIT_2状态可用netstat看到。
FIN_WAIT_2:主动关闭链接的一方,发出FIN收到ACK以后进入该状态。称之为半连接或半关闭状态。该状态下的socket只能接收数据,不能发。
TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,等2MSL后即可回到CLOSED可用状态。如果FIN_WAIT_1状态下,收到对方同时带 FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。
CLOSING: 这种状态较特殊,属于一种较罕见的状态。正常情况下,当你发送FIN报文后,按理来说是应该先收到(或同时收到)对方的 ACK报文,再收到对方的FIN报文。但是CLOSING状态表示你发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况呢?如果双方几乎在同时close一个SOCKET的话,那么就出现了双方同时发送FIN报文的情况,也即会出现CLOSING状态,表示双方都正在关闭SOCKET连接。
CLOSE_WAIT: 此种状态表示在等待关闭。当对方关闭一个SOCKET后发送FIN报文给自己,系统会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态。接下来呢,察看是否还有数据发送给对方,如果没有可以 close这个SOCKET,发送FIN报文给对方,即关闭连接。所以在CLOSE_WAIT状态下,需要关闭连接。
LAST_ACK: 该状态是被动关闭一方在发送FIN报文后,最后等待对方的ACK报文。当收到ACK报文后,即可以进入到CLOSED可用状态
PS:半开启?半关闭?
半关闭就是FIN_WAIT_2状态;
半开启是指在Client和Server正常完成握手链接后,一方由于断电等原因关闭且未能发送FIN,此时剩余的一方在发送消息前是无法感知的,这种状态称为半开启状态,很少见,再发送消息时会得到服务器的RST从而关闭。
抛出一个疑问为什么主动发起的一方在发送完最后的ACK之后还要再等2ML时间后才能真正关闭?
答:当Client最后的ACK由于网络原因未到Server时,Server还会重新发送FIN申请关闭,此时如果Client已经关闭则Server会一直卡在LAST_ACK状态无法关闭,所以Client延时关闭就是为了防止最后ACK消失的情况。
注:全连接半连接问题
rtt=网络+排队+真正服务时间
半连接队列的大小由/proc/sys/net/ipv4/tcp_max_syn_backlog
控制,Linux的默认是1024。
全连接队列的大小通过/proc/sys/net/core/somaxconn
指定Linux的默认是128,在使用listen函数时,内核会根据传入的backlog
参数与系统参数somaxconn,取二者的较小值。
SYN Cookie
技术可以让服务器在收到客户端的SYN
报文时,不分配资源保存客户端信息,而是将这些信息保存在SYN+ACK
的初始序号和时间戳中。对正常的连接,这些信息会随着ACK
报文被带回来。
/proc/sys/net/ipv4/tcp_abort_on_overflow=0默认
服务器
package com.lago;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.Iterator;
import java.util.Set;
public class SelectorServer {
public static void main(String[] args) throws Exception {
// 创建Socket服务器,绑定端口8080(调用socekt()函数时第二个参数为SOCK_STREAM)
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8080));
serverSocketChannel.configureBlocking(false);
// 创建多路复用器selector :调用epoll_create函数创建eventpoll
Selector selector = Selector.open();
// 为socket服务器添加selector的引用:调用epoll_ctl将eventpoll添加至socket的等待队列中,并制定感兴趣的事件为有客户端连接时。
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true){
// 调用epoll_wait,如果eventpoll中的rdlist(readList)不为空不阻塞,如果为空阻塞,并且将main函数java进程
// 添加至eventpoll的等待队列(进程挂起,释放cpu)
int select = selector.select();
// 如果有客户端连接服务器、或者收到客户端发来的消息,网卡会向cpu发送中断信号,触发中断程序唤醒java进程并且将有状态改变socket
// 对象添加至rdlist。所以能走到这一步说明rdlist里必然有socket,这里获取eventpoll中rdlist里的socket集合。
// rdlist中的socket处理完后不会清空,再有新的socket收到消息时会继续追加到rdlist中,为了防止socketA使用后
// 还始终在rdlist中,rdlist在遍历处理完后程序显示清空一下。
Set<SelectionKey> selectionKeys = selector.selectedKeys();
for (SelectionKey selectionKey : selectionKeys) {
if(selectionKey.isAcceptable()){
// 触发事件为有客户端连接,说明这个socket为服务端ServerSocketChannel
// 如果为服务端接收到新的连接,则获取新接入的客户端并且将客户端也添加selector的引用
ServerSocketChannel serverSocket = (ServerSocketChannel) selectionKey.channel();
// 获取新接入的客户端
SocketChannel client = serverSocket.accept();
// 为客户端也添加selector的引用:调用epoll_ctl将eventpoll添加至新接入客户端的等待队列中,并制定感兴趣的事件为接收到数据时。
client.configureBlocking(false);
client.register(selector,SelectionKey.OP_READ);
System.out.println("已经有客户端"+client.socket().getInetAddress().getHostAddress()+":"+client.socket().getPort());
}else if(selectionKey.isReadable()){
// 触发事件为接收到数据,说明这个socket为客户端SocketChannel
SocketChannel socketClient= (SocketChannel) selectionKey.channel();
ByteBuffer byteBuffers=ByteBuffer.allocateDirect(200);
// 获取客户端传入的消息
long read = socketClient.read(byteBuffers);
byteBuffers.flip();
String receiveData= Charset.forName("UTF-8").decode(byteBuffers).toString();
byteBuffers.clear();
System.out.println("接收到客户端"+socketClient.socket().getInetAddress().getHostAddress()+":"+socketClient.socket().getPort()+":"+receiveData);
}
}
// 清空已经rdlist里已经经过处理的socket引用
selectionKeys.clear();
}
}
}
客户端
public class Client01 {
public static void main(String[] args) throws Exception {
// 创建客户端设置服务器ip端口
Socket socket = new Socket("192.168.2.170", 8080);
Scanner scanner=new Scanner(System.in);
while (true){
String content = scanner.nextLine();
socket.getOutputStream().write(content.getBytes());
}
}
}
要想理解分片问题首先需要科普几个名词:分片、分段、MTU、MSS
MTU:以太网(Ethernet)数据帧的长度必须在46-1500字节之间,这是由以太网的物理特性决定的。这个1500字节被称为链路层的MTU(最大传输单元).
分片:当传输层传入网络层的数据过大,加上网络层IP前缀后大于1500是,为了满足MTU限制,会在网络层对数据进行分片,使每一片都小于MTU,在接收端的网络层在进行重新组合,这样做的弊端是当其中一片数据包丢失时,导致无法组合,在UDP协议中此时如果上层(应用层)没有重传机制则只能丢弃全部数据,即使有重传机制也要将整个数据包进行重传(重传机制应用层肯定要为数据包做编号,应用层的编号只能加到数据报文的开始和结尾,如果触发了分片就算应用层设置了编号也不能确认那个分片丢失,需要整个数据报文重发),所以不管上层是什么协议尽量要避免网络层触发数据分片。
MSS:MSS就是TCP数据包每次能够传输的最大数据分段。TCP协议在连接建立阶段进行三次握手的时候会确认此次连接的MSS值,默认取通讯双方MSS的的最小值作为此次连接的MSS最大值即TCP数据包每次能够传输的最大数据分段。
分段:分段是TCP的一个特性,当应用层传输的数据过大超过MSS时,TCP在传输层会进行分段,保证每个分段都小于MSS,TCP的每个分段都带有序号,在接收端的传输层可以进行重新排序组合,相比与网络层的分片组合,如果有一个分段消失,结合TCP的“确认和重传机制
”可以支持单个分段的重传而不用全部重传。
看完上面四个名词的解释,大家应该也明白网络分片带来的影响不会出现的TCP协议中,因为MSS的值会小于MTU,所以如果传输层使用的是TCP协议到达网络层后都满足MTU,不用进行分片,所以在使用TCP协议时,无需在应用层(用户程序)去控制每次发送数据包的大小。
常规总结肯定是,TCP协议是一种作用在传输层的面向连接的可靠的数据传输控制协议;但是这里的总结是结合上面所讲来分析这就话的加粗部分体现在哪儿。
UDP的报文相对于TCP来看简单了很多,因为UDP要做的事情也比TCP简单的多,甚至都不需要建立连接,在发送端只负责将应用层要发送的数据包丢给网络层,在接收端只需要将网络层中接收的数据包丢给应用层,不保证数据是否丢失。
由于UDP无需三次握手创建连接,也无需四次挥手断开连接,所以UDP的通讯时序图没什么意思,每条交互都是双方互发数据,没什么特别含义,这里用socket的系统调用图来展现UDP的交互细节,同时附一个TCP版的方便比较:
根据上图比较可看成,UDP客户端缺少了connect(),服务端缺少了accept(),在TCP的总结里也有提到connect函数触发了三次握手创建连接,这里也论证了UDP无需面向连接这一理论。
服务器
PS:此服务器为简单版UDP服务器,仅仅添加了个消息收到确认机制,并未添加超时重发、排序等机制。
public class SelectorUDPServer {
public static void main(String[] args)throws Exception {
// 创建UDP服务器,绑定端口,设置为非阻塞(调用socekt()函数时第二个参数为SOCK_DGRAM)
DatagramChannel datagramChannel=DatagramChannel.open();
datagramChannel.bind(new InetSocketAddress("192.168.2.170",8080));
datagramChannel.configureBlocking(false);
// 创建多路复用器:调用epoll_create创建eventpoll
Selector selector = Selector.open();
// 为socket服务器添加selector的引用:调用epoll_ctl将eventpoll添加至socket的等待队列中,并制定感兴趣的事件为有客户端连接时。
datagramChannel.register(selector, SelectionKey.OP_READ);
while (true){
// 调用epoll_wait,如果eventpoll中的rdlist(readList)不为空不阻塞,如果为空阻塞,并且将main函数java进程
// 添加至eventpoll的等待队列(进程挂起,释放cpu)
int select = selector.select();
if(select>0){
// 如果有客户端连接服务器、或者收到客户端发来的消息,网卡会向cpu发送中断信号,触发中断程序唤醒java进程并且将有状态改变socket
// 对象添加至rdlist。所以能走到这一步说明rdlist里必然有socket,这里获取eventpoll中rdlist里的socket集合。
// rdlist中的socket处理完后不会清空,再有新的socket收到消息时会继续追加到rdlist中,为了防止socketA使用后
// 还始终在rdlist中,rdlist在遍历处理完后程序显示清空一下。
Set<SelectionKey> selectionKeys = selector.selectedKeys();
ByteBuffer byteBuffer=ByteBuffer.allocateDirect(1480);
for (SelectionKey selectionKey : selectionKeys) {
if(selectionKey.isReadable()){
// 触发事件为接收到数据,说明这个socket为客户端SocketChannel
DatagramChannel channel= (DatagramChannel) selectionKey.channel();
// 获取客户端传入的消息
InetSocketAddress receive = (InetSocketAddress) channel.receive(byteBuffer);
byteBuffer.flip();
String receiveData= Charset.forName("UTF-8").decode(byteBuffer).toString();
System.out.println("服务器接收到客户端"+receive.getAddress().getHostAddress()+":"+receive.getPort()+"发来的消息》》"+receiveData);
// 给客户端回复消息已收到
channel.send(ByteBuffer.wrap(("以接收到消息**"+receiveData+"**").getBytes()),receive);
byteBuffer.clear();
}
}
// 清空已经rdlist里已经经过处理的socket引用
selectionKeys.clear();
}
}
}
}
客户端
PS:客户端与服务器基本类似故未增加过度注释
public class UDPClient {
public static void main(String[] args) throws Exception {
DatagramChannel client=DatagramChannel.open();
// 只是在javaAPI中为了流程统一调用了connect方法,其实并未调用系统函数connect
client.connect(new InetSocketAddress("192.168.2.170",8080));
Selector selector=Selector.open();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
while (true){
Scanner scanner=new Scanner(System.in);
String content = scanner.nextLine();
client.write(ByteBuffer.wrap(content.getBytes()));
int select = selector.select();
if(select>0){
Set<SelectionKey> selectionKeys = selector.selectedKeys();
ByteBuffer byteBuffer=ByteBuffer.allocateDirect(1480);
for (SelectionKey selectionKey : selectionKeys) {
if(selectionKey.isReadable()){
DatagramChannel channel= (DatagramChannel) selectionKey.channel();
InetSocketAddress receive = (InetSocketAddress) channel.receive(byteBuffer);
byteBuffer.flip();
String receiveData= Charset.forName("UTF-8").decode(byteBuffer).toString();
System.out.println("客户端接收到服务器"+receive.getAddress().getHostAddress()+":"+receive.getPort()+"发来的消息》》"+receiveData);
byteBuffer.clear();
}
}
selectionKeys.clear();
}
}
}
}
UDP比起TCP就简单暴力的多,应用层给我什么数据我就一次性都给到网络层,并没有MSS等限制,所以在应用层一次想要发送大量数据时就会触发网络层的数据分片,然而UDP的接收端也不会考虑网络层重组的分片是否完整直接将数据给到应用层,所以应用层最好控制每次发送数据包的大小,保证到达网络层后数据包小于MTU不会触发网络分片(应用层的编号只能加到数据报文的开始和结尾,如果触发了分片就算应用层设置了编号也不能确认那个分片丢失,需要整个数据报文重发),除此之外需要为每个数据包设计编号,并且包含超时重发机制。
PS:UDP常说收到消息时顺序有可能错乱,并不是由分片引起的,分片是指发送端发送一段报文数据过大,被网络层进行分片处理,这里分片处理后到达接收端的网络层肯定可以按顺序排好序的;UDP乱序情景是发送端发送多段符合MTU的报文时,到达接收端后由于每个段报文走的路由可能不同可能导致多段数据到达顺序错乱,所以如果对数据展示顺序有严格要求时应用层最要有排序机制。同时也是因为UDP应用层需要考虑很多机制所以不可能到达一个报文展示一个报文,所以也推荐有缓存机制,接收端消息模块 处理完一组报文后放入缓存,接收端展示模块去读缓存中正确排序且完整的数据进行展示。
同TCP一样这里的总结的主要内容就是解析“UDP协议是一种如用在传输层的无连接的不可靠的数据传输控制协议”中加粗字的含义体现在哪里。
TCP的拥塞控制机制是TCP中相对较复杂的一块,但是有利于我们根据报文规律分析当前网络状态。
在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络性能就要变坏,这种情况就叫做网络拥塞。
在计算机网络中数位链路容量(即带宽)、交换结点中的缓存和处理机等,都是网络的资源。
若出现拥塞而不进行控制,整个网络的吞吐量将随输入负荷的增大而下降。
TCP有四种拥塞控制算法:
这里需要先假定一个场景:
数据是单方向传送,而另一个方向只传送确认
接收方总是有足够大的缓存空间,因而发送方发送窗口的大小由网络的拥塞程度来决定(正常情况下还会受接收方的滑动窗口大小限制)
以TCP报文段MSS的个数为讨论问题的单位,而不是以字节为单位
发送方维护一个叫做拥塞窗口cwnd的状态变量,其值取决于网络的拥塞程度,并且动态变化
拥塞窗口cwnd的维护原则:只要网络没有出现阻塞,拥塞窗口就增大一些,如果出现阻塞,拥塞窗口就变小一些。判断网络拥塞的依据:没有按时收到应当到达的确认报文即发生了超时重传。
在假定2的情况下,发送方将拥塞窗口作为发送窗帘swnd,即swnd=cwnd。
维护一个慢开始门限ssthresh状态变量:
当cwnd
当cwnd>ssthresh时使用拥塞避免算法
当cwnd=ssthresh时使用慢开始算法或使用拥塞控制算法都可以
慢开始算法是每轮传输后窗口翻倍是指数增加,直到cwnd=ssthresh此时切换拥塞避免算法,每轮传输后窗口+1,直到出现拥塞后(出现因丢包导致的超时重传现象),将ssthresh值更新为出现拥塞时cwnd的一半,将cwnd置为1并重新开始执行慢开始算法。
慢开始:是指一开始先网络注入的报文段少,并不是指拥塞窗口cwnd的增长速度慢。
拥塞避免:并非指完全能够避免阻塞,而是指在拥塞避免阶段拥塞窗口增长比较慢不太容易出现拥塞。
快重传算法和慢恢复算法是对慢开始和拥塞避免算法的优化。
因为有时个别报文段会在网络中丢失,但是网络并未发生拥塞,在没有快重传算法时,在假定1的前提下将导致发送方超时重传,误认为网络发生了拥塞,重新启用慢开始算法,因而降低了网络传输的效率。采用快重传算法可以让发送方尽早知道发生了个别报文的丢失并,避免触发超时重传。
所谓快重传就是使发送方尽快进行重传,而不是等超时计时器超时再重传。
要求接收方不要等待自己发送数据时才进行捎带确认,而是要立即发送确认
即使收到了失序报文段也要立即发出对已收到报文段的重复确认
发送方一旦收到3个连续的重复确认就将相应的报文立即重传,避免超时计时器超时
如果没有快重传算法,在M3报文丢失后除非接受方有业务数据主动传给发送方(捎带发送确认M2)将不会再次回复确认M2,此时网络并没有拥塞而发送方法必然触发超时重传导致启动慢开始算法。有了快重传机制后在接收方接受失序报文M4、M5、M6时也会回复确认M2(对已收到报文段的重复确认),发送方即可在M3报文确认回复超时计时器超时前发现M3未发送成功并重新发送,由于网络正常很快就能收到M3的确认回复(上图的确认M6为累计确认现象,代表M6之前报文已经全部收到了)避免触发慢开始算法。据监测快重传算法可以使网络吞吐量提升百分之20。
发送方一旦收到了三个重复确认(触发了快重传),就知道只是丢失了个别报文段,并不是网络阻塞此时不启动慢开始算法而是启动快恢复算法。
发送方将慢开始门限ssthresh值和拥塞窗口cwnd值调整为当前窗口的一般并开始执行拥塞避免算法
也有的快恢复实现是把快恢复开始时的拥塞窗口cwnd值再增大一些,即等于新的ssthresh+3
此节通过java代码IO模型实现配合内核调用日志来分析linux的IO模型由阻塞IO——>非阻塞IO——>IO多路复用 的演变过程,以及演变原因,由于信号驱动 IO模型与TCP协议不适配,异步IO在linux中不成熟故不进行讨论。
BIO为阻塞型IO模型,在接收客户端连接(accept)和读取客户端发送数据(recv)时会发生阻塞。
解释一下这里的阻塞:
首先我们看下单线程下的BIO服务器实现:
package com.lago;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class OneThreadBIOServer {
public static void main(String[] args)throws Exception {
//创建单线程阻塞型Socket服务器,绑定监听端口
ServerSocket serverSocket = new ServerSocket();
InetSocketAddress inetSocketAddress = new InetSocketAddress("192.168.2.170", 8080);
serverSocket.bind(inetSocketAddress);
while (true){
// 等待客户端连接
Socket client = serverSocket.accept();
System.out.println("已经有客户端"+client.getInetAddress().getHostAddress()+":"+client.getPort());
byte[] buffer=new byte[200];
int read = client.getInputStream().read(buffer);
// 通过连接通道读取客户端发送的消息调用系统函数rect();
String body = new String(buffer, 0, read, "UTF-8");
System.out.println("接到客户端"+client.getInetAddress().getHostAddress()+":"+client.getPort()+"消息:"+body);
}
}
}
结合1.1中的弊端和解决思路看下多线程下的BIO服务端实现,此时服务器支持同时处理多个连接但是也存在显而易见的问题
package com.lago;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class ManyThreadBIOServer {
public static void main(String[] args) throws Exception{
// 创建阻塞型消息服务器,并且绑定监听端口8080
ServerSocket serverSocket = new ServerSocket();
InetSocketAddress inetSocketAddress = new InetSocketAddress("192.168.2.170", 8080);
serverSocket.bind(inetSocketAddress);
while (true){
// 等待客户端连接
Socket client = serverSocket.accept();
System.out.println("已经有客户端"+client.getInetAddress().getHostAddress()+":"+client.getPort());
// 创建新的线程,在线程里进行客户端的消息读取,避免客户端只连接不发消息时服务器无法接受其他客户端连接
Thread thread = new Thread(new ClientThread(client));
thread.start();
}
}
public static class ClientThread implements Runnable{
private Socket client;
public ClientThread(Socket client){
this.client=client;
}
@Override
public void run(){
byte[] buffer=new byte[200];
while (true){
// 通过连接建立的通道读取客户端发送的消息
String content = null;
try {
int read = client.getInputStream().read(buffer);
content = new String(buffer, 0, read, "UTF-8");
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("接到客户端"+client.getInetAddress().getHostAddress()+":"+client.getPort()+"消息:"+content);
}
}
}
}
应用户需求,linux内核进行了升级推出来新的函数fcntl可以标记socket为非阻塞,标记了非阻塞后的socket在调用acept和recv函数时无论有无连接或数据都会返回不会阻塞。看下NIO模型单线程下支持并发的服务器的代码,在某些情境下还是存在弊端:
package com.lago;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.LinkedList;
import java.util.List;
public class NIOServer {
public static void main(String[] args)throws Exception {
// 以连接服务器的客户端集合
List<SocketChannel> sockets=new LinkedList<>();
// 创建socket服务器,设置为非阻塞类型,绑定并监听端口
ServerSocketChannel serverSocketChannel=ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
InetSocketAddress inetSocketAddress = new InetSocketAddress("192.168.2.170", 8080);
serverSocketChannel.bind(inetSocketAddress);
while (true){
Thread.sleep(1000);
// 获取连接服务器的客户端
SocketChannel client = serverSocketChannel.accept();
if (client!=null){
// 设置客户端为非阻塞类型,保证rect()时,无消息也不阻塞。
client.configureBlocking(false);
System.out.println("已经有客户端"+client.socket().getInetAddress().getHostAddress()+":"+client.socket().getPort());
// 将新连接的客户端添加到以连接客户端集合
sockets.add(client);
}else {
System.out.println("无客户端连接...");
}
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(200);
for (SocketChannel socketChannel : sockets) {
int read = socketChannel.read(byteBuffer);
if(read>0){
byteBuffer.flip();
byte[] buffer=new byte[byteBuffer.limit()];
byteBuffer.get(buffer);
String content = new String(buffer, 0, read, "UTF-8");
System.out.println("接到客户端"+socketChannel.socket().getInetAddress().getHostAddress()+":"+socketChannel.socket().getPort()+"消息:"+content);
byteBuffer.clear();
}
}
}
}
}
应用户需求,内核升级提供了三个多路复用器函数依次是selector、poll、epoll,目前在并发量很大的情景下用的最多的是epoll,而且优势明显。多路复用即让一个进程去监听多个socket。
依次看下这三个多路复用器的实现原理:
要想真真正正的理解selector就必须要理解fd_set这种数据结构(selector之fd_set),是一种long类型的数组,每一个元素都能与一个一打开的文件句柄(文件描述符)建立联系,建立关心的过程由程序员完成,当调用select()时由内核根据文件的IO状态来修改fd_set中对应位置的元素值,由此来通知执行了select()的进程那个socket或文件是可读的。
了解的selector的fd_set后下面分别从代码实现(应用程序角度)和实现原理(内核角度)两方面来剖析selector,并分析其被取代的原因。
由于java中IOAPI中的Selector.open();在最新的linux中显式调用时底层都是采用的epoll并未采用selector,所以selector的代码实现我们采用c++来显式调用,并且用c++实现更能体现fd_set的妙用和重要性,方便理解。对c++不熟悉的同学,可以只关注我备注的代码行。
// 创建服务器socket,sockfd为socket文件的文件描述符。
sockfd =socket(AF_INET,SOCK_STREAM,0);
memset(&addr,0,sizeof(addr));
addr.sin_family=AF_INET;
addr.sin_port=htons(2000);
addr.sin_addr.s_addr=INADDR_ANY;
// 为服务器socket绑定2000端口
bind(sockfd,(struct sockaddr*)&addr,sizeof(addr));
// 监听2000端口,并设置客户端最大等待数量为5
listen(sockfd,5);
//(~ ̄▽ ̄)~ 此时我们启动了5个客户端连接当前服务端(客户端代码就不赘述了)
for(i=0;i<5;i++){
memset(&client,0,sizeof(client));
addrlen=sizeof(client);
// 由于此时已有五个客户端连接了服务器,此处for循环调用accept函数(看不懂参数无需关心,只要知道accpet函数作用即可)获取5个客户端的文件描述符,组成文件描述符数组fds[],假设数组值为[3,5,6,7]
fds[i]=accept(sockfd,(struct sockaddr*)&client,&addrlen);
if(fds[i]>max){
// 记录这五个client的文件描述符最大值,max=7
max=fds[i];
}
}
//(~ ̄▽ ̄)~ 别眨眼,主角登场
while(1){
// rset即fd_set类型的数据,FD_ZERO的作用是将rset中的值全部置零[0,0,0,0,0,0,0,0,....]
FD_ZERO(&rset)
for(i=1;i<5;i++){
// 将rset中下标等于fds[i]的元素置1,循环完毕后rset=[0,0,0,1,0,1,1,1,0,....]
FD_SET(fds[i],&rset);
}
// 调用select函数监听五个客户端文件是否有消息可读
// 第一个参数max+1代表rset的最大有效长度,rset默认长度为1024,但是目前最大有效长度为8,第8位以后都是0,内核遍历到8即可,可以提高性能。
// 第二个参数要求传入监听文件有可惜可读事件,第三个监听可写事件...,我们这里只关心客户端是否发送消息过来,故只想参数2中传入rset
// select会做什么事情呢? select函数会在内核中遍历rset检测对应的文件句柄是否有消息可读,如果有消息可读则对应值不变,如果无消息可读则值置0,加入只要客户端3,6发了消息,那执行完select之后rset值为[0,0,0,1,0,0,1,0,0,....]
select(max+1,&rset,null,null,null);
for(i=0;i<5;i++){
// 判断rset中下标为fds[i]的值是否为1
if(FD_INSET(fds[i],&rset)){
memset(buffer,0,MAXBUF);
// 读取客户端发来的消息
read(fds[i],buffer,MAXBUF);
}
}
}
在代码实现层面(应用程序角度)分析selector的弊端:
selector原理分析的原理剖析其实就是剖析select()函数到底干了什么事情,在应用程序执行select函数时会有两种情景1.select要监听的socket集合已接收到客户端消息,读缓存区中已有可读取数据;2.select要监听的socket集合未接收到客户端消息,读缓存区中没有可读取数据。接下来分别进行分析。
当前情景下遍历完rset会发现rset所有元素都是0,即说明socket集合中无接收到消息的socket,当前线程需要进入阻塞状态,从运行队列中取出来,遍历 socket集合为全部socket的等待队列添加当前进程的引用。
当socket1对应客户端程序发来消息时,在消息到达服务器网卡时会触发网卡中断执行中断程序,首先将消息内容copy到socket1的读缓存区中,然后遍历全部socket解除进程中在缓存区中的引用(必须解除全部缓存区引用进程A才能解除阻塞,进入运行队列,分取cpu资源)。
然后切换成用户态,并且将rset复制回来,由应用程序进行消息读取
selector弊端总结:
解决设想:
poll的代码实现和实现原理与selector一样,只是rset不在限制数量,打破了1024的限制,可以监听更多socket,但是并没有解决selector的弊端,在大量并发的情景下,由于应用程序和内核都需要进行多次socket的遍历以及越来越大rset copy,随着并发量的增加性能会越来越低。
epoll的出现彻底打破了selector的弊端,下面还是在代码实现(用应程序角度)和实现原理(内核角度)来分析epoll是如何打破selector的弊端完成我们的解决设想的
此处采用了java代码进行实现,但是要知道java之所以跨平台是因为jdk已经在底层进行了封装为我们屏蔽了操作系统的差异性,epoll在linux中一共提供了三个核心函数epoll_create/epoll_ctl/epoll_wait,在下面的代码备注中有注明。
package com.lago;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.Iterator;
import java.util.Set;
public class SelectorServer {
public static void main(String[] args) throws Exception {
// 创建Socket服务器,绑定端口8080
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8080));
serverSocketChannel.configureBlocking(false);
// 创建多路复用器selector :调用epoll_create函数创建eventpoll
Selector selector = Selector.open();
// 为socket服务器添加selector的引用:调用epoll_ctl将eventpoll添加至socket的等待队列中,并制定感兴趣的事件为有客户端连接时。
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true){
// 调用epoll_wait,如果eventpoll中的rdlist(readList)不为空不阻塞,如果为空阻塞,并且将main函数java进程
// 添加至eventpoll的等待队列(进程挂起,释放cpu)
int select = selector.select();
// 如果有客户端连接服务器、或者收到客户端发来的消息,网卡会向cpu发送中断信号,触发中断程序唤醒java进程并且将有状态改变socket
// 对象添加至rdlist。所以能走到这一步说明rdlist里必然有socket,这里获取eventpoll中rdlist里的socket集合。
// rdlist中的socket处理完后不会清空,再有新的socket收到消息时会继续追加到rdlist中,为了防止socketA使用后
// 还始终在rdlist中,rdlist在遍历处理完后程序显示清空一下。
Set<SelectionKey> selectionKeys = selector.selectedKeys();
for (SelectionKey selectionKey : selectionKeys) {
if(selectionKey.isAcceptable()){
// 触发事件为有客户端连接,说明这个socket为服务端ServerSocketChannel
// 如果为服务端接收到新的连接,则获取新接入的客户端并且将客户端也添加selector的引用
ServerSocketChannel serverSocket = (ServerSocketChannel) selectionKey.channel();
// 获取新接入的客户端
SocketChannel client = serverSocket.accept();
// 为客户端也添加selector的引用:调用epoll_ctl将eventpoll添加至新接入客户端的等待队列中,并制定感兴趣的事件为接收到数据时。
client.configureBlocking(false);
client.register(selector,SelectionKey.OP_READ);
System.out.println("已经有客户端"+client.socket().getInetAddress().getHostAddress()+":"+client.socket().getPort());
}else if(selectionKey.isReadable()){
// 触发事件为接收到数据,说明这个socket为客户端SocketChannel
SocketChannel socketClient= (SocketChannel) selectionKey.channel();
ByteBuffer byteBuffers=ByteBuffer.allocateDirect(200);
// 获取客户端传入的消息
long read = socketClient.read(byteBuffers);
byteBuffers.flip();
String receiveData= Charset.forName("UTF-8").decode(byteBuffers).toString();
byteBuffers.clear();
System.out.println("接收到客户端"+socketClient.socket().getInetAddress().getHostAddress()+":"+socketClient.socket().getPort()+":"+receiveData);
}
}
// 清空已经rdlist里已经经过处理的socket引用
selectionKeys.clear();
}
}
}
在代码层面分析比较epoll的优势:
epoll原理其实就是分析epoll_create/epoll_ctl/epoll_wait三个核心函数究竟干了什么,下面逐个分析
epoll_create会创建一个eventpoll对象包括:
将需要监听的socket添加到epoll_event的监听事件列表中
当进程A调用epoll_wait时会首先检测eventpoll的就绪列表中有误数据,如果有数据之间返回;如果没有数据则将进程中变为阻塞状态,从运行队列中取出来,添加到eventpoll的等待队列中,并且将event_poll添加到监听事件列表中所有socket的等待队列中(此处注意两点1.相对于selector的遍历rset看是否有socket就绪,epoll无需遍历之间检测就绪队列是否有数据即可,在高并发时可以提升性能;2.相对于selector进程添加到各个socket的等待队列,epoll将进程A添加到event_poll的等待队列,方便后面的释放)
当client1对应客户端发来消息时,消息到达了网卡时触发网卡硬件中断,中断程序首先将消息copy到client1对应socket的读缓存区里,然后通过等待队列中的event_poll引用找到event_poll,为event_poll的就绪队列里添加自己的引用,并且移除等待队列中的event_poll同时移除event_poll等待队列中的进程A
进程A回到运行队列分配到cup,获取到就行列表中的socket引用,遍历进行消息读取(遍历的全都是活跃连接,都是有效遍历)
注:epoll的优势就是不会随着socket的增加而性能下降。但事情无绝对,并不是全部场景都推荐使用epoll。在并发量小,并且都是活跃连接的情况下selector反而更合适一些;通过上面的原理分析不难看出epoll设计相对于selector的无脑遍历更复杂一些,类似于空间换时间,自然会有一些额外消耗,只有在连接高到一定数量的情况下,epoll的额外消耗才能抵消selector的遍历,在高并发下才能显现他的优势