NIO原理及浅析

Linux环境

Linux的内核将所有外部设备都看做一个文件来操作。我们对一个文件的读写,都通过调用内核提供的系统调用。内核返回给我们一个file descriptor(fd,文件描述符)。即所有执行I/O操作的系统调用都通过文件描述符。

参考:Linux中的文件描述符与打开文件之间的关系

一个基本的IO,它会涉及到两个系统对象,一个是调用这个IO的进程对象,另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:

  1. 通过read系统调用向内核发起读请求。
  2. 内核向硬件发送读指令,并等待读就绪。
  3. 内核把将要读取的数据复制到描述符所指向的内核缓存区中。
  4. 将数据从内核缓存区拷贝到用户进程空间中。

IO模型

阻塞I/O模型

最常见的I/O模型是阻塞I/O模型,缺省情形下,所有文件操作都是阻塞的。套接口为例,在进程空间中调用recvfrom,其系统调用直到数据报到达且被拷贝到应用进程的缓冲区中或者发生错误才返回,期间一直在等待。我们就说进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。

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

非阻塞I/O模型

进程把一个套接口设置成非阻塞是在通知内核:当所请求的I/O操作不能满足要求时候,不把本进程投入睡眠,而是返回一个错误。也就是说当数据没有到达时并不等待,而是以一个错误返回。

è¿éåå¾çæè¿°

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

I/O复用模型

linux提供select/poll,进程通过将一个或多个fd传递给select或poll系统调用,阻塞在select,这样select/poll可以帮我们侦测许多fd是否就绪。但是select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限。linux还提供了一个epoll系统调用,epoll是基于事件驱动方式,而不是顺序扫描,当有fd就绪时,立即回调函数rollback。

è¿éåå¾çæè¿°

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

信号驱动I/O模型

首先开启套接口信号驱动I/O功能, 并通过系统调用sigaction安装一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。当数据报准备好被读时,就为该进程生成一个SIGIO信号。随后可以在信号处理程序中调用recvfrom来读数据报,并通知主循环数据已准备好被处理中。也可以通知主循环,让它来读数据报。 

è¿éåå¾çæè¿°

信号驱动IO的特点是当内核数据准备就绪时会发送一个信号给进程,用户进程便在信号处理函数中开始把数据拷贝的用户空间中。

异步I/O模型

告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核拷贝到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动I/O是由内核通知我们何时可以启动一个I/O操作。异步I/O模型是由内核通知我们I/O操作何时完成。

è¿éåå¾çæè¿°

asynchronous IO的特点就是在IO执行的两个阶段都不会被block

总结

前四种都是同步IO,在内核数据copy到用户空间时都是阻塞的。 
最后一种是异步IO,通过API把IO操作交由操作系统处理,当前进程不关心具体IO的实现,通过回调函数,或者信号量通知当前进程直接对IO返回结果进行处理。

     同步就是指调用者主动等待调用结果异步就是指被调用者来通知调用者调用结果就绪二者在消息通信机制上有所不同,一个是调用者检查调用结果是否就绪,一个是被调用者通知调用者结果就绪。
   阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态阻塞调用是指在调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会继续执行。非阻塞调用是指在不能立刻得到结果之前,调用线程不会被挂起,还是可以执行其他事情。


Java NIO的底层实现

为什么使用Selector?

仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。因为对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源。因此,使用的线程越少越好。

open方法

 顺着Selector.open()方法一个类一个类的找下去,很容易就发现Selector的初始化是由DefaultSelectorProvider根据不同操作系统平台生成的不同的SelectorProvider。

对于Linux系统,它会生成EPollSelectorProvider实例,而这个实例会生成EPollSelectorImpl作为最终的Selector实现。

class EPollSelectorImpl extends SelectorImpl
{
    protected  int fd0;
    protected  int fd1;

    EPollArrayWrapper pollWrapper;
    
    private Map<Integer,SelectionKeyImpl> fdToKey; //构建文件描述符与SelectionKeyImpl映射表
//所有注册到选择器的通道对应的SelectionKey和与之对应的文件描述符都会放入到该映射表中。

   EPollSelectorImpl(SelectorProvider sp) throws IOException {
        super(sp);
        long pipeFds = IOUtil.makePipe(false);
        fd0 = (int) (pipeFds >>> 32);
        fd1 = (int) pipeFds;
        try {
            pollWrapper = new EPollArrayWrapper();
            pollWrapper.initInterrupt(fd0, fd1);
            fdToKey = new HashMap<>();
        } catch (Throwable t) {
            try {
                FileDispatcherImpl.closeIntFD(fd0);
            } catch (IOException ioe0) {
                t.addSuppressed(ioe0);
            }
            try {
                FileDispatcherImpl.closeIntFD(fd1);
            } catch (IOException ioe1) {
                t.addSuppressed(ioe1);
            }
            throw t;
        }
    }

}

