异步I/O编程

场景:9点上班时,发现9点半有一个需求评审会

场景场景

理解JAVA NIO之前先了解下Linux IO类型是非常有帮助的。

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点:

数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

IO模型

I/O 多路复用I/O 多路复用

阻塞 I/O(blocking IO)

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:

阻塞 I/O阻塞 I/O

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

blocking IO的特点就是在IO执行的两个阶段都被block了

非阻塞 I/O(nonblocking IO)

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子

非阻塞 I/O非阻塞 I/O

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有

I/O 多路复用( IO multiplexing)

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程

I/O 多路复用I/O 多路复用

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个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。

epoll

相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

  •   LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
  •   ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

JAVA NIO

有了上述背景知识理解java nio就相当容易了。

Java NIO 由以下几个核心部分组成:

  • Buffers
  • Channels
  • Selectors

工作过程:有一个线程selector,负责检查channel的事件(比如读数据,就是否已经将数据从磁盘等读到内核空间;得向Selector注册Channel,然后调用它的select()方法)是否就绪,如果检查到某事件就绪,用户进程就可以进行相应处理(如可以将数据从channel写到buffer)。

Buffers

缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块用户空间内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
为了理解Buffer的工作原理,需要熟悉它的三个属性:

  • capacity
  • position
  • limit

position和limit的含义取决于Buffer处在读模式还是写模式。不管Buffer处在什么模式,capacity的含义总是一样的。

capacity

buffer的大小,这个比较容易理解,就是buffer的大小,分配好之后就不再变了。

position

  • 写数据到Buffer中时,position表示当前(也就是下一次读或者写)的位置。初始的position值为0.当一个数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。
  • position最大可为capacity -1.
  • 当读取数据时,也是从某个特定位置开始读。

limit

  1. 在写模式下,Buffer的limit表示最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。
  2. 当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。

读写buffer基本就是针对于这些参数展开的,使用Buffer读写数据一般遵循以下四个步骤:

  • 写入数据到Buffer
  • 调用flip()方法,flip方法将Buffer从写模式切换到读模式。其实调用flip()方法会将position设回0,并将limit设置成之前写position的值。
  • 从Buffer中读取数据
  • 调用clear()方法或者compact()方法

一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。

  • clear()方法会清空整个缓冲区。
  • compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

其它常用方法:

  • rewind()方法:Buffer.rewind()将position设回0,所以你可以重读Buffer中的所有数据。
  • mark()与reset()方法:通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
 package com.meituan.bpdata.nio;

import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * @author jhaoniu
 * @description
 * @date 15-11-25 上午10:18
 */
public class BufferDemo {

    public static void main(String[] args) throws IOException {

        /** for i in `seq 0 9`;do printf $i >>/opt/tmp/test.txt ;done */
        RandomAccessFile aFile = new RandomAccessFile("/opt/tmp/test.txt", "rw");
        FileChannel inChannel = aFile.getChannel();

        /** create buffer with capacity of 10 bytes*/
        ByteBuffer buf = ByteBuffer.allocate(10);
        /** read into buffer.*/
        int readBytes = inChannel.read(buf);
        System.out.println("readBytes:" + readBytes);
        /** 准备读, position设回0,并将limit设置成之前position的值 */
        buf.flip();
        while (buf.hasRemaining()) {
            System.out.println((char) (buf.get()));
        }
        /** position设回0 可以重新读 */
        buf.rewind();
        while (buf.hasRemaining()) {
            System.out.println((char) (buf.get()));
            if ((char) (buf.get()) == '5') {
                /** 在6处做一个mark*/
                buf.mark();
            }
        }
        /** reset 到mark的位置*/
        buf.reset();
        while (buf.hasRemaining()) {
            System.out.println((char) (buf.get()));
        }
        /** 将buf转为自己数组 */
        System.out.println(new String(buf.array()));
        buf.clear(); //make buffer ready for writing
    }
}

Channels

Java NIO的通道类似流,但又有些不同:

  • 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。
  • 通道可以异步地读写。
  • 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入

Selectors

为了将Channel和Selector配合使用,必须将channel注册到selector上。通过SelectableChannel.register()方法来实现

1
2
3
4
5
selector = Selector.open();
servChannel = ServerSocketChannel.open();
servChannel.configureBlocking(false);
servChannel.socket().bind(new InetSocketAddress(port), 1024);
servChannel.register(selector, SelectionKey.OP_ACCEPT);

 

一旦向Selector注册了一或多个通道,就可以调用几个重载的select()方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。换句话说,如果你对“读就绪”的通道感兴趣,select()方法会返回读事件已经就绪的那些通道。

1
2
3
selector.select(3000);
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> it = selectedKeys.iterator(); //返回就绪的SelectionKey

 

处理就绪的通道

1
2
3
4
5
6
7
8
9
if (key.isAcceptable()) {
    // TODO
}
if (key.isReadable()) {
    // Read the data
    SocketChannel sc = (SocketChannel) key.channel();
    ByteBuffer readBuffer = ByteBuffer.allocate(1024);
    int readBytes = sc.read(readBuffer);
    .....

java nio的优势

  1. 读写数据都是面向缓冲区的(buffer本质是一块内存区域),数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中,读写一个buffer相对于基于字节流和字符流进行操作来说效率高。
  2. 用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。如果不采用nio,我们可能需要使用更多的线程来完成类似的功能,对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。

参考:
http://www.ibm.com/developerworks/cn/linux/l-cn-directio/
http://www.cnblogs.com/bigwangdi/p/3182958.html
http://segmentfault.com/a/1190000003063859
http://ifeve.com/selectors/

猜你喜欢

转载自fxbull.iteye.com/blog/2292846