BIO、NIO、AIO笔记
BIO
文件 IO 方面
操作系统分为用户空间和内核空间,应用程序一般不能直接操作系统资源,需要通过操作系统开放的接口才能使用系统资源,例如网络,磁盘等等。
Linux 系统写数据的步骤流程如下图:
从上往下分析张图片,用户数据通过 stdio lib 的 printf(), fputc() 等将数据转换到 stdio buffer(位于用户内存空间), 当 stdio buffer 区满时,stdio lib 将会使用系统调用 write 方法,将数据转移到 kernel buffer cache (位于内核内存空间),最后通过 kernel initiated write 将 kernel buffer cache 的数据写入到磁盘。
为什么有 stdio buffer的存在,为什么不直接通过系统调用write方法?
使用缓存的原因是系统调用总是昂贵的。如果用户代码以较小的size不断的读或写文件的话,stdio lib将多次的读或者写操作通过buffer进行聚合是可以提高程序运行效率的。
在 Java 中将 BIO 按读取最小单元的不同分为字节流和字符流( 2 个字节),BIO 是以流的方式读或者写数据,并且是单向方式。
下面是部分类的结构图:
网络 IO 方面
在Linux 操作系统,一切皆文件。网络IO 可以看似文件IO,但是网络和文件有些不同。
系统调用和socket使用如下图:
socket 关键系统调用如下:
1. socket() 方法通过系统调用会创建新的socket。
2. bind()方法通过系统调用一个socket绑定一个地址。
3. listen()方法允许一个对于接受入站的来自其他socket连接的socket流。
4. accept()方法接受一个来自对等应用的socket。
5. connect()方法与另外一个socket建立连接。
可以发现socket的建立过程基本都使用了系统调用,那么意味着socket的建立是比较昂贵的。
我有以下的疑问
1.有什么方式能减少socket的建立的开销成本?
2.socket能不能复用?有没有像线程池一样的类似socket池的东西?
如果对以上问题感兴趣,可以自行查询资料。
在 Java 层面使用和上图 Linux 的调用大致一样,有个问题就是常说的 TCP 的三次握手在哪个方法中执行的呢?
从上面图来分析,三次握手的发起方是客服端,socket() 方法是创建一个 socket,可以知道三次握手的阶段是在 connect() 方法中完成的。
NIO
文件 IO 方面
这部分内容通过 JDK 源码来展示。
1. 使用 NIO 普通的文件读写
下面以读文件为例
FileChannel fileChannel = FileChannel.open(Paths.get("D:\\a.txt")); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); fileChannel.read(byteBuffer); byteBuffer.flip(); System.out.println(new String(byteBuffer.array())); fileChannel.close();
sun.nio.ch.FileChannelImpl#read
public int read(ByteBuffer var1) throws IOException { //... 省略代码 if (this.isOpen()) { do { var3 = IOUtil.read(this.fd, var1, -1L, this.nd); } while(var3 == -3 && this.isOpen()); // ... 省略代码 }
sun.nio.ch.IOUtil#read
static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException { if (var1.isReadOnly()) { // 判断是否只读,如果为只读抛异常 throw new IllegalArgumentException("Read-only buffer"); } else if (var1 instanceof DirectBuffer) { // 如果为DirectBuffer 读取 return readIntoNativeBuffer(var0, var1, var2, var4); } else { ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining()); // 如果不是,将开辟一个堆外的空间 DirectBuffer int var7; try { int var6 = readIntoNativeBuffer(var0, var5, var2, var4); // 读取数据 var5.flip(); // 翻转 if (var6 > 0) { var1.put(var5); // 读到数据放入到传入的ByteBuffer中 } var7 = var6; } finally { Util.offerFirstTemporaryDirectBuffer(var5); // 将DirectBuffer放入到BufferCache中,如果空间过大直接释放,否则可用作下次重复使用 } return var7; } }
从全局来看读数据的过程如下:
DirectBuffer 是在堆外空间并且在用户态空间,从源码上来看使用 DirectBuffer 可以减少一次数据的拷贝,从堆外的 DirectBuffer 拷贝到 堆内的 HeapByteBuffer 中。
2. 文件内存映射文件读写 (mmap)
对于文件的内存映射,先需要理解操作系统中的内存管理。下图是逻辑地址、物理地址以及磁盘间的关系。
CPU 读或者写数据,使用逻辑地址通过 MMU (内存管理单元) 配合 PT(Page Table) 查询到物理地址,从内存中读取或者写入对应的数据,如果在内存中不存在,将会从磁盘加载大量内存中。
从Linux 系统的角度来看,大致如下:
用户空间的逻辑地址和内核空间的地址映射到相同的物理地址。
内存映射 I/O 之所以能够带来性能优势的原因如下:
1.正常的 read()或 write()需要两次传输:一次是在文件和内核高速缓冲区之间,另一次 是在高速缓冲区和用户空间缓冲区之间。使用 mmap()就无需第二次传输了。对于输 入来讲,一旦内核将相应的文件块映射进内存之后用户进程就能够使用这些数据了。 对于输出来讲,用户进程仅仅需要修改内存中的内容,然后可以依靠内核内存管理器 来自动更新底层的文件。
2.除了节省了内核空间和用户空间之间的一次传输之外,mmap()还能够通过减少所需使 用的内存来提升性能。当使用 read()或 write()时,数据将被保存在两个缓冲区中:一 个位于用户空间,另一个位于内核空间。当使用 mmap()时,内核空间和用户空间会 共享同一个缓冲区。此外,如果多个进程正在在同一个文件上执行 I/O,那么它们通 过使用 mmap()就能够共享同一个内核缓冲区,从而又能够节省内存的消耗。
网络 IO 方面
NIO在网络方面的特点是 IO 多路复用。
1. IO 多路复用是什么?
多路是指网络连接,复用指的是同一个线程。I/O 多路复用允许我们同时监控多个文件描述符,以及查看其中任意一个有 I/O 操作的可能。
2. IO 多路复用解决了什么问题?
普通的 IO 每一个系统调用被阻塞直到有数据的传输。例如,从 pipe 中读取数据,如果当前没有数据,read() 就会阻塞,同时 write(), 如果 pipe 空间不足,也会出现阻塞。这种情况有 2 种方式来解决一种是线程,一个连接一个线程,如果大量的IO 请求,大量的线程将会占用大量的内存空间,另一种是 IO 多路复用。
3. IO 多路复用如何实现的?
在 Linux 系统有 select(), poll(), epoll() 方式。
select() 模型如下图所示:
图中的fd1...fd5 是在一个数组的结构中,如下图
在 select() 时,fd3,fd4 以及 fd6 有 I/O event,在 select()之后, fd4 和fd6 在 rset (read descriptor set) 中为 0 可以理解,为什么 fd3 还是 1 呢? 自己猜想了一下是不是服务端的监听的 socket 的文件描述符,但是不能确定,最后从网上找相关的资料,找到了一个文档,证明了猜想是正确的,同时也说明服务端监听的 socket 和客户端请求来的 socket 会在同一个集合中,从下图还可以看出 fd0 是标准输入,fd1是标准输出,fd2 是错误输出。
AIO
文件 IO 方面
先看一下简单读文件的例子
AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(Paths.get("F:\\application.properties"), StandardOpenOption.READ); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); fileChannel.read(byteBuffer, 0, byteBuffer, new CompletionHandler<Integer, ByteBuffer>() { @Override public void completed(Integer result, ByteBuffer attachment) { System.out.println("result:" + result); attachment.flip(); byte[] data = new byte[attachment.limit()]; attachment.get(data); System.out.println(new String(data)); attachment.clear(); } @Override public void failed(Throwable exc, ByteBuffer attachment) { } }); System.in.read();// 阻塞
从上面例子需要弄明白下面的几个问题。
1. 异步 IO 是如何实现的?
使用线程池
2.是否存在线程或者线程池?
使用线程池
3.CompletionHandler 是如何被回调的?
在文件读取完,在异步线程中被回调
下面我们带着问题从源码的角度来分析 AsynchronousFileChannel
以 Linux 版本,实现类为 SimpleAsynchronousFileChannelImpl#implRead
@Override <A> Future<Integer> implRead(final ByteBuffer dst, final long position, final A attachment, final CompletionHandler<Integer,? super A> handler) { // 省略部分代码 ..... // 构造Future final PendingFuture<Integer,A> result = (handler == null) ? new PendingFuture<Integer,A>(this) : null; // 构造Runnable Runnable task = new Runnable() { public void run() { int n = 0; Throwable exc = null; int ti = threads.add(); try { begin(); do { //读取文件和 NIO 内部实现一致 n = IOUtil.read(fdObj, dst, position, nd); } while ((n == IOStatus.INTERRUPTED) && isOpen()); if (n < 0 && !isOpen()) throw new AsynchronousCloseException(); } catch (IOException x) { if (!isOpen()) x = new AsynchronousCloseException(); exc = x; } finally { end(); threads.remove(ti); } if (handler == null) { result.setResult(n, exc); } else { // 回调handler中的方法 Invoker.invokeUnchecked(handler, attachment, n, exc); } } }; //将任务提交到线程池中 executor.execute(task); return result; }
默认线程池是怎么样配置的?
public static AsynchronousFileChannel open(FileDescriptor fdo, boolean reading, boolean writing, ThreadPool pool) { // Executor is either default or based on pool parameters ExecutorService executor = (pool == null) ? DefaultExecutorHolder.defaultExecutor : pool.executor(); return new SimpleAsynchronousFileChannelImpl(fdo, reading, writing, executor); }
默认线程池
static ThreadPool createDefault() { int var0 = getDefaultThreadPoolInitialSize(); if (var0 < 0) { var0 = Runtime.getRuntime().availableProcessors(); } ThreadFactory var1 = getDefaultThreadPoolThreadFactory(); if (var1 == null) { var1 = defaultThreadFactory; } // 创建线程池 ExecutorService var2 = Executors.newCachedThreadPool(var1); return new ThreadPool(var2, false, var0); }
网络 IO 方面
从Linux 层面称之为信号驱动 IO (Signal-Driven I/O), 使用IO多路复用,一个进程通过系统调用 (select()/poll()) 为了检查文件描述是否存在 IO事件。使用信号驱动 IO,当文件描述符存在 IO事件,内核将会发送一个信号给进程。进程可以执行其他操作,直到有IO事件发生。
Linux 通过 epoll_create 方法创建 epoll 实例,使用 epoll_ctl 方法将 interest list 注册到文件描述符上,epoll_wait 返回关联epoll实例上的就绪的列表。
对于AIO网络部分内容,通过查看源码来分析,使用 Linux 系统的 JDK 来分析,UnixAsynchronousServerSocketChannelImpl#implAccept
@Override Future<AsynchronousSocketChannel> implAccept(Object att, CompletionHandler<AsynchronousSocketChannel,Object> handler) { // 省略代码 ...... try { begin(); // 接受连接 int n = accept(this.fd, newfd, isaa); if (n == IOStatus.UNAVAILABLE) { // need calling context when there is security manager as // permission check may be done in a different thread without // any application call frames on the stack PendingFuture<AsynchronousSocketChannel,Object> result = null; synchronized (updateLock) { if (handler == null) { this.acceptHandler = null; result = new PendingFuture<AsynchronousSocketChannel,Object>(this); this.acceptFuture = result; } else { this.acceptHandler = handler; this.acceptAttachment = att; } this.acceptAcc = (System.getSecurityManager() == null) ? null : AccessController.getContext(); this.acceptPending = true; } // 关键代码 注册事件 epoll // register for connections port.startPoll(fdVal, Net.POLLIN); return result; } } catch (Throwable x) { // accept failed if (x instanceof ClosedChannelException) x = new AsynchronousCloseException(); exc = x; } finally { end(); } // 省略代码 ...... if (handler == null) { return CompletedFuture.withResult(child, exc); } else { Invoker.invokeIndirectly(this, handler, att, child, exc); return null; } }
关键代码 EpollPort#startPoll
// invoke by clients to register a file descriptor @Override void startPoll(int fd, int events) { // update events (or add to epoll on first usage) int err = epollCtl(epfd, EPOLL_CTL_MOD, fd, (events | EPOLLONESHOT)); if (err == ENOENT) err = epollCtl(epfd, EPOLL_CTL_ADD, fd, (events | EPOLLONESHOT)); if (err != 0) throw new AssertionError(); // should not happen }
epollCtl为native方法
static native int epollCtl(int epfd, int opcode, int fd, int events);
找到JVM源码,查看实现
JNIEXPORT jint JNICALL Java_sun_nio_ch_EPoll_epollCtl(JNIEnv *env, jclass c, jint epfd, jint opcode, jint fd, jint events) { struct epoll_event event; int res; event.events = events; event.data.fd = fd; // epoll_ctl 为系统调用 RESTARTABLE(epoll_ctl(epfd, (int)opcode, (int)fd, &event), res); return (res == 0) ? 0 : errno; }
总结
对BIO、NIO、AIO 从 Java 层面到操作系统层面的实现简单的做了一下介绍,从中可以看到Java 层面的IO操作,基本依赖底层操作系统的实现,里面还有很多的细节需要探究,希望读者有所收获吧。
参考文献:
聊聊 Linux IO https://www.0xffffff.org/2017/05/01/41-linux-io/
《The Linux Programming Interface》
http://www.cs.ucy.ac.cy/courses/EPL428/labs/wk11/IO_multiplexing.pdf
http://www.cs.toronto.edu/~krueger/csc209h/lectures/Week11-Select.pdf
http://www.cse.fau.edu/~sam/course/netp/lec_note/ioMux.pdf