EpollArrayWapper将Linux的epoll相关系统调用封装成了native方法供EpollSelectorImpl使用.

    private native int epollCreate();
    private native void epollCtl(int epfd, int opcode, int fd, int events);
    private native int epollWait(long pollAddress, int numfds, long timeout,
                                 int epfd) throws IOException;

上述三个native方法就对应Linux下epoll相关的三个系统调用。

//创建一个epoll句柄,size是这个监听的数目的最大值.
int epoll_create(int size);
//事件注册函数,告诉内核epoll监听什么类型的事件,参数是感兴趣的事件类型,回调和监听的fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//等待事件的产生,类似于select调用,events参数用来从内核得到事件的集合
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

所以,我们会发现在EpollArrayWapper的构造函数中调用了epollCreate方法,创建了一个epoll的句柄。这样,Selector对象就算创造完毕了。

用完Selector后调用其close()方法会关闭该Selector,即使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。

linux下Selector底层是通过epoll来实现的,当创建好epoll句柄后,它就会占用一个fd值,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

register方法

先构建代表通道和选择器间关系的SelectionKey对象,再调用子类的implRegister方法。

protected final SelectionKey register(AbstractSelectableChannel ch,
                                      int ops,
                                      Object attachment)
{
    if (!(ch instanceof SelChImpl))
        throw new IllegalSelectorException();
    //生成SelectorKey来存储到hashmap中,以供之后获取
    SelectionKeyImpl k = new SelectionKeyImpl((SelChImpl)ch, this);
    //attach用户想要存储的对象
    k.attach(attachment);
    //调用子类的implRegister方法
    synchronized (publicKeys) {
        implRegister(k);
    }
    //设置关注的option
    k.interestOps(ops);
    return k;
}

implRegister方法

  1. 将通道对应的fd和对应的SelectionKeyImpl放到fdToKey映射表中
  2. 将通道对应的fd添加到EPollArrayWrapper中,其有一个byte数组记录所有的channel的fd值
  3. 将selectionKey放到keys集合中
    protected void implRegister(SelectionKeyImpl ski) {
        if (closed)
            throw new ClosedSelectorException();
        SelChImpl ch = ski.channel;
        //获取Channel所对应的fd,因为在linux下socket会被当作一个文件,也会有fd
        int fd = Integer.valueOf(ch.getFDVal());
        fdToKey.put(fd, ski);
        //调用pollWrapper的add方法,将channel的fd添加到监控列表中
        pollWrapper.add(fd);
        //保存到HashSet中,keys是SelectorImpl的成员变量
        keys.add(ski);
    }

Select方法

 和register方法类似,SelectorImpl中的select方法最终调用了其子类EpollSelectorImpldoSelect方法。

  1. 先处理注销的SelectionKey队列
  2. poll方法,进行了底层的epoll_wait操作
  3. 更新SelectionKey
protected int doSelect(long timeout) throws IOException {
    .....
    //处理注销的selectionKey队列
    processDeregisterQueue();

    try {
        ....
        //调用了poll方法,底层调用了native的epollCtl和epollWait方法
        pollWrapper.poll(timeout);
    } finally {
        ....
    }
    ....
    //更新selectedKeys,为之后的selectedKeys函数做准备
    int numKeysUpdated = updateSelectedKeys();

    ....
    return numKeysUpdated;
}

processDeregisterQueue()方法:

  1. 从cancellledKeys集合中取出注销的SelectionKey,执行注销操作。将处理后的SelectionKey从cancelledKeys集合中移除。执行processDeregisterQueue()后cancelledKeys集合会为空。
void processDeregisterQueue() throws IOException {
        Set var1 = this.cancelledKeys();
        synchronized(var1) {
            if (!var1.isEmpty()) {
                Iterator var3 = var1.iterator();

                while(var3.hasNext()) {
                    SelectionKeyImpl var4 = (SelectionKeyImpl)var3.next();

                    try {
                        this.implDereg(var4);
                    } catch (SocketException var12) {
                        IOException var6 = new IOException("Error deregistering key");
                        var6.initCause(var12);
                        throw var6;
                    } finally {
                        var3.remove();
                    }
                }
            }

        }
    }

implDereg()方法

  1. 执行完该方法后,注销的SelectionKey就不会出现在keys、selectedKeys以及cancelledKeys这三个集合中。
