linux的五种IO模型
概念:
同步、异步、阻塞、非阻塞的概念
同步:所谓同步,发起一个功能调用的时候,在没有得到结果之前,该调用不返回,也就是必须一件事一件事的做,等前一件做完了,才能做下一件。
提交请求->等待服务器处理->处理完毕返回 这个期间客户端浏览器不能干任何事
异步:调用发出后,调用者不能立刻得到结果,而是实际处理这个调用的函数完成之后,通过状态、通知和回调来通知调用者。
比如ajax:请求通过事件触发->服务器处理(这是浏览器仍然可以作其他事情)->处理完毕
(在服务器处理的时候,客户端还可以干其他的事)
阻塞:指调用结果返回之前,当前线程会被挂起(CPU不给线程分配时间片),函数只能在得到结果之后才会返回。
(阻塞调用和同步调用的区别)同步调用的时候,当前线程仍然可能是激活的,只是在逻辑上当前函数没有返回。例如:在Socket中调用recv函数,如果缓冲区没有数据,这个函数会一直等待,知道数据返回。而在此时,这个线程还是可以处理其他消息的。
非阻塞:当调用后,不能直接得到结果之前,该函数不能阻塞当前线程,而是会立刻返回。
总结:
同步是指A调用了B函数,B函数需要等处理完事情才会给A返回一个结果。A拿到结果继续执行。
异步是指A调用了B函数,A的任务就完成了,去继续执行别的事了,等B处理完了事情,才会通知A。
阻塞是指,A调用了B函数,在B没有返回结果的时候,A线程被CPU挂起,不能执行任何操作(这个线程不会被分配时间片)
非阻塞是指,A调用了B函数,A不用一直等待B返回结果,可以先去干别的事。
Linux下的五种IO模型:
1.阻塞IO
2.非阻塞IO
3.IO复用
4.信号驱动IO
5.异步IO
阻塞IO模型:
从上图可知,因为socket接口是阻塞型的,用户进程会调用recvfrom函数,查看内核里有没有数据报准备好,如果没有,那么只能继续等待,此时用户进程什么也不能做,一直等内核的数据报准备好了,才会将数据报从内核空间复制到用户空间里面,用户进程得到了数据,这个任务才算结束。这就是阻塞型的IO。
非阻塞型IO
用户进程调用了recvfrom函数,向内核要数据报,内核会立刻返回一个结果,如果告诉用户进程没有数据报,那么用户进程还需要继续发送调用请求。。。知道有了数据报,然后复制到用户空间,这样就结束了调用。
非阻塞的IO可能并不会立即满足,需要应用程序调用许多次来等待操作完成。这可能效率不高,因为在很多情况下,当内核执行这个命令时,应用程序必须要进行忙碌等待,直到数据可用为止。
另一个问题,在循环调用非阻塞IO的时候,将大幅度占用CPU,所以一般使用select等来检测”是否可以操作“。
多路复用IO
前面说过非阻塞型IO的缺点,就是占用CPU的资源,使用select函数可以避免非阻塞IO中的轮询等待问题
可以看出用户首先要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回,当数据到达时,socket被激活,select函数返回。这时socket可读了,然后用户线程正式发起read请求,读取数据并继续执行。
这个模型在流程上和同步阻塞模型好像没有区别,甚至还需要监听socket,但使用了select以后最大的优势就是用户可以在一个线程内同时处理多个socket的IO请求,用户可以注册多个socket,然后不断的调用select读取被激活的socket,可以达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须要使用多线程,线程池技术来实现。
{ select(socket); while(1) { sockets = select(); for(socket in sockets) { if(can_read(socket)) { read(socket, buffer); process(buffer); } } } }
但是上面的模型仍然有很大的问题,虽然单个线程可以处理多个IO请求,但每个IO请求也是阻塞的。因此可以让用户线程注册自己感兴趣的socket或者Io请求,然后去做自己的事情,等到数据来到的时候,再进行处理
这里是使用Reactor设计模式来实现。
通过Reactor方式,将用户线程轮询IO操作状态的工作统一交给handle_event事件循环进行处理,用户注册事件处理器之后就可以继续执行其他的工作了,而Reactor线程负责调用内核的select函数来检查socket状态。当socket被激活之后,通知响应的用户线程,执行handle_event进行数据读取。由于select函数是阻塞的,因此多路IO复用模型也被称为异步阻塞IO模型。
后面两种IO模型就先不说了。。。
然后来介绍java中的IO模型怎么实现。
BIO(Blocking IO)
同步阻塞IO模型,数据的读取写入必须阻塞在一个线程内等待完成。
在BIO通信模型的服务端,由一个独立的Acceptor线程负责监听客户端的连接,
如上图所示,如果想要处理多个线程,则必须使用多线程,因为socket.accept()、socket.read()、socket.write()这三个函数都是同步阻塞的。
在使用了多线程之后,服务端接收到客户端的连接请求之后,会为每一个客户端创建一个新的线程进行链路处理。处理完成后,通过输出流返回应答客户端,然后线程销毁。也可以通过线程池来改善性能。利用线程池可以实现N(客户端请求数量):M(处理客户端请求的线程数量)的伪异步I/O模型(N 可以远远大于 M)。
Acceptor监听客户端请求,每有一个新的请求都会通过线程池创建一个新的线程,然后将socket套接字封装成一个task继承runnable,丢到线程里去执行。线程池维护一个消息队列和 N 个活跃线程,对消息队列中的任务进行处理,由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。
但问题也很明显,仍然占用了大量的资源。其底层是BIO的事实还是没有改变。
在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
public class ServerMain { public static void main(String[] args) throws IOException { //绑定端口 ServerSocket serverSocket=new ServerSocket(3333); new Thread(()->{ //accept监听 while(true) { try { Socket socket = serverSocket.accept(); //这里发生了阻塞 Thread.sleep(10000); // 按字节流方式读取数据 try { int len; byte[] data = new byte[1024]; InputStream inputStream = socket.getInputStream(); // 按字节流方式读取数据 while ((len = inputStream.read(data)) != -1) { System.out.println(new String(data, 0, len)); } } catch (IOException e) { } } catch (Exception e) { e.printStackTrace(); } } }).start(); } }
public class CLientMain { public static void main(String[] args) { //创建多个线程模拟多个客户端来连接服务器 new Thread(()->{ try { //创建一个套接字对象 Socket socket=new Socket("127.0.0.1",3333); for(int i=0;i<10;i++) { //发送数据 socket.getOutputStream().write((new Date() + ":hello").getBytes()); Thread.sleep(2000); } } catch (Exception e) { e.printStackTrace(); } }).start(); } }
NIO(newIO)
java中的NIO是一种结合了同步非阻塞和IO多路复用的IO模型。
NIO和非阻塞模型是有区别的,NIO是java自己的API。即支持阻塞也支持非阻塞。
(2)BIO 方式适用于连接数目比较小并且一次发送大量数据的场景,这种方式对服务器资源要求比较高,并发局限于应用中。
NIO有三大组件:Channel、BUffer、Selector。
1.CHannel 通道
是对原IO包中流的模拟,流的作用是把磁盘上的数据写入内存以及读取内存中的数据到磁盘上。Channel也可以实现对数据的写入和读取。
通道和流的不用之处在于,流只能在一个方向上移动,要么inputstream,要么outputstream。而Channel则可以用于读也可以用于写。
通道类型包括:
-
-
DatagramChannel:通过 UDP 读写网络中数据;
-
SocketChannel:通过 TCP 读写网络中数据;
-
后面这两个配合使用。
具体操作:
-
-
CharBuffer
-
ShortBuffer
-
IntBuffer
-
LongBuffer
-
FloatBuffer
-
3.选择器
NIO是非阻塞模型和多路复用io的结合。
一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。
通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。
因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件,对于 IO 密集型的应用具有很好地性能。
NIO在处理单线程的IO时性能并不如BIO,但对于多IO,多客户端请求,有着非常好的性能。
如上图所示,将Channel注册在Selector里面,然后selector去轮询有没有Channel事件到达。
事实上,NIO是采用一种Reactor模式。
Reactor被称为事件分离者,其核心就是一个Selector,负责响应IO事件,一旦发生,就广播给响应的Handle去处理。具体为一个Selector和一个ServerSocketChannel,把ServerSocketChannel注册到Selector里面去,获取的SelectionKey绑定一个Acceptor,可以理解为一个handle。
import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.util.Iterator; import java.util.Set; /** * 反应器模式 用于解决多用户访问并发问题 */ public class Reactor implements Runnable { public final Selector selector; public final ServerSocketChannel serverSocketChannel; public Reactor(int port) throws IOException { selector = Selector.open(); serverSocketChannel = ServerSocketChannel.open(); InetSocketAddress inetSocketAddress = new InetSocketAddress(InetAddress.getLocalHost(), port); serverSocketChannel.socket().bind(inetSocketAddress); serverSocketChannel.configureBlocking(false); // 向selector注册该channel SelectionKey selectionKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); // 利用selectionKey的attache功能绑定Acceptor 如果有事情,触发Acceptor selectionKey.attach(new Acceptor(this)); } @Override public void run() { try { while (!Thread.interrupted()) { selector.select(); Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> it = selectionKeys.iterator(); // Selector如果发现channel有OP_ACCEPT或READ事件发生,下列遍历就会进行。 while (it.hasNext()) { // 来一个事件 第一次触发一个accepter线程,SocketReadHandler SelectionKey selectionKey = it.next(); dispatch(selectionKey); selectionKeys.clear(); } } } catch (IOException e) { e.printStackTrace(); } } /** * 运行Acceptor或SocketReadHandler * * @param key */ void dispatch(SelectionKey key) { Runnable r = (Runnable) (key.attachment()); if (r != null) { r.run(); } } }
Acceptor被理解为一个handle,这个Handle只负责创建具体处理IO请求的Handle,如果Reactor广播时SelectionKey创建一个Handler负责绑定相应的SocketChannel到Selector中。下次再次有IO事件时会调用对用的Handler去处理。
public class Acceptor implements Runnable { private Reactor reactor; public Acceptor(Reactor reactor) { this.reactor = reactor; } @Override public void run() { try { SocketChannel socketChannel = reactor.serverSocketChannel.accept(); if (socketChannel != null){ // 调用Handler来处理channel new SocketReadHandler(reactor.selector, socketChannel); } } catch (IOException e) { e.printStackTrace(); } } }
Handler是具体的事件处理者,例如ReadHandler、SendHandler,ReadHandler负责读取缓存中的数据,然后再调用一个工作处理线程去处理读取到的数据。具体为一个SocketChannel,Acceptor初始化该Handler时会将SocketChannel注册到Reactor的Selector中,同时将SelectionKey绑定该Handler,这样下次就会调用本Handler。
public class SocketReadHandler implements Runnable { private SocketChannel socketChannel; public SocketReadHandler(Selector selector, SocketChannel socketChannel) throws IOException { this.socketChannel = socketChannel; socketChannel.configureBlocking(false); SelectionKey selectionKey = socketChannel.register(selector, 0); // 将SelectionKey绑定为本Handler 下一步有事件触发时,将调用本类的run方法。 // 参看dispatch(SelectionKey key) selectionKey.attach(this); // 同时将SelectionKey标记为可读,以便读取。 selectionKey.interestOps(SelectionKey.OP_READ); selector.wakeup(); } /** * 处理读取数据 */ @Override public void run() { ByteBuffer inputBuffer = ByteBuffer.allocate(1024); inputBuffer.clear(); try { socketChannel.read(inputBuffer); // 激活线程池 处理这些request // requestHandle(new Request(socket,btt)); } catch (IOException e) { e.printStackTrace(); } } }
为什么不愿意用原生的NIO开发呢?
JDK 的 NIO 底层由 epoll 实现,该实现饱受诟病的空轮询 bug 会导致 cpu 飙升 100%
下面是NIO服务端通信序列图
select、poll、epoll的区别:
三者都是IO多路复用的机制,IO多路复用就是通过一种机制,去监视多个描述符,一旦某个描述符就绪,(读就绪或者写就绪),能够通知程序进行相应的读写操作,但select、poll、epoll本质都是同步IO,因为他们都需要在读写事件就绪后,自己负责进行读写,读写过程是阻塞的。而异步IO不需要自己读写,异步IO的实现会负责把数据从内核拷贝到用户空间,
1.select函数:
该函数准许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒。函数原型如下:
#include <sys/select.h> #include <sys/time.h> int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout) 返回值:就绪描述符的数目,超时返回0,出错返回-1
函数参数介绍如下:
(1)第一个参数maxfdp1指定待测试的描述字个数,它的值是待测试的最大描述字加1(因此把该参数命名为maxfdp1),描述字0、1、2...maxfdp1-1均将被测试。
因为文件描述符是从0开始的。
(2)中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述字。如果对某一个的条件不感兴趣,就可以把它设为空指针。struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
void FD_ZERO(fd_set *fdset); //清空集合
void FD_SET(int fd, fd_set *fdset); //将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset); //将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写
(3)timeout告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。
struct timeval{
long tv_sec; //seconds
long tv_usec; //microseconds
};
这个参数有三种可能:
(1)永远等待下去:仅在有一个描述字准备好I/O时才返回。为此,把该参数设置为空指针NULL。
(2)等待一段固定时间:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
(3)根本不等待:检查描述字后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0。
基本原理:
1)使用copy_from_user从用户空间拷贝fd_set到内核空间
(2)注册回调函数__pollwait
(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
(4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
(8)把fd_set从内核空间拷贝到用户空间。
select的几大缺点,
(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024
2.poll实现
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
3.epoll实现
epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。
对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。
对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
总结:
(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。