我们知道现代计算机CPU在执行任务是都是将CPU的时间划分为很细粒度的时间片,一个任务每次只能执行这么多的时间,时间到了就必须交出使用权,那么时间到了之后系统做了什么?这就要用到中断了,时间片到了会有定时器触发一个软中断,然后进入相应的的处理历程。当然鼠标、键盘也可以触发中断,这就是硬中断。
中断是指在程序执行过程中,遇到急需处理的事件时,暂时停止CPU上现行程序的运行,转去执行相应的时间处理程序,待处理完成后再返回原程序被中断或调度其他程序执行的过程。
同步和异步是针对应用程序和内核的交互(应用程序与操作系统的处理关系,如何处理,如何执行)。
1 、 同 步 \color{green}{1、同步} 1、同步
指用户进程触发IO操作并等待或者轮询的去查看IO操作是否就绪。
2 、 异 步 \color{green}{2、异步} 2、异步
指用户进程触发IO操作以后边开始做自己的事情,而当IO操作已经完成的时候会得到IO完成的通知。
3 、 阻 塞 和 非 阻 塞 \color{green}{3、阻塞和非阻塞} 3、阻塞和非阻塞
阻塞和非阻塞是针对于进程在访问数据时,根据IO操作的就绪状态来采取的不同方式,是一种读取或者写入操作方法的实现方式。
阻 塞 方 式 \color{green}{阻塞方式} 阻塞方式
在读取或者写入方法是将一直等待。
非 阻 塞 方 式 \color{green}{非阻塞方式} 非阻塞方式
在读取或者写入方法时会立即返回一个状态值。
BIO是同步阻塞式IO,同步就是上一个步骤没有完成,就不能进行下一个步骤。阻塞就是必须等待一个步骤处理完,并得到结果。举个例子,当用read去读取网络的数据时,是无法预知对方是否已经发送数据的。因此在收到数据之前,能做的只有等待,直到对方把数据发过来,或者等到网络超时。 对于单线程的网络服务,这样做就会有卡死的问题,因为当等待时,整个线程会被挂起,无法执行,也无法做其他的工作。 于是,网络服务为了同时响应多个并发的网络请求,必须实现为多线程的。每个线程处理一个网络请求。线程随着并发连接数线性增长。但这样会带来两个问题。
线程越多,线程的上下文切换越多,而线程的上下文切换时一个耗时的操作,会浪费大量的CPU。每个线程会占用一定的内存作为线程的栈,所比如有上百万的线程同时运行,每个占用1m,这对内存来说是非常可怕的。当然,你可能会说,可以弄一个线程池,但是这样会限制最大并发的连接数,没办法响应更多请求。
# 服务端代码如下: public class ServiceSocket { public static void main(String[] args) throws Exception { ServerSocket serverSocket = new ServerSocket(9000); while (true) { System.out.println("等待连接..."); //等待客户端连接 Socket socket = serverSocket.accept(); System.out.println("客户端连接了,开始处理请求"); //每次来一个连接就创建一个线程去处理 new Thread(() -> { byte[] bytes = new byte[1024]; try { int read = socket.getInputStream().read(bytes); if (-1 != read) { System.out.println("读取客户端发来的数据:" + new String(bytes, 0, read)); } //回写数据给客户端 socket.getOutputStream().write("Hello Client".getBytes()); socket.getOutputStream().flush(); } catch (Exception e) { e.printStackTrace(); } }).start(); } } } # 客户端代码如下: public class ClientSocket { public static void main(String[] args) throws Exception{ Socket socket = new Socket("127.0.0.1",9000); //发数据给服务端 System.out.println("客户端准备发消息给服务端..."); socket.getOutputStream().write("Hello Server".getBytes()); socket.getOutputStream().flush(); byte[] bytes = new byte[1024]; int read = socket.getInputStream().read(bytes); System.out.println("客户端收到服务端消息:"+new String(bytes,0,read)); socket.close(); } }上面的代码中,可以看出 Socket socket = serverSocket.accept();这段代码会阻塞住,只有当有连接进来时才会继续执行; int read = socket.getInputStream().read(bytes);这个代码也会阻塞住,只有读取到数据后才会处理。所以使用BIO的场景可以是对于那些连接数较少,系统架构比较固定的场合。要是操作IO接口时,操作系统能够总是直接告诉有没有数据,而不是阻塞去等就好了,于是有了NIO。
在NIO模式下,调用read,如果发现没数据已经到达,就会立刻返回-1,不会被阻塞。
NIO主要有三大核心部分:Channel(通道)。Buffer(缓冲区)、Selector。
Channel:通道,和IO中的Stream流是差不多一个等级的,只不过Stream是单向的,譬如:InputStream,OutputStream,而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作。Buffer:缓冲区,实际上是一个字节数组,NIO中,所有数据都是用缓冲区处理的,任何时候访问NIO中的数据,都是将它放到缓冲区中。Selector:多路复用器,可以将通道注册进选择器中,其重要作用就是使用一个线程来对多个通道中的已就绪通道进行选择,然后就可以对选择的通道进行数据处理,属于一对多的关系,这种机制就叫IO多路复用。文件描述符:Linux的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令(api),返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有相应的描述符,称为socket fd(socket文件描述符),描述符就是一个数字,指向内核中的一个结构体(文件路径,数据区等一些属性)。
所以说:在Linux下对文件的操作是利用文件描述来符实现的。
IO多路复用:就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是写就绪或者读就绪),能够通知程序进行相应的读写操作。
因为他们都需要在读写时间就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。
NIO是同步非阻塞IO,一个线程可以处理多个连接,客户端发送的连接请求都会注册到多路复用器selector上,多路复用器轮询到连接有IO请求就进行处理。I/O多路复用底层一般用的Linux API(select、poll、epoll)来实现。
select函数监视的文件描述符分3类,分别是读取(readdfs)、写入(writefds)、异常(expectfds)。使用了三个大小为1024bit的bitmap位图结构的数组来存储这些文件描述符。 调用select函数后会阻塞,直到有文件描述符就绪(读,写,异常),函数返回,当select函数返回后可以通过遍历fdset,来找到就绪的描述符。
这个select函数,它第一遍轮询,它没有发现就绪状态的socket,他就会把当前进程,保留给需要检查的socket的等待队列中。也就是说这个socket接口,它又三块核心区域,分别是:读缓存、写缓存、等待队列。
select的缺点:
select能够支持的最大的fd数组的长度是1024,对于处理高并发的服务器不可接受。对文件描述符数组扫描时时线性扫描,效率很低,时间复杂度是O(n)。大量的fd数组被整体复制于用户态和内核态之间,一开始调用时select,需要将fd数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果就绪则在设备等待队列中加入一项并继续遍历。poll不再使用三个数组来存储fd,而是使用pollfd结构的链表来实现,pollfd结构包含了文件描述符fd、请求的时间events、返回的事件revents。pollfd没有了最大数量限制,和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
poll的缺点
应用程序仍然无法很方便的拿到那些已经就绪的fd,还是需要遍历所有注册的fd。大量的fd的数组被整体复制于用户态和内核态地址空间之间。epoll它主要就是为了解决select和poll函数的缺陷,两个主要缺点:
涉及到fd数组在用户空间到内核空间数据拷贝的过程select和poll函数返回值只能代表有几个socket就绪,没法表示具体是哪个socket就绪,这就需要重新遍历fd数组去检查那个socket是就绪的。epoll是在2.6内核中提出的,是之前select和poll的增强版本,相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll操作过程:
1、创建epoll
int epfd = epoll_create(int sise); //size用来告诉内核这个监听的数目一共有多大。2、使用epoll_ctl注册要监听的事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //epfd:第一步创建的 //op:表示如何对文件名进行操作,有3种: EPOLL_CTL_ADD - 注册一个事件 EPOLL_CTL_DEL - 取消一个事件的注册 EPOLL_CTL_MOD - 修改一个事件的注册 //fd:要操作的文件描述符 //epoll_event *event:是一个epoll_event类型的数据,表达了注册的时间的具体信息。3、管理fd事件注册
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);这一步是阻塞的,只有当注册的时间至少有一个发生,或者timeout达到时,该调用才会返回,这个与select和epoll基本一致,但不一样的地方是evlist,它是epoll_wait的返回数组,里面只包含那些被触发的事件对应的fd,而不是像select和poll那样返回所有注册的fd。
epoll相比select和poll的优势? select和poll每次都需要把完成的fd列表传入到内核,迫使内核每次必须从头到扫描到尾。而epoll完全是反过来的。epoll在内核的数据被建立好了之后,每次某个被监听的fd一旦有事件发生,内核就直接标记之。epoll_wait调用时,会尝试直接读取到当时已标记好的fd列表,如果没有就会进入等待状态。
同时,epoll_wait直接只返回了被触发的fd列表,这样上层应用写起来也轻松愉快,再也不用从大量注册的fd中筛选出有事件的fd了。
但是,假设发生时间的fd的数量接近所有注册事件fd的数量,那么epoll的优势就没有了。
epoll除了性能优势,还有一个优点:同时支持水平触发和边沿触发。
水平触发:只关心文件描述符中是否还有没完成处理的数据,如果有,不管怎样epoll_wait,总是被返回。简单说——水平触发代表了一种状态。边沿触发:只关心文件描述符是否有新的事件产生,如果有,则返回,如果返回过一次,不管程序是否处理了,只要没有新的事件产生,epoll_wait不会再认为这个fd被触发了,简单说——边沿触发代表了一个事件。epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger),LT模式是默认模式。
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,**应用程序必须立即处理该事件,**如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。