protected void implDereg(SelectionKeyImpl ski) throws IOException {
        assert (ski.getIndex() >= 0);
        SelChImpl ch = ski.channel;
        int fd = ch.getFDVal();

        // 将已经注销的selectionKey从fdToKey(文件描述符与SelectionKeyImpl的映射表)中移除
        fdToKey.remove(Integer.valueOf(fd));

        // 将selectionKey所代表的channel的文件描述符从EPollArrayWrapper中移除
        pollWrapper.remove(fd);
        ski.setIndex(-1);

        // 将selectionKey从keys集合中移除
        keys.remove(ski);
        selectedKeys.remove(ski);
        deregister((AbstractSelectionKey)ski);
        SelectableChannel selch = ski.channel();

        // 如果对应的频道已经关闭并且没有注册其他的选择了,则将该信道关闭
        if (!selch.isOpen() && !selch.isRegistered())
            ((SelChImpl)selch).kill();
    }

doSelect中的pollWrapper.poll()方法:

  1. 该方法会先调用epollCtl将先前在register方法中保存的Channel的fd和感兴趣的事件类型注册到linux系统中。
  2. 然后epollWait方法(linux系统中的epoll_wait方法)等待感兴趣事件的生成,导致线程阻塞。
  3. 等待关注的事件产生之后(或在等待时间超过预先设置的最大时间),epollWait函数就会返回在epoll_wait期间有事件触发的条目的个数,同时select函数从阻塞状态恢复。
int poll(long timeout) throws IOException {
    updateRegistrations(); //  先调用epollCtl,更新关注的事件类型
    //  导致阻塞,等待事件产生
    updated = epollWait(pollArrayAddress, NUM_EPOLLEVENTS, timeout, epfd);
    .....
    return updated;
}

selectedKeys方法

//是通过Util.ungrowableSet生成的,不能添加,只能减少
private Set<SelectionKey> publicSelectedKeys;
public Set<SelectionKey> selectedKeys() {
    ....
    return publicSelectedKeys;
}

publicSelectedKeys这个对象其实是selectedKeys变量的一份副本,你可以在SelectorImpl的构造函数中找到它们俩的关系,我们再回头看一下select中updateSelectedKeys方法.

  1. 如果SelectionKeyImpl已经存在于selectedKeys集合中,并且发现触发的事件已经存在于readyOps中了,则不会使numKeysUpdated++;这样会使得我们无法得知该事件的变化
  2. 我们在每次从selectedKey中获取到SelectionKey后,都需要将其从SelectionKey集合中移除,就是为了当有事件触发时能使SelectionKey正确地放入到selectedkey集合中,并正确的通知给调用者
  3. 如果不将已经处理的SelectionKey从selectedKeys集合中移除,那么下次有新事件到来时,在遍历selectedKeys集合时又会遍历到这个SelectionKey,这时就很可能出错了。
  4. 比如,如果没有在处理完OP_ACCEPT事件后将对应SelectionKey从selectedKeys集合移除,那么下次遍历selectedKeys集合时,处理到到该SelectionKey,相应的ServerSocketChannel.accept()将返回一个空(null)的SocketChannel。
  5.  如果发现channel所发生I/O事件不是当前SelectionKey所感兴趣,则不会将SelectionKeyImpl放入selectedKeys集合中,也不会使numKeysUpdated++。
private int updateSelectedKeys() {
    //更新了的keys的个数,或在说是产生的事件的个数
    int entries = pollWrapper.updated; 
    int numKeysUpdated = 0;
    for (int i=0; i<entries; i++) {
        //对应的channel的fd
        int nextFD = pollWrapper.getDescriptor(i);
        //通过fd找到对应的SelectionKey
        SelectionKeyImpl ski = fdToKey.get(Integer.valueOf(nextFD));
        if (ski != null) {
            int rOps = pollWrapper.getEventOps(i);
            //更新selectedKey变量,并通知响应的channel来做响应的处理
            if (selectedKeys.contains(ski)) {
                if (ski.channel.translateAndSetReadyOps(rOps, ski)) {
                    numKeysUpdated++;
                }
            } else {
                ski.channel.translateAndSetReadyOps(rOps, ski);
                if ((ski.nioReadyOps() & ski.nioInterestOps()) != 0) {
                    selectedKeys.add(ski);
                    numKeysUpdated++;
                }
            }
        }
    }
    return numKeysUpdated;
}

参考资料:

  1. java nio及操作系统底层原理
  2.  select、poll、epoll之间的区别总结[整理]
  3. Netty源码(三):I/O模型和Java NIO底层原理
  4. 浅谈Linux中Selector的实现原理
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值