一 Java I/O
对就那个各种outputStrem,inputStream的看上去很杂乱,但实际上很有规则的东西。借由两张图就能讲清楚。
IO流的分类:
- 按照流的流向分,可以分为输入流和输出流;
- 按照操作单元划分,可以划分为字节流和字符流;
- 按照流的角色划分为节点流和处理流。
java Io流共涉及40多个类,这些类看上去很杂乱,但实际上很有规则,而且彼此之间存在非常紧密的联系, Java Io流的40多个类都是从如下4个抽象类基类中派生出来的。
上面我们用两个图搞定了I/O,到这里你在本地处理个文件呀,处理个输入、输出呀通过API都没啥问题了。但是,我们发现现在的软件和应用越来越多的使用网络来传输数据,那也就是说我们I/O所要处理的对象可能没变,都是数据嘛,但是数据输入、输出的渠道很大程度上是通过网络,既然是网络,那结合网络就会有一些特点,比如网络的大量连接和高并发。至此我们从单纯的I/O处理变成已经和网络扯上关系了。
来看看这关系要怎么建立吧。分两块,先说网络,就是说数据要在网络中传输,我们用java的方式怎么编程实现?嗯,想起我们熟悉的java网络编程必备的Socket了。是的,就是它。例子太多就不写了,你可以随便找个Demo回顾一下,大致过程就是利用socket API建立两台主机的连接,然后一边发送数据,一边接收数据(当然也可以双向通信)。再说IO,数据的具体IO操作过程就是通过各种 InputStrem、OutputStrem、Reader Writer把数据读出来或写出去。好了,至此,我们通过Socket+I/O就可以实现数据在网络中的两台主机间的传输,完成了广义上的通信了。
简单总结一下,由于我们要实现网络间的数据传输或通信,所以需要网络编程接口Socket以及数据的输入、输出处理API I/O来共同完成这一任务。这里我们不扯更多的,像网络底层协议、TCP/IP什么的都是更底层的数据传输理论,已经懂的自不必说,想弄清楚的,建议大家还要顺着问题去查一查,了解了底层原理,更有利于理解建立在其上的应用。
到这里我们可以通过网络实现网络间的IO处理了,但问题来了,网络自身的特点上文提到了,比如大量连接和高并发。而现在我们的IO是同步阻塞I/O处理(也就是BIO,BlockingI/O),怎么讲? 说白了就是它在读、写操作时只能阻塞着等它完成,CPU中间不能干别的。这要是一两个连接那等就等吧,我们忍了,而网络的特点告诉我们连接会很多,那就不是等一会儿的事儿了,我们忍不了,得解决,于是有了下图的经典BIO编程模型。
图中为伪代码,这是一个经典的每连接每线程的模型,之所以使用多线程,主要原因在于socket.accept()、socket.read()、socket.write()三个主要函数都是同步阻塞的,当一个连接在处理I/O的时候,系统是阻塞的,如果是单线程的话必然就挂死在那里;但CPU是被释放出来的,开启多线程,就可以让CPU去处理更多的事情。其实这也是所有使用多线程的本质:1. 利用多核。2. 当I/O阻塞系统,但CPU空闲的时候,可以利用多线程使用CPU资源。
现在的多线程一般都使用线程池,可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。
不过,这个模型最本质的问题在于,严重依赖于线程。但线程是很”贵”的资源,主要表现在:
1. 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
2. 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
4. 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。
所以,当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的I/O处理模型。
二 IO模型
我们先不聊NIO,先来聊一聊IO模型,或者说是Unix 网络 IO 模型,这里我要问个问题,你的程序在哪里运行?大多数生产环境是在服务器上运行,而服务器的操作系统绝大多数是Linux的,至于Linux和Unix的关系这里就不赘述了,也就是说我们程序最终是通过Linux操作系统的函数来间接调用的系统资源,那当然会受到操作系统的影响和限制,所以要了解下在操作系统层面IO是怎么处理的。(对UNIX网络编程感兴趣的可以看看 《unix网络编程第三版》)
大家不要被IO模型几个字吓唬住了,所谓模型也就是处理IO的方式方法而已。不同的模型就是不同的方式。
在说IO模型之前,我们先来讲几个基本概念,对这些概念了解的可以直接跳过了。先来说说文件描述符(fd)。
文件描述符(file descriptor,简称 fd)在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
在 Linux 中,内核将所有的外部设备都当做一个文件来进行操作,而对一个文件的读写操作会调用内核提供的系统命令,返回一个 fd,对一个 socket 的读写也会有相应的描述符,称为 socketfd(socket 描述符),实际上描述符就是一个数字,它指向内核中的一个结构体(文件路径、数据区等一些属性)。如下图所示。
系统为维护文件描述符,建立了三个表:
进程级的文件描述符表、系统级的文件描述符表、文件系统的i-node表 (转到:阮一峰——理解inode)
实际工作中我们有时会碰到“Too many openfiles”的问题,那很可能就是进程可用的文件描述符过少的原因。然而很多时候,并不是因为进程可用的文件描述符过少,而是因为程序bug,打开了大量的文件连接(web连接也会占用文件描述符)而没有释放。程序申请的资源在用完后及时释放,才是解决“Too many open files”的根本之道。
用户空间与内核空间、内核态与用户态
这个是经常提到的概念,具体含义可以参考这篇文章用户空间与内核空间,进程上下文与中断上下文【总结】,大概内容如下:
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操心系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 linux 操作系统而言(以32位操作系统为例)
将最高的 1G 字节(从虚拟地址0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间;
将较低的 3G 字节(从虚拟地址0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。
每个进程可以通过系统调用进入内核,因此,Linux 内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间。
当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈,每个进程都有自己的内核栈;
当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。
好了,接下来进入IO模型正题。根据 UNIX 网络编程对IO 模型的分类,UNIX 提供了以下 5 种 IO 模型。注意下面讲的跟JAVA没有什么关系哈,不要想串了,下面说的都是操作系统层面的东西,属于基础部分,而java这种“上层建筑”我们后面再看。我们先概览一下,如下图:
下面一个一个来说,最流行的 IO 操作是阻塞式IO(Blocking IO). 以 UDP 数据报套接字为例,下图是其阻塞 IO 的调用过程:
上图有个recvfrom调用,这是啥?recvfrom是C语言的函数,也就是linux内核函数(操作系统也是用编程语言写的嘛),所以可想而知我们上层不管用什么语言写的应用,最终的调用是会执行操作系统内核的函数的。而recvfrom函数,大致含义是:从(已连接)套接口上接收数据,并捕获数据发送源的地址。假如套接字上没有消息可以读取,除非套接字已被设置为非阻塞模式,否则接收调用会等待消息的到来。
如上图中所示的一样,recvfrom使进程阻塞,它是一个阻塞函数。我们以套接字接口为例来讲解此模型,在进程空间中调用recvfrom,其系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误时才返回,在此期间一直会等待,进程在从调用recvfrom开始到它返回的整段时间内都是被阻塞的,因此被称为阻塞IO模型。如上文所述,阻塞I/O下请求无法立即完成则保持阻塞。阻塞I/O分为如下两个阶段。
阶段1:等待数据就绪。网络 I/O 的情况就是等待远端数据陆续抵达;磁盘I/O的情况就是等待磁盘数据从磁盘上读取到内核态内存中。
阶段2:数据拷贝。出于系统安全,用户态的程序没有权限直接读取内核态内存,因此内核负责把内核态内存中的数据拷贝一份到用户态内存中。
非阻塞式IO模型,如下图所示:
非阻塞I/O请求包含如下三个阶段
阶段1:socket设置为 NONBLOCK(非阻塞)就是告诉内核,当所请求的I/O操作无法完成时,不要将线程睡眠,而是返回一个错误码(EWOULDBLOCK) ,这样请求就不会阻塞。
阶段2:I/O操作函数将不断的测试数据是否已经准备好,如果没有准备好,继续测试,直到数据准备好为止。整个I/O 请求的过程中,虽然用户线程每次发起I/O请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的 CPU 的资源。
阶段3:数据准备好了,从内核拷贝到用户空间。
总结来说,recvfrom 从应用到内核的时,如果该缓冲区没有数据,就会直接返回EWOULDBLOCK 错误,一般都对非阻塞 IO 模型进行轮询检查这个状态,看看内核是不是有数据到来也就是说非阻塞的 recvform 系统调用调用之后,进程并没有被阻塞,内核马上返回给进程。如果数据还没准备好,此时会返回一个 error。进程在返回之后,可以干点别的事情,然后再发起 recvform 系统调用。重复上面的过程,循环往复的进行 recvform 系统调用,这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
在 Linux 下,可以通过设置socket 使其变为 non-blocking。非阻塞IO过于消耗CPU时间,将大部分时间用于轮询。
IO 多路复用模型,如下图所示:
上图中有个select函数,我们先来解释下这个函数:
//在linux/posix_types.h头文件中有这样的声明:#define __FD_SETSIZE 1024 // 返回值:做好准备的文件描述符的个数,超时为0,错误为-1. // int maxfdp是一个整数值,是指集合中所有的文件描述符的范围,即所有的文件描述符的最大值加1,不能错。 // fd_set *readfds是指向fd_set结构的指针,是我们关心的,是否可以从这些文件中读取数据的集合, //若有大于等于一个可读文件,则select会返回大于0的值。若无,则根据timeout判断。 // timeout==NULL 等待无限长时间即select处于阻塞状态。等待可以被一个信号中断。 //当有一个描述符做好了准备或者是捕获到了一个信号函数会返回。如果捕获到一个信号,select函数将返回-1,并将变量errno设置为EINTR。 // timeout->tv_sec=0&&timeout->tv_usec=0不等待,直接返回。加入到描述符集的描述符都会被测试, //并且返回满足要求的描述符的个数,这种方法通过轮询,无阻塞地获得了多个文件描述符的状态。 //timeout->tv_sec != 0 ||timeout->tv_usec != 0等待指定的时间,当有描述符符合条件或者是超过时间的话,函数返回。 //在超时时间即将用完,但是有没有描述符符合条件的话,返回0。对于第一种情况,等待也会被信号中断。 #include <sys/select.h> int select(int maxfdp1, fd_set *readset,fd_set *writeset, fd_set *exceptset,struct timeval *timeout); struct timeval{ long tv_sec;//秒 long tv_usec;//微秒 }
在Linux中,我们可以使用select函数实现I/O端口的复用,传递给 select函数的参数会告诉内核:
• 我们所关心的文件描述符
• 对每个描述符,我们所关心的状态。(我们是要想从一个文件描述符中读或者写,还是关注一个描述符中是否出现异常)
• 我们要等待多长时间。(我们可以等待无限长的时间,等待固定的一段时间,或者根本就不等待)
从 select函数返回后,内核告诉我们以下信息:
• 对我们的要求已经做好准备的描述符的个数
• 对于三种条件哪些描述符已经做好准备.(读,写,异常)
有了这些返回信息,我们可以调用合适的I/O函数(通常是 read 或 write),并且这些函数不会再阻塞.
一个文件描述集保存在fd_set类型当中,fd_set类型变量的每一位代表了一个描述符。我们也可以认为它只是由一个很多二进制位构成的数组
基本原理如下图:
如果你对上面那一坨理论不感冒的话,那我们简明的总结一下,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
再来看个select流程伪代码:
对,就是顾名思义不断去select处于可用状态的socket。你可能会说使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。如果你的网络请求量比较大的情况下,这种模式是不是比阻塞式好啊。
总结一下IO多路复用模型:IO multiplexing(多路复用)就是我们说的select,poll,epoll(关于这三个函数的对比和介绍,后文再讲),有些地方也称这种IO方式为event driven (事件驱动)IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个systemcall (select 和 recvfrom),而blockingIO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:
1、单个进程可监视的fd数量被限制,即能监听端口的大小有限。一般来说这个数目和系统内存关系很大,具体数目可以cat/proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.
2、 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
3、需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
信号驱动式I/O模型
这种模式一般很少用,所以不重点说了,大概说一下,如图所示:
为了使用该I/O模型,需要开启套接字的信号驱动I/O功能,并通过sigaction系统调用安装一个信号处理函数。sigaction函数立即返回,我们的进程继续工作,即进程没有被阻塞。当数据报准备好时,内核会为该进程产生一个SIGIO信号,这样我们可以在信号处理函数中调用recvfrom读取数据报,也可以在主循环中读取数据报。无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间不被阻塞。
来看下这种模式的缺点:信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失。对这个模式感举的可以再看看这个。
异步IO模型
调用aio_read 函数(当然AIO的API不止这一个,如下图还有很多),
告诉内核描述字,缓冲区指针,缓冲区大小,文件偏移以及通知的方式,然后立即返回。当内核将数据拷贝到缓冲区后,再通知应用程序。所以异步I/O模式下,阶段1和阶段2全部由内核完成,完成不需要用户线程的参与。异步 IO 模型和信号驱动的 IO 模型的主要区别在于: 信号驱动 IO 是由内核通知我们何时可以启动一个 IO 操作, 而异步 IO 模型是由内核通知我们 IO 操作何时完成。
到此我们已经分别介绍完了5种IO模型,来看一下他们的比较:
可以看到,前四种I/O模型的主要区别在于第一个阶段,它们的第二个阶段是一样的:在数据从内核复制到应用进程的缓冲区期间,进程会被阻塞于recvfrom系统调用。而异步I/O模型则是整个操作完成内核才通知应用进程。
下面引用知乎上有一个比较生动的例子可以说明这几种模型之间的关系。
老张爱喝茶,废话不说,煮开水。
出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。
1 老张把水壶放到火上,立等水开。(同步阻塞)
老张觉得自己有点傻
2 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)
老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。
3 老张把响水壶放到火上,立等水开。(异步阻塞)
老张觉得这样傻等意义不大
4 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)
老张觉得自己聪明了。
所谓同步异步,只是对于水壶而言。
普通水壶,同步;响水壶,异步。
虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。
同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。
所谓阻塞非阻塞,仅仅对于老张而言。
立等的老张,阻塞;看电视的老张,非阻塞。
情况1和情况3中老张就是阻塞的,媳妇喊他都不知道。虽然3中响水壶是异步的,可对于立等的老张没有太大的意义。所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。
多路复用之select、poll、epoll
上文中提到的多路复用模型的图中只画了select,实际上这种模型的实现方式是可以基于不同方法有多个实现的。比如基于select或poll或epoll方法,那么它们有什么不同呢?
select
select函数监视的 fd 分3类,分别是 writefds、readfds、和exceptfds。调用后select 函数会阻塞,直到有fd 就绪(有数据 可读、可写、或者有 except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当select函数返回后,可以通过遍历 fdset,来找到就绪的 fd。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select 的一个最大的缺陷就是单个进程对打开的 fd 是有一定限制的,它由 FD_SETSIZE 限制,默认值是1024,如果修改的话,就需要重新编译内核,不过这会带来网络效率的下降。
select和 poll 另一个缺陷就是随着 fd 数目的增加,可能只有很少一部分 socket 是活跃的,但是 select/poll 每次调用时都会线性扫描全部的集合,导致效率呈现线性的下降。
poll
poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历 fd。这个过程经历了多次无谓的遍历。它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样以下几个缺点:
1 大量的 fd 的数组被整体复制于用户态和内核地址空间之间;
2 poll还有一个特点是【水平触发】,如果报告了 fd 后,没有被处理,那么下次 poll 时会再次报告该 fd;
3 fd 增加时,线性扫描导致性能下降。
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll 支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些 fd 变为就绪态,并且只会通知一次。还有一个特点是,epoll 使用【事件】的就绪通知方式,通过 epoll_ctl 注册 fd,一旦该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd,epoll_wait 便可以收到通知。
epoll 对 fd 的操作有两种模式:LT(leveltrigger)和ET(edge trigger)。LT 模式是默认模式,有关水平触发(level-trggered)和边缘触发(edge-triggered)这里多说两句:
水平触发(level-trggered)
只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知,当文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直发出可写信号进行通知LT模式支持阻塞和非阻塞两种方式。epoll默认的模式是LT。
边缘触发(edge-triggered)
当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知,当文件描述符关联的内核写缓冲区由满转化为不满的时候,则发出可写信号进行通知。两者的区别在哪里呢?水平触发是只要读缓冲区有数据,就会一直触发可读信号,而边缘触发仅仅在空变为非空的时候通知一次,
LT(leveltriggered)是缺省的工作方式,并且同时支持block和no-blocksocket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
网上有一个关于水平和边缘触发的例子大家感受一下:
水平触发
儿子:妈妈,我收到了500元的压岁钱。
妈妈:嗯,省着点花。
儿子:妈妈,我今天花了200元买了个变形金刚。
妈妈:以后不要乱花钱。
儿子:妈妈,我今天买了好多好吃的,还剩下100元。
妈妈:用完了这些钱,我可不会再给你钱了。
儿子:妈妈,那100元我没花,我攒起来了
妈妈:这才是明智的做法!
儿子:妈妈,那100元我还没花,我还有钱的。
妈妈:嗯,继续保持。
儿子:妈妈,我还有100元钱。
妈妈:…
接下来的情形就是没完没了了:只要儿子一直有钱,他就一直会向他的妈妈汇报。LT模式下,只要内核缓冲区中还有未读数据,就会一直返回描述符的就绪状态,即不断地唤醒应用进程。在上面的例子中,儿子是缓冲区,钱是数据,妈妈则是应用进程了解儿子的压岁钱状况(读操作)。
边缘触发
儿子:妈妈,我收到了500元的压岁钱。
妈妈:嗯,省着点花。
(儿子使用压岁钱购买了变形金刚和零食。)
儿子:
妈妈:儿子你倒是说话啊?压岁钱呢?
这个就是ET模式,儿子只在第一次收到压岁钱时通知妈妈,接下来儿子怎么把压岁钱花掉并没有通知妈妈。即儿子从没钱变成有钱,需要通知妈妈,接下来钱变少了,则不会再通知妈妈了。在ET模式下, 缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程。
三种模型的区别
到这里我们总结一下select,poll和epoll:
1 select的几大缺点:
(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024
2 epoll的优点:
(1) 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
(2) 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
(3)表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
3 select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善
4 select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
5 select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
讲到现在我们把基础模型都说完了,终于NIO该登场了。
三 NIO
在 JDK 推出 Java NIO 之前,基于 Java 的所有 Socket 通信都采用了同步阻塞模式(BIO),这种一对一的通信模型虽然简化了开发的难度,但在性能和可靠性方面却存在这巨大的瓶颈,特别是无法处理高并发的场景,使得 Java 在服务器端应用十分有限。
正是由于 Java 传统 BIO 的拙劣表现,使得 Java 不得不去开发新版的 IO 模型,最终,JDK1.4 提供了新的 NIO 类库,Java可以支持非阻塞 IO;之后,JDK1.7 正式发布,不但对 NIO 进行了升级,还提供了 AIO 功能。Java NIO 部分,其底层原理就是 UNIX 的 IO 多路复用。
正如我们文章开篇的那段BIO程序。传统 BIO 中,ServerSocket负责绑定 IP 地址,启动监听端口;Socket 负责发起连接操作,连接成功后,双方通过输入和输出流进行同步阻塞通信。采用 BIO 通信模型的 Server,通常由一个独立的 Acceptor 线程负责监听 Client 端的连接,它接受到 Client 端连接请求后为每个 Client 创建一个新的线程进行处理,处理完之后,通过输出流返回给 Client 端,线程销毁,过程如下图所示。
这个模型最大的问题是:
缺乏扩展性,不能处理高性能、高并发场景,线程是 JVM 中非常宝贵的资源,当线程数膨胀后,系统的性能就会急剧下降,随着并发访问量的继续增大,系统就会出现线程堆栈溢出、创建新线程失败等问题,导致 Server 不能对外提供服务。
为了改进这种一对一的连接模型,后来又演进出了一种通过线程池或者消息队列实现 1 个或者多个线程处理所有 Client 请求的模型,由于它底层依然是同步阻塞 IO,所以被称为【伪异步 IO 模型】。相比于传统 BIO 后端不断创建新的线程处理 Client 请求,它在后端使用一个线程池来代替,通过线程池可以灵活的调配线程资源,设置线程的最大值,防止由于海量并发接入导致线程资源耗尽,过程如下图所示:
看似这个模型解决了 BIO 面对的问题,实际上,由于它是面向数据流的模型,底层依然是同步阻塞模型,在处理一个 socket 输入流,它会一直阻塞下去,除非:有数据可读、可用数据读取完毕、有异常,否则会一直一直阻塞下去。这个模型最大的问题是:
阻塞的时间取决于对应 IO 线程的处理速度和网络 IO 的传输速度,处理效率不可控。
那么如何破解上述难题?NIO将给出答案。
Java NIO 是 Java IO 模型中最重要的 IO 模型,也是本文主要讲述的内容,正式由于 NIO 的出现,Java 才能在服务端获得跟 C 和C++ 一样的运行效率,NIO 是 New IO(或者 Non-block IO)的简称。
与 Socket 类和 ServerSocket 类相对应,NIO 也提供了 SocketChannel 和 ServerSocketChannel 两种不同套接字通道的实现,它们都支持阻塞和非阻塞两种模式。一般来说,低负载、低并发的应用程序可以选择同步阻塞 IO 以降低复杂度,但是高负载、高并发的网络应用,需要使用 NIO 的非阻塞模式进行开发。
在 NIO 中有三种非常重要的概念,下面一个一个来说,首先是缓冲区Buffer.
Java IO是面向流的,每次从流(InputStream/OutputStream)中读一个或多个字节,直到读取完所有字节,它们没有被缓存在任何地方。另外,它不能前后移动流中的数据,如需前后移动处理,需要先将其缓存至一个缓冲区。Java NIO面向缓冲,数据会被读取到一个缓冲区,需要时可以在缓冲区中前后移动处理,这增加了处理过程的灵活性。但与此同时在处理缓冲区前需要检查该缓冲区中是否包含有所需要处理的数据,并需要确保更多数据读入缓冲区时,不会覆盖缓冲区内尚未处理的数据。
Buffer,本质上是一块内存区,可以用来读写数据,它包含一些要写入或者要读出的数据。在 NIO 中,所有数据都是通过 Buffer 处理的,读取数据时,它是直接读到缓冲区中,写入数据时,写入到缓冲区。
最常用的缓冲区是 ByteBuffer,一个 ByteBuffer 提供了一组功能用于操作 byte 数组,除了 ByteBuffer,还有其他的一些 Buffer,如:CharBuffer、IntBuffer 等,它们之间的关系如下图所示。
Buffer 基本用法(读写数据过程):
1 把数据写入 Buffer;
2 调用 flip(),Buffer 由写模式变为读模式;
3 Buffer 中读取数据;
4 调用 clear() 清空buffer,等待下次写入。
示例如下:
byte[] req = "QUERY TIME ORDER".getBytes(); ByteBuffer byteBuffer = ByteBuffer.allocate(req.length); byteBuffer.put(req); byteBuffer.flip(); while (byteBuffer.hasRemaining()){ System.out.println((char)byteBuffer.get()); } byteBuffer.clear();
这里重点讲下flip()方法, Buffer 中的flip() 方法涉及到 Buffer 中的capacity、position、limit三个概念。
capacity:在读/写模式下都是固定的,就是我们分配的缓冲大小(容量)。
position:类似于读/写指针,表示当前读(写)到什么位置。
limit:在写模式下表示最多能写入多少数据,此时和capacity相同。在读模式下表示最多能读多少数据,此时和缓存中的实际数据大小相同。
Buffer有两种模式,写模式和读模式。在写模式下调用flip()之后,Buffer从写模式变成读模式。如下图所示:
下图显示了flip()在读写过程中position、limit、capacity是怎样变化的:
Buffer 常用方法:
flip():把 buffer 从模式调整为读模式,在读模式下,可以读取所有已经写入的数据;
clear():清空整个 buffer;
compact():只清空已读取的数据,未被读取的数据会被移动到 buffer 的开始位置,写入位置则紧跟着未读数据之后;
rewind():将 position 置为0,这样我们可以重复读取 Buffer 中的数据,limit 保持不变;
mark()和reset():通过mark方法可以标记当前的position,通过reset来恢复mark的位置
equals():判断两个 Buffer 是否相等,需满足:类型相同、Buffer 中剩余字节数相同、所有剩余字节相等;
compareTo():compareTo 比较 Buffer 中的剩余元素,只不过这个方法适用于比较排序的。
然后是Channel, Java IO的各种流是阻塞的。当某个线程调用read()或write()方法时,该线程被阻塞,直到有数据被读取到或者数据完全写入。阻塞期间该线程无法处理任何其它事情。Java NIO为非阻塞模式。读写请求并不会阻塞当前线程,在数据可读/写前当前线程可以继续做其它事情,所以一个单独的线程可以管理多个输入和输出通道。Channel与流的不同之处在于Channel 是全双工的,可以比流更好地映射底层操作系统的 API。流只是在一个方向上移动(一个流必须 是InputStream或者Outputstream的子类)。而通道可以用于读、写或者二者同时进行。通道可以异步读写;它是基于缓冲区(Buffer)进行读写的;
在 Java 中提供以下几种Channel:
- FileChannel:用于文件的读写;
- DatagramChannel:用于UDP 数据读写;
- SocketChannel:用于Socket 数据读写;
- ServerSocketChannel:监听 TCP 连接请求。
这些 Channel 类之间的继承关系如下图所示:
Java NIO 发布时内置了对 scatter/gather的支持:
Scattering read 指的是从通道读取的操作能把数据写入多个 Buffer,也就是 sctters 代表了数据从一个 Channel 到多个 Buffer的过程。
Gathering write 则正好相反,表示的是从多个 Buffer 把数据写入到一个 Channel中。
代码示例如下:
// Scattering read ByteBuffer header =ByteBuffer.allocate(128); ByteBuffer body = ByteBuffer.allocate(1024); ByteBuffer[] bufferArray = { header, body }; channel.read(bufferArray); // Gathering write ByteBuffer header =ByteBuffer.allocate(128); ByteBuffer body = ByteBuffer.allocate(1024); ByteBuffer[] bufferArray = { header, body}; channel.write(bufferArray);
最后是Selector
Selector 是 Java NIO 核心部分,简单来说,它的作用就是:Selector 不断轮询注册在其上的 Channel,如果某个 Channel 上面有新的 TCP 连接、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来,然后通过 SelectorKey() 可以获取就绪 Channel 的集合,进行后续的 IO 操作。
一个 Selector 可以轮询多个 Channel,由于 JDK 底层使用了 epoll() 实现,它并没有最大连接句柄 1024/2048 的限制,这就意味着只需要一个线程负责 Selector 的轮询,就可以连接上千上万的 Client。
Java NIO的选择器允许一个单独的线程同时监视多个通道,可以注册多个通道到同一个选择器上,然后使用一个单独的线程来“选择”已经就绪的通道。这种“选择”机制为一个单独线程管理多个通道提供了可能。
NIO 2.0 中引入异步通道的概念,并提供了异步文件通道和异步套接字导通的实现,它是真正的异步非阻塞I IO,底层是利用事件驱动(AIO)实现,不需要多路复用器(Selector)对注册的通道进行轮组操作即可实现异步读写。
到此我们已经介绍完了NIO的基本概念,看过上面这些介绍的你是不是觉得用NIO编程还是比较麻烦?是的,用原生NIO api进行开发是比较复杂,门槛比较高,所以出现了Netty这样好用、强大的NIO框架。