概念:java
同步、异步、阻塞、非阻塞的概念react
同步:所谓同步,发起一个功能调用的时候,在没有获得结果以前,该调用不返回,也就是必须一件事一件事的作,等前一件作完了,才能作下一件。ajax
提交请求->等待服务器处理->处理完毕返回 这个期间客户端浏览器不能干任何事编程
异步:调用发出后,调用者不能马上获得结果,而是实际处理这个调用的函数完成以后,经过状态、通知和回调来通知调用者。设计模式
好比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、一、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内部定义的等待队列)。这也能节省很多的开销。