一个Java NIO问题引发的思考

转自:http://www.seflerzhou.net/post-25.html

问题背景:

最近在测试环境遇到大量的这种错误:

java.io.IOException: 文件已存在
        at sun.nio.ch.EPollArrayWrapper.epollCtl(Native Method)
        at sun.nio.ch.EPollArrayWrapper.updateRegistrations(EPollArrayWrapper.java:233)
        at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:214)
        at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:65)
        at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:69)
        at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:80)
        at org.apache.mina.transport.socket.nio.NioProcessor.select(NioProcessor.java:72)
        at org.apache.mina.core.polling.AbstractPollingIoProcessor$Processor.run(AbstractPollingIoProcessor.java:1077)
        at org.apache.mina.util.NamePreservingRunnable.run(NamePreservingRunnable.java:64)
        at java.util.concurrent.ThreadPoolExecutor$Worker.runTask(ThreadPoolExecutor.java:885)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:907)
        at java.lang.Thread.run(Thread.java:619)
2013-07-24 16:50:52,668 WARN  - Unexpected exception.

经过一番google,发现是JDK6在b55里存在的一个bug:http://bugs.sun.com/view_bug.do?bug_id=6693490好吧,现在知其然了,那么我们需要知其所以然。


首先我们需要知道什么是epoll

epoll是linux 2.6引入的一种新型异步I/O多路复用(Synchronous I/O Multiplexing)技术。它的前辈为:poll。所以想弄明白epoll,我们先来看看poll。在异步I/O出现之前,所有的I/0都是同步的。由于I/0操作都比较慢,势必会造成程序阻塞,浪费大量的CPU时间,这是非常低效的,于是便出现了poll模型。

poll模型有两个操作函数:select与poll。其中poll函数需要设备驱动程序的支持。poll模型实现异步的思路就是用户进程使用select对每个文件描述符(fd)对应的设备使用poll函数,查询其是否有事件需要处理(读或写),如果有事件需要处理,则返回需要处理的事件个数,如果暂时没有事件处理,则驱动程序会将select进程放置到等待队列里进行睡眠等待直到超时或被唤醒。这个唤醒操作是设备驱动程序实现的,每当有数据到达,驱动程序就是去check有否有进程在其读/写等待队列里,有则将其唤醒。这样做确实可以实现异步I/O,但问题在于:

  1. 每次循环select都需要对fd指向的设备进行poll操作,在fd非常的多的情况下(比如网络连接),这个是非常低效的。因此poll会限制每个select注册的最大文件数(FD_SETSIZE)
  2. select采取的是内存拷贝的方法来实现消息通知的,这个也是影响效率的因素之一。

那么epoll与poll有什么不同呢?对于poll模型,select只能反回就绪的fd个数,用户进程还需要去对所有fd进行遍历才能处理事件。epoll对poll的数据结构进行了增强,允许用户自传入自家义数据结构(如指针、文件描述符fd等,详见下),这样就可以快速定位事件位置了。

structepoll_event {

    __uint32_t events;      // Epoll events

    epoll_data_t data;      // User datavariable

};

typedef union epoll_data {

    void *ptr;

   int fd;

    __uint32_t u32;

    __uint64_t u64;

} epoll_data_t;
但从根本上来说,无论是poll还是epoll其都是采用轮询的方式来实现异步I/0,并没有基于中断达到真正的异步(实际上NIO也就只是Non-Blocking IO而已,人家也没说是异步的呀),当需要poll的文件明显增多时,性能就会受到影响。所以类Linux上的异步I/O严格上来讲只能算半个异步。


导致异常的根本原因

现在回到我们的问题。官方给出的bug原因是:

To explain the issue requires understanding that registrations in epoll are keyed in the kernel on [file*,fd] rather than [fd] as suggested by original man page (updates to the man page are in the works). This causes problems for the close mechanism which uses dup2 to dup the file descriptor to a special pre-close socket before later releasing the file descriptor. When a thread T1 closes a selectable channel at just around the same time that T2 registers the channel's file descriptor with epoll then it is possible for the pre-close socket to get registered with epoll. Due to the way that de-registration works the registration isn't removed from epoll. When the race condition is repeated with same file descriptor then the attempt to register with epoll fails with EEXISTS that the submitter and others observe. A second implication of this bug is that the Selector will appear to spin as the pre-close socket is always ready. We have an preliminary fix to this issue and are currently testing it.

