IO模型(同步、非同步、阻塞、非阻塞IO)总结
20210819补充:线程阻塞(“阻塞”可以是指线程阻塞,也可以是指IO阻塞,两者不是一件事)的本质,详情可参阅文章 线程阻塞的本质。
深入到Linux内核源码看,阻塞与非阻塞的最终效果是线程状态的改变——阻塞就是将当前线程的状态标记为非RUNNABLE状态(例如Java scanner.nextLine() 最终在Linux层面就是将当前线程标为TASK_INTERRUPTIBLE状态),这样进程调度(最终是线程调度)时该线程就不会被分配CPU执行权限,从而看上去是"阻塞"了;
相应地,线程唤醒的最终效果就是将该线程状态变为RUNNABLE(例如上述例子中用户在终端输入字符时,就会产生中断,中断程序的执行效果是改变线程状态),这样该线程就可以被调度器分配CPU执行权限,看上去就是唤醒了。
下面所说的“阻塞”指IO阻塞,IO阻塞中涉及到线程阻塞。
几个概念:
IO:以内存为参照物,数据在内存与外设(网卡、磁盘等)间的输入Input和输出Output。包括网络IO、文件IO。
IO事件:IO过程中的一些状态,如建立连接、接受连接、数据可读(数据从外设复制到内存缓冲区完成)、数据可写(数据从用户态内存写到内存缓冲区完成)等。
IO密集型、CPU密集型任务:程序操作的主要时间花在外设和内存间数据读写时属于IO密集型操作,如网络数据收发、文件读写等;主要时间花费在CPU计算的属于CPU密集型操作,如矩阵乘法、大数的因数分解等。
多线程/多进程处理:通常通过他们来并发执行一批任务。然而多线程是否一定能提高执行效率呢?并没有固定的结论。
从CPU核数看,如果CPU有多核,那么用多线程执行一批任务显然比用单线程的执行效率高(不论是CPU密集型还是IO密集型任务)。
从任务类型看,假设CPU只有一核(控制变量法..):
对于CPU密集型任务,这些多线程任务本质上是在CPU上串行执行的(只不过每个线程任务都不是连续执行完而是被分配多个时间片交叉轮流执行),因此整体效率并不比单线程执行快。但是多线程使得在用户看来这多个任务是并发执行的从而不用等待上一个任务完成再执行下一任务,因此此类型任务虽然整体效率不见得比单线程高但对用户体验来说是值得的。
对于IO密集型任务,在数据IO的第一阶段(见下文IO内部机制部分)时用户线程是要阻塞的,即不占用CPU,因此此时能把CPU让给其他线程进行IO而不是空占CPU。可见,此类型任务下多线程可比单线程效率高。
综上,现代CPU都有多核了,因此整体而言一批任务用多线程执行比单线程执行的效率高。当然,一切都不是绝对的——线程数过多带来的线程创建、调度、上下文切换等的时间、空间开销可能超过多线程带来的效率优势,因此,要结合具体的需求和场景分析。
IO操作的姿势:同步(synchronous)IO、异步(asynchronous)IO、阻塞(blocking)IO、非阻塞(nonblocking)IO
1、IO内部机制
出于安全考虑,用户程序(用户态)是没办法直接操作IO设备进行数据读入或输出的,需要借助操作系统(内核态)提供的API来进行IO,所以通常我们说IO其实是通过系统调用来完成的。(关于IO的具体过程,可参阅 https://www.cnblogs.com/z-sm/p/15163921.html)
程序发起IO调用时涉及两个阶段,以read为例:
- 数据准备阶段(DMA Copy,不需要CPU参与):等待内核态将数据从外设读入内核态内存并准备好,进入就绪状态 (Waiting for the data to be ready)。这步是外设与内核态内存间的复制,是耗时操作!涉及用户态切换到内核态的切换。
- 数据复制阶段(CPU Copy,需要CPU参与):将数据从内核态复制到用户态即从内核态内存复制到用户态内存 (Copying the data from the kernel to the process)。这步是内存间的复制,比上步快很多。涉及内核态切换到用户态的切换。
可见,一次IO调用涉及到2次Copy、2次用户态和内核态的上下文切换。通常说的IO“阻塞”是指在上述步骤1完成前系统调用是阻塞还是立马返回。
2、同步、异步、阻塞、非阻塞IO的区别
阻塞、非阻塞IO(针对系统调用(内核态)而言?):发起的IO调用时该调用是否立即返回。阻塞IO等IO完成才返回(即等1、2都结束才返回。1、2均阻塞),非阻塞IO立即返回,此时IO还没完成(即1立即返回,若数据没准备好则循环检测直到就绪,就绪后等阶段2。1不阻塞、2阻塞)。可见,阻塞非阻塞的的区别体现在是否等待耗时步骤1完成。
一个不那么恰当的比喻:假设你在深圳家里,你朋友在北京要来找你且刚出发,你要去深圳车站接你朋友。你朋友到车站前这段过程、从车站到你家这段过程分别相当于上述阶段1、2。阻塞IO:你此时就去车站接你朋友,显然需要等很久;非阻塞IO:你让他到车站时跟你说你再去车站接他,或者你不断询问他到车站了没若到了就去接他。
同步、异步IO(针对用户线程(用户态)而言?):调用者(线程)在发起IO调用后在IO完成前能否继续执行之后的代码或工作。
阻塞不一定是同步的,非阻塞也不一定是异步的,反之亦然。
- 同步阻塞IO:如JDK IO(发起IO调用不立即返回,调用者在IO完成前也没法进行之后的操作)。
- 同步非阻塞IO:如JDK NIO(发起IO调用后立即返回,此后通过循环检查等手段直到IO就绪才进行IO操作,操作完了才进行之后的工作,因此是同步的)。
- 异步IO:如可以写一个带回调参数的方法,该方法启用新线程进行IO——根据String content、String filePath参数将conent写入指定文件,完成后调用回调函数通知调用者。至于是阻塞还是非阻塞则看新线程内进行的IO是阻塞还是非阻塞的。一个异步阻塞IO的示例如下:
1 public class FileIO { 2 public void saveStrToFile(String fileName, String str, IFileIOCallback callback) { 3 new Thread(new Runnable() { 4 @Override 5 public void run() { 6 try { 7 File file = getExistsFile(fileName); 8 writeStrToFile(str, file); 9 callback.onResult(true); 10 } catch (IOException e) { 11 e.printStackTrace(); 12 callback.onResult(false); 13 } 14 } 15 }).start(); 16 } 17 }
阻塞、非阻塞的区别简述,可参阅:https://mp.weixin.qq.com/s/bfAYDalNcZpqsnyt_NvIhQ(码农翻身)
3、Unix下的五种IO模型
(详见 IO同步异步阻塞非阻塞 )
IO模型是随着时代变化而逐渐演进的,其实际上就是时代的变化倒逼操作系将更多本来不得不在应用层实现的功能加到自己的内核的过程。例如应用层的“非阻塞IO + 轮询判断数据就绪”的过程最终就是内核的多路复用IO。
1 阻塞IO(BIO,blocking IO,属于synchronous,阶段1、2皆阻塞)
Java IO对应这里的BIO。
2 非阻塞IO(NIO,nonblocking IO,属于synchronous,阶段1非阻塞、2阻塞)
阶段1非阻塞,通过不断轮询看数据是否准备好。
对单个IO请求意义不大,但给IO多路复用提供了条件,使能在一个线程里处理多个IO,从而提高并发处理IO能力。如对于很多个socket连接:
若连接一个就建立新线程阻塞处理则可能导致线程数很多(线程数多了则线程的创建、销毁、调度、上下文切换等的时间和空间开销大),而可创建的线程数受OS和内存等系统资源的限制;就算采用线程池来处理阻塞IO,每个线程一次仍只能处理一个socket,连接多的话仍会有很多连接在等待处理,因此并发效率低,其根本原因在于因采用阻塞IO从而用户线程仍在等耗时的IO数据准备阶段完成。
此时可采用非阻塞IO在一个线程里同时处理多个连接,通过轮询等方式看哪个socket数据就绪,这其实就是Unix的IO多路复用(下文介绍)的思想。用户可自己基于非阻塞IO实现该过程,但此时的每次轮询都是系统调用,故整体效率实际上与阻塞IO区别不大,因此关键是要把轮询操作交给内核而不是用户自己去完成,这也正是多路复用IO内部所干的事(从用户角度上看效果就是多次系统调用变成了一次系统调用,也就是多次单个查询变成了一次批量查询)。
3 多路复用IO或事件驱动IO(IO multiplexing or event driven IO,属于synchronous,阶段1非阻塞、2阻塞)
主要就是利用非阻塞IO提高并发处理能力:用户进程调用系统调用函数select/poll(阻塞),select/poll内部不断轮询所负责的所有socket(以非阻塞方式调用了socket,包括正在建立连接的socket、连接建立之后读写数据的socket),当某些socket有数据了用户进程就能拿到这些socket,接着用户进程再从这些socket read数据(阻塞或非阻塞)。优点:单线程实现简单、IO并发性能高——能处理更多连接。不过,在web server中连接数少的情况下用IO multiplexing性能不一定比multi-threading + blocking IO好。
在Linux中的实现有系统调用select、poll、epoll(关于这三个系统调用及多路复用IO的演进和内部原理,强烈推荐参阅文章 你管这叫IO的多路复用-公众号低并发编程)
select、poll时内核需要去轮询哪些IO的步骤1是就绪的,且两者功能和实现几乎一样,区别是后者通过用链表改进了前者能管理的文件描述符数的限制。
epoll与select/poll 的主要区别是epool不是去轮询而是注册了回调函数因此就绪时会有事件通知。具体而言,解决了select/poll的三个不足(关于epoll内部原理可参阅 源码解密epoll如何实现IO多路复用):
//select、epoll内部逻辑示意 while(1) { nready = select(list); // 用户层依然要遍历,只不过少了很多无效的系统调用 for(fd <-- fdlist) { if(fd ready) { // 只读已就绪的文件描述符 read(fd, buf); // 总共只有 nready 个已就绪描述符,不用过多遍历 if(--nready == 0) break; } } } //可看出几个细节 1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制) 2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知) 3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历) // epoll相对于select、poll的优化(即针对上述三个细节的优化): 1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。 2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。 3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
Java NIO就用了这里的多路复用。结合后文Java NIO SocketChannel的例子会有深刻的理解,其selector.select()、selector.wakeup() 对应这里的用户调用和就绪通知。
上述的事件就绪通知,是通过中断机制实现的。
4 信号驱动IO(signal driven IO,属于synchronous,阶段1非阻塞、2阻塞)
与事件驱动IO类似,只不过在IO的步骤1数据就绪时是通过Unix系统信号而不是事件来通知的——注册SIGIO、信号处理函数。
5 异步IO(AIO,asynchronous IO),此模式下,调用的阶段1、2都由内核完成,不需要用户线程参与。
Java NIO2对应这里的AIO,基于Java NIO的Netty框架也是AIO。通常是借助回调方法来完成。
总结:
可见,上述同步IO模型的阶段2是阻塞的。
需要注意的是:
与阻塞IO模型相比,使用非阻塞IO(或多路复用IO、信号驱动IO、异步IO)模型的主要目的是提高并发处理IO(用户线程不用为步骤1执行而等待)的能力,后者一般也只在IO请求量大且每个请求读写的数据量较少的场景下才比前者有优势,因为:
请求数小则不一定比multi-threading + blocking IO 性能好;
因为通常一个线程要处理多个就绪的IO,若数据量大则处理当前的IO太久会影响其他待处理的就绪IO,极端情况下就退化成了阻塞。不过因这一步是内存间的数据复制,一般问题不大。
非阻塞的特点使得一个线程可处理多个IO,从而提高并发IO能力,并且单线程模型使得代码实现上很简单。但现代计算机有多个CPU,如果死脑筋地只用一个线程,则无疑相当于浪费了CPU资源,因此实际场景中通常会有少量的多个线程来并发处理这些IO,即通常会有多个IO多路复用器。
多路复用IO的典型应用场景是服务端Socket编程(如聊天服务):借助IO多路复用,用一个线程去监听socket connect就绪、socket accept就绪、socket read就绪、socket write就绪的事件并执行相应的回调方法(此即所谓的Reactor模式,当然后面也演进为可用少数的多线程处理,即所谓的多Reactor模式),见后面Java NIO SocketChannel 示例。Redis的IO实现原理本质上与此同理。
4、Java NIO
Java IO:阻塞、IO Stream(file stream、socket stream)、面向字节、单向
Java NIO:非阻塞、IO Channel(file channel、socket channel)、面向Buffer、单向或双向。
A channel represents an open connection to an entity such as a hardware device, a file, a network socket, or a program component that is capable of performing one or more distinct I/O operations, for example reading or writing.
A buffer is a linear, finite sequence of elements of a specific primitive type.
NIO把它支持的IO对象抽象为Channel,译为“通道”类似于 java.io 中的流Stream,两者的区别:
1、流是单向的,要么只能读要么只能写;通道可以是单向( unidirectional)或者双向的( bidirectional),后者可读可写。
2、流读写是阻塞的,通道可以非阻塞读写。
3、流中读过的数据不能再读,Buffer中的则可以。
4、流中的数据可以选择性地先读到缓存中,通道的数据总是要先读到一个缓存中,或从缓存中写入。
4.1、基本概念
主要概念:Channel、Buffer、Selector(多路复用器)
- Channel:ServerSocketChannel、SocketChannel、DatagramChannel(UDP)、FileChannel。Channel代表一个客户端或服务端,也可以代表一个文件。Channel将数据传输给 ByteBuffer 对象或者从 ByteBuffer 对象获取数据进行传输。
- Buffer:ByteBuffer、MappedByteBuffer(抽象类,具体实现是DirectByteBuffer,即所谓的内存映射文件)、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer、CharBuffer。前两者有两种分配方式:从直接内存或从Java堆内存分配,前者是为了解决非直接缓冲区(如通过wrap()函数所创建的被包装的缓冲区)的效率问题而引入直接的。
- Selector:JAVA NIO中的多路复用器,配合Channel使用。Channel可把自己注册到Selector中,并告诉Selector自己关注的事件及事件发生时的处理函数。这是一个线程管理多个Channel的关键。
- 还有 Path、Files 等,相比于java.io中的 File 提供了更优雅更强大的API。
4.2、Buffer
(更多详情参考:http://www.cnblogs.com/leesf456/p/6713741.html)
1、Buffer内部含有四个属性:(0 <= mark <= position <= limit <= capacity)
- 容量( Capacity):缓冲区能够容纳的数据元素的最大数量,容量在缓冲区创建时被设定,并且永远不能被改变。
- 上界(Limit):写模式下limit表示最多能往Buffer里写多少数据,等于capacity值;读模式下,limit表示最多可以读取多少数据。
- 位置(Position):下一个要被读或写的元素的索引。位置会自动由相应的 get( )和 put( )函数更新。
- 标记(Mark):一个备忘位置。调用 mark( )来设定 mark = postion。调用 reset( )设定 position = mark。标记在设定前是未定义的(undefined)。
初始化一个容量为10的BtyeBuffer,逻辑视图如下(mark未被设定,position初始为0,capacity为10,limit为10):
Buffer读写示意:
2、Buffer操作:分配(allocate、allocateDirect、wrap)、read(flip、rewind)、write(clear、compact)、mark(mark、reset)
- 分配:allocate、allocateDirect操作创建一个缓冲区对象并分配一个私有的空间来储存指定容量大小的数据;wrap创建一个缓冲区对象但是不分配任何空间来储存数据元素,使用所提供的数组作为存储空间来储存缓冲区中的数据,因此在缓冲区的操作会改动数组,反之亦然。
- 进入读模式:flip(limit设为position、position设为0,mark失效)——只允许单次调因为limit变了、rewind(假定limit已经被正确设置,position设为0,mark失效)——允许多次重复调,因为limit不变。
- 进入写模式:clear(position设为0,limit设为capacity,mark失效)、compact(数据整体移到前面后,limit设为capacity,position设为最后一个元素后面,mark失效)
- 标记:mark(mark设为position)、reset(position设为mark)
由上可见,如果连续两次调用flip()则缓冲区的大小变为0。
示例:
package cn.edu.buaa.nio; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; /** * @author zsm * @date 2017年2月21日 下午3:04:27 Java NIO 有以下Buffer类型:ByteBuffer、MappedByteBuffer(内存映射文件)、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer、CharBuffer。前两者有两种分配方式:从直接内存或从Java堆内存分配 */ public class T0_BufferDemo { // read:flip,rewind; // write:clear,compact // mark:mark,reset // read from buffer:inChannel.write(buf)、buf.get() // write into buffer:inChannel.read(buf)、buf.put(..) public static void main(String[] args) throws IOException { // TODO Auto-generated method stub RandomAccessFile aFile = new RandomAccessFile("src/cn/edu/buaa/nio/nio-data.txt", "rw"); FileChannel inChannel = aFile.getChannel(); ByteBuffer buffer = ByteBuffer.allocate(48); // write to buffer int bytesRead = inChannel.read(buffer);// buffer write manner 1 buffer.put((byte) 'z');// buffer write manner 2 while (bytesRead != -1) { System.out.println("read " + bytesRead); // read from buffer buffer.flip();// 从写buffer模式切换到读buffer模式 while (buffer.hasRemaining()) { byte b = buffer.get();// buffer read manner 1 System.out.println((char) b + " " + (0xff & b)); } buffer.clear();// 进入写模式 inChannel.write(buffer);// buffer read manner 2 bytesRead = inChannel.read(buffer); } inChannel.close(); aFile.close(); } }
各种类型的Buffer:ByteBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer、CharBuffer、MappedByteBuffer
1 ByteBuffer bb = ByteBuffer.wrap(new byte[] { 0, 1, 2, 3, 4, 5, 6, 'a' }); 2 3 bb.rewind(); 4 System.out.print("Byte Buffer "); 5 while (bb.hasRemaining()) 6 System.out.print(bb.position() + " -> " + bb.get() + ", "); 7 System.out.println(); 8 9 CharBuffer cb = ((ByteBuffer) bb.rewind()).asCharBuffer(); 10 System.out.print("Char Buffer "); 11 while (cb.hasRemaining()) 12 System.out.print(cb.position() + " -> " + cb.get() + ", "); 13 System.out.println(); 14 15 FloatBuffer fb = ((ByteBuffer) bb.rewind()).asFloatBuffer(); 16 System.out.print("Float Buffer "); 17 while (fb.hasRemaining()) 18 System.out.print(fb.position() + " -> " + fb.get() + ", "); 19 System.out.println(); 20 21 IntBuffer ib = ((ByteBuffer) bb.rewind()).asIntBuffer(); 22 System.out.print("Int Buffer "); 23 while (ib.hasRemaining()) 24 System.out.print(ib.position() + " -> " + ib.get() + ", "); 25 System.out.println(); 26 27 LongBuffer lb = ((ByteBuffer) bb.rewind()).asLongBuffer(); 28 System.out.print("Long Buffer "); 29 while (lb.hasRemaining()) 30 System.out.print(lb.position() + " -> " + lb.get() + ", "); 31 System.out.println(); 32 33 ShortBuffer sb = ((ByteBuffer) bb.rewind()).asShortBuffer(); 34 System.out.print("Short Buffer "); 35 while (sb.hasRemaining()) 36 System.out.print(sb.position() + " -> " + sb.get() + ", "); 37 System.out.println(); 38 39 DoubleBuffer db = ((ByteBuffer) bb.rewind()).asDoubleBuffer(); 40 System.out.print("Double Buffer "); 41 while (db.hasRemaining()) 42 System.out.print(db.position() + " -> " + db.get() + ", "); 43 44 //结果 45 Byte Buffer 0 -> 0, 1 -> 1, 2 -> 2, 3 -> 3, 4 -> 4, 5 -> 5, 6 -> 6, 7 -> 97, 46 Char Buffer 0 -> , 1 -> ȃ, 2 -> Ѕ, 3 -> ١, 47 Float Buffer 0 -> 9.2557E-41, 1 -> 1.5637004E-36, 48 Int Buffer 0 -> 66051, 1 -> 67438177, 49 Long Buffer 0 -> 283686952306273, 50 Short Buffer 0 -> 1, 1 -> 515, 2 -> 1029, 3 -> 1633, 51 Double Buffer 0 -> 1.401599773079337E-309,
4.3、FileChannel
- FileChannel 不能直接创建,只能通过在一个打开的RandomAccessFile、FileInputStream或FileOutputStream的对象上调用getChannel( )方法来获取,并且getChannel是线程安全的。
- FileChannel 位置position()、大小size()是从底层的文件描述符获得的,channel对position的修改底层文件也能看到,反之亦然。示例如下:
1 RandomAccessFile randomAccessFile = new RandomAccessFile("F:/gps data/2016-11-11 18087 60399647/all 0800-0810_576832.txt", "r"); 2 // Set the file position 3 randomAccessFile.seek(1000); 4 // Create a channel from the file 5 FileChannel fileChannel = randomAccessFile.getChannel(); 6 // This will print "1000" 7 System.out.println("file pos: " + fileChannel.position()); 8 // Change the position using the RandomAccessFile object 9 randomAccessFile.seek(500); 10 // This will print "500" 11 System.out.println("file pos: " + fileChannel.position()); 12 // Change the position using the FileChannel object 13 fileChannel.position(200); 14 // This will print "200" 15 System.out.println("file pos: " + randomAccessFile.getFilePointer());
- FileChannel无法设置为非阻塞模式,它总是运行在阻塞模式下。与Selector一起使用时,Channel必须处于非阻塞模式下,这意味着不能将FileChannel与Selector一起使用。
4.3.1、零拷贝
Java NIO中的FileChannel拥有transferTo和transferFrom两个方法,可直接把FileChannel中的数据拷贝到另外一个Channel,或直接把另外一个Channel中的数据拷贝到FileChannel。该接口常被用于高效的网络/文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态、再从用户态拷贝到目标通道的内核态(即zero CPU Copy),同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于Java IO中提供的方法。
详见 Java零拷贝-MarchOn
4.3.2、内存映射文件
Java NIO的 FileChannel 类提供了一个名为 map( )的方法,该方法将一个打开的文件和一个特殊类型的 ByteBuffer 之间建立一个虚拟内存映射,由 map( )方法返回的 MappedByteBuffer 对象的行为类似于基于堆内存的ByteBuffer对象,只不过该对象的数据存储在磁盘上的文件中。通过内存映射机制来访问一个文件会比使用常规java.io 中方法的读写高效得多,甚至比使用FileChannel的普通读写方法效率都高(原因见后面原理一节)。主要优点:效率高、自动按需映射、多映射共享。详见 Java内存映射文件-MarchOn
4.3.3、文件锁
Java NIO的FileChannel提供了 多进程间的文件锁功能(多线程间对同一个文件加锁会报错)FileLock,保证了对同一文件同一时刻【只有一个进程在写(写独占锁)】或【有多个进程同时在读(读共享锁)】,从而可用于解决多进程访问同一个文件的并发安全问题。
该文件锁的核心就是调用Linux的 fnctl 函数来从内核对文件进行加锁。
示例:
//进程1 RandomAccessFile randomAccessFile = new RandomAccessFile("/Users/xx/Downloads/filelock.txt","rw"); FileChannel fileChannel = randomAccessFile.getChannel(); // 这里是独占锁 //FileLock fileLock = fileChannel.lock(); System.out.println("进程 1 开始写内容:" + LocalTime.now()); for(int i = 1 ; i <= 10 ; i++) { randomAccessFile.writeChars("chenssy_" + i); // 等待两秒 TimeUnit.SECONDS.sleep(2); } System.out.println("进程 1 完成写内容:" + LocalTime.now()); // 完成后要释放掉锁 //fileLock.release(); fileChannel.close(); randomAccessFile.close(); //进程2 RandomAccessFile randomAccessFile = new RandomAccessFile("/Users/xx/Downloads/filelock.txt","rw"); FileChannel fileChannel = randomAccessFile.getChannel(); // 这里是独占锁 //FileLock fileLock = fileChannel.lock(); System.out.println("开始读文件的时间:" + LocalTime.now()); for(int i = 0 ; i < 10 ; i++) { // 这里直接读文件的大小 System.out.println("文件大小为:" + randomAccessFile.length()); // 这里等待 1 秒 TimeUnit.SECONDS.sleep(1); } System.out.println("结束读文件的时间:" + LocalTime.now()); // 完成后要释放掉锁 //fileLock.release(); fileChannel.close(); randomAccessFile.close();
虽然同一进程内的不同线程对同一个文件加锁会报错,但可通过异常捕获来实现线程间的文件锁。
FileLock fileLock; while (true){ try{ fileLock = fileChannel.tryLock(); break; } catch (Exception e) { System.out.println("其他线程已经获取该文件锁了,当前线程休眠 2 秒再获取"); TimeUnit.SECONDS.sleep(2); } }
需要注意的是,不能对目录加锁,否则会报错。
从上述例子可看出,关键是获取个FileChannel,上面例子是通过RandomAccessFile来获取FileChannel,实际上有更方便的方式: FileChannel.open(lockPath, CREATE, WRITE);
分析及原理可参阅这篇文章。
4.4、SocketChannel、ServerSocketChannel
Socket通信的流程:
服务端客户端通信模型的演变:阻塞IO(单线程处理所有请求->每个请求创建一个线程处理[1]->线程池处理所有请求[2])-> 非阻塞IO——NIO Reactor模式[3]
[1][2][3]优劣:
-
优势:实现非常简单,在小规模环境中能完美工作;
劣势:在高并发环境下基本无法工作,因为线程的创建、销毁、调度、上下文切换需要额外的时间和空间开销,且可创建的线程数受OS和内存等系统资源的限制; -
优势:实现比较简单,在一般规模的环境中能够很好地工作;
劣势:在高并发环境下很可能会因为处理某些任务时需要等待一些外部资源而导致处理时间很长,最终导致整个线程池的所有线程全部繁忙,无法对外提供服务,给用户的感觉就是网站挂了; -
优势:在高并发大规模环境下也能工作得很好,性能好。IO时的主要瓶颈就是前文IO内部机制中所述的步骤1,故针对该步的优化能取得很大效果。与上种的线程池模型相比有优势的核心原因:采用非阻塞IO使得可用一个线程处理多个IO事件、查找就绪事件的轮询过程由内核态而非用户态完成从而避免频繁的用户态内核态切换。
劣势:实现比较复杂,Java NIO有该模型的实现
Java NIO的socket channel技术是对传统Java Socket API的改进,主要是实现了多路复用IO(即实现了同步非阻塞IO,但NIO也仍支持同步阻塞IO),并改进了传统的单向流API,Channel同时支持读写(其实就是加了个中间层Buffer)。
SocketChannel、DatagramChannel:实现了定义读和写功能的接口,因此实现了实现数据读、写功能。
ServerSocketChannel:负责监听传入的连接和创建新的 SocketChannel 对象,它本身不涉及服务端客户端的传输数据,即未实现读、写功能,但实现了建立连接、接受连接的功能。
SocketChannel是线程安全的。并发访问时无需采取同步措施,实际上任何时候都只有一个线程的读写操作在进行中。
- Java NIO可以实现阻塞IO的功能,也可以实现非阻塞IO的功能,后者与Selector结合时即为Reactor模式(即IO多路复用模型)
- NIO Channel也可以不结合Selector实现服务端客户端,但当与Selector一起使用时,Channel必须处于非阻塞模式下(多路复用IO的精髓就在于非阻塞)。这意味着不能将FileChannel与Selector一起使用,因为FileChannel只能以阻塞模式工作。
SelectionKey包含属性:
- interest集合:SelectionKey.OP_CONNECT、OP_ACCEPT、OP_READ、OP_WRITE,accept服务端用、conect客户端用、read/write服务端客户端都用。
- ready集合:selectionKey.readyOps() ; (isAcceptable()、isConnectable()、isReadable()、isWritable())
- Channel:selectionKey.channel();
- Selector:selectionKey.selector();
- 附加的对象(可选):selectionKey.attach(theObject); Object attachedObj = selectionKey.attachment();
示例:
0、不结合Selector
1 //服务端 2 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); 3 serverSocketChannel.configureBlocking(true); 4 serverSocketChannel.bind(new InetSocketAddress("localhost", 1234)); 5 while (true) { 6 SocketChannel socketChannel = serverSocketChannel.accept(); 7 if (socketChannel != null) { 8 System.out.println(socketChannel.getRemoteAddress()); 9 } else { 10 System.out.println("no connect"); 11 } 12 }
1、简单Reactor模式(一个线程处理连接、监听就绪及读写操作)
1 package cn.edu.buaa.nio; 2 3 import java.io.IOException; 4 import java.net.InetSocketAddress; 5 import java.nio.ByteBuffer; 6 import java.nio.channels.SelectionKey; 7 import java.nio.channels.Selector; 8 import java.nio.channels.ServerSocketChannel; 9 import java.nio.channels.SocketChannel; 10 import java.util.Iterator; 11 import java.util.Scanner; 12 import java.util.Set; 13 14 /** 15 * @author zsm 16 * @date 2017年3月14日 上午10:32:57<br> 17 * 18 */ 19 //Java NIO的选择器允许一个单独的线程同时监视多个通道,可以注册多个通道到同一个选择器上,然后使用一个单独的线程来“选择”已经就绪的通道。这种“选择”机制为一个单独线程管理多个通道提供了可能。 20 //http://www.jasongj.com/java/nio_reactor/#精典Reactor模式 21 /** 22 * 单线程Reactor模式<br> 23 * 多个Channel可以注册到同一个Selector对象上,实现了一个线程同时监控多个请求状态(Channel)。同时注册时需要指定它所关注的事件,例如上示代码中socketServerChannel对象只注册了OP_ACCEPT事件,而socketChannel对象只注册了OP_READ事件。 24 */ 25 public class T3_ReactorDemo1_NIOServer { 26 // 与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。 27 public static void main(String[] args) throws IOException { 28 Selector selector = Selector.open(); 29 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); 30 serverSocketChannel.configureBlocking(false); 31 serverSocketChannel.bind(new InetSocketAddress(1234)); 32 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); 33 while (selector.select() > 0) { 34 Set<SelectionKey> keys = selector.selectedKeys(); 35 Iterator<SelectionKey> iterator = keys.iterator(); 36 while (iterator.hasNext()) { 37 SelectionKey key = iterator.next(); 38 iterator.remove(); 39 if (key.isAcceptable()) { 40 ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel(); 41 SocketChannel socketChannel = acceptServerSocketChannel.accept(); 42 socketChannel.configureBlocking(false); 43 System.out.println("Accept request from " + socketChannel.getRemoteAddress()); 44 socketChannel.register(selector, SelectionKey.OP_READ); 45 } else if (key.isReadable()) { 46 SocketChannel socketChannel = (SocketChannel) key.channel(); 47 ByteBuffer buffer = ByteBuffer.allocate(1024); 48 int count = socketChannel.read(buffer);// 内核态数据复制到用户态,阻塞 49 if (count <= 0) { 50 socketChannel.close(); 51 key.cancel(); 52 System.out.println("Received invalide data, close the connection"); 53 continue; 54 } 55 System.out.println("Received message " + new String(buffer.array())); 56 } 57 keys.remove(key); 58 } 59 } 60 } 61 } 62 63 class T3_ReactorDemo1_NIOClient { 64 public static void main(String[] args) throws IOException { 65 SocketChannel socketChannel = SocketChannel.open(); 66 socketChannel.connect(new InetSocketAddress(1234)); 67 ByteBuffer buffer = ByteBuffer.allocate(1024); 68 Scanner scanner = new Scanner(System.in); 69 String tmpStr; 70 while ((tmpStr = scanner.nextLine()) != null) { 71 buffer.clear(); 72 buffer.put(tmpStr.getBytes()); 73 buffer.flip(); 74 while (buffer.hasRemaining()) { 75 socketChannel.write(buffer); 76 } 77 } 78 } 79 }
2、多线程Reactor模式(一个线程处理连接、监听就绪工作,多线程处理读写操作)
1 package cn.edu.buaa.nio; 2 3 import java.io.IOException; 4 import java.net.InetSocketAddress; 5 import java.nio.ByteBuffer; 6 import java.nio.channels.SelectionKey; 7 import java.nio.channels.Selector; 8 import java.nio.channels.ServerSocketChannel; 9 import java.nio.channels.SocketChannel; 10 import java.util.Iterator; 11 import java.util.Scanner; 12 import java.util.Set; 13 import java.util.concurrent.ExecutorService; 14 import java.util.concurrent.Executors; 15 16 /** 17 * @author zsm 18 * @date 2017年3月14日 上午10:45:05 19 */ 20 // http://www.jasongj.com/java/nio_reactor/#多工作线程Reactor模式 21 /** 22 * 多线程Reactor模式<br> 23 * 经典Reactor模式中,尽管一个线程可同时监控多个请求(Channel),但是所有读/写请求以及对新连接请求的处理都在同一个线程中处理,无法充分利用多CPU的优势,同时读/写操作也会阻塞对新连接请求的处理。因此可以引入多线程,并行处理多个读/写操作 24 */ 25 public class T3_ReactorDemo2_NIOServer { 26 // 与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。 27 public static void main(String[] args) throws IOException { 28 Selector selector = Selector.open(); 29 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); 30 serverSocketChannel.configureBlocking(false); 31 serverSocketChannel.bind(new InetSocketAddress(1234)); 32 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); 33 while (true) { 34 if (selector.selectNow() < 0) { 35 continue; 36 } 37 Set<SelectionKey> keys = selector.selectedKeys(); 38 Iterator<SelectionKey> iterator = keys.iterator(); 39 while (iterator.hasNext()) { 40 SelectionKey key = iterator.next(); 41 iterator.remove(); 42 if (key.isAcceptable()) { 43 ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel(); 44 SocketChannel socketChannel = acceptServerSocketChannel.accept(); 45 socketChannel.configureBlocking(false); 46 System.out.println("Accept request from " + socketChannel.getRemoteAddress()); 47 SelectionKey readKey = socketChannel.register(selector, SelectionKey.OP_READ); 48 readKey.attach(new Processor1()); 49 } else if (key.isReadable()) { 50 Processor1 processor = (Processor1) key.attachment(); 51 processor.process(key); 52 } 53 } 54 } 55 } 56 } 57 58 class Processor1 { 59 private static final ExecutorService service = Executors.newFixedThreadPool(16); 60 61 public void process(SelectionKey selectionKey) { 62 service.submit(() -> { 63 ByteBuffer buffer = ByteBuffer.allocate(1024); 64 SocketChannel socketChannel = (SocketChannel) selectionKey.channel(); 65 int count = socketChannel.read(buffer);// 内核态数据复制到用户态,阻塞 66 if (count < 0) { 67 socketChannel.close(); 68 selectionKey.cancel(); 69 System.out.println(socketChannel + "\t Read ended"); 70 return null; 71 } else if (count == 0) { 72 return null; 73 } 74 System.out.println(socketChannel + "\t Read message " + new String(buffer.array())); 75 return null; 76 }); 77 } 78 } 79 80 class T3_ReactorDemo2_NIOClient { 81 public static void main(String[] args) throws IOException { 82 SocketChannel socketChannel = SocketChannel.open(); 83 socketChannel.connect(new InetSocketAddress(1234)); 84 ByteBuffer buffer = ByteBuffer.allocate(1024); 85 Scanner scanner = new Scanner(System.in); 86 String tmpStr; 87 while ((tmpStr = scanner.nextLine()) != null) { 88 buffer.clear(); 89 buffer.put(tmpStr.getBytes()); 90 buffer.flip(); 91 while (buffer.hasRemaining()) { 92 socketChannel.write(buffer); 93 } 94 } 95 } 96 }
3、多Reactor模式(一个线程处理连接工作,多线程处理监听就绪及读写操作)
1 package cn.edu.buaa.nio; 2 3 import java.io.IOException; 4 import java.net.InetSocketAddress; 5 import java.nio.ByteBuffer; 6 import java.nio.channels.ClosedChannelException; 7 import java.nio.channels.SelectionKey; 8 import java.nio.channels.Selector; 9 import java.nio.channels.ServerSocketChannel; 10 import java.nio.channels.SocketChannel; 11 import java.nio.channels.spi.SelectorProvider; 12 import java.util.Iterator; 13 import java.util.Scanner; 14 import java.util.Set; 15 import java.util.concurrent.ExecutorService; 16 import java.util.concurrent.Executors; 17 18 /** 19 * @author zsm 20 * @date 2017年3月14日 上午10:55:01 21 */ 22 //http://www.jasongj.com/java/nio_reactor/#多Reactor 23 /** 24 * 多Reactor模式<br> 25 * Netty中使用的Reactor模式,引入了多Reactor,也即一个主Reactor负责监控所有的连接请求,多个子Reactor负责监控并处理读/写请求,减轻了主Reactor的压力,降低了主Reactor压力太大而造成的延迟。 26 * 并且每个子Reactor分别属于一个独立的线程,每个成功连接后的Channel的所有操作由同一个线程处理。这样保证了同一请求的所有状态和上下文在同一个线程中,避免了不必要的上下文切换,同时也方便了监控请求响应状态。 27 */ 28 public class T3_ReactorDemo3_NIOServer { 29 // 与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。 30 public static void main(String[] args) throws IOException { 31 Selector selector = Selector.open(); 32 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); 33 serverSocketChannel.configureBlocking(false); 34 serverSocketChannel.bind(new InetSocketAddress(1234)); 35 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); 36 int coreNum = Runtime.getRuntime().availableProcessors(); 37 Processor2[] processors = new Processor2[coreNum]; 38 for (int i = 0; i < processors.length; i++) { 39 processors[i] = new Processor2(); 40 } 41 int index = 0; 42 while (selector.select() > 0) { 43 Set<SelectionKey> keys = selector.selectedKeys(); 44 for (SelectionKey key : keys) { 45 keys.remove(key); 46 if (key.isAcceptable()) { 47 ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel(); 48 SocketChannel socketChannel = acceptServerSocketChannel.accept(); 49 socketChannel.configureBlocking(false); 50 System.out.println("Accept request from " + socketChannel.getRemoteAddress()); 51 Processor2 processor = processors[(index++) / coreNum]; 52 processor.addChannel(socketChannel); 53 } 54 } 55 } 56 } 57 } 58 59 class Processor2 { 60 private static final ExecutorService service = Executors 61 .newFixedThreadPool(2 * Runtime.getRuntime().availableProcessors()); 62 private Selector selector; 63 64 public Processor2() throws IOException { 65 this.selector = SelectorProvider.provider().openSelector(); 66 start(); 67 } 68 69 public void addChannel(SocketChannel socketChannel) throws ClosedChannelException { 70 socketChannel.register(this.selector, SelectionKey.OP_READ); 71 } 72 73 public void start() { 74 service.submit(() -> { 75 while (true) { 76 if (selector.selectNow() <= 0) { 77 continue; 78 } 79 Set<SelectionKey> keys = selector.selectedKeys(); 80 Iterator<SelectionKey> iterator = keys.iterator(); 81 while (iterator.hasNext()) { 82 SelectionKey key = iterator.next(); 83 iterator.remove(); 84 if (key.isReadable()) { 85 ByteBuffer buffer = ByteBuffer.allocate(1024); 86 SocketChannel socketChannel = (SocketChannel) key.channel(); 87 int count = socketChannel.read(buffer);// 内核态数据复制到用户态,阻塞 88 if (count < 0) { 89 socketChannel.close(); 90 key.cancel(); 91 System.out.println(socketChannel + "\t Read ended"); 92 continue; 93 } else if (count == 0) { 94 System.out.println(socketChannel + "\t Message size is 0"); 95 continue; 96 } else { 97 System.out.println(socketChannel + "\t Read message" + new String(buffer.array())); 98 } 99 } 100 } 101 } 102 }); 103 } 104 } 105 106 class T3_ReactorDemo3_NIOClient { 107 public static void main(String[] args) throws IOException { 108 SocketChannel socketChannel = SocketChannel.open(); 109 socketChannel.connect(new InetSocketAddress(1234)); 110 ByteBuffer buffer = ByteBuffer.allocate(1024); 111 Scanner scanner = new Scanner(System.in); 112 String tmpStr; 113 while ((tmpStr = scanner.nextLine()) != null) { 114 buffer.clear(); 115 buffer.put(tmpStr.getBytes()); 116 buffer.flip(); 117 while (buffer.hasRemaining()) { 118 socketChannel.write(buffer); 119 } 120 } 121 } 122 }
说明:
上述的服务端执行过程从效果上看主要包括 多路复用器selector和事件event(accept、read、write event) 两种实体,整体逻辑本质上是事件的注册、就绪事件的选择(本质上是事件主动触发而非调用者主动选择)和执行。
事件注册时的数据为三元组 <channelId, readyEventType, eventHandler> ,表示要向selector监听该channel的指定eventType且事件触发时执行指定的eventHandler。
从效果上看服务端的执行过程可概括为三步:
1 创建服务端:像普通的socker编程那样,创建socker server(bind端口、listen端口),即serverSocketChannel。
2 事件注册到selector:
服务端注册:三元组为 <serverSocketChannel, “连接就绪”的事件(accept event), acceptEventHanlder> 。该回调方法的内部逻辑是:收到客户端连接时创建socketChannel,并注册客户端:三元组为 <socketChannel, "客户端发来数据就绪或写客户端数据就绪"的事件(read/write event), readEventHandler/writeEventHandler >
3 selector选择就绪的事件并执行事件的回调方法:用户代码调用 selector.select() 获取(会阻塞直到有就绪事件触发)就绪的事件,并执行该事件相应的eventHandler。select() 最终其实就是调用了OS的多路复用IO实现,如Linux的select、poll、epoll。
可见,多路复用IO的本质是应用程序通过IO复用函数向内核注册关注的IO事件和事件处理函数,当这些注册的IO事件发生时就通过IO复用程序来通知应用程序执行事件处理函数。
上述过程与普通的多线程模型相比,性能好的核心原因有两点:
1 采用非阻塞IO(即前文“IO内部机制”所述的步骤1在这里以非阻塞的方式执行,因此用户代码中收到事件就绪通知时数据已经从外设复制到内核态内存了)从而能用一个线程处理多个IO事件。
2 轮询事件是否就绪的实现由内核态完成而不是用户态完成,从而避免频繁用户态内核态切换。
Java NIO支持一个或多个线程来管理channel,Redis最初也是用一个线程来处理IO事件,后来的版本也支持了多个线程处理IO事件(Redis服务端的IO处理过程与上述过程总结的几乎一致,详情可参阅 Redis为什么快—低并发编程公众号 );Netty(下文介绍)也类似。可见“众纷繁技术本质相通”,Java NIO、Redis、Netty的IO模型本质上类似。
Java NIO使能用一个(或几个)单线程管理多个channel(网络channel或文件的channel),但付出的代价是解析数据可能会比从一个阻塞流中读取数据更复杂,且提供的API使用起来略复杂,实际项目中不建议直接使用它们进行开发,而是用Netty等第三方库。
4.5 底层原理
参阅文章 https://www.jianshu.com/p/ba3bf3c62018
可以发现:
Java Socket Channel多路复用IO最终底层是调用了OS的多路复用IO。
Java File Channel 数据读入到ByteBuffer 或 ByteBuffer数据写入到File Channel 时都会先建立一个与要读或写的数据量大小相同的DireactByteBuffer(内存映射文件,是堆外内存)。对于写,是先将要写的堆内存数据复制到DireactByteBuffer、再将DireactByteBuffer数据写到外设;对于读,过程类似但流程相反。可见:由于用到了DireactByteBuffer,因此写的效率比普通的文件读写效率高;与内存映射文件相比,多了一次到堆内存到直接内存(堆外内存)的复制,故效率比内存映射文件低。
5、Netty
Netty是JBOSS针对网络编程开发的一套AIO应用框架,它也是在Java NIO的基础上发展起来的。netty基于异步的事件驱动,具有高性能、高扩展性等特性,它提供了统一的底层协议接口,使得开发者从底层的网络协议(比如 TCP/IP、UDP)中解脱出来。
详见: Netty使用示例
6、参考资料
1、http://blog.csdn.net/historyasamirror/article/details/5778378 ——阻塞非阻塞同步异步
2、https://my.oschina.net/andylucc/blog/614295 ——阻塞非阻塞同步异步
3、http://www.jasongj.com/java/nio_reactor/ ——Java NIO Server/Client
4、http://www.iteye.com/magazines/132-Java-NIO ——Java NIO API较详细介绍