现代计算机是由硬件、操作系统组成,操作系统通过内核与硬件交互。操作系统可以划分为:内核与应用两部分,内核提供进程管理、内存管理、网络等底层功能,封装了与硬件交互的接口,通过系统调用提供给上层应用使用。
由于CPU某些指令比较危险,如果错用会导致系统崩溃,为了保护系统,操作系统将内存空间划分为了两部分:
当进程运行在内核空间时,它就处于内核态;当进程运行在用户空间时,它就处于用户态。
其实所有的系统资源管理都是在内核空间中完成的,比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。进程在用户控件是无法直接进行这样的操作的,但是我们可以通过内核提供的接口来完成这样的任务,即从用户态切换到内核态。而且用户空间也不能直接访问内核空间,必须要把内核空间的数据拷贝到用户空间。
比如一个Java应用程序要读取磁盘上的一个文件,它可以向内核发起一个 “系统调用” 告诉内核:“我要读取磁盘上的某某文件”。其实就是通过一个特殊的指令让进程从用户态进入到内核态(到了内核空间)。在内核空间中,CPU 可以执行任何的指令,当然也包括从磁盘上读取数据。具体过程是先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。此时应用程序已经从系统调用中返回并且拿到了想要的数据,可以开开心心的往下执行了。
简单说就是应用程序把高科技的事情(从磁盘读取文件)外包给了系统内核,系统内核做这些事情既专业又高效。
整个过程如下:
可以看到,在整个IO读写过程中进行了四次上下文切换。read系统调用发起,此时进程发生一次上下文切换,由用户态切换到内核态。进程从磁盘拷贝数据完毕后,read系统调用返回,此时又从内核态切换到用户态,并把磁盘数据从内核空间拷贝到用户空间。IO读操作完成,开始往网卡里写数据。进程由用户态切换到内核态,将用户空间中的数据拷贝到内核空间中的Socket缓冲区,然后将数据发送给网卡。这次系统调用也进行了两次上下文切换,用户态–内核态–用户态。即一次完整的IO读写操作,要进行两次系统调用,四次上下文切换。
这种IO操作的效率是比较低的,因为进行了多次数据拷贝,如果希望提高IO效率,可以减少其中的数据拷贝,比如说:零拷贝技术。
Unix定义了五种I/O模型
而我们Java中常提到的:BIO 、NIO、AIO,这里就先不讨论信号驱动IO。
首先我们要先搞清楚几个概念,什么是阻塞和非阻塞,什么是同步和异步。
当应用程序发起 I/O 调用后,会经历两个阶段:
先来说阻塞和非阻塞:
阻塞调用会一直等待远程数据就绪再返回,即上面的阶段1会阻塞调用者,直到读取结束;
而非阻塞无论在什么情况下都会立即返回,虽然非阻塞大部分时间不会被block,但是它仍要求进程不断地去主动询问kernel是否准备好数据,也需要进程主动地再次调用recvfrom来将数据拷贝到用户内存。
再说一说同步和异步:
同步方法会一直阻塞进程,直到I/O操作结束,注意这里相当于上面的阶段1,阶段2都会阻塞调用者。其中BIO,NIO,IO多路复用,信号驱动IO,这四种IO都可以归类为同步IO;
而异步方法不会阻塞调用者进程,即使是从内核空间的缓冲区将数据拷贝到进程中这一操作也不会阻塞进程,拷贝完毕后内核会通知进程数据拷贝结束。
同步阻塞 IO 模型中,服务器应用程序发起 read 系统调用后,会一直阻塞,直到内核把数据拷贝到用户空间。完整的架构应该是 客户端-内核-服务器,客户端发起IO请求,服务器发起系统调用,内核把IO数据从内核空间拷贝到用户空间,服务器应用程序才能使用到客户端发送的数据。一般来说,客户端、服务端其实都属于用户空间,借助内核交流数据。
当用户进程发起了read系统调用,kernel就开始了IO的第一个阶段:准备数据。对于网络IO来说,很多时候数据在一开始还没有到达内核(比如说客户端目前只是建立了连接,还没有发送数据 或者是 网卡等待接收数据),所以kernel就需要要等待足够的数据到来。
而在服务器进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除阻塞状态,重新运行起来。
Java中的JDBC也使用到了BIO技术。BIO在客户端连接数量不高的情况下是没问题的,但是当面对十万甚至百万级连接的时候,无法处理这种高并发情况,因此我们需要一种更高效的 I/O 处理模型来应对。
Java 中的 NIO 于 JDK 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)情况下,应使用 NIO 。
如上图,当服务器进程发出read操作时,如果kernel中数据还没准备好,那么并不会阻塞服务器进程,而是立即返回error,用户进程判断结果是error,就知道数据还没准备好,此时用户进程可以去干其他的事情。一段时间后用户进程再次发read,一直轮询直到kernel中数据准备好,此时用户发起read操作,产生system call,kernel 马上将数据拷贝到用户内存,然后返回,进程就能使用到用户空间中的数据了。
BIO一个线程只能处理一个IO流事件,想处理下一个必须等到当前IO流事件处理完毕。而NIO其实也只能串行化的处理IO事件,只不过它可以在内核等待数据准备数据时做其他的工作,不像BIO要一直阻塞住。NIO它会一直轮询操作系统,不断询问内核是否准备完毕。
但是,NIO这样又引入了新的问题,如果当某个时间段里没有任何客户端IO事件产生时,服务器进程还在不断轮询,占用着CPU资源。所以要解决该问题,避免不必要的轮询,而且当无IO事件时,最好阻塞住(线程阻塞住就会释放CPU资源了)。所以NIO引入了多路复用机制,可以构建多路复用的、同步非阻塞的IO程序。
Java 中的 NIO ,提供了 Selector(选择器)这个封装了操作系统IO多路复用能力的工具,通过Selector.select(),我们可以阻塞等待多个Channel(通道),知道任意一个Channel变得可读、可写,如此就能实现单线程管理多个Channels(客户端)。
当所有Socket都空闲时,会把当前线程(选择器所处线程)阻塞掉,当有一个或多个Socket有I/O事件发生时,线程就从阻塞态醒来,并返回给服务端工作线程所有就绪的socket(文件描述符)。
各个操作系统实现方案:
IO多路复用题同非阻塞IO本质一样,只不过利用了新的select系统调用,由内核来负责本来是服务器进程该做的轮询操作。看似比非阻塞IO还多了一个系统调用的开销,不过因为可以支持多路复用IO,即一个进程监听多个socket,才算提高了效率。进程先是阻塞在select/poll上(进程是因为select/poll/epoll函数调用而阻塞,不是直接被IO阻塞的),再是阻塞在读写操作的第二阶段上(等待数据从内核空间拷贝到用户空间)。
IO多路复用的实现原理:利用select、poll、epoll可以同时监听多个socket的I/O事件的能力,而当有I/O事件产生时会被注册到Selector中。在所有socket空闲时,会把当前选择器进程阻塞掉,当有一个或多个流有I/O事件(或者说 一个或多个流有数据到达)时,选择器进程就从阻塞态中唤醒。通过select或poll轮询所负责的所有socket(epoll是只轮询那些真正产生了事件的socket),返回fd文件描述符集合给主线程串行执行事件。注意:select和poll每次调用时都需要将fd_set(文件描述符集合)从用户空间拷贝到内核空间中,函数返回时又要拷贝回来(epoll使用mmap,避免了每次wait都要将数组进行拷贝)。
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是进程操作之后会直接返回,不会阻塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
如上图,用户进程发起read操作之后,立刻就可以开始去做其它的事。内核收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。