要解释这个问题我们需要理解epoll是通过[file*,fd]注册的而不是[fd],这一点是由老版man page中的错误造成的(已经在修正了)。这个问题导致(socket)关闭机制出现问题,这种机制会在释放fd之前使用dup2来将fd复制至一个快要关闭的特定socket。当一个线程T1关闭一个selectable channel,同时另一个线程T2正使用epoll注册那个channel的fd,那么那个正准备关闭的socket就可能被epoll注册。这样由于注销操作使用的是没从epoll上移除的fd,当上面的情况再次出现时,尝试使用epoll注册同样的fd就会失败,同时报EEXISTS异常给提交者与相应的观察者。另外,这个bug隐含着另一个问题,那就是selector似乎会把准备关闭的socket总是当作就绪然后一直循环。我们已经有了初步的解决方案并正在进行测试。


好了,在这里已经很清楚了。根本原因就是需要被从epoll上移除的fd又被另一个线程给注册上了,导致其没有被成功移除,造成EEXISTS这个错。

复现代码

好,既然我们已经弄清楚了问题的原因,我们就需要编写一段代码来复现一下,但ORACLE他老人家已经帮咱们写好啦,所以我们来分析一下。按照上面的说法,首先我们需要两个线程, 一个不停尝试注册fd,即创建连接然后马上关闭,其承担的就是上文所说的T1的职责,另一个不停地接受T2的连接请求,然后向selector注册(也就是向epoll注册),承担的就是T2的职责。


线程T1的代码如下:


/**
 * A task that continuously connects to a given address and immediately
 * closes the connection.
 */
static class Connector implements Runnable {
    private final SocketAddress sa;

    Connector(int port) throws IOException {
        InetAddress lh = InetAddress.getLocalHost();
        this.sa = new InetSocketAddress(lh, port);
    }

    public void run() {
        while (!done) {
            try {
                SocketChannel.open(sa).close();
            } catch (IOException x) {
                // back-off as probably resource related
                try {
                    Thread.sleep(10);
                } catch (InterruptedException ignore) {
                }
            }
        }
    }
}


线程T2的代码如下:

while (!done) {
    sel.select();
    if (key.isAcceptable()) {
        SocketChannel sc = ssc.accept();
        if (sc != null) {
            sc.configureBlocking(false);
            sc.register(sel, SelectionKey.OP_READ);
            executor.execute(new Closer(sc));
        }
    }
    sel.selectedKeys().clear();
}


然后我们再使用newScheduledThreadPool来计时跑1分钟,照文档上的意思是这样肯定能重现那个bug。从上面的代码,我们可以清楚的看到T2做的正是不停的注册这个操作,而T1做的就是不停的关闭channel,符合bug原因描述。OK,现在我们来跑一下,运行要的JRE环境为:

java version "1.6.0_07"
Java(TM) SE Runtime Environment (build 1.6.0_07-b06)
Java HotSpot(TM) Server VM (build 10.0-b23, mixed mode)

果然没过多久bug就出来了:


解决方式

这里我们先来看看官方给出的patch是什么样子。首先是 SelChImpl.java 这个文件:

-    // Set of "idle" file descriptors
-    private final HashSet<Integer> idleSet;
+    // Set of "idle" channels
+    private final HashSet<SelChImpl> idleSet;
这里针对的就是上面说的一个epoll channel对应的是 [file*,fd]而不是[fd],由于原来只是一个int,现在变更成了一个对象,所以很多地方都需要跟着改变。我们着重来看一下 release 方法,其作用是将fd从epoll上移除的。
     /**
-     * Remove a file descriptor from epoll
+     * Remove a channel's file descriptor from epoll
      */
