网络IO模型BIO->Select->Epoll多路复用的进化史原创
tcpdump抓取网络请求包
-
监听从eth0网卡发出去的,请求80端口的网络包
-i 是iterface接口,eth0是网卡;抓80端口,抓从eth0网卡出去的访问80端口的网络包。
-
通过curl访问百度首页
访问百度,http协议80端口,
就可以监听到完整的网络请求过程,
其中包含TCP三次握手、四次分手的过程,但没有体现出http协议的概念,
-
与百度服务器建立TCP连接
和百度80端口,建立了TCP连接,
-
用文件描述符代表这个连接
用一个数字9代表这个连接,现在只是和百度握手,握手了之后,再把基于http协议的数据包发给百度, 百度收到数据包后,进行解析,才知道要请求的是哪个页面,把该页面返回,这个环节就用到http协议了,
和百度建立TCP连接后,基于http协议,发送一段文本,规定了客户端的请求方式(比如GET、POST)、URL资源(访问是哪个页面),http/1.0 指定http协议的版本。
-
从文件描述符读取服务端响应数据
重定向到9,9是一个socket文件描述符(代表着与百度建立的连接),把这个信息发给百度,百度通过这个socket收到之后,把页面信息通过socket连接返回,
客户端从文件描述符9中可以拿到返回的主页信息,这样就在没有使用浏览器的情况下获取到了百度首页。
-
三次握手的过程
客户端要发出一个数据包,先三次握手建立连接,由传输控制层创建握手的第一个数据包sync,
服务端回复一个sync的确认包,客户端再回复一个ack。
经过三次数据包的往复就建立连接了,并且双方在各自的内存中开辟一些资源,这些资源是为了存放对方发送过来的数据使用。
只有三次握手之后,才能让双方建立资源并为对方提供服务,所以面向连接是经过三次握手开辟的为对方服务的资源而并不是一个真正的物理连接(比如网线)。
Dos攻击
比如几万台或几十万台客户端肉机,对某一个网站发起sync请求,这个网站会给这些客户端回送ack,肉机修改了TCP协议,不给服务器回复ack,服务器会为这些连接开辟很多的资源,一直等着不同的肉机给它回送ack状态。服务器资源会被瞬间填满,真正想去连接的人却挤不进去了。
一般用负载均衡技术+黑名单技术解决。
而识别出来这些肉机并加入黑名单期间也会造成服务器的不稳定。
应用层中,无论是命令还是浏览器发http请求头之前,必须先调用系统内核的传输控制层,传输控制层先产生第一个数据包:握手包,然后给对方发送过去,回来3次之后,才能告诉应用层:你把http请求头的数据给我 ,我给你封装数据包,再帮你完成数据传输的过程。
在数据传输的过程当中,客户端把请求头发送出去了,对方再返回一个确认,客户端才能知道它发送成功了。
服务端收到数据包,解析完数据包之后,再给服务端的应用层(比如tomcat),应用层把请求的主页的数据再返回给客户端,客户端再返回ack确认。
查看网络状态
-n是显示ip和端口号;-a是所有;-t是tcp;-p是进程的id和名称。
状态列state中的LISTEN是监听状态,ESTABLISHED是联通状态。
只有服务端才有LISTEN,tomcat启动来之后要绑定8080端口,这个时候8080就会有一个LISTEN监听状态。
客户端的数据包一进入这台主机的网卡,这时候谁先访问?有没有可能写数据的时候被覆盖?
就会出现混乱的情况,所以必然会有一个kernel来接管所有的application排队使用硬件、权限控制等一系列过程。
传输控制层、网络层、数据链路层,这些都在kernel内核里面实现的。
http协议是在应用层来实现的。
计算机加载kernel的过程
主机中包含内存、CPU、磁盘、主板上的blos,电源按钮等这些都是硬件,当接通电源的时候,blos就会把它的一段代码放入了内存当中,即内存中出现的第一个程序是blos,它里面有一些最基本的引导程序,假设跳过其他的引导,直接从磁盘中引导,磁盘中的第一个起始位置有一个分区表MBR(记录了磁盘分了几个区),blos会从磁盘的MBR中加载一段数据,从中找到C盘可引导分区,这个引导分区是被格式化的,文件格式有可能是FAT、FAT32、NTFS或其他的文件系统,这个分区前面埋了一个线性地址,比如linux的grud程序,grud程序会被blos加载入内存,grud程序中有一个驱动(代码),这个驱动可以识别文件格式,就可以读取文件系统,在文件系统中就会读取到第一个文件叫kernel(操作系统内核程序),kernel会通过引导程序被引导进入内存,这个时候kernel就占领了内存,给cpu发reset指令,让cpu从kernel这个空间的第一个位置开始加载指令,此时kernel就接管了操作系统,然后开始完成操作系统的初始化,比如启动ssh、bash、网络服务程序、tomcat等都是由kernel把程序一个一个启动起来。
从文件系统加载kernel到内存的时候,线性地址空间会从实模式进入保护模式。
因为kernel是一个能够操纵所有硬件的程序,如果有人随意的修改它里面的代码,就变成病毒了,就会破坏计算机里面的内容,所以需要将这段地址空间保护起来,剩余的地址空间叫user space(用户空间)。
内核启动一个程序的话,从磁盘中加载一个程序,放入内存的用户空间中,保护模式是不允许用户空间区域的程序直接访问修改kernel区域的地址。
程序想访问硬件,又不能直接访问,就通过syscall系统调用或者叫80中断,通过中断的方式来间接的从用户态的用户空间切换到内核态的内核空间。
不同的操作系统有各自的内核,不同内核提供的系统调用也不一样。
socket
# 安装帮助文档 有8类文档 系统调用systemcall是2类文档
yum install man man-pages
man 2 socket
如果成功,则返回一个文件描述符且类型是int数值类型。
在java中object对象代表一个资源;
linux中用一个数字来代表来代表一个资源。
面向对象是基于对象的很多方法;
面向过程也有很多的方法,比如socket有accept方法,accept是得到客户端的一个连接。
man 2 bind
先调用socket得到一个文件描述符,再给这个文件描述符绑定系统端口号,再监听该端口号。
tomcat是用java代码写的,交由JVM管理,JVM在内核调用了socket的bind、listen、accept这3个系统调用,才得到监听8080的文件描述符,比如文件描述符3,3就代表着监听的8080。
在tomcat里面要开始调用内核的系统调用accept,死循环一直调用accept,参数就是文件描述符3,看有没有客户端接入,在没有客户端接入的情况下一直是阻塞的。
假设一个客户端TCP三次握手建立了连接,accept就由阻塞变成返回了一个文件描述符来代表这个客户端,
比如用文件描述符4来代表这个客户端1。
JVM主线程是一个方法,这个主方法里有一个循环,先是通过调用socket的bind、listen、accept得到一个文件描述符3,除了accept接受客户端之外,还得读写客户端中的数据,
系统调用读取这个文件描述符,
tomcat主线程代码逻辑:先是while循环,然后每次循环都调用accpet,如果有客户端连接,就返回一个文件描述符来代表这个客户端,当前循环不结束,然后读取read文件描述符4,一直阻塞,直到有数据到达。
客户端与你建立连接之后,可能没有给你发送数据,这个read会被阻塞。
一阻塞,这个循环没有结束的话,就进入不了下一次循环,其他客户端想连接也会被阻塞。
所以tomcat早期在写socket编程的时候,会使用多线程模型来解决这个问题,
多线程模型、同步阻塞(BIO)
在这个循环里面,得到一个文件描述符,先不去read读,先抛出去一个线程, 把文件描述符4作为参数传给它,由子线程去read读。
主线程可以抛一个线程,然后继续第二次循环,这就是BIO模型,解决了单服务器多客户端连接的问题。
一台服务器一个客户端开启了n多个线程去访问同一台服务器,每个线程要给一个随机端口号,客户端这边的端口号数量最多65535个,但服务端只需要一个8080就可以,因为每来一个,会得到一个文件描述符,这个文件描述符代表着服务端IP:8080-客户端IP:3333,再来一个客户端,IP:8080-客户端IP:3334,所以服务端不需要再开辟端口号。
BIO弊端
问题就在于多线程,你来了5000个客户端,代表要开启5000个线程,线程是要消耗内存资源的,JVM默认线程大小为1MB。有大量的线程切换,CPU消耗也会越来越多。考究一台服务器的性能就要看是在用户空间花的时间多一些还是在内核上花的时间多一些。
同步非阻塞SOCK_NONBLOCK
为什么要抛这么多线程?
是因为阻塞,只能用多线程。
man 2 socket
socket有一个方法SOCK_NONBLOCK,
设置文件状态为非阻塞,非阻塞是什么意思?
read fd4的时候,曾经是阻塞,直到数据到达才返回,
现在调用SOCK_NONBLOCK,有数据则返回数据,没有数据不会进入阻塞,而是返回一个错误。
那程序应该怎么设计?
先绑定8080端口,死循环,accept接收,得到相应的fd4、fd5,这个时候不需要抛出多个线程,一个线程就可以了,这个线程的工作是接收、读取数据。
这个环节就是非阻塞的,这个过程减少了线程数,减少了没有必要的系统调用。
但这种方式顶多叫非阻塞,还不能叫多路复用。
如果有5000个客户端建立了连接,会有5000个文件描述符,在一次循环的时候,你会调用5000次read,read是系统调用,肯定会有用户态和内核态切换,read方法是在内核实现的。
随着客户端的更多,单一线程里面会有5000次循环,可能只有1个客户端的数据要来,4999次的read调用是浪费的,read调用的太多,浪费了很多次。
如果程序知道哪些文件描述符有了数据,就调有限次数的read,就减少了每个都调用一次的浪费。
select同步非阻塞
man 2 select
参数nfds表示有多少个文件描述符,readfds想读取的文件描述符集合,writefds想写入的文件描述符集合。
一个程序可以通过系统调用让内核一下可以监控很多的文件描述符,等待直到一个或多个文件描述符变成可用状态就返回了。
tomcat先是监听8080端口,比如得到一个fd3,来了一个客户端,到达内核说:我想和你建立连接。
tomcat先调用accept得到了一个fd4,
然后开始调用select,第一个传入进去的是fd3。
客户端想建立连接的时候,还没有fd4,通过fd3这个文件描述符,产生了一个连接事件:客户端1想建立连接。
这个时候,调用select叫内核等待着,内核判断数据是否会达到,比如fd4数据到达之后,select就会返回一个文件描述符fd4。
当你有1000客户端(文件描述符)的时候,每循环一次,只需要调用一次select,把1000个文件描述符作为参数传给内核,由内核去遍历的数据状态。
曾经也需要遍历,但是会读取有没有数据到达,而select模式不需要读取,只需要判断数据状态即是否有数据到达。
程序的一次循环里面调用select,内核对1000个文件描述符进行状态的判断并通过tomcat返回了这1000里面有几个状态是可读可写,最终由tomcat对select返回的可用的文件描述符发起read,读取还得由tomcat完成。
一次select和read读取有状态文件描述符,而不是全局遍历所有的文件描述符,这样减少了系统调用。
所有读数据都得由程序自己去读,依然属于同步非阻塞。
同步阻塞到同步非阻塞,依然是同步模型,从辩别到读取都得自己干。
异步模型是read不是自己去做了,只是调了多路复用器给了一个回调函数,未来是由内核帮你把数据读完 ,再调用你的处理函数处理完,不需要自己再去完成read调用了这才叫异步。
在linux当中AIO标准一直没有统一起来,只有window上IOCP实现了真正的AIO。
select模型的弊端
第一个弊端,每循环一次,要调用一次select,把文件描述符的集合从用户态传到内核态,每次循环都要传递这个参数。
第二个弊端是内核需要完成1000个描述符状态的判断,循环遍历的过程依然是费时的,O(n)的时间复杂度,这是主动遍历的过程,比较慢。
先是传的东西比较多,每次都传过来之后,还得挨个去遍历。
如果想让它优美点的话,就需用到中断了。
计算机只有一个CPU,插了好多设备,网卡、键盘、鼠标等任何的输入输出设备,
CPU可能正在处理网卡数据,鼠标会产生一个中断,中断会产生切换,CPU会切换到让鼠标挪动。
别人中断了,切换到别人,让别人干一点。
在单位时间内CPU的执行是碎片化的、分时的,所有的IO设备都是轮询着去处理,谁有事就处理谁。
网卡有数据到达了,也会产生中断,把自己忙的事情放一放,看看网卡里面的数据是不是要拷贝到内核空间等等之类的。
还有一个DMA:内存是一个线性地址空间,是一个黑盒子,划分不同的区域干不同的事情。
每个不同的设备访问内存的不同地方,给空间划分权限就可以满足设备不通过CPU直接使用内存的某个区域。
比如这个区域就给网卡用,网卡就可以直接访问。
网卡收到数据了,通过DMA驱动,直接拷贝到内存,
这时候给内核产生一个中断,内核其实就已知道这个DMA区域当中有网卡传过来的数据了,
如果别人对这个网卡在内核有注册过中断回调事件,即别人关注过这个区域,待数据到达的时候,中断事件就会调用回调函数callback。
由中断产生了一个函数的调用,产生了事件调用。
如果内核有1000个文件描述符,不去遍历了,而是由网卡的中断来知道是否有数据到达。
数据到达了,就被动的知道了哪个文件描述符、哪块的数据到达了,就减少了对1000个文件描述符没必要的全局的遍历,从而变成了一个被动事件。
Epoll
man epoll
# epoll属于7类杂项
epoll_create、epoll_ctl、epoll_wait这三个是二类系统调用,
man epoll_create
如果成功,则返回epoll的文件描述符,
参数是刚刚创建的epoll文件描述符并且把操作符和相应的客户端文件描述符传进去,比如把代表一个客户端连接的文件描述符8传入进来,并准备一个epoll events,等待文件描述符8可读写的时候,调用回调事件。
man epoll_wait
参数中也要传刚才的epoll文件描述符,这个调用其实是告诉内核说把我曾经给过你的文件描述符里面有状态的可读的那一个返回给我或者将可以读写了的文件描述符返回。
tomcat先去准备8080端口,得到一个文件描述符fd3,tomcat下一个指令是调用内核的epoll create,返回一个epoll的文件描述符,比如epoll的fd5,然后tomcat调用内核epoll_ctl,传入2个参数,一个是epoll的fd5,第二个参数是fd3。
内核中准备了一个区域,里面放入了fd3和fd5。
假设有一个客户端想通过TCP和tomcat 8080建立一个连接,fd3上一定会产生一个客户端建立连接的事件,这个事件一定会被内核的事件机制(中断机制)发现,所以内核会把这个区域的fd3拿出来,因为它有数据到达了,再放入另外一个区域里,另外一个区域放入fd3,只要tomcat这个程序未来调用过了epoll wait,就会把这个区域的fd3给tomcat,tomcat就会判断fd3的事件是有人建立了连接,所以它会调用accept接收并得到一个fd4,
这个时候又会调用epoll_ctl,再把fd4放入进去。
fd3注册是accept事件。
fd4是客户端连接的文件描述符,fd4注册是read读取事件。
tomcat只需要while循环epoll wait,
如果fd4有数据达到了同时有一个客户端2也想建立连接,这俩都到达内核了,即同时产生了2个中断(2个事件),
那么这个区域会放fd3和fd4(红色部分),表示fd4有新的可读数据到达了。
一个新的客户端想建立连接走的fd3 accept事件,这个时候fd 3、fd 4上都有中断或都有事件回调,
fd 3、fd 4都会被移动到这个区域,如果你的应用程序调用epoll wait的话,就会把fd 3、fd 4都取回来,取回来之后,会在fd 3上为新的客户端连接分配一个fd 5,下一个指令会从fd 4读取回数据进行处理,处理完之后,再进入下一个循环,把fd 5放入这个区域,因为fd 5也是一个客户端,也关注可读这件事情,放完之后,再调epoll_wait,这个epoll_wait等它们三个谁有事件,如果fd4、fd5数据到达的话,那么另外一个区域就要放fd4、fd5了,epoll_wait发现有fd4和fd5了,就把fd4、fd5就带回来了,这个时候你的进程就会读fd4、读fd5,然后再epoll_wait,这个是时候,再有一个客户端,经过fd3,得到一个fd6,这个时候要把fd6放入进来,再去epoll_wait,未来谁到了,就把谁的文件描述符拿出来,这就是epoll的工作机制,靠的是内核的事件机制,使用epoll这种事件驱动的多路复用。
曾经是每次循环都会把fd传一次,现在内核开辟了一个区域之后,每个文件描述符一创建就会通过你的应用程序把它放入到内核里面,减少了数据的传递。
每循环调用的是epoll_wait,曾经的是每循环调用的是select传入的是所有文件描述符,然后等着select的阻塞返回。
现在直接调用epoll_wait ,因为内核通过中断事件,数据到达的fd被动的放入了另一个区域,所以应用程序通过epoll_wait只需要取回这个空间的fd就可以了,最主要的是通过中断事件机制避免了内核的全量遍历。
早期是通过mmap技术实现的,它也是一个系统调用,叫内存映射。
开辟一个空间,这个空间是用户程序和内核都可以访问的一个空间,即在内存中划分出来一个区域,用户态和内核态都可以访问。
man 2 mmap
创建一个虚拟空间,内核和进程可以同时访问这个空间。
早期的时候就需要人为的使用epoll开辟open/dev/epoll得到epoll fd,再通过mmap开辟一个共享空间,文件描述符在共享空间里面,这样更能减少拷贝数据的成本了,因为数据只需要放入空间一次就可以了,感觉速度会快些,但会存在数据安全问题,因为这个区域的数据,用户态和内核态都是可以访问的,多线程的情况下,会产生数据的混乱。
epoll_cratel、epoll_wait、epoll_ctl则避免了使用mmap,不需要用户准备的空间了。
由内核完成,只要将文件描述符传入内核的地址空间,再将到达数据的文件描述符拷贝到用户空间当中去,在多线程的情况下,尤其在IO threads的情况下,数据的处理过程都是线程安全的。