-    void release(int fd) {
+    void release(SelChImpl channel) {
         synchronized (updateList) {
-            // if file descriptor is idle then remove from idle set, otherwise
-            // delete from epoll
-            if (!idleSet.remove(fd)) {
-                updateList.add(new Updator(EPOLL_CTL_DEL, fd, 0));
+            // flush any pending updates
+            int i = 0;
+            while (i < updateList.size()) {
+                if (updateList.get(i).channel == channel) {
+                    updateList.remove(i);
+                } else {
+                    i++;
+                }
             }
+
+            // remove from the idle set (if present)
+            idleSet.remove(channel);
+
+            // remove from epoll (if registered)
+            epollCtl(epfd, EPOLL_CTL_DEL, channel.getFDVal(), 0);
         }
     }
从上面的代码可以看出,原来的逻辑是如果fd在idle上则只从idle上移除,否则会向updateList加入一个EPOLL_CTL_DEL包,即从epoll上删除,这并不是一个立即操作。而变更后的逻辑变为无论在不在idleSet中都会从epoll中移除fd,而且是一个立即操作。原来的逻辑并不是立即从epoll上移除的,这就为bug的产生造成了可能。咱们继续来看下面的变更
         synchronized (updateList) {
             Updator u = null;
             while ((u = updateList.poll()) != null) {
-                epollCtl(epfd, u.opcode, u.fd, u.events);
+                SelChImpl ch = u.channel;
+                if (!ch.isOpen())
+                    continue;
+
+                // if the events are 0 then file descriptor is put into "idle
+                // set" to prevent it being polled
+                if (u.events == 0) {
+                    boolean added = idleSet.add(u.channel);
+                    // if added to idle set then remove from epoll if registered
+                    if (added && (u.opcode == EPOLL_CTL_MOD))
+                        epollCtl(epfd, EPOLL_CTL_DEL, ch.getFDVal(), 0);
+                } else {
+                    // events are specified. If file descriptor was in idle set
+                    // it must be re-registered (by converting opcode to ADD)
+                    boolean idle = false;
+                    if (!idleSet.isEmpty())
+                        idle = idleSet.remove(u.channel);
+                    int opcode = (idle) ? EPOLL_CTL_ADD : u.opcode;
+                    epollCtl(epfd, opcode, ch.getFDVal(), u.events);
+                }
             }
         }
     }

比较明显地,原来的代码就是单纯地执行updateList里的任务,对epoll进行操作。变更之后,如果channel已经关闭了就不再进行任何操作了,这里就是解决问题的关键。也就是说原来的逻辑是注销与注册都是放入一个updateList后再进行操作的,这样一来EPOLL_CTL_ADD或EPOLL_CTL_MOD完全可能紧跟在EPOLL_CTL_DEL之后造成fd注销失败。那selector似乎会把准备关闭的socket总是当作就绪然后一直循环”这个隐含Bug又是怎么回事呢?fd在被注销后原本应该是被放到idle list上去(这个时候的event为0),但被重复注册后在set interests时导致其又从idle list上移了下来,而且之后便没有机会再移到idle list上去了,代码如下:

-            // if the interest events are 0 then add to idle set, and delete
-            // from epoll if registered (or pending)
-            if (mask == 0) {
-                if (idleSet.add(fd)) {
-                    updateList.add(new Updator(EPOLL_CTL_DEL, fd, 0));
-                }
-                return;
-            }
-
-            // if file descriptor is idle then add to epoll
-            if (!idleSet.isEmpty() && idleSet.remove(fd)) {
-                updateList.add(new Updator(EPOLL_CTL_ADD, fd, mask));
-                return;
-            }
其实这里的问题就在于放至idle list与从idle list拿下来都是一个地方的,所以修正之后将这部分代码迁移到了updateList处理部分,从而保证了fd状态的正确性,如下:

         synchronized (updateList) {
             Updator u = null;
             while ((u = updateList.poll()) != null) {
-                epollCtl(epfd, u.opcode, u.fd, u.events);
+                SelChImpl ch = u.channel;
+                if (!ch.isOpen())
+                    continue;
+
+                // if the events are 0 then file descriptor is put into "idle
+                // set" to prevent it being polled
+                if (u.events == 0) {
+                    boolean added = idleSet.add(u.channel);
+                    // if added to idle set then remove from epoll if registered
+                    if (added && (u.opcode == EPOLL_CTL_MOD))
+                        epollCtl(epfd, EPOLL_CTL_DEL, ch.getFDVal(), 0);
+                } else {
+                    // events are specified. If file descriptor was in idle set
+                    // it must be re-registered (by converting opcode to ADD)
+                    boolean idle = false;
+                    if (!idleSet.isEmpty())
+                        idle = idleSet.remove(u.channel);
+                    int opcode = (idle) ? EPOLL_CTL_ADD : u.opcode;
+                    epollCtl(epfd, opcode, ch.getFDVal(), u.events);
+                }
             }
         }
     }

参考文献或资料

http://blog.csdn.net/sparkliang/article/details/4770655

http://bbs.chinaunix.net/thread-2021810-1-1.html

http://www.cnblogs.com/moonlove/archive/2012/03/17/2509150.html


------------------------------------------------草稿------------------------------------------------------

我们得先来聊聊poll

总的来说,它是这样一种模型:



另一方面,由于SelChImpl类的引入(即用[file*,fd]代表一个channel的而不是[fd])?要回答这个问题就要来看看XXX这个方法



Edge与Level两种触发模式


通过阅读


首先我们需要编写一个测试用例来复现这个问